iOS/OSX 调试:用例调研

原文链接:http://www.objc.io/issue-19/debugging-case-study.html

// 速翻,无校对版

我是前言

代码世界也不存在圣人,所以调试也是我们大家所必备的良好技能。不是乱枪打鸟,我将会看看回归到UIKit中的BUG,并告诉你我以前理解,隔离,并最终解决问题的工作流程。

问题来了

我们收到一个BUG报告:快速电议按钮弹出一个popover,popover消失掉以后尼玛父视图控制器一同消失掉了。幸运的是,栗子还在,那么让我们开始第一步 – 重现这个BUG – 需要注意的是:
《iOS/OSX 调试:用例调研》

我的第一感觉是我们可以在代码中主动消失了视图控制器,并且我们错误地让父视图控制器消失了。但是,当使用Xcode的集成视图调试功能调试的时候,很明显这里有一个全局的UIDimmingView作为外界触点事件输入源的第一响应者:

《iOS/OSX 调试:用例调研》

大苹果在Xcode 6中增加了调试视图图层特性,这个特性可能是被现下流行的RevealSpark Inspector应用所激发的灵感,当然上述两款应用在许多方面还是完爆Xcode.

使用LLDB

在可视化调试以前,通常的作法是在LLDB中键入po [[UIWindow keyWindow] recursiveDescription]用文字形式打印出图层的关系来检视视图层级。

与检视视图层级类似的,我们也需要通过[[[UIWindow keyWindow] rootViewController] _printHierarchy]检视视图控制器的层级。这是大苹果在iOS8中针对增加UIViewController添加隐藏的私有帮助指令

(lldb) po [[[UIWindow keyWindow] rootViewController] _printHierarchy]
<PSPDFNavigationController 0x7d025000>, state: disappeared, view: <UILayoutContainerView 0x7b3218d0> not in the window
   | <PSCatalogViewController 0x7b3100d0>, state: disappeared, view: <UITableView 0x7c878800> not in the window
   + <UINavigationController 0x8012c5d0>, state: appeared, view: <UILayoutContainerView 0x8012b7a0>, presented with: <_UIFullscreenPresentationController 0x80116c00>
   |    | <PSPDFViewController 0x7d05ae00>, state: appeared, view: <PSPDFViewControllerView 0x80129640>
   |    |    | <PSPDFContinuousScrollViewController 0x7defa8e0>, state: appeared, view: <UIView 0x7def1ce0>
   |    + <PSPDFNavigationController 0x7d21a800>, state: appeared, view: <UILayoutContainerView 0x8017b490>, presented with: <UIPopoverPresentationController 0x7f598c60>
   |    |    | <PSPDFContainerViewController 0x8017ac40>, state: appeared, view: <UIView 0x7f5a1380>
   |    |    |    | <PSPDFStampViewController 0x8016b6e0>, state: appeared, view: <UIView 0x7f3dbb90>

LLDB功能强劲并可以脚本化。FB放出了一系列的叫做Chisel的大python脚本来帮助日常的调试工作。pviewspvc对于视图与视图控制器的层级打印来说是等价的。Chisel的视图控制器树也类似,但是同时展示视图的边框属性。我通常是使用它来检视响应者链,并且尽管你可以手动循环找出你所感兴趣对象的下一个响应者,或是可以添加一个扩展指令,总归键入presponder object是目前为止最便捷的方式。

添加断点

让我们好好琢磨下是什么代码让我们的试图控制器消失得。最明显的方式是设置一个断点打在viewWillDisappear:来看看函数栈的记录:

(lldb) bt
* thread #1: tid = 0x1039b3, 0x004fab75 PSPDFCatalog`-[PSPDFViewController viewWillDisappear:](self=0x7f354400, _cmd=0x03b817bf, animated='\x01') + 85 at PSPDFViewController.m:359, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
  * frame #0: 0x004fab75 PSPDFCatalog`-[PSPDFViewController viewWillDisappear:](self=0x7f354400, _cmd=0x03b817bf, animated='\x01') + 85 at PSPDFViewController.m:359
    frame #1: 0x033ac782 UIKit`-[UIViewController _setViewAppearState:isAnimating:] + 706
    frame #2: 0x033acdf4 UIKit`-[UIViewController __viewWillDisappear:] + 106
    frame #3: 0x033d9a62 UIKit`-[UINavigationController viewWillDisappear:] + 115
    frame #4: 0x033ac782 UIKit`-[UIViewController _setViewAppearState:isAnimating:] + 706
    frame #5: 0x033acdf4 UIKit`-[UIViewController __viewWillDisappear:] + 106
    frame #6: 0x033c46a1 UIKit`-[UIViewController(UIContainerViewControllerProtectedMethods) beginAppearanceTransition:animated:] + 200
    frame #7: 0x03380ad8 UIKit`__56-[UIPresentationController runTransitionForCurrentState]_block_invoke + 594
    frame #8: 0x033b47ab UIKit`__40+[UIViewController _scheduleTransition:]_block_invoke + 18
    frame #9: 0x0327a0ce UIKit`___afterCACommitHandler_block_invoke + 15
    frame #10: 0x0327a079 UIKit`_applyBlockToCFArrayCopiedToStack + 415
    frame #11: 0x03279e8e UIKit`_afterCACommitHandler + 545
    frame #12: 0x060669de CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 30
    frame #20: 0x032508b6 UIKit`UIApplicationMain + 1526
    frame #21: 0x000a119d PSPDFCatalog`main(argc=1, argv=0xbffcd65c) + 141 at main.m:15
(lldb) 

通过LLDB的bt指令,你可以打印出断点。bt all功能一样,其会打印出所有线程的状态,而不是仅仅当前线程。

审视函数栈记录,我们发现当我们调用一个设计排好的动画的时候视图控制器便已经消失了,搜易我们需要添加一个更早的断点。在这种情况下,我们对-[UIViewController dismissViewControllerAnimated:completion:]这个方法调用比较感冒。我们添加了一个断点并继续跑代码。

Xcode断点接口功能同样强劲,让你添加所有条件,忽略次数,或者甚至是自定义的事件比如说播放一个声音效果和自动运行。这里我们用不到这些特性,但是他们着实可以节省我们不少时间:

(lldb) bt
* thread #1: tid = 0x1039b3, 0x033bb685 UIKit`-[UIViewController dismissViewControllerAnimated:completion:], queue = 'com.apple.main-thread', stop reason = breakpoint 7.1
  * frame #0: 0x033bb685 UIKit`-[UIViewController dismissViewControllerAnimated:completion:]
    frame #1: 0x03a7da2c UIKit`-[UIPopoverPresentationController dimmingViewWasTapped:] + 244
    frame #2: 0x036153ed UIKit`-[UIDimmingView handleSingleTap:] + 118
    frame #3: 0x03691287 UIKit`_UIGestureRecognizerSendActions + 327
    frame #4: 0x0368fb04 UIKit`-[UIGestureRecognizer _updateGestureWithEvent:buttonEvent:] + 561
    frame #5: 0x03691b4d UIKit`-[UIGestureRecognizer _delayedUpdateGesture] + 60
    frame #6: 0x036954ca UIKit`___UIGestureRecognizerUpdate_block_invoke661 + 57
    frame #7: 0x0369538d UIKit`_UIGestureRecognizerRemoveObjectsFromArrayAndApplyBlocks + 317
    frame #8: 0x03689296 UIKit`_UIGestureRecognizerUpdate + 3720
    frame #9: 0x032a226b UIKit`-[UIWindow _sendGesturesForEvent:] + 1356
    frame #10: 0x032a30cf UIKit`-[UIWindow sendEvent:] + 769
    frame #21: 0x032508b6 UIKit`UIApplicationMain + 1526
    frame #22: 0x000a119d PSPDFCatalog`main(argc=1, argv=0xbffcd65c) + 141 at main.m:15

继续说!跟预期的一样,满屏UIDimmingView截获到我们的想触碰以及处理句柄handleSingleTap:,然后将它丢给了UIPopoverPresentationController的dimmingViewWasTapped:,它(应该)执行了消失视图控制器的动作。但是,当我们快速点击的时候,这个断点停了两次。这里是不是有第二个隐藏(未被发现)的图层呢?还是说调用了同一个实例?我们只能进入这个断点,这时po self就没啥用了。

Calling Conventions 101

根据组装跟功能调用约定的基本常识,我们还是能获取self的值。在iOS模拟器中的iOS ABI Function Call GuideMac OS X ABI Function Call Guide是两大资源。

我们知道每个OC方法中有两个隐藏参数:self_cmd。所以我们需要栈上的第一个对象。针对32位指令集架构,保存在栈上的使用$esp,所以你可以使用po *(int*)($esp+4)来获取self,还有使用p (SEL)*(int*)($esp+8)来获取OC方法中的_cmd。在$esp中的第一个值返回的是一个地址。接下来的值存在$esp+12, $esp+16类似等等。

X86-64体系提供了更多得寄存器,所以变量被放置在$rdi, $rsi, $rdx, $rxc, $r8, $r9。所有之后的栈上变量存储在$rbp,从$rbp+16,$rbp+24等。

armv7体系总的来说将变量丢在$r0, $r1, $r2, $r3,然后将剩余的丢到栈上$sp:

(lldb) po $r0
<PSPDFViewController: 0x15a1ca00 document:<PSPDFDocument 0x15616e70 UID:amazondynamososp2007_0c7fb1fc6c0841562b090b94f0c1c890 files:1 pageCount:16 isValid:1> page:0>

(lldb) p (SEL)$r1
(SEL) $1 = "dismissViewControllerAnimated:completion:"

arm64与armv7相似,然后,因为有了更多可用的寄存器, $x0到$x7全部用来存变量,而不是放在栈寄存器$sp上。

你可以学习更多X86X86-64中关于栈的知识,同时还可以阅读AMD64 ABI Draft

使用Runtime

另外一个技巧来追溯方法执行是使用方法重写成在调用父类方法之前进行带日志打印的模式。但是,手动的进行一个实际方法切换(swizzling)仅仅只是做到调试起来比较方便但是做不到时间节省。前阵子,我写了一个小方法库叫做Aspects来实现后者的需求。它可以用来做产品化的代码,但是我基本是用来调试我的测试用例。(如果你有疑问,你可以戳这里

#import "Aspects.h"

[UIPopoverPresentationController aspect_hookSelector:NSSelectorFromString(@"dimmingViewWasTapped:") 
                                         withOptions:0 
                                          usingBlock:^(id <AspectInfo> info, UIView *tappedView) {
    NSLog(@"%@ dimmingViewWasTapped:%@", info.instance, tappedView);
} error:NULL];

dimmingViewWasTapped:因为是私有钩子 – 所以我们使用了NSSelectorFromString。你可以核对这个方法是存在的,然后通过iOS运行时头文件也检查下每个框架类得所有的私有和公有方法。这个工程的真相是调用了比之苹果提供给我们更为完整的头文件。(调用私有API未必是好事,这个要懂)

在钩子方法打印的信息如下:

PSPDFCatalog[84049:1079574] <UIPopoverPresentationController: 0x7fd09f91c530> dimmingViewWasTapped:<UIDimmingView: 0x7fd09f92f800; frame = (0 0; 768 1024)>
PSPDFCatalog[84049:1079574] <UIPopoverPresentationController: 0x7fd09f91c530> dimmingViewWasTapped:<UIDimmingView: 0x7fd09f92f800; frame = (0 0; 768 1024)>

我们看到对象的地址是一样的,所以我们可怜的隐藏视图真的是调用了两次。我们可以再一次使用Aspects来看看到底是那个视图控制器真正进行了调用:

[UIViewController aspect_hookSelector:@selector(dismissViewControllerAnimated:completion:)
                          withOptions:0
                           usingBlock:^(id <AspectInfo> info) {
    NSLog(@"%@ dismissed.", info.instance);
} error:NULL];
2014-11-22 19:24:51.900 PSPDFCatalog[84210:1084883] <UINavigationController: 0x7fd673789da0> dismissed.
2014-11-22 19:24:52.209 PSPDFCatalog[84210:1084883] <UINavigationController: 0x7fd673789da0> dismissed.

这个隐藏的视图对我们的导航栏控制器调用了dismiss共两次。UIViewControllers的dismissViewControllerAnimated:completion:将试图控制器的请求丢给了它的中间子视图控制器,如果有一个这个东西的话,不然它就将让自己消失掉。所以第一次,消失请求丢给popover,然后第二次,导航栏控制器自己消失了。

找到方案

我们现在已经知道了发生了什么 – 现在让我们转向“为什么”。UIKit是个封闭的资源,但是我们可以使用反汇编像Hopper这样的工具来大概阅读UIKit并且来深层次的洞悉下UIPopoverPresentationController的具体机制。你可以从/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks/UIKit.framework.现获取到二进制文件然后使用Hopper选中File -> Read Executable to Disassemble…来查看跟进而今和符号化的代码到底长什么样。32位反汇编是最成熟的一种技术,所以你可以通过选择32位文件来获取最好的反汇编结果。IDA by Hex-Rays是另外一个强大昂贵的反汇编工具来提供比下图更牛逼的反汇编结果:
《iOS/OSX 调试:用例调研》

在阅读代码时候带上一些基础技能是非常有用的。不过,你也可以使用伪代码视图结构来获取一些类似C语言的东东:
《iOS/OSX 调试:用例调研》

阅读伪代码相当大开眼界。有两个代码路径 – 其实一个是如果代理实现了popoverPresentationControllerShouldDismissPopover:,并且另一个是如果没有 – 代码路径则会截然不同。尽管又继续判断代理是否(controller.presented && !controller.dismissing),另一个路径则没有并且总是使消失。根据这之中的交代,我们可以尝试去通过实现我们自己的UIPopoverPresentationControllerDelegate:去解决BUG:

- (BOOL)popoverPresentationControllerShouldDismissPopover:(UIPopoverPresentationController *)popoverPresentationController {
    return YES;
}

我第一个尝试是设置主视图控制器来创建popover。然而,这样破坏了UIPopoverController。尽管没有提到,popover控制器在_setupPresentationController中设置它自身作为代理,然后代理消失得时候会坏事。相反的,我使用UIPopoverController子类并且添加上述方法。这样两个类得连接没有被说明,并且修复了为说明的行为;但是,这个更贴切默认和存在问题的实现解决了这个问题,显然它是未来代码。

雷达报告

先不要止步。你应该总是正确记录这些解决方法,最重要的是,在苹果文档中设置一个雷达。作为一个额外的收益,这样让你可以验证你是否已经了解了这个BUG,并且在你的应用中出现的时候不会出现副作用 – 并且当你变更iOS版本的时候更容易回溯和验证雷达是否仍然有效:

// The UIPopoverController is the default delegate for the UIPopoverPresentationController
// of it's contentViewController.
//
// There is a bug when someone double-taps on the dimming view, the presentation controller invokes
// dismissViewControllerAnimated:completion: twice, thus also potentially dismissing the parent controller.
//
// Simply implementing this delegate runs a different code path that properly checks for dismissing.
// rdar://problem/19053416
- (BOOL)popoverPresentationControllerShouldDismissPopover:(UIPopoverPresentationController *)popoverPresentationController {
    return YES;
}

写一个雷达是一个非常有趣的挑战,并且不像你想象中那样花时间。略略略。。。
介绍两个雷达:

资源

略(参照原文)

    原文作者:Cruise_Chan
    原文地址: https://segmentfault.com/a/1190000002413731
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞