9.16号Apple向公众开放了iOS 16.1的第一个Beta版本,已经有不少用户更新到了此版本(截止9.18日京东APP 的单日用户已达4w+)。
新版本发布后,我们崩溃监控系统监控到了在16.1版本出现了大量导航栏相关的崩溃,崩溃量数已经排到第一。下面介绍下此崩溃问题的解决过程。Terminating app due to uncaught exception 'NSGenericException', reason: 'Unable to
activate constraint with anchors <NSLayoutDimension:0x283e9cc40 "_UINavigationBarTitleControl:0x115566ce0.height">
and <NSLayoutDimension:0x283eb3500 "UILayoutGuide:0x281205500'TitleViewGuide(0x115542e70)'.height"> because they have no common ancestor.
Does the constraint or its anchors reference items in different view hierarchies? That's illegal.'
-[XXViewController viewWillDisappear:] (in XXApp) (XXViewController.m:298)
调用堆栈为某个ViewController调用了viewWillDisappear:方法,在该方法中调用了self.navigationController.navigationBarHidden = NO;
-[_UINavigationBarTitleControl updateConstraints] (in UIKitCore) + 1368,
-[UIView(Hierarchy) layoutBelowIfNeeded] (in UIKitCore) + 292
-[UINavigationController _positionNavigationBarHidden:edge:] (in UIKitCore) + 268
......
-[UINavigationController setNavigationBarHidden:animated:] (in UIKitCore) + 96
-[XXViewController viewWillDisappear:] (in JD...) (XXViewController.m:298)
崩溃堆栈最后一个调用方法为系统导航栏更新约束布局方法。这里从堆栈暂时看不出任何有用信息(仅仅调用系统导航栏展示方法就触发了Crash),只能看到是导航相关的两个布局对象因为约束层级不对导致的无法激活特定的约束条件引起的异常。https://developer.apple.com/forums/thread/712166 目前来看崩溃都是因为页面切换时将系统导航栏隐藏状态变更导致的Crash,崩溃页面为首页或者RN页等页面层级比较靠前的页面。经过和同事测试发现,Xcode 14-beta版本编译运行iOS 16.1机型并不能复现Crash,但是使用Xcode 13+iOS 16.1模拟器经过不断切页面测试,复现了崩溃。复现路径为:启动App进入首页,首页进一个隐藏导航栏的页面,然后再进一个原生导航栏的页面,再返回到首页。
由于"self.navigationController.navigationBarHidden = NO"这个方法本身调用的就是系统方法,没有特殊性,报错的地方也是系统库,比较诡异。这里先通过Xcode布局看下这个_UINavigationBarTitleControl和UILayoutGuide有没有特殊的地方。调试截图如下:可以看到出问题的地方是标红的地方。通过查资料得知,“iOS 16系统针对导航栏titleView的内部逻辑发生了变化,新系统会将自定义视图包在一个新类_UINavagationBarTitleControl里,但其内部的视图关系在展示出来之前是不确定的,也就是自定义视图titleView.superView在完全展示前并不一定会存在。所以,自定义视图中不要随意重写 -updateConstraints, 并保证其autolayout条件正确,解决了我们问题”。我们查了下工程中并无 -updateConstraints方法的调用,所以这个解决方案不适用于我们。通过LLDB调试,我们发现UINavagationBarTitleControl和其有约束的UILayoutGuide对象这里并无明显的异常,而UINavagationBarTitleControl对象的superView 和 LayoutGuide虚拟布局对象的owingView也正常,都是NavigationBarContentView,并不会出现视图层级不一致的问题。由于业务需要,我们的很多页面都采用了自定义导航栏的方式去呈现页面,所以存在较多的场景是A页面使用自定义导航,B页面使用系统导航,那A页面会在ViewWillAppear:方法中将系统导航栏隐藏,等到viewWillDisAppear:生命周期方法中再将系统导航栏展示。这里可能是因为在再次展示的时候导航栏还没有被添加到当前ViewController的view中,_UINavagationBarTitleControl.superView.superView还没有被添加到视图中,就引发约束异常Crash。有了这个疑问,就去验证下。在基类导航中重写 setNavigationBarHidden:(BOOL )hiden animated: (BOOL )animated 方法:-(void)setNavigationBarHidden:(BOOL)hidden animated:(BOOL)animated{
if (!self.navigationBar.superview) return;
[super setNavigationBarHidden:hidden animated:animated];
}
经验证,App确实是不崩溃了,但是导航栏标题区域变空白了。这种方式虽然修复了崩溃,但是导航栏titleView布局也没有被更新到视图中,引出了新的问题。继续研究了下_UINavagationBarTitleControl这个类的相关布局,结合崩溃的布局对象<NSLayoutDimension:0x283e9cc40 "UINavigationBarTitleControl:0x115566ce0.height">
and <NSLayoutDimension:0x283eb3500 "UILayoutGuide:0x281205500'TitleViewGuide(0x115542e70)'.height">
我们发现这个约束在_UINavagationBarTitleControl->sosConstraint约束成员变量中。如图:那么我们能不能直接把约束给去掉,来达到防止崩溃的目的呢?通过特殊方式确实可以做到。利用runtime将sosConstraint成员给设置成空,也解决了问题。具体逻辑:判断UINavagationBarTitleControl->titleLayoutGuide不为空,且sosConstraint约束成员变量不为空,就通过Ivar指针将其设置空对象,代码如下图:
经过简单打包验证,确实可以解决问题,但是这个做法不妥:此方法破坏了原有的代码逻辑,风险极高。经过进行页面切换返回的一个多次验证,发现这个路径下App不会Crash:首页(自定义导航栏)--》页面2(自定义导航栏)--》页面3(系统导航栏+使用系统titleView)--》页面4(系统导航栏+使用自定义titleView),依次返回页面,不会触发Crash。如果到页面3就依次返回首页,就会Crash;如果进入了页面4再返回,就Crash。通过LLDB发现页面4导航栏没有UINavagationBarTitleControl对象生成,也就是说页面4因为使用自定义titleView,用不到UINavagationBarTitleControl,也不会崩溃,错误的布局被更新掉了,自然也不会崩溃了。那会不会是因为系统导航栏的_UINavagationBarTitleControl更新不及时,譬如说有个延时更新的机制,导致上次的约束相关的对象已经被提前释放了,导致下次更新的时候找不到对象,就引发了Crash?验证:复现场景发现,异常情况下,_sosConstraint对象确实已经被标记为 _unsafe_unretained Class,如图:看来这个猜想有一定可靠度,那么如何去修复呢?能不能可以在每次设置导航栏隐藏/显示状态前提前更新上次未完成的布局呢?系统确实提供给了我们更新导航栏布局的方法,可以通过触发[navigationbar layoutSubViews]方法来做到:-(void)setNavigationBarHidden:(BOOL)hidden animated:(BOOL)animated{
if (@available(iOS 16.1, *)) {
[self.navigationBar setNeedsLayout];
[self.navigationBar layoutIfNeeded];
}
[super setNavigationBarHidden:hidden animated:animated];
}
经过验证,上述代码逻辑确实可以修复问题。在Apple 开发论坛上苹果的工程师也明确了当前测试版(16.1 -20B5050f)系统有这个bug存在,后续版本会予以修复。具体情况,可以参考以下链接:https://developer.apple.com/forums/thread/714679通过调试发现对于iOS 16.1(20B5050f)系统,针对导航栏在默认系统titleView的场景,新增的TitleControl不会及时去更新布局约束,导致其layout约束成员变量释放后才更新布局,等下次更新这个约束对象成了unsafe_unretained 对象,造成了Crash。对应的解决方案为:在每次更新导航栏状态前,先主动调用一下更新布局方法,防止更新不及时触发系统Crash。经验证,Apple刚刚发布的iOS 16.1第二个版本(20B5050f),修复了此问题。