Bug
出现机型:iPhone7Plus iOS15.1。
描述:在开发过程中发现弹出弹窗点击关闭之后界面就卡死了。多次尝试之后,最终发现只有在调试的时候出现,把线一拔不调试的时候一切又正常了。拿另一部iPhone6Plus iOS12.5.4的手机又都不会出现。顿时感觉遇到”薛定谔的bug”了。
分析
项目中对UIAlertController做了一层简单的封装,是在自己的window上present出来的,这样在使用的时候就不用操心谁来present了。
方法接口:
1 | + (void)showActionSheetWithTitle:(nullable NSString *)title message:(nullable NSString *)message cancelButtonTitle:(nullable NSString *)cancelButtonTitle destructiveButtonIndex:(NSInteger)destructiveButtonIndex buttonTitles:(nullable NSArray *)buttonTitles showedPatternHandler:(nullable NSDictionary *(^)(void))showedPatternHandler buttonClickedHandler:(nullable void(^)(NSUInteger buttonIndex))buttonClickedHandler; |
内部部分实现:
1 | UIAlertController *alertController = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:preferredStyle]; |
刚开始时,对象持有关系:
1 | alertController------>alertWindow------>rootViewController |
当present后
1 | [alertController.alertWindow.rootViewController presentViewController:alertController animated:YES completion:nil]; |
rootViewController的控制器栈会持有alertController,于是对象关系变为:
1 | alertController------>alertWindow------>rootViewController |
形成了一个循环,因此三者都不会被释放。
当点击alertController上的某个按钮时系统会自动调用dismiss于是alertController从控制器栈移除,于是rootViewController不再持有alertController,循环被打破,三者释放,window自然也被移除。为方便表述暂且将这种打破循环的方式称为自动打破。
1 | alertController------>alertWindow------>rootViewController |
由于循环会自动打破,因此内存能够正常回收。调用dismiss后,在iOS10.3.3、iOS12.5.4、iOS15.1上都能销毁,和分析的一致。
在看实现的时候,看到封装的人为了避免潜在的引用循环,在类别里重写了viewDidDisappear:
1 | - (void)viewDidDisappear:(BOOL)animated { |
这样当弹窗控制器dismiss时,viewDidDisappear会被调用,alertWindow会被置为nil,循环被打破。这里将这种循环打破方式称为主动打破。
比较一下这两种打破方式,
自动打破:
1 | alertController------>alertWindow------>rootViewController |
主动打破:
1 | alertController---X--->alertWindow------>rootViewController |
可以看到当dismiss时主动打破有两处打破的地方。
考虑如下场景,假如有其他地方强引用了alertController,对于自动打破关系图为:
1 | obj------>alertController------>alertWindow------>rootViewController |
当调用dismiss后,关系图变为:
1 | obj------>alertController------>alertWindow------>rootViewController |
可以看到window并不会被销毁,window不销毁会出现调用dismiss后弹窗虽然消失了,但由于没有将window隐藏,它其实还在视图层级中,只不过是透明的,这时用户是无法点击看到的控件的,整个APP就像卡死了一样,实际上是因为最上层有一层透明的window,事件都被这个透明的window吃了。
而主动打破关系图为:
1 | obj------>alertController---X--->alertWindow------>rootViewController |
可以确保window被销毁,window被销毁了自然不会出现在视图层级中,因此就算有外部强引用alertController也不会发生上面的可怕bug。然而实际情况却出现了上面的bug。WTF?
可能的原因
有没有一种可能,在iPhone7plus iOS15.1上,脱机时dismiss弹窗代码走的是类别的实现viewDidDisappear,但是联机时代码走的是系统的viewDidDisappear实现。而在iOS12.5.4上走的都是类别的实现?
但是这又跟我看的runtime底层实现类别的加载有冲突,按照类别的加载机制,类别会覆盖原类的实现,如果有多个类别则最后一个编译的类别有效果。所以肯定是不会走系统的实现啊!OMG!
先不管了,先搜一下跟UIAlertController相关的代码,结果发现项目中居然有三个UIAlertController的类别而且都实现了viewDidDisappear:
真是不搜不要紧,一搜吓一跳。
于是把这三个类别拖出来,做了一个小demo验证一下。
这是脱机打印日志:
符合编译顺序:
这是联机时的日志:
viewDidDisappear的日志没了???实际上代码走的是系统的实现,这一点可以打符号断点验证。
结果:
神奇不神奇?完全无视runtime底层实现类别的加载,走的竟然是系统的实现。
解决
至此,真相终于大白,和猜想一致,在iPhone7plus iOS15.1上,脱机时dismiss弹窗代码会走类别的实现viewDidDisappear,但是联机时代码走的是系统的viewDidDisappear实现。而在iOS12.5.4上走的都是类别的实现。
有两种解决办法:一种是在load里hook原类的viewDidDisappear,一种是子类化UIAlertController。考虑到load太多影响启动时间,还是子类化吧,这样viewDidDisappear和dealloc方法都可以重写。
从此以后我再也没有遇到这只薛定谔的猫了。不过我还是有疑惑联机调试的时候为啥就走到系统的实现?是系统出Bug了吗?不得而知,或许事情的真相只有系统他自己才知道了。