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

问题的起源

今天在 qq 上看到有人发了一段代码,在 iOS 8 里按 button 会闪退,在 iOS 9 以上的版本就能够正常运行。html

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 以上的版本就不会,报错信息是这样子的:git

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

一看就感受是 addTarget 调用的时候 self 还没初始化完成,指向了内存里任意一段数据。swift

找缘由

初始化的顺序?

首先我怀疑是初始化的顺序出了问题,会不会由于在 iOS 8 里,编译器自动生成的 init 方法内部实现有问题,相似于这样:闭包

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

    super.init(coder: aDecoder)
}复制代码

self 初始化以前,button 就提早访问了 self,而后在 iOS 9 以后是为了这方面兼容性的考虑,在自动生成的 init 方法里,先调用 super.init,再初始化属性。架构

一开始以为可能大概就是这样,后面越想越不对,写了段代码去验证本身的想法:ide

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 里把这几个相关的属性打印了出来,都是如出一辙的结果:ui

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

button.allTargets
// null

self
// (ViewController) -> () -> Viewcontroller
// 在 button 初始化的 block 里复制代码

能够确定猫腻就在 addTarget 方法里,由于 input 都是同样的。spa

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 之类的架构可能会有局限。

以为文章还不错的话能够关注一下个人博客

相关文章
相关标签/搜索