問題的起源
今天在 qq 上看到有人發(fā)了一段代碼,在 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 還沒初始化完成,指向了內存里任意一段數(shù)據(jù)。
找原因
初始化的順序?
首先我懷疑是初始化的順序出了問題,會不會因為在 iOS 8 里,編譯器自動生成的 init 方法內部實現(xiàn)有問題,類似于這樣:
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 ...
}
在任意版本的系統(tǒng)上,先打印出的是 "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 的具體實現(xiàn)
這里最奇怪的地方是 self 是一個 block,但根本沒有方法通過這個 block 去獲取初始化之后的對象。我想了好幾種可能性,后面甚至把 addTarget 的第一個參數(shù)換成了相同類型的空閉包,發(fā)現(xiàn)竟然還可以正常運行,接著又再試著傳入各種值,例如 Int,String,() -> 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,事件觸發(fā)時沿著 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)
}
}
在 button 和 ViewController 這條響應鏈中間再插入一個 responder 去攔截消息,只要有打印出 click 方法,就代表著確實是順著響應鏈尋找 responder。運行之后確實打印出了 click 方法,猜想正確。
之后我又給 addTarget 傳入了好幾種值,最后發(fā)現(xiàn)具體的實現(xiàn)應該是類似于這樣的:
// iOS 8
func addTarget(_ target: Any?, action: Selector, for event: UIControlEvent) {
if let objectCanRespond = target {
// 在 event 觸發(fā)之后,直接給 target 發(fā)送一個 action 消息
} else {
// 在 event 觸發(fā)之后,順著響應鏈尋找能夠響應 action 的對象
}
}
// iOS 9 以上
func addTarget(_ target: Any?, action: Selector, for event: UIControlEvent) {
if let objectCanRespond = target as? NSObject { ... }
else { ... }
}
書寫 addTarget 的正確姿勢
理清了這個問題之后,我開始覺得其實這種直接順著響應鏈尋找 responder 的做法也不錯,寫 Swift 經(jīng)常會遇到這種情況:
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 之類的架構可能會有局限。
覺得文章還不錯的話可以關注一下我的博客