iOS 16.1(20B5045d)導航欄崩潰問題解決實錄

語言: CN / TW / HK

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),只能看到是導航相關的兩個佈局物件因為約束層級不對導致的無法啟用特定的約束條件引起的異常。

Apple Forums也能看到相關反饋:

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),修復了此問題。