Swift 与 Objective-C

Delegate(代理)vs Closure(闭包):选择回调的 3 个标准

Delegate 和 Closure 都能处理相同的回调需求,但特性不同。本文用同一个示例并列实现,揭示五项实际差异,并总结如何根据事件数量、关系生命周期以及是否需要返回值来选择。

6 分钟阅读
Delegate(代理)vs Closure(闭包):选择回调的 3 个标准 封面图

“这个回调应该提取成 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 协议也充当“如何与这个组件通信”的文档。在多个页面复用同一个组件时,契约明确是一项优势。

将 Delegate 和 Closure 比作会议室里的契约与服务台的便签的对比插图
Delegate 是会议室里的契约,Closure 是服务台的便签

选择标准 — 根据事件数量、生命周期和方向决定

了解差异后,把它们压缩成选择标准。大多数情况只需回答三个问题。

问题 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。

延伸阅读