约束冲突调试工具:解决iOS7调试难题

功能

  • 在非调试模式下,获取出错的具体约束。
  • 监测约束冲突,并获取出错的view和viewController。
  • 监测iOS7上layoutSubViews导致的crash问题

现状

iOS7对Auto Layout的支持问题

  • iOS7的约束有一些奇怪的bug,对Auto Layout支持并不完美。
  • 在出现约束冲突时,系统会尝试修复约束。iOS7和iOS8的修复结果有可能不一样。
  • 如果view的layoutSubviews里没有调用[super layoutSubviews],那么在往这个view上添加子view时,在iOS7以下会crash。例如UITableViewUITableViewCell

iOS7的调试问题

  • Xcode7虽然不能使用iOS7模拟器调试,但是还能使用iOS7真机调试。而Xcode8已经连iOS7的真机调试都不支持了。
  • Xcode8中编辑过的xib文件在Xcode7上会有兼容性问题,需要手动删除xib中的<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>这一行才能在Xcode7上编译。如果要继续使用Xcode7调试,就需要修改这些xib,十分麻烦。
  • 内网开发时,无法进行真机调试,如果要用模拟器调试,需要另一台低版本的Mac OSX系统的机子以安装Xcode6,同时也会遇到Xcode的兼容性问题,因此遇到iOS7的约束问题十分麻烦,如果没有环境的话只能靠猜。
  • 约束冲突导致的crash往往在堆栈上无法得到有用的信息,因为是在系统库里crash,无法直接看出是哪个界面的约束出错。如果是在Xcode里调试,还能使用llvm的内存命令进行调试,但是在真机上就没办法了。

解决思路

如果app能用代码监测到约束冲突,就可以在非调试模式下捕获到有用的信息,帮助快速定位问题。
当发生约束冲突时,控制台会输出这样的提示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
**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上打断点调试。
这是一个检测到出错约束时,进行处理的C函数。上面那串控制台的log就是在这个函数里输出的。

于是可以尝试用method swizzling替换系统库的方法,记录出现冲突时的信息。
监测iOS7的crash问题也是同理。

实现方法

获取UIView

runtime无法替换私有C函数,而调用栈里NSISEngine的那几个方法都没附带什么有用的信息,于是用hopper反编译UIKit.framework,找到使用UIViewAlertForUnsatisfiableConstraints的地方,是-[UIView engine:willBreakConstraint:dueToMutuallyExclusiveConstraints:]

这个方法附带了出错约束的信息,也可以获取到冲突所在的UIView,于是也能通过UIView获取对应的viewController。接下来只要hook这个方法就可以了。

获取view controller

获取view对应的view controller的方法有两种。

  • 使用UIView的私有API:_viewDelegate
  • 使用UIRespondernextResponder

>
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?

我选择了第二种方式。

监测iOS7约束导致的crash

当你在实现自定义view的layoutSubviews方法时,记住:

  • 调用[super layoutSubviews]
  • 不要在layoutSubviews里增加约束

如果不遵守这两条,当你向这个view上增加子view时,在iOS6和iOS7上会crash,控制台会输出提示:'Auto Layout still required after executing - layoutSubviews..' 。iOS8开始则不会crash。

某些系统控件,例如UITableViewUITableViewCell没有调用[super layoutSubviews],所以在iOS6和iOS7上不能在它们上面增加子view,除非你用method swizlling修复它们的layoutSubviews方法。

经过反编译分析,'Auto Layout still required after executing - layoutSubviews..'发生在UIViewlayoutSublayersOfLayer:里,发生错误之前会用-[UIView _wantsWarningForMissingSuperLayoutSubviews]来监测是否调用了[super layoutSubviews],如果没有则抛出异常。
因此只需要hook_wantsWarningForMissingSuperLayoutSubviews就可以了。

最终效果

设置监听方式如下,返回约束冲突所在的view,viewController,系统尝试打破的约束,目前所有的约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[ZIKConstraintsGuard monitorUnsatisfiableConstraintWithHandler:^(UIView *view, UIViewController *viewController, NSLayoutConstraint *constraintToBreak, NSArray<NSLayoutConstraint *> *currentConstraints) {
NSLog(@"检测到约束冲突!");
NSString *className = NSStringFromClass([viewController class]);
if ([className hasPrefix:@"UI"] && ![className isEqualToString:@"UIApplication"]) {
//使用某些系统控件时会出现约束冲突,例如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(@"系统尝试打破的约束:\n%@",constraintToBreak);
}];

打印结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
检测到约束冲突!
冲突所在的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)>"
)
系统尝试打破的约束:
<NSLayoutConstraint:0x17008a500 UITableView:0x10081cc00.top == UITableView:0x10081cc00.top + 10 (active)>

这样就能根据记录到的内存地址,准确地找到是哪个界面的哪个控件的约束出错了,即便在iOS7上crash,也能在crash之前记录到错误信息。

需要注意的问题

  • 某些系统控件本身存在约束冲突的问题,例如在使用UIAlertController的时候。目前是在检测到冲突时,再检测viewController的类型前缀,如果是UI前缀则忽略。
  • 同一个约束冲突有时候会有多次回调。这些回调来自处理auto layout的不同阶段,例如添加重复约束时、addSubview时,layoutSubLayer时等。

源代码

工具地址在此:ZIKConstraintsGuard