「這個回呼要抽成 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 會先將通訊契約宣告為協定這個型別,再由接收端採用整份契約。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。

