Swift 里正確地 addTarget(_:action:for:)

weixin_34041003發表於2017-08-23

問題的起源

今天在 qq 上看到有人發了一段程式碼,在 iOS 8 裡按 button 會閃退,在 iOS 9 以上的版本就可以正常執行。

class ViewController: UIViewController {

    dynamic func click() { ... }
    
    let button: UIButton = {
        let button = UIButton()
        
        button.addTarget(self,
            action: #selector(click),
            for: .touchUpInside)
        
        return button
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(button)
    }
    
    ... other code ...
}

第一眼的感覺是這段程式碼寫得很有問題,不應該在 button 初始化的時候 addTarget,因為這個時候 self 還沒有初始化完成,或者應該使用 lazy var,但還是不理解為什麼 iOS 9 以上的版本就不會,報錯資訊是這樣子的:

-[__NSCFString tap]: unrecognized selector sent to instance 0x7fac00d0bf40

一看就感覺是 addTarget 呼叫的時候 self 還沒初始化完成,指向了記憶體裡任意一段資料。

找原因

初始化的順序?

首先我懷疑是初始化的順序出了問題,會不會因為在 iOS 8 裡,編譯器自動生成的 init 方法內部實現有問題,類似於這樣:

init(coder aDecoder: NSCoder) {
    button = { ... }()
    
    super.init(coder: aDecoder)
}

self 初始化之前,button 就提前訪問了 self,然後在 iOS 9 之後是為了這方面相容性的考慮,在自動生成的 init 方法裡,先呼叫 super.init,再初始化屬性。

一開始覺得可能大概就是這樣,後面越想越不對,寫了段程式碼去驗證自己的想法:

class FatherVC: UIViewController {
    init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        
        print("FatherVC")
    }
}

class ChildVC: FatherVC {
    
    var button: UIButton = {
        var button = UIButton
        
        ... set up ...
        
        print("button initialized")
        
        return button
    }()
    
    ... other code ...
}

在任意版本的系統上,先列印出的是 "button initialized",super.init 最後才呼叫的,初始化的順序的猜想是錯誤的。

問題在於 addTarget 方法

想了很久都沒有思路,就試著在 iOS 8,9,10 裡把這幾個相關的屬性列印了出來,都是一模一樣的結果:

button.target(forAction: #selector(click), withSender: nil)
// ViewController

button.allTargets
// null

self
// (ViewController) -> () -> Viewcontroller
// 在 button 初始化的 block 裡

可以肯定貓膩就在 addTarget 方法裡,因為 input 都是一樣的。

addTarget 的具體實現

這裡最奇怪的地方是 self 是一個 block,但根本沒有方法通過這個 block 去獲取初始化之後的物件。我想了好幾種可能性,後面甚至把 addTarget 的第一個引數換成了相同型別的空閉包,發現竟然還可以正常執行,接著又再試著傳入各種值,例如 IntString() -> Int,都可以正常執行(iOS 9)。

這個時候就又卡住了,只好去翻文件看看有沒有什麼線索,看到這麼一段話:

The target object—that is, the object whose action method is called. If you specify nil, UIKit searches the responder chain for an object that responds to the specified action message and delivers the message to that object.

突然在想,會不會是 addTarget 方法會先判斷一下 target 是否為 block?如果是 block 的話,就當做是 nil,事件觸發時沿著 responder chain 去找,如果能夠響應 click 的話,就呼叫,這樣的話 button.allTargets 為 null 也就說得通了。寫程式碼測試:

class CustomView: UIView {
    func responds(to aSelector: Selector!) -> Bool {
        print(aSelector)
        
        return super.responds(to: aSelector)
    }
}

class ViewController: UIViewController {
    ... other code ...
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        customView.addSubview(button)
        view.addSubview(customView)
    }
}

buttonViewController 這條響應鏈中間再插入一個 responder 去攔截訊息,只要有列印出 click 方法,就代表著確實是順著響應鏈尋找 responder。執行之後確實列印出了 click 方法,猜想正確。

之後我又給 addTarget 傳入了好幾種值,最後發現具體的實現應該是類似於這樣的:

// iOS 8 
func addTarget(_ target: Any?, action: Selector, for event: UIControlEvent) {
    if let objectCanRespond = target {
        // 在 event 觸發之後,直接給 target 傳送一個 action 訊息
    } else {
        // 在 event 觸發之後,順著響應鏈尋找能夠響應 action 的物件
    }
}

// iOS 9 以上
func addTarget(_ target: Any?, action: Selector, for event: UIControlEvent) {
    if let objectCanRespond = target as? NSObject { ... }
    else { ... }
}

書寫 addTarget 的正確姿勢

理清了這個問題之後,我開始覺得其實這種直接順著響應鏈尋找 responder 的做法也不錯,寫 Swift 經常會遇到這種情況:

class ViewController: UIViewController {
    
    // 1.
    let button: UIButton = ...
    
    override func viewDidLoad() {
        ...
        button.addTarget(self,
            action: #selector(click),
            for: .touchUpInside)
    }
    
    // 2.
    let button: UIButton
    
    override init() {
        button = ...
        
        super.init()
        
        button.addTarget(self,
            action: #selector(click),
            for: .touchUpInside)
    }
    
    // 3.
    lazy var button: UIButton = {
        ...
        button.addTarget(nil,
            action: #selector(click), 
            for: .touchUpInside)
        return button
    }()
}

第一和第二種寫法會讓 button 的配置程式碼變得分散,在初始化的時候配置樣式,之後再 addTarget;而第三種寫法則會必須使用 var 去宣告 button,但我們根本不希望 button 是 mutable 的。

而直接給 addTarget 傳入 nil 的話,讓 action 順著響應鏈去尋找 responder 的話,就沒有必要在 button 初始化時明確 responder,有一篇文章專門寫如何通過響應鏈機制進行解耦,推薦大家可以看。

這樣程式碼可以組織得更好,而且也是一種合理的抽象。唯一的缺點就是 target 必須處於響應鏈上,使用 MVVM 之類的架構可能會有侷限。

覺得文章還不錯的話可以關注一下我的部落格

相關文章