Swift 程式語言

淺談回應鏈 (Responder Chain) 讓你認識這個靈活又實用的設計模式!

淺談回應鏈 (Responder Chain) 讓你認識這個靈活又實用的設計模式!
淺談回應鏈 (Responder Chain) 讓你認識這個靈活又實用的設計模式!
In: Swift 程式語言, UIKit

在 UIKit 當中負責處理使用者動作的東西,叫做回應鏈 (Responder Chain)。回應鏈是由許多部件一起組成的一個複合元件,包括 view、view controller、window、application 等等。這些元件經由單向鏈結串列 (singly linked list) 的架構連接在一起,使得接收動作與處理動作的物件可以不用是同一個。下層的元件接收到使用者動作之後,可以選擇把動作往上層傳,讓上層的物件去攔截動作並處理(「回應」)。這樣的架構使回應鏈的變化彈性極大,隨時可以插入或移除 view 或 view controller 等的節點,卻還是保有它的整體性。

responder-chain-1

回應鏈是一個單向鏈結串列,用來處理使用者動作。

我們可以用類型繼承來類比回應鏈的設計。類型繼承也是一種單向的鏈結串列,由子類型單方向地指向父類型。子類型可以透過父類型所定義的介面接收訊息,並且可以決定要攔截訊息,或者是把訊息轉傳給父類型處理。而回應鏈則是透過 UIResponder 的介面來接收訊息,並由每個物件決定要攔截訊息,還是要轉傳給上一層處理。

UIKit 的回應鏈架構

responder-chain-2

Smalltalk MVC 當中,Controller 就是負責處理使用者輸入的元件,而 UIKit 當中的 view controller 也同樣適合擔任這個工作。舉凡鍵盤輸入搖動偵測等動作,都可以直接讓 view controller 去接收和處理,只有觸控輸入是必定由 view 接收(window 會執行 hit test 去找到對應的 view 來發送事件)。所以,view 與 view controller 都是回應鏈的一環。

View 上層 (next) 是它的 view controller,而 view controller 上層是它的 viewsuperview。如果 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 就會使用 UIResponderbuildMenu(for:from:) 方法來建構 URL 專用的選單,再把它加到快捷選單裡去。因此,我們就可以在回應鏈的其它元件裡取得相關的內容 (URL),以提供對應的動作(打開 URL、分享 URL 等等)。

還有一個跟回應鏈有關的功能:鍵盤輸入。當第一回應者接收到某個鍵組的輸入時,會去遍歷回應鏈,找到第一個能回應該鍵組的物件來處理它。這大概是最依賴回應鏈的功能了,因為相比起快捷選單,快捷鍵通常能提供更全面的控制,像是關閉文件等等。至於快捷鍵的實作方式,就留給讀者參考官方文件自己去試試看了。

keyboard-shortcuts

與 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)開發都是好處多多!

作者
Hsu Li-Heng
iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。