功能:
- 在非調(diào)試模式下,獲取出錯的具體約束。
- 監(jiān)測約束沖突,并獲取出錯的view和viewController。
- 監(jiān)測iOS7上layoutSubViews導(dǎo)致的crash問題。
現(xiàn)狀
iOS7對Auto Layout的支持問題
- iOS7的約束有一些奇怪的bug,對Auto Layout支持并不完美。
- 在出現(xiàn)約束沖突時,系統(tǒng)會嘗試修復(fù)約束,此時在iOS7上有可能crash,iOS8則只是在控制臺輸出警告,并不會crash。
iOS7的調(diào)試問題
- Xcode7雖然不能使用iOS7模擬器調(diào)試,但是還能使用iOS7真機調(diào)試。而Xcode8已經(jīng)連iOS7的真機調(diào)試都不支持了。(update: Xcode8可以真機調(diào)試iOS7,但是需要從Xcode7上拷貝一點東西,參考這里)
- Xcode8中編輯過的xib文件在Xcode7上會有兼容性問題,需要手動刪除xib中的
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>這一行才能在Xcode7上編譯。如果要繼續(xù)使用Xcode7調(diào)試,就需要修改這些xib,十分麻煩。 - 內(nèi)網(wǎng)開發(fā)時,無法進(jìn)行真機調(diào)試,如果要用模擬器調(diào)試,需要另一臺低版本的Mac OSX系統(tǒng)的機子以安裝Xcode6,同時也會遇到Xcode的兼容性問題,因此遇到iOS7的約束問題十分麻煩,如果沒有環(huán)境的話只能靠猜。
- 約束沖突導(dǎo)致的crash往往在堆棧上無法得到有用的信息,因為是在系統(tǒng)庫里crash,無法直接看出是哪個界面的約束出錯。如果是在Xcode里調(diào)試,還能使用lldb的內(nèi)存命令進(jìn)行調(diào)試,但是在真機上就沒辦法了。
解決思路
如果app能用代碼監(jiān)測到約束沖突,就可以在非調(diào)試模式下捕獲到有用的信息,幫助快速定位問題。
當(dāng)發(fā)生約束沖突時,控制臺會輸出這樣的提示:
**Unable to simultaneously satisfy constraints.**
Probably at least one of the constraints in the following list is one you don't want. Try this: (1) look at each constraint and try to figure out which you don't expect; (2) find the code that added the unwanted constraint or constraints and fix it. (Note: If you're seeing NSAutoresizingMaskLayoutConstraints that you don't understand, refer to the documentation for the UIView property translatesAutoresizingMaskIntoConstraints)
(
"<NSLayoutConstraint:0x7fc82d3e18a0 H:[UIView:0x7fc82aba1210(768)]>",
"<NSLayoutConstraint:0x7fc82d6369e0 H:[UIView:0x7fc82aba1210]-(0)-| (Names: '|':UIView:0x7fc82d6b9f80 )>",
"<NSLayoutConstraint:0x7fc82d636a30 H:|-(0)-[UIView:0x7fc82aba1210] (Names: '|':UIView:0x7fc82d6b9f80 )>",
"<NSLayoutConstraint:0x7fc82d3e7fd0 'UIView-Encapsulated-Layout-Width' H:[UIView:0x7fc82d6b9f80(50)]>"
)
Will attempt to recover by breaking constraint
<NSLayoutConstraint:0x7fc82d3e18a0 H:[UIView:0x7fc82aba1210(768)]>
Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKit/UIView.h> may also be helpful.
提示我們在UIViewAlertForUnsatisfiableConstraints上打斷點調(diào)試。
這是一個檢測到出錯約束時,進(jìn)行處理的C函數(shù)。上面那串控制臺的log就是在這個函數(shù)里輸出的。
于是可以嘗試用method swizzling替換系統(tǒng)庫的方法,記錄出現(xiàn)沖突時的信息。
實現(xiàn)方法
獲取UIView
runtime無法替換C函數(shù),而調(diào)用棧里NSISEngine的那幾個方法都沒附帶什么有用的信息,于是用hopper反編譯UIKit.framework,找到使用UIViewAlertForUnsatisfiableConstraints的地方,是-[UIView engine:willBreakConstraint:dueToMutuallyExclusiveConstraints:]。
這個方法附帶了出錯約束的信息,也可以獲取到?jīng)_突所在的UIView,于是也能通過UIView獲取對應(yīng)的viewController。接下來只要hook這個方法就可以了。
獲取view controller
獲取view對應(yīng)的view controller的方法有兩種。
- 使用
UIView的私有API:_viewDelegate。 - 使用
UIResponder的nextResponder:
The UIResponder class does not store or set the next responder automatically, instead returning nil by default. Subclasses must override this method to set the next responder. UIView implements this method by returning the UIViewController object that manages it (if it has one) or its superview (if it doesn’t); UIViewController implements the method by returning its view’s superview; UIWindow returns the application object, and UIApplication returns nil.
參考:Given a view, how do I get its viewController?
我選擇了第二種方式。
監(jiān)測iOS7約束導(dǎo)致的crash
當(dāng)你在實現(xiàn)自定義view的layoutSubviews方法時,記?。?/p>
- 調(diào)用
[super layoutSubviews] - 不要在
layoutSubviews里增加約束
如果不遵守第一條,當(dāng)你向這個view上增加子view時,在iOS6和iOS7上會crash,控制臺會輸出提示:'Auto Layout still required after executing - layoutSubviews..' 。iOS8開始則不會crash。如果不遵守第二條,iOS7以下會發(fā)生死循環(huán)。
某些系統(tǒng)控件,例如UITableView,UITableViewCell沒有調(diào)用[super layoutSubviews],所以在iOS6和iOS7上不能在它們上面增加子view,除非你用method swizlling修復(fù)它們的layoutSubviews方法。
經(jīng)過反編譯分析,'Auto Layout still required after executing - layoutSubviews..'發(fā)生在UIView的layoutSublayersOfLayer:里,發(fā)生錯誤之前會用-[UIView _wantsWarningForMissingSuperLayoutSubviews]來監(jiān)測是否調(diào)用了[super layoutSubviews],如果沒有則拋出異常。
因此只需要hook_wantsWarningForMissingSuperLayoutSubviews就可以了。
最終效果
設(shè)置監(jiān)聽方式如下,返回約束沖突所在的view,viewController,系統(tǒng)嘗試打破的約束,目前所有的約束。
[ZIKConstraintsGuard monitorUnsatisfiableConstraintWithHandler:^(UIView *view, UIViewController *viewController, NSLayoutConstraint *constraintToBreak, NSArray<NSLayoutConstraint *> *currentConstraints) {
NSLog(@"檢測到約束沖突!");
NSString *className = NSStringFromClass([viewController class]);
if ([className hasPrefix:@"UI"] && ![className isEqualToString:@"UIApplication"]) {
//使用某些系統(tǒng)控件時會出現(xiàn)約束沖突,例如UIAlertController
NSLog(@"ignore conflict in UIKit:%@",viewController);
return;
}
NSLog(@"沖突所在的viewController:\n%@ \nview:\n%@",viewController,view);
//使用recursiveDescription來打印view的層級,注意這是private API
NSLog(@"view hierarchy:\n%@",[view valueForKeyPath:@"recursiveDescription"]);
NSLog(@"目前所有的約束:\n%@",currentConstraints);
NSLog(@"系統(tǒng)嘗試打破的約束:\n%@",constraintToBreak);
}];
打印結(jié)果如下:
檢測到約束沖突!
沖突所在的viewController:
<MyViewController: 0x100201ba0>
view:
<UIView: 0x10020cbb0; frame = (0 0; 375 667); autoresize = W+H; gestureRecognizers = <NSArray: 0x170242b50>; layer = <UIWindowLayer: 0x17002b240>>
view hierarchy:
<UIView: 0x10020cbb0; frame = (0 0; 375 667); autoresize = W+H; gestureRecognizers = <NSArray: 0x170242b50>; layer = <UIWindowLayer: 0x17002b240>>
| <UIView: 0x10020fd00; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x17002b780>>
| | <_UILayoutGuide: 0x1002100a0; frame = (0 0; 0 0); hidden = YES; layer = <CALayer: 0x17002b820>>
| | <_UILayoutGuide: 0x100210650; frame = (0 0; 0 0); hidden = YES; layer = <CALayer: 0x17002b8e0>>
| | <UITableView: 0x10081cc00; frame = (100 100; 100 100); clipsToBounds = YES; gestureRecognizers = <NSArray: 0x170243e70>; layer = <CALayer: 0x17002bf20>; contentOffset: {0, 0}; contentSize: {0, 0}>
| | | <UITableViewWrapperView: 0x10080fe00; frame = (0 0; 100 100); gestureRecognizers = <NSArray: 0x1702441a0>; layer = <CALayer: 0x17002bf80>; contentOffset: {0, 0}; contentSize: {100, 100}>
目前所有的約束:
(
"<NSLayoutConstraint:0x17008a500 UITableView:0x10081cc00.top == UITableView:0x10081cc00.top + 10 (active)>"
)
系統(tǒng)嘗試打破的約束:
<NSLayoutConstraint:0x17008a500 UITableView:0x10081cc00.top == UITableView:0x10081cc00.top + 10 (active)>
這樣就能根據(jù)記錄到的內(nèi)存地址,準(zhǔn)確地找到是哪個界面的哪個控件的約束出錯了,即便在iOS7上crash,也能在crash之前記錄到錯誤信息。
需要注意的問題
- 某些系統(tǒng)控件本身存在約束沖突的問題,例如在使用
UIAlertController的時候。建議在檢測到?jīng)_突時,再檢測viewController的類型前綴,如果是UI前綴則忽略。其他不在UIKit里的系統(tǒng)控件,請自行判斷。 - 同一個約束沖突有時候會有多次回調(diào)。這些回調(diào)來自處理auto layout的不同階段,例如添加重復(fù)約束時、
addSubview時,layoutSubLayer時等。
源代碼
工具地址在此:ZIKConstraintsGuard。