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

问题的起源

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

觉得文章还不错的话可以关注一下我的博客

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容