0%

类别覆盖原始实现引发的薛定谔的bug

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
2
3
4
5
6
7
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:preferredStyle];
alertController.alertWindow = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
alertController.alertWindow.backgroundColor = [UIColor clearColor];
alertController.alertWindow.hidden = NO;
...
alertController.alertWindow.rootViewController = [UIViewController new];
[alertController.alertWindow.rootViewController presentViewController:alertController animated:YES completion:nil];

刚开始时,对象持有关系:

1
alertController------>alertWindow------>rootViewController

当present后

1
[alertController.alertWindow.rootViewController presentViewController:alertController animated:YES completion:nil];

rootViewController的控制器栈会持有alertController,于是对象关系变为:

1
2
alertController------>alertWindow------>rootViewController
↑----------------------------------------|

形成了一个循环,因此三者都不会被释放。

当点击alertController上的某个按钮时系统会自动调用dismiss于是alertController从控制器栈移除,于是rootViewController不再持有alertController,循环被打破,三者释放,window自然也被移除。为方便表述暂且将这种打破循环的方式称为自动打破。

1
2
alertController------>alertWindow------>rootViewController
------------------ X ------------------- |

由于循环会自动打破,因此内存能够正常回收。调用dismiss后,在iOS10.3.3、iOS12.5.4、iOS15.1上都能销毁,和分析的一致。

在看实现的时候,看到封装的人为了避免潜在的引用循环,在类别里重写了viewDidDisappear:

1
2
3
4
- (void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
self.alertWindow = nil;
}

这样当弹窗控制器dismiss时,viewDidDisappear会被调用,alertWindow会被置为nil,循环被打破。这里将这种循环打破方式称为主动打破。

比较一下这两种打破方式,

自动打破:

1
2
alertController------>alertWindow------>rootViewController
------------------ X ------------------- |

主动打破:

1
2
alertController---X--->alertWindow------>rootViewController
------------------ X ------------------- |

可以看到当dismiss时主动打破有两处打破的地方。

考虑如下场景,假如有其他地方强引用了alertController,对于自动打破关系图为:

1
2
obj------>alertController------>alertWindow------>rootViewController
↑----------------------------------------|

当调用dismiss后,关系图变为:

1
2
obj------>alertController------>alertWindow------>rootViewController
↑--------------------X--------------------|

可以看到window并不会被销毁,window不销毁会出现调用dismiss后弹窗虽然消失了,但由于没有将window隐藏,它其实还在视图层级中,只不过是透明的,这时用户是无法点击看到的控件的,整个APP就像卡死了一样,实际上是因为最上层有一层透明的window,事件都被这个透明的window吃了。

而主动打破关系图为:

1
2
obj------>alertController---X--->alertWindow------>rootViewController
--------------------X--------------------|

可以确保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了吗?不得而知,或许事情的真相只有系统他自己才知道了。

觉得文章有帮助可以打赏一下哦!