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

功能

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

现状

iOS7对Auto Layout的支持问题

  • iOS7的约束有一些奇怪的bug,对Auto Layout支持并不完美。
  • 在出现约束冲突时,系统会尝试修复约束,此时在iOS7上有可能crash,iOS8则只是在控制台输出警告,并不会crash。

iOS7的调试问题

  • Xcode7虽然不能使用iOS7模拟器调试,但是还能使用iOS7真机调试。而Xcode8已经连iOS7的真机调试都不支持了。(update: Xcode8可以真机调试iOS7,但是需要从Xcode7上拷贝一点东西,参考这里
  • 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里调试,还能使用lldb的内存命令进行调试,但是在真机上就没办法了。

解决思路

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

**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替换系统库的方法,记录出现冲突时的信息。

实现方法

获取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。如果不遵守第二条,iOS7以下会发生死循环。

某些系统控件,例如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,系统尝试打破的约束,目前所有的约束。

    [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);
        
    }];

打印结果如下:

检测到约束冲突!

冲突所在的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前缀则忽略。其他不在UIKit里的系统控件,请自行判断。
  • 同一个约束冲突有时候会有多次回调。这些回调来自处理auto layout的不同阶段,例如添加重复约束时、addSubview时,layoutSubLayer时等。

源代码

工具地址在此:ZIKConstraintsGuard

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,717评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,501评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,311评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,417评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,500评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,538评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,557评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,310评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,759评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,065评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,233评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,909评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,548评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,172评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,420评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,103评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,098评论 2 352

推荐阅读更多精彩内容