在 UIKit 當中負責處理使用者動作的東西,叫做回應鏈 (Responder Chain)。回應鏈是由許多部件一起組成的一個複合元件,包括 view、view controller、window、application 等等。這些元件經由單向鏈結串列 (singly linked list) 的架構連接在一起,使得接收動作與處理動作的物件可以不用是同一個。下層的元件接收到使用者動作之後,可以選擇把動作往上層傳,讓上層的物件去攔截動作並處理(「回應」)。這樣的架構使回應鏈的變化彈性極大,隨時可以插入或移除 view 或 view controller 等的節點,卻還是保有它的整體性。
回應鏈是一個單向鏈結串列,用來處理使用者動作。
我們可以用類型繼承來類比回應鏈的設計。類型繼承也是一種單向的鏈結串列,由子類型單方向地指向父類型。子類型可以透過父類型所定義的介面接收訊息,並且可以決定要攔截訊息,或者是把訊息轉傳給父類型處理。而回應鏈則是透過 UIResponder
的介面來接收訊息,並由每個物件決定要攔截訊息,還是要轉傳給上一層處理。
UIKit 的回應鏈架構
在 Smalltalk MVC 當中,Controller 就是負責處理使用者輸入的元件,而 UIKit 當中的 view controller 也同樣適合擔任這個工作。舉凡鍵盤輸入、搖動偵測等動作,都可以直接讓 view controller 去接收和處理,只有觸控輸入是必定由 view 接收(window 會執行 hit test 去找到對應的 view 來發送事件)。所以,view 與 view controller 都是回應鏈的一環。
View 上層 (next
) 是它的 view controller,而 view controller 上層是它的 view
的 superview
。如果 view controller 是被 present 出來的,那它的上層也可能是它的 presentingViewController
。在所有 view 與 view controller 之上的是 window,而 window 之上則是 scene 跟它的 delegate。
看起來很複雜嗎?其實只要把 view 階層當作回應鏈的中流砥柱,且所有 controller 跟 delegate 都是半途插入的元件,就比較好懂了。
回應鏈的頂端是 application 以及它的 delegate。如果動作訊息沒有被攔截的話,這兩個元件就會是訊息的終點。接續剛剛的比喻,它們就相當於 UIResponder
這個抽象類型的實作,而它們下層的物件則相當於它們的子類型,可以隨時「複寫」掉它們的實作。
UIKit 提供了許多回應鏈以外的模式來處理使用者動作,包括 Target-Action、Delegate、Observer、Closure 等等。它們大多都採取了有別於回應鏈的路徑來傳遞動作訊息,但是回應鏈仍然是 UIKit 當中最基礎的一環,負責最主要的使用者動作 —— 觸控、鍵盤輸入、搖動偵測等等 —— 的低階處理。其它的模式許多都是把回應鏈的訊息攔截起來,抽象化之後再處理而已。
增加回應鏈的功能
回應鏈雖然基礎,但這不代表它就只能處理低階的訊息而已。我們可以透過 extension 去擴展 UIResponder
的介面,增加回應鏈的處理能力。比如說,開啟鏈結的能力:
import UIKit extension UIResponder { // 必須定義成 @objc 才可以被覆寫。 @objc func openLink(_ url: URL) { // 預設將呼叫轉給上層(next)處理。 next?.openLink(url) } }
透過這一個介面,我們就可以在回應鏈裡的任何一個地方,將開啟 URL 的動作傳遞出去,並由上層的其它物件去攔截並反應這個訊息:
import UIKit class LinkCell: UITableViewCell { var url: URL? override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) if let url = url { // 呼叫我們在 UIResponder extension 裡定義的方法,將動作傳給回應鏈去處理。 openLink(url) } } } extension UIApplication { // 接收從下層傳來的動作。 override func openLink(_ url: URL) { // 自己處理而不轉給上層。 if canOpenURL(url) { open(url) } } }
這樣做的好處,是不需要去主動建立連結,比如說指派 delegate 或註冊為 observer 等等。畢竟回應鏈的連結已經建立好了,只等我們去使用而已。
回應鏈的訊息傳遞方式也是獨一無二的。作為一個單向鏈結串列,它既不像是 Delegate 或 Closure 一樣的一對一,也不像是 Observer 或 Target-Action 一樣的輻射狀一對多,而是一個線性的一對多路徑。這使它在保有一對多的彈性的同時,也可以限制動作訊息處理者的數量,避免兩個以上的物件同時回應同一個使用者動作。
比如說,在 application 之外,我也想讓 view controller 可以開啟 URL:
import UIKit
import SafariServices
class ViewController: UIViewController {
override func openLink(_ url: URL) {
let safariVC = SFSafariViewController(url: url)
present(safariVC, animated: true)
}
}
在這個實作中,我們沒有將訊息傳下去,所以 application 就不會也去開啟 URL,造成重複開啟了。
用回應鏈來建構動作選單
另一種非常適合用回應鏈來實作的功能,叫做選單。選單這個東西,是 Controller 的一環,將許多可用的動作放在一起讓使用者去選擇。而選單裡面的動作,則是由開啟選單時的脈絡來定義,比如說快捷選單 (context menu) 跟開啟它的位置有關,而編輯選單則可能跟第一回應者 (first responder) 有關等等。但這些動作不一定只跟單一物件有關,也可能牽涉到更高層級的物件。舉例來說,當使用者針對一個 view 裡的 URL 開啟快捷選單的時候,除了 view 自己可以提供的拷貝到剪貼簿的動作之外,也可能會需要上層的 view controller 或者 application 物件來提供開啟與分享的動作。我們當然可以用 delegate 等其它模式來提供動作,但這樣的話,能提供動作的物件就很有限。如果我們用回應鏈機制來提供動作的話,就可以遍歷整個回應鏈,給每個鏈結上的物件一個增加動作的機會。
事實上,UIKit 本身在 iOS 13 開始,已經用回應鏈來建構選單了。在 UIResponder
裡面有一個新的方法叫做 buildMenu(with:)
,就是當系統要顯示各種選單前,給回應鏈一個修改選單的機會。不過,我們也可以針對某個內容,去定義另外的方法來建構選單,比如說針對 URL 的選單:
import UIKit
extension UIResponder {
// 建構專門給 URL 內容用的選單。
@objc func buildMenu(for url: URL, from menu: UIMenu) -> UIMenu {
// 如果有上一層的話就轉給上一層,否則就原樣回傳 menu。
return next?.buildMenu(for: url, from: menu) ?? menu
}
}
class LinkView: UIView, UIContextMenuInteractionDelegate {
var url: URL!
override func buildMenu(with builder: UIMenuBuilder) {
// 建構一個空選單當基礎。
let menu = UIMenu(title: "URL", options: .displayInline, children: [])
// 交給回應鏈去建構選單。
let builtMenu = buildMenu(for: url, from: menu)
// 將 URL 選單插入根選單。
builder.insertChild(builtMenu, atEndOfMenu: .root)
// 再把根選單 builder 丟給回應鏈去修改。
super.buildMenu(with: builder)
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
// 給 self 顯示快捷選單的能力。
addInteraction(UIContextMenuInteraction(delegate: self))
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { suggestedActions in
// 不在這裡建立動作,而是直接使用回應鏈所提供的動作。
return UIMenu(title: "URL", children: suggestedActions)
})
}
}
class ViewController: UIViewController {
override func buildMenu(for url: URL, from menu: UIMenu) -> UIMenu {
// 加入分享 URL 動作。
let shareAction = UIAction(title: "分享 URL") { action in
// 顯示分享選單...
}
let newMenu = menu.replacingChildren(menu.children + [shareAction])
return super.buildMenu(for: url, from: newMenu)
}
}
extension UIApplication {
override func buildMenu(for url: URL, from menu: UIMenu) -> UIMenu {
// 加入打開 URL 動作。
let openAction = UIAction(title: "打開 URL") { action in
// 在外部打開 URL...
}
let newMenu = menu.replacingChildren(menu.children + [openAction])
return super.buildMenu(for: url, from: newMenu)
}
}
如此一來,當我們針對 LinkView
或者 LinkView
的某個 subview 開啟快捷選單時,LinkView
就會使用 UIResponder
的 buildMenu(for:from:)
方法來建構 URL 專用的選單,再把它加到快捷選單裡去。因此,我們就可以在回應鏈的其它元件裡取得相關的內容 (URL),以提供對應的動作(打開 URL、分享 URL 等等)。
還有一個跟回應鏈有關的功能:鍵盤輸入。當第一回應者接收到某個鍵組的輸入時,會去遍歷回應鏈,找到第一個能回應該鍵組的物件來處理它。這大概是最依賴回應鏈的功能了,因為相比起快捷選單,快捷鍵通常能提供更全面的控制,像是關閉文件等等。至於快捷鍵的實作方式,就留給讀者參考官方文件自己去試試看了。
與 Swift 的相容性問題
回應鏈有一個小問題:因為原生 Swift 目前並不支援去複寫在 extension 裡面定義的方法,所以我們必須將方法標記為 @objc
。這使得我們沒辦法在方法的介面裡,使用非 Objective-C 的型別:
// Person 是一個純 Swift 的型別。
struct SomeAction {
// 各種屬性...
}
// 如果我們在 @objc func 的介面中使用 Person 的話...
extension UIResponder {
@objc func handleAction(_ action: SomeAction) { // 會產生錯誤!
next?.handleAction(action)
}
}
解決方法很簡單,把純 Swift 的型別打包起來:
// 所有 NSObject 的子類型都相容於 Objective-C。
class SomeActionWrapper: NSObject {
var value: SomeAction
init(value: SomeAction) {
self.value = value
}
}
extension UIResponder {
// SomeActionWrapper 相容於 Objective-C,所以這裡不會出現錯誤。
@objc func handleAction(_ action: SomeActionWrapper) {
next?.handleAction(action)
}
}
extension ViewController {
override func handleAction(_ action: SomeActionWrapper) {
// 取出 SomeAction 的值。
let action = action.value
// 處理 action...
}
}
結論
回應鏈 (Responder Chain) 是一個很不一樣的設計模式。當其它設計模式需要手動指派/註冊/訂閱時,回應鏈不用,但是需要熟悉回應鏈的路徑。它也非常依賴方法的複寫,因為它的介面是由抽象類型 (UIResponder
) 來定義。它必須用 extension
來增加自訂功能的介面,然後再在 view 或 view controller 等 UIResponder
子類型物件裡,透過複寫來實作自訂功能。
就算不想透過回應鏈來處理自訂的功能,UIKit 還是有許多內建功能會需要用到它,像是 undo manager、快捷鍵,以及各種觸控、搖動的事件傳遞等等,甚至 Target-Action 模式也可以透過回應鏈來發送訊息。所以,熟悉回應鏈,對於 iOS(以及 AppKit)開發都是好處多多!