“这个回调应该提取成 Delegate,还是用 Closure 接收?”这是 iOS 代码评审中尤其常见的问题。
两者都是处理“发生事情时通知我”这一需求的工具,因此仅从功能来看,使用哪一种都可以实现。
UIKit 中充满了 Delegate(UITableViewDelegate、UITextFieldDelegate)。但 Apple 的新 API 会接收 Closure,而且各团队的约定也不尽相同。
本文使用同一个示例并列实现两种方式,展示它们的差异并整理选择标准。
Delegate 模式本身的语法以及与 Observer 的比较已在另一篇文章中介绍,Closure 的捕获和循环引用原理也在 Closure 篇中整理过。本文聚焦于两篇文章的交集:如何选择。
同一个问题,两种答案 — 并列比较
假设我们要制作一个图片选择器页面。用户选择照片后,需要通知调用它的页面。
这是 Delegate 版本。
protocol ImagePickerDelegate: AnyObject {
func imagePicker(_ picker: ImagePickerVC, didSelect image: UIImage)
func imagePickerDidCancel(_ picker: ImagePickerVC)
}
final class ImagePickerVC: UIViewController {
weak var delegate: ImagePickerDelegate?
private func selectionDone(_ image: UIImage) {
delegate?.imagePicker(self, didSelect: image)
}
}
// 调用方
extension ProfileVC: ImagePickerDelegate {
func imagePicker(_ picker: ImagePickerVC, didSelect image: UIImage) {
avatarView.image = image
}
func imagePickerDidCancel(_ picker: ImagePickerVC) { /* 忽略 */ }
}
这是 Closure 版本。
final class ImagePickerVC: UIViewController {
var onSelect: ((UIImage) -> Void)?
var onCancel: (() -> Void)?
private func selectionDone(_ image: UIImage) {
onSelect?(image)
}
}
// 调用方
let picker = ImagePickerVC()
picker.onSelect = { [weak self] image in
self?.avatarView.image = image
}
行为相同,差异在于结构。
Delegate 会先把通信契约声明为 protocol 类型,再由接收方采用完整契约。Closure 则不需要契约,而是将每个事件作为值进行传递。
这种结构差异会进一步带来下面这些实际差异。
五项实际差异
第一,事件数量增加时的可扩展性。 使用 Delegate 时,即使事件从 5 个增加到 10 个,也只需向协议添加方法。
相关事件会被归入同一个契约,采用者则在一个 extension 中集中实现“与这个页面的完整对话”。这就是 UITableViewDelegate 即使有几十个方法也能管理的原因。
使用 Closure 时,每个事件都会增加一个属性。超过三四个后,配置代码会变得分散,编译器也无法发现“哪个回调没有接入”。
Delegate 未实现必需方法会产生编译错误,而 Closure 属性可以保持为 nil 并静默跳过。
第二,配置位置之间的距离。 Closure 的优势在于,消费事件的代码紧挨着触发事件的调用。
打开选择器的代码和接收结果的代码相隔不超过三行,因此流程一眼就能看懂。Delegate 的配置(delegate = self)与实现(extension)则分散在文件中的不同位置。
对于一次性互动,这套仪式过于繁琐。因此,对于网络请求完成、警报按钮响应、动画结束等“一次发生后就结束”的事件,Closure 成为了标准做法。
第三,状态与身份。 按照惯例,Delegate 方法会将发送方作为第一个参数传入(imagePicker(_:didSelect:)picker)。
当一个页面使用两个 table view 时,正是这个惯例让你能够区分事件来自哪一个。
Closure 不会自动传递发送方,因此在相同情况下,你必须接入两组 Closure,或在参数中直接设计发送方。
第四,内存管理陷阱的位置。 两者都有循环引用风险,但陷阱的形态不同。
Delegate 在声明时用weak var delegate一次判断即可,而且由于约定非常强,出错的情况很少。Closure 则需要在每个接入点重复判断是否使用[weak self]。
正如 ARC(Automatic Reference Counting,自动引用计数)篇中整理的那样,没有所有权环的一次性执行不需要 weak,但存储在属性中的回调可能形成循环。
也就是说,每个使用位置都要重新做出判断。Closure 更容易暴露出错误。
第五,测试与复用。 Closure 在测试中很轻量。
无需 Mock 对象,只要在测试正文中直接接入回调并验证是否被调用即可。Delegate 则需要创建测试用 Spy 类,会增加准备代码。
但 Delegate 协议也充当“如何与这个组件通信”的文档。在多个页面复用同一个组件时,契约明确是一项优势。
选择标准 — 根据事件数量、生命周期和方向决定
了解差异后,把它们压缩成选择标准。大多数情况只需回答三个问题。
问题 1 — 有多少个事件? 一个或两个用 Closure;超过三个,或者关系预计会继续增加时,用 Delegate。
事件集合越接近“对话”,契约(协议)的价值就越高。
问题 2 — 关系的生命周期如何? 像请求-响应一样很快结束时用 Closure;只要页面存在就持续交互的关系(滚动事件、编辑文本时的验证)用 Delegate。
关系持续得越久,一个 weak delegate 的安全性就越优于在每个使用位置判断[weak self]。
问题 3 — 是否需要返回值? Delegate 方法可以拥有返回值。
textField(_:shouldChangeCharactersIn:)就是典型例子。“是否允许执行”这种查询式通信,是 Delegate 的主场。
Closure 也可以设计返回类型,但处理已存储可选 Closure 的返回值(如果是 nil,默认值是什么?)会显得别扭。
当然也有边界模糊的情况。这时可以参考 Apple 最近的发展方向。
基于 UIAction 的按钮处理器,以及UICollectionViewDiffableDataSource的 Closure provider 都是例子。迁移到 async/await(错误处理篇中提到的 completion handler 换代)也是同一趋势。
一次性、数据提供型通信正在持续转向 Closure 和 async。
另一方面,持续互动的标准仍然是 Delegate。UITableViewDelegate 和 UINavigationControllerDelegate 就是例子。
框架中的这种分工与上面的三个问题完全一致。
这里只指出一个反模式:在每个页面重新创建只有一个方法的 Delegate 协议。
为单个事件经历协议声明、采用、weak 属性和 extension 四层仪式,大多数情况下都是过度设计。这里用一行 Closure 就够了。
反过来,一个类挂着六七个 Closure 属性,就是应该用 Delegate 统一封装的信号。
总结
- Delegate 和 Closure 是同一需求(“发生事情时通知我”)的两种实现。差异的根源在于:是将契约声明为类型(协议),还是将事件作为值进行传递。
- 五项实际差异:Delegate 具备事件扩展性并能检测未实现项;Closure 让配置位置更具内聚性;发送方识别依赖 Delegate 约定;Closure 的内存陷阱暴露面更大;测试轻量性则 Closure 更占优势。
- 用三个问题来选择:事件是否至少有三个,关系是否长期存在,是否需要返回值?三个都不是时用 Closure,只要有一个明确是,就用 Delegate。
- 单方法 Delegate 协议和五六个 Closure 属性,分别是两个相反方向的过度设计信号。
如果想了解两种工具的细节,请延伸阅读。Delegate 模式篇介绍了它与 Observer 的比较,以及 1:1 通信的标准做法。
Closure 完整整理篇则整理了捕获、weak self 和 escaping。

