先说说我自己的结论:写自定义控件这件事,决定成败的往往不是“会不会调 API”,而是“有没有在动手前想清楚它到底属于哪一类”。很多初学者一上来就继承 UIView,重写 draw(_:) 拼命画画,结果卡在性能、布局、事件冲突的泥潭里。我在做过几个 SDK、踩过一堆坑之后,把自定义控件的实现路径归纳成了一套自己的判断逻辑。
这篇总结不适合只想抄一段代码的人,更适合那些希望形成“遇到新控件需求时,能快速判断该怎么写”的思维框架的人。我会按从决策到落地的顺序来写,中间穿插完整的实战 Demo、性能排查思路和绕过编译器/系统坑位的经验。
1. 什么时候该亲自写控件:边界判断与三种实现路线
1.1 先回答“能不能不写”
我在代码评审里最常见到的问题,不是自定义控件写得烂,而是“这个控件根本没必要自定义”。
UILabel、UIButton、UIImageView、UIStackView、UICollectionView 这些基础组件,加上 UIView 的 cornerRadius、mask、layer 属性,能解决至少七成“看起来需要自定义”的需求。比如一个圆角带阴影的卡片,很多人会去继承 UIView 重写 draw(_:),但用 layer.cornerRadius 加 layer.shadowPath 就能搞定,性能还好得多。
所以动手前先做一次需求拆解,问问自己这几个问题:
- 这个控件是静态展示,还是需要响应用户交互?
- 它需要频繁更新内容(比如每秒钟刷新进度、波形、图表)吗?
- 它的视觉形态系统控件是否真的无法满足?如果只是改颜色、圆角、边框,那不是自定义控件,是样式配置。
- 未来它会不会在多处复用?如果只在一个页面用一次,也许直接在
viewDidLoad里拼几个系统控件更值。
只有当答案是“系统控件组合无法满足”“复用面足够广”“需要精细的绘制/交互控制”这三者之一时,才值得进入自定义流程。
1.2 三条路线的取舍
据我个人的归纳,iOS 里自定义控件有三种实现路线,它们各自适用的场景完全不同:
| 路线 | 实现方式 | 适用场景 | 性能特征 |
|---|---|---|---|
| 组合式 | 通过 addSubview 把多个系统控件组装起来,封装成独立类 |
表单输入框、带图标的按钮、E-mail/手机号输入框、卡片头像 | 最低,GPU 合成,不耗 CPU 绘制 |
| 继承式 | 继承 UIButton、UILabel、UIControl 等,重写部分方法或属性 |
需要系统控件原有交互能力,但需要扩展样式或行为的场景 | 较低,保留系统优化 |
| 自绘式 | 继承 UIView 并重写 draw(_:),用 Core Graphics / UIKit 直接绘制 |
图表、进度环、信号波形、表情评分、不规则图形 | 较高,每次重绘都走 CPU/GPU 渲染管线 |
一个很常见的误区是:总想把控件写成自绘式,觉得这样才显得“高级”。实际上,自绘路线的维护成本是最高的。同样一个圆环进度条,如果用 CAShapeLayer 加 UIBezierPath,代码量比 draw(_:) 少三分之一,性能也更好;而如果这个进度条里还需要加文本、加图标,那干脆用组合式。
所以我的经验法则是:能用系统层属性解决的事,绝不上 draw(_:);能组合系统控件搞定的事,绝不自绘;必须自绘时,优先用 CALayer 子类而不是 draw(_:)。 因为 CALayer 的绘制走的是 Core Animation 的渲染树,性能上限远高于每次 setNeedsDisplay() 之后的 CPU 光栅化。
1.3 组件应该暴露什么接口
确定要自定义之后,建议先花十分钟定义它的对外 API。这决定了后续维护的舒适度。
一个自定义控件对外可以分三层:
- 数据层接口:控件展示什么数据。比如
progress: CGFloat、items: [String]。 - 样式层接口:颜色、字体、圆角、间距等外观配置项。
- 行为层接口:回调事件、代理、手势响应。
写接口的时候遵循一条原则——“让别人用起来像在用系统控件”。例如系统 UIButton 有 setTitle(_:for:),你的按钮控件也应该提供类似的语义化方法,而不是暴露一堆 public var 让调用方去改内部子视图。封装的意义在于:调用方不需要知道内部是三段式结构还是纯 draw(_:) 结构,他只需要关心“传入数据、接收事件”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 渲染路径的选择:draw(_:) 背后的绘制逻辑与性能取舍
2.1 draw(_:) 到底在做什么
很多教程会在教你写自定义控件时直接甩一段 override func draw(_ rect: CGRect),但这行代码背后的机制才是性能问题的根源。
当你调用 setNeedsDisplay() 时,系统会在下一个 RunLoop 周期标记该视图为“需要重绘”。到了渲染阶段,UIKit 会为视图分配一块 backing store(后备存储),然后调用你的 draw(_:) 方法,用 Core Graphics 的 context 把内容画进这块内存位图。之后,这个位图会被上传到 GPU 作为纹理参与合成。
这里有两点容易被忽略:
draw(_:)是同步 CPU 操作。画的内容越复杂,CPU 时间片消耗越大,如果赶上滚动或动画,就会造成卡顿。- 只要调用
setNeedsDisplay(),整个bounds范围内的内容都会重画一遍。即使你只改了一个角落的小色块,系统也没法只重绘那一小块。
我做过的性能分析中,最典型的一个案例是自定义一个波形图控件。最初版本每秒钟对整条波形调用 30 次 setNeedsDisplay(),在 iPhone 8 上 CPU 占用率直接飙到 60%。后来改成只刷新新增的波形区域,并且波形背景用 CALayer 的 contents 缓存,CPU 占用降到 8%。
2.2 draw(_:) 与 CALayer 的职责边界
理解 draw(_:) 的一个关键点:它属于 UIView 层的绘制回调,而真正决定“什么时候把内容画到屏幕上”的,是底层 CALayer。UIView 只是 CALayer 的 delegate。
所以绘制方案可以拆成两套:
draw(_:)绘制:适合内容会随着状态频繁变化的场景,比如图表数据刷新、二维码、验证码。CALayer绘制:适合静态内容、或只变化部分属性的场景,比如圆角、边框、阴影、渐变色、进度圆环,用CAShapeLayer+UIBezierPath可以完全避免重绘。
举个例子。如果用 draw(_:) 画一个圆环进度,每次进度变化都要清空整个上下文重画。但用 CAShapeLayer,你只需要创建一个 UIBezierPath 实例,然后更新它的 strokeEnd 属性,剩下的交给 Core Animation 插值处理,得到动画效果的同时 CPU 开销几乎可以忽略。
我的建议是:绘制复杂路径、不规则图形时,优先用 CAShapeLayer;文字绘制,优先考虑用 CATextLayer 配合 NSAttributedString;只有图像合成、渐变等复杂位图操作,才考虑 draw(_:)。
2.3 模拟器测不出来的性能差异
一个很重要的经验:性能问题一定要在真机上验证。模拟器使用 Mac 的 CPU 和 GPU,性能远强于真机,而且渲染路径和真机不一致。
我踩过的一个坑是,在模拟器上跑一个自绘的点赞动画,完全流畅,结果上了 iPhone 6 一代真机直接掉到十几帧。原因就是真机每帧的 GPU 填充率有限,而模拟器则直接借用 Mac 的图形栈,掩盖了真实设备的压力。
所以在写自绘控件时,从一开始就要有这样的意识:
- 避免在
draw(_:)里创建大对象(比如UIColor、UIFont、UIBezierPath)。这些应该在初始化阶段准备好并缓存。 - 避免在
draw(_:)里做文本测量和排版计算(sizeWithAttributes之类)。文本排版是 CPU 密集型操作,能缓存就缓存。 - 避免在滚动视图中使用大量自绘控件。如果必须用,尽量降低重绘频率,或者用异步绘制框架。
3. 布局与自适应:intrinsicContentSize 和 layoutSubviews 的正确配合
3.1 为什么自定义控件总是布局错乱
自定义控件最常见的 bug 不是画得难看,而是放进 Auto Layout 里就变形。
原因往往在于对 intrinsicContentSize 的忽视。Auto Layout 在计算布局时,会优先读取视图的固有内容尺寸。系统控件都有自己合理的 intrinsicContentSize:UILabel 会根据文字内容和字体计算宽高,UIButton 会根据 title 和 image 计算。而自定义 UIView 默认返回 UIView.noIntrinsicMetric,意思是“我没有固有尺寸,全靠约束撑”。
如果你自定义的控件内部有固定的内容(比如一个图标的尺寸 + 文字大小),而你没有实现 intrinsicContentSize,那么使用方必须手动添加宽高约束,否则布局会乱。这违背了“让调用方像使用系统控件一样简单”的目标。
3.2 正确实现 intrinsicContentSize 的方式
intrinsicContentSize 的正确实现方式并不复杂,但有几个细节要注意。
swift复制class BadgeButton: UIButton {
var badgeValue: String? {
didSet {
badgeLabel.text = badgeValue
invalidateIntrinsicContentSize()
}
}
private let badgeLabel = UILabel()
let badgeInsets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
let badgeWidth = badgeLabel.intrinsicContentSize.width + badgeInsets.left + badgeInsets.right
let badgeHeight = badgeLabel.intrinsicContentSize.height + badgeInsets.top + badgeInsets.bottom
return CGSize(width: max(badgeWidth, size.width), height: max(badgeHeight, size.height))
}
}
关键点在于:
- 当内部内容变化时,必须调用
invalidateIntrinsicContentSize(),否则 Auto Layout 不会重新计算固有尺寸。 intrinsicContentSize不是越大越好,应该返回“刚好包裹内容”的尺寸,这样使用方在约束设定上有更大的自由度。- 如果你的控件有多行文本或动态宽度,
intrinsicContentSize的计算需要依赖当前已确定的宽度。这时要重写layoutSubviews并在宽度变化后重新计算。
3.3 重写 layoutSubviews 的正确姿势
layoutSubviews 是自定义控件布局的“大总管”。很多人喜欢在里面写大量 frame 计算,但写得不好会导致性能问题。
两个重要的经验:
第一,不要直接调用 layoutSubviews()。它由系统在布局阶段自动调用。你需要做的是修改 frame 或约束后调用 setNeedsLayout() 和 layoutIfNeeded(),前者是标记需要布局,后者是立即执行布局。直接调用 layoutSubviews() 会造成不可预期的布局状态。
第二,在 layoutSubviews 中要处理好“依赖自身宽高”的布局逻辑。举个例子,如果你的控件有一个圆角背景,需要根据当前 bounds 大小设置 cornerRadius,那么必须在 layoutSubviews 里更新,因为只在 init 里设置一次,等到父视图改变尺寸后圆角就不会跟着变了。
swift复制override func layoutSubviews() {
super.layoutSubviews()
backgroundLayer.frame = bounds
backgroundLayer.cornerRadius = bounds.height / 2
}
3.4 应对内容动态变化的 Self-Sizing 策略
如果你的控件要放在 UITableViewCell 或 UICollectionViewCell 里并实现动态高度,那么除了 intrinsicContentSize 之外,还要处理好 preferredMaxLayoutWidth。
这个属性是 UILabel 在多行文本时用来计算高度的。设置了它,Label 在布局阶段会优先以这个宽度换行,而不是等整个布局完成后才知道最终宽度,从而避免计算出错误的高度。
具体到自定义控件上,我一般会在 layoutSubviews 里,先更新子视图的 frame,再对多行文本 Label 设置 preferredMaxLayoutWidth = bounds.width - insets,最后再更新 Label 的高度约束或 frame。这样自适应用起来才不会出现“先显示错误高度,滚动一下才跳变”的 bug。
4. 交互细节:命中测试、手势协作与无障碍支持
4.1 扩大点击热区:hitTest 的正确写法
自定义控件经常遇到一个需求:视觉上图标只有 20x20,但点击区域需要 44x44 以上。系统控件虽然会自动扩展一点,但完全自定义的视图不会。
我以前的做法是直接改 frame,把透明背景区域也占满 44x44,后来发现这样干扰了视觉布局的对称性。更好的做法是重写 hitTest(_:with:) 或 point(inside:with:),在不改变布局的情况下扩大点击区域。
swift复制override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let margin: CGFloat = -12.0
let extendedBounds = bounds.insetBy(dx: margin, dy: margin)
return extendedBounds.contains(point)
}
这里有一个坑:insetBy(dx:) 的参数如果是负数,是向外扩展;正数是向内收缩。我见过不少同事在这里写反,导致点击区域反而缩小了。
另外要注意,如果你重写了 point(inside:with:),子视图的点击判断也会受到影响。如果你的控件内部还有 UIButton 这样的子视图,一定要先判断子视图的点击区域,再判断扩展区域,否则子视图会变成“点不到”。
4.2 手势与系统交互的冲突
自定义控件里经常要加 UIPanGestureRecognizer、UILongPressGestureRecognizer 之类的交互。这时你很容易踩进“手势冲突”的坑里。
最典型的场景是:自定义控件嵌在 UITableViewCell 里,你希望拖动手势让控件跟着手指移动,但 UITableView 自带的滚动手势抢先响应了。这时需要在 UIGestureRecognizerDelegate 里实现:
swift复制func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer,
otherGestureRecognizer is UIPanGestureRecognizer {
return false
}
return true
}
这里还有一个更微妙的场景:自定义的 UIButton 子类,在 touchUpInside 事件里做动画,手势却已经抢先取消了触摸。我遇到的现象是,快速滑动时按钮的高亮状态会“卡住”,松开手指时按钮仍处于高亮态,无法恢复正常。
排查到最后,根因是系统在手势识别成功后发送了 touchesCancelled,而我的按钮子类没有正确处理这个事件:
swift复制override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
isHighlighted = false
}
这类小问题,不实际滚一遍很难发现。
4.3 别忘了 Accessibility
自定义控件最容易被忽略的就是无障碍支持。系统控件自带语义,但自定义控件在屏幕阅读器眼里是“一个无名矩形”。
如果需要做好,至少要做到:
- 设置
isAccessibilityElement = true。 - 配置
accessibilityLabel、accessibilityValue、accessibilityTraits。 - 如果是可交互控件,
traits要包含.button或.adjustable等。 - 如果控件的视觉状态有“选中/未选中”之分,用
accessibilityValue体现出来。
具体操作:
swift复制override var isAccessibilityElement: Bool {
get { true }
set { super.isAccessibilityElement = newValue }
}
override var accessibilityTraits: UIAccessibilityTraits {
get { [.button] }
set { super.accessibilityTraits = newValue }
}
做无障碍不仅是为了合规,它还能帮你发现自己控件在语义上的不清晰。如果你的控件很难用一两句话描述它是什么、做什么,那很可能你的抽象逻辑本身就存在问题。
5. 完整实战:从零实现一个带角标的图像按钮控件
5.1 需求定义
我拿实际项目里经常出现的一个需求来做完整示例:带角标(Badge)且支持点击高亮状态的图像按钮。
要求拆解:
- 支持设置普通态和选中态的图标。
- 右上角显示数字角标,数字为 0 时不显示,超过 99 显示 “99+”。
- 点击时有高亮态,松手恢复。
- 禁用状态下图标变灰。
- 支持 Auto Layout 自适应尺寸。
这类控件如果用继承 UIButton 的方式做,工作量最小,同时还能保留 UIControl 的 touchUpInside 回调能力。我选择继承 UIButton,配合子视图 UILabel 做角标,图标部分直接用 setImage(_:for:) 设置。
5.2 核心代码实现
swift复制class BadgeButton: UIButton {
var badgeValue: Int = 0 {
didSet {
badgeLabel.text = badgeText
badgeLabel.isHidden = badgeValue == 0
invalidateIntrinsicContentSize()
}
}
var badgeBackgroundColor: UIColor = .red {
didSet { badgeLabel.backgroundColor = badgeBackgroundColor }
}
var badgeTextColor: UIColor = .white {
didSet { badgeLabel.textColor = badgeTextColor }
}
var badgeFont: UIFont = .systemFont(ofSize: 10, weight: .bold) {
didSet { badgeLabel.font = badgeFont }
}
/// 角标偏移量,默认贴右上角
var badgeOffset: UIOffset = UIOffset(horizontal: 0, vertical: 0) {
didSet { setNeedsLayout() }
}
private let badgeLabel = UILabel()
private let badgeHeight: CGFloat = 16
private let badgeMinWidth: CGFloat = 16
private var badgeText: String {
if badgeValue > 99 { return "99+" }
return "\(badgeValue)"
}
override init(frame: CGRect) {
super.init(frame: frame)
setupBadge()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupBadge()
}
private func setupBadge() {
badgeLabel.isHidden = true
badgeLabel.textAlignment = .center
badgeLabel.textColor = badgeTextColor
badgeLabel.backgroundColor = badgeBackgroundColor
badgeLabel.font = badgeFont
badgeLabel.clipsToBounds = true
badgeLabel.isUserInteractionEnabled = false
addSubview(badgeLabel)
}
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0 else { return }
badgeLabel.sizeToFit()
let badgeWidth = max(badgeMinWidth, badgeLabel.bounds.width + 8)
let badgeSize = CGSize(width: badgeWidth, height: badgeHeight)
// 角标固定在右上角,按 badgeOffset 平移
let badgeX = bounds.width - badgeSize.width / 2 + badgeOffset.horizontal
let badgeY = -badgeSize.height / 2 + badgeOffset.vertical
badgeLabel.frame = CGRect(origin: CGPoint(x: badgeX, y: badgeY), size: badgeSize)
badgeLabel.layer.cornerRadius = badgeHeight / 2
}
override var intrinsicContentSize: CGSize {
var size = super.intrinsicContentSize
// 给角标留出右上角的空间,避免被裁切
size.width += 8
size.height += 4
return size
}
}
5.3 实现中容易被忽略的细节
-
角标的
isUserInteractionEnabled必须设为 false。否则它阻挡了按钮的触摸事件,点击会偶然失灵。这算是我自己踩过的一个隐坑。 -
角标的位置计算依赖
bounds而非frame。因为layoutSubviews执行时bounds才是最终确定的尺寸,frame在 Auto Layout 下不一定是最终值。 -
sizeToFit()和intrinsicContentSize的关系。在layoutSubviews里先sizeToFit()让 Label 根据内容调整自身尺寸,然后再根据这个尺寸加 padding 计算角标整体宽度。顺序反了会出现宽度不对的问题。 -
状态切换的联动。如果我需要在按钮禁用时把角标也置灰,需要在
isEnabled的didSet里做处理。UIButton的isEnabled没有公开的闭包回调,所以我会在选中态图片设置时,同时把禁用态图标改成灰色版本。
5.4 使用示例
swift复制let bellButton = BadgeButton(type: .system)
bellButton.setImage(UIImage(systemName: "bell"), for: .normal)
bellButton.setImage(UIImage(systemName: "bell.fill"), for: .selected)
bellButton.badgeValue = 100
bellButton.addTarget(self, action: #selector(bellTapped), for: .touchUpInside)
这样写完后,调用方不需要知道角标的任何布局细节,只需设置 badgeValue。Auto Layout 下控件也能合理自适应尺寸。如果你把它放进 UICollectionViewCell,只要设置好约束,复用也不会乱。
6. 状态驱动的刷新机制:用属性观察器管控重绘
6.1 从 setNeedsDisplay 到属性观察器
自绘控件最常见的性能问题,是“不知道什么时候该重绘,干脆每次刷新都全量重绘”。我在一段时间里也这么干过:不管属性有没有变化,只要数据源刷新就往视图上甩一个 setNeedsDisplay()。
后来总结出的模式是:让状态变化驱动重绘,而不是让外部告诉视图“你要重绘了”。
具体做法是:把需要参与绘制的属性用 didSet 观察器管理起来。当属性真正变化时,才触发 setNeedsDisplay()。
swift复制var progress: CGFloat = 0 {
didSet {
let clamped = min(max(progress, 0), 1)
guard abs(clamped - oldValue) > 0.001 else { return }
progressLayer.strokeEnd = clamped
}
}
这里加了一个精度比较:如果新旧值差异小于千分之一,就不做任何操作。这看起来是小题大做,但在高频刷新(比如拖动滑块时)能省掉大量无意义的图层属性赋值和 CPU 运算。
6.2 避免在 didSet 里做昂贵操作
属性观察器虽然好用,但要注意别在里面做会触发布局循环的操作。比如 invalidateIntrinsicContentSize()、layoutIfNeeded()、setNeedsLayout() 这些要谨慎使用,因为如果属性的 didSet 里调用了布局方法,而某个布局方法里又修改了该属性,就会形成无限循环。
我遇到过的真实例子是:一个自定义控件在 progress 的 didSet 里调用 layoutIfNeeded(),而 layoutSubviews 里会根据 progress 更新一个文本 Label 的宽度约束,约束更新之后又触发了 layoutIfNeeded()。结果界面直接卡死。
解决方案是:延迟到下一次 RunLoop 再刷新布局,或者用 CATransaction 批量包裹。
swift复制var progress: CGFloat = 0 {
didSet {
// 延迟到下一个布局周期,避免当前布局循环里再次触发
DispatchQueue.main.async { [weak self] in
self?.setNeedsLayout()
}
}
}
6.3 绘制内容的缓存:contents 与 shouldRasterize
如果自定义控件的背景或装饰性内容是静态的,但在每次重绘时都要花大量时间绘制,那就应该把它做一次离屏缓存。
具体做法是:把静态部分绘制到一张 UIImage,然后赋值给 layer 的 contents,之后无论怎么刷新动态部分,静态背景都不会被重新绘制。
swift复制private func makeBackgroundImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(size: bounds.size)
return renderer.image { ctx in
// 绘制渐变、圆角、边框等
}
}
这里还有 shouldRasterize 属性值得一提。如果你的控件有复杂的阴影或圆角组合,打开 layer.shouldRasterize = true 可以让 Core Animation 把整个 layer 树渲染成位图缓存,后续合成时直接使用缓存位图。
但要注意两点:第一,shouldRasterize 再配合 rasterizationScale = UIScreen.main.scale,否则在高清屏上缓存图会发虚;第二,不要在滚动视图中对会频繁变化的控件使用 shouldRasterize,因为缓存刷新和失效的开销比直接渲染还大。
7. 我踩过的几个坑:离屏渲染、循环引用与转屏布局
7.1 离屏渲染的元凶:shadow 与 mask
自定义控件做到后期,最常见的问题不是功能缺失,而是 “看起来简单,但滑动掉帧”。有一次我做了一个浮层气泡控件,带圆角、阴影和箭头,在首页列表里放了几个,一滚动就掉到 40 帧。
用 Instruments 的 Core Animation 工具一看,满屏的黄色标记,全是离屏渲染警告。罪魁祸首是 layer.shadowOffset + layer.cornerRadius 的组合。
关键来了:阴影配合圆角时,系统需要先离线计算一个带透明圆角的图层形状,再基于这个形状生成阴影。这个过程极其昂贵。
解决方案是给阴影指定一条明确的路径:
swift复制layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
一旦设置了 shadowPath,系统就不需要根据图层形状动态计算阴影了,直接按路径去生成,性能提升立竿见影。但要注意:bounds 变化时必须同步更新 shadowPath,否则阴影会固定在旧尺寸。
如果你用的是 mask 来实现圆角或遮罩,也要小心。mask 本身会触发离屏渲染,尤其是 mask 还在频繁变化时,性能会雪崩。能用 cornerRadius 就尽量不用 mask。
7.2 循环引用:CADisplayLink 与闭包
自定义控件里如果用了 CADisplayLink 做动画或逐帧刷新,最常见的坑是循环引用导致控件无法释放。
CADisplayLink 会强引用 target,如果你把 target 设为控件自身,同时控件持有 CADisplayLink,那么两者互相引用,deinit 永远不会被调用。更隐蔽的是,CADisplayLink 本质上被 RunLoop 持有,即使你移除了它,如果 invalidate() 没有被调用,它还是会持有 target。
我建议的做法:
swift复制final class WaveView: UIView {
private var displayLink: CADisplayLink?
func startAnimating() {
stopAnimating()
displayLink = CADisplayLink(target: self, selector: #selector(updateWave))
displayLink?.add(to: .main, forMode: .common)
}
func stopAnimating() {
displayLink?.invalidate()
displayLink = nil
}
deinit {
displayLink?.invalidate()
}
}
还有闭包导致的循环引用也值得注意。控件持有闭包,闭包又捕获控件自身,这种循环在 Swift 里很常见。特别是在属性观察器里用 [weak self] 写多了之后,容易在不需要 [weak self] 的地方随手写上,这倒问题不大;怕的是应该写 [weak self] 却没有写。
7.3 转屏与尺寸变化的布局适配
自定义控件最容易被测试遗漏的场景,是转屏。很多控件在竖屏下设计得完美,一转横屏就露出马脚:角标飞出边界、圆角比例失调、自绘内容被拉伸。
解决的思路是:布局逻辑不要假设初始 bounds,每次 layoutSubviews 都要以当前 bounds 为基准重新计算全部子视图 frame。
我的一般做法:
swift复制override func layoutSubviews() {
super.layoutSubviews()
// 所有子视图布局都从这里走
contentLayer.frame = bounds
textLabel.frame = bounds.insetBy(dx: 16, dy: 8)
// 根据宽度决定是否隐藏辅助元素
auxiliaryView.isHidden = bounds.width < 120
}
另外,如果控件里有自绘内容,要重写 draw(_:) 时使用 bounds 而不是固定的 frame 尺寸。转屏后 bounds 变化会触发重绘,但如果绘制代码里硬编码了 width = 320,横屏后内容就会被拉伸或裁剪。
7.4 主题切换与深色模式
现在的 App 基本都要适配深色模式。自定义控件最容易踩的坑就是颜色写死。
我见过太多自定义控件内部直接写 UIColor.white、UIColor.gray。一旦系统切到深色模式,整个控件在一堆动态颜色中显得特别突兀。
推荐的做法是:尽量使用语义颜色,或者在控件内部监听 traitCollection 变化。
swift复制override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
updateColors()
}
}
private func updateColors() {
backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
badgeLabel.backgroundColor = .systemRed
}
这里额外提一个 API 层面的细节:UIColor(light:dark:) 这类动态颜色在某些绘制路径下不会自动切换。比如你在 draw(_:) 里通过 setFill() 设置的动态颜色,在模式切换后可能不会重新渲染。所以我一般在自绘代码里使用动态颜色时,同时监听 traitCollectionDidChange 并调用 setNeedsDisplay(),确保绘制结果跟随模式切换。
7.5 单测与预览:让控件可验证
最后不展开太多,但值得提一句:自定义控件如果没法在 Xcode Preview 里快速预览,也没法写几个 XCTest,那后续迭代会非常痛苦。我给自己定的规矩是:自定义控件至少提供 #Preview 宏或专用的 PreviewProvider,展示不同状态组合,方便调试视觉;同时把纯计算逻辑(如角标尺寸计算、文案格式化)抽成独立类型,方便单测。
写自定义控件写了这几年,最大的感受不是 API 记得多熟,而是“能用系统控件就少动手,要动手就一次把状态、布局、性能、无障碍都考虑干净”。每做一个新控件,用这套思路走一遍,后面维护的人(包括几个月后的自己)都会感谢你。
