问题背景
最近新版本发布后,出现了一个偶现的crash并且迅速增加为Top1,这里对该问题做一个分析。
报错内容如下:
NSException -[UITabBarController setSelectedViewController:] only a view controller in the tab bar controller's list of view controllers can be selected.
crash堆栈如下:
问题分析
APM分析
首先是查看APM提供的信息,没有系统聚集、没有机型聚集,是版本新增问题都是隐私界面(也就是新用户冷启场景)。
该问题在灰度有出现过,一位同事在排查过程中,发现另外一个类似问题是在UITabBarController的 _viewControllerForTabBarItem:方法出现异常,这个问题量级并不大,场景类似但是没有特别信息帮助定位。
多维分析
由于crash出现在系统的UITabBarController类,无法调试获取更多信息,逆向排查周期太长。这里可以通过Slardar的信息,结合日志和业务场景逐步缩小排查范围。
首先通过crash场景,我们猜测是在用户新用户冷启才会遇到,这里通过回捞日志和crash的pv/uv相比可以确定;
其次通过排查新用户冷启场景的特有逻辑,关注点放在新版本相关的代码和实验改动,发现在底tab在新用户冷启场景的底tab刷新逻辑有较大可疑。
结合crash信息only a view controller in the tab bar controller's list of view controllers can be selected
以及crash堆栈里有viewWillAppear时机,合理猜测一个场景:是否tab切换时,导致某个vc不在tabbar的子vc里面。比如说,没有某个tab但是又指定跳到该vc,类似self.tabbarVC setSelectedViewController:self.xxxVC
;又或者,某个子vc不在self.viewConrollers里面,但是又要跳转到该vc。
通过业务代码排查,业务并无直接设置setSelectedViewController
的操作;在排查过程中发现只有setSelectedIndex
的操作,从堆栈上来看,如果是setSelectedIndex
触发crash,堆栈上应该会有这个方法。
于是重点排查子vc不存在的情况,在查看新用户切换tab的逻辑时,发现了有一个vc复用的逻辑,旧tabbarVC的vc会被复用到新的tabbarVC,结合ViewController只能有一个parentVC的限制,从逻辑上分析是有可能出现堆栈所描述的场景。
结合这个猜测,当vc被复用到新的tabbarVC时,加了一段代码让新的tabbarVC不添加到window,从而旧的tabbar继续触发viewWillAppear,问题可以复现。
反向分析
当问题可以稳定复现后,就可以进一步分析逻辑上的缺陷。
首先是vc的复用逻辑分析:
App在启动时就要初始化tabbarVC,并且在后续会刷新底tab的数量。由于我们使用了某个tabbarVC的组件,组件并不支持动态新增底tab,这里采用的是重新创建tabbarVC的方式。
而我们的vc复用逻辑就是将vc从旧的tabbarVC移到新的tabbarVC。
这里写了一个复用的模拟代码:
- (void)testAnotherTabbarVC {
UITabBarController *anotherTabbarVC = [UITabBarController new];
[anotherTabbarVC addChildViewController:self.tabVC.viewControllers.firstObject];
}
复用逻辑比较简单清晰,但是UIKit有一个限制:每个vc只能有一个parentVC。当我们给新tabbarVC设置子vc,其中复用vc已经有parentVC,此时因为复用到新的tabbarVC,parentVC也会从旧的tabbarVC变成新的tabbarVC。
当旧的tabbarVC触发viewWillAppear的时候,复用vc的parentVC已经变成新的tabbarVC(截图为nil是因为新的tabbarVC被释放了),但是没被复用的另外一个vc的parentVC仍然是旧tabbarVC。
此时出现了错误:
only a view controller in the tab bar controller's list of view controllers can be selected
问题解决
方案1:在viewWillAppear之前,不触发reloadTab,也就是等待展示之后再把旧的tabbarVC替换为新的tabbarVC;(这也是之前采用的方案)
方案2:在设置新的tabbarVC的viewController属性时,将复用vc从旧的tabbarVC的viewController移除;(这是UIKit的默认做法,但是需要修改tabbarVC的组件)
方案3:不复用vc,只复用数据源;(需要修改复用方案)
代码地址
为了验证分析没有出错,特意写了demo,问题可以复现,github地址。