概述

iOS开发调试概览-LMLPHP

我们都知道Xcode默认的调试器是LLDB(在此之前使用的是GDB),但是关于LLDB的debug技巧并非所有人都比较清楚,可能所有人都知道p或者po命令打印一些变量。但是实际的情况时这些还远远不够。比如说有没有一些情况下crash无法定位到有用信息,直接出现exc_bad_access,有没有遇到过每次调试一个UI就要重新run一次Xcode(话说编译时间真的影响一个UI开发者的效率)。

LLDB命令

po/p variable:打印变量信息or表达式

ppo有什么区别呢?

本质上两个都是expression(可以缩写成e)表达式的简单表达(p = expression --,因为lldb会自动进行前缀匹配,但是使用pr不行因为无法消除和process的歧义;而po = e -o -- = expression -o --,以对象的方法打印),但是不同的是po打印对象的description(类似于NSLog/print)。

既然它们等价于expression表达式,当然它们也可以进行一些变量赋值操作了,比如:e let $a = 1(后面就是使用p $a + 1),只要记住这个变量必须以$开头,否则后面使用时无效。

p还有另外一个用法是格式化(po则不行),例如:p/x 16 则会打印出:0x0000000000000010

有些时候通过expression执行了UI操作后并不想直接中断接下来的调试而想要看到更新效果可以执行e CATransaction.flush(),因为lldb的断点会中断UI更新所以使用flush是一种比较好的更新方式。

frame

frame用于查看当前栈帧信息(栈上面的函数调用内存片段),比如说frame info就会打印当前所在函数、行数和所在文件等信息。

frame variable:查看当前栈帧中的变量(包括外部全局变量,当然如果只想要看当前文件中的全局变量使用 target variable = ta v
frame variable x = fr v x = v x:查看当前栈帧中的变量值(如果配合-L参数可以查看一个变量的内存地址),但是不同于expression的是frame variable仅仅是查看当前栈帧中的实际存储变量,如swift中的一个计算变量它是没办法看到的,因为它的运行原理并不是像expression一样会在一个context中执行相关的代码进行结果输出。

watchpoint

除了breakpoint之外watchpoint应该是一种比较常用的内存访问断点机制,有时会随着界面越来越复杂,有些变量可能会在多处修改造成看起来你的修改并不能达到你理解的目的,这时如果使用watchpoint监控所有的修改甚至读取就比较有用了。watchpoint使用有两种方式:

watchpoint set variable:监听一个变量的变化
watchpoint set expression:通过一个表达式为一个地址添加断点

比如说:watchpoint set variable self.isLoad(假设isLoad是VC中一个标记viewDidLoad()是否执行过的标记),此当可以看到输出:
Watchpoint created: Watchpoint 1: addr = 0x100a04500 size = 1 state = enabled type = w declare @ '/Users/cuijiangtao/Library/Mobile Documents/com.apple.CloudDocs/XcodeDebug/XcodeDebug/ViewController.swift:73' watchpoint spec = 'self.isLoad' new value: false watchpoint set variable self.isLoad

一旦self.isLoad被修改则会命中断点,同时输出:
Watchpoint 1 hit: old value: false new value: true

但是到了这里可能我们还会遇到一种情况是如果要是想要监听一个控件的属性修改怎么办呢,可以试一下用上面的方法watchpoint set variable subview1.framesubview1是某个UIView类型的属性)应该会提示没有frame属性。常用的方式可以通过断点到一个UIView的setFrame方法上(注意:这里的语法是OC,即使在swift环境中),比如:breakpoint set -F '-[UIView setBounds:]' -c '((int*)$esp)[1] == 0x10131e440'当然这里最主要是还是获取寄存器索引和对应UIView的地址,可以使用:po self.view

thread backtrace

显示程序停止在breakpoint前当前线程所有帧,用于追踪调用堆栈信息。值得一提的是类似功能的还有bt,它并非thread backtrace的简写(如果是简写应该是tb才对吧)而是_regexp-bt简写。

image(target modules)

借助image我们能够查看当前的Binary Images相关的信息,日常开发我们主要利用它寻址。比如有了栈地址通过image lookup -a xxx即可查找到具体执行的位置。

其他第三方脚本

chiles

因为lldb内部完整的支持python调用,比如执行一段python脚本:script print (sys.version),所以可以借助这个功能实现更加复杂的命令进行调试。

借助lldb提供的python api可以让debug更加得心应手,不仅可以导入一个python文件还可以通过在每次lldb启动时自动加载script来让你的配置使用更加方便(将python api放到此目录:~/.lldbinit)。当然这样以来你的调试脚本就可以分发给更多人使用,比较常用的就是facebook的Chisel。这里简单列举一下chiles常用的命令。

pvc:查看当前控制器状态
pviews:查看UIWindow及其子视图层级关系
presponder:打印一个对象的响应链关系
pclass:根据内存地址打印相关信息
visualize:使用mac系统preview程序查看UIImage、CGImage、UIView、CALayer、NSData(of an UIImage)、UIColor、CIColor。
show/hide:显示or隐藏一个UIView
mask/umask:给一个UIView或CALayer添加一个半透明蒙版
border/unborder:给指定的UIView或CALayer添加边框或移除边框用于调试,记得执行后紧接着执行caflush
caflush:刷新界面UI,类似于前面介绍的flush
bmessage:添加一个断点,即使这个函数在子类没有实现(比如说在UIViewController中想在viewWillAppear中打断点,但是很可能没有实现父类方法,就可以通过bmessage [UIViewController viewWillAppear:]添加)
wivar:相当于kvo,监听一个变量,例如wivar self _subviews
taplog:开启点击log功能,当点击某个控件时会打印相关控件的信息
paltrace: 打印指定view的自动布局信息,比如:paltrace self.view
ptv:打印当前界面中的UITableView,相对应的还有pcells打印当前界面中的UITableViewCell
pdata:Data的string解码
vs:搜索指定的view并加上半透明蒙版(包含子命令),例如:vs 0x13a9efe00 就可以标注出对应的控件
slowanim/unslowanim:降低(或取消)动画速度,默认0.1 ,可以在任意断点或Xcode暂定执行slowanim即可,方便动画调试

lldb_commands

lldb_commands是另一个第三方的lldb扩展库,其中提供了很多实用的文件操作,可以让你的调试更加如虎添翼。

ls:显示指定路径的目录或文件列表
pexecutable:打印当前可执行文件所在位置
dumpenv:查看环境信息,比如说沙盒地址
yoink:拷贝指定目录的文件到mac的临时目录
keychain:查看keychain信息

debug流程控制

debug流程控制本质还是lldb,但是为了方便大家理解这里单独列出来。大家都知道Xcode调试器下面的四个按钮:continuestep overstep intostep out。但是如果你不想点击也可以直接在lldb中使用cnsfinish(or thread step-out
另外有一个流程控制的命令比较常用就是thread return [variable]不过到目前为止swift貌似还不支持此命令,存在已知bug。

有时会想要使用lldb查看一些信息,比如说查看当前在运行的程序视图层级而并没有添加合适的断点,可以使用XCode debug区域的暂停(debug状态它是continue)即可进入调试模式。比如此时打印UI层级:po [[UIApplication sharedApplication].keyWindow recursiveDescription]可以看到类似信息:
<UIWindow: 0x127e085d0; frame = (0 0; 414 896); gestureRecognizers = <NSArray: 0x281403120>; layer = <UIWindowLayer: 0x281a584a0>> | <UITransitionView: 0x127d0b210; frame = (0 0; 414 896); autoresize = W+H; layer = <CALayer: 0x281a6d520>> | | <UIDropShadowView: 0x127d0b920; frame = (0 0; 414 896); clipsToBounds = YES; autoresize = W+H; layer = <CALayer: 0x281a6d420>> | | | <UIView: 0x127d0a710; frame = (0 0; 414 896); autoresize = W+H; layer = <CALayer: 0x281a6d3e0>>

这时比如说想要修改rootView的颜色直接执行:

e id $v = (id)0x127d0a710 e (void)[$v setBackgroundColor:[UIColor yellowColor]] e (void)[CATransaction flush]

前面提到了recursiveDescription,但是确是在swift项目中的打印。大家有可能也会注意到在swift的环境有时运行的代码方式还是objc风格,之所以可以在swift项目中这么打印因为在暂停状态其实就是在端口mach_msg_trap中,这是一个objc运行环境,可以点击左侧调用栈看一下:

iOS开发调试概览-LMLPHP

从注释也可以看出都objc_msgSend,所以此时用objc语法在lldb中调用是可以的。但是如果在Swift项目中你在某处断点然后打印上面的命令应该会看到:

error: <EXPR>:3:17: error: expected ',' separator [[UIApplication sharedApplication].keyWindow recursiveDescription], error: <EXPR>:3:46: error: expected ',' separator [[UIApplication sharedApplication].keyWindow recursiveDescription]

明显的语法错误,那换成swift语法(po UIApplication.shared.keyWindow?.recursiveDescription)是不是就行了呢,这是会看到如下的错误,倒不是语法错误,是说UIWindow压根就没有recursiveDescription属性(在swift中根本没有实现这个方法)。
error: <EXPR>:3:33: error: value of type 'UIWindow' has no member 'recursiveDescription' UIApplication.shared.keyWindow?.recursiveDescription

不过不用担心,新的lldb已经支持在swift运行环境执行objc代码,将上面的命令换成如下命令即可,不过注意swift语法必须在``中:
e -l objc -o -- [`UIApplication.shared.keyWindow` recursiveDescription]
当然你也可以直接使用objc语法:
e -l objc -o -- [[UIApplication sharedApplication].keyWindow recursiveDescription]

常见异常解决

通过添加全局异常捕获断点并非可以定位到所有问题,有些问题的定位并不会那么直接,首先看一下常见的几种crash(异常中断类型,当然有些中断并不是Crash例如exit、main函数执行结束、最后一个线程结束等):

  • NSException:遇到OC没有捕获的异常或者C++ Exception,这个也是一般第三方crash收集工具常用的方法(NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);),因为iOS本身如果遇到了没有捕获的异常会调用一个UncaughtExceptionHandler,比如数组访问越界。但是也并不是所有的Exception都可以执行到此回调,有些objc_exception_throw是无法执行到此handler的(可能直接terminate、abort然后执行pthread_kill,比如非主线程UI布局)。

  • CPU无法执行的指令(EXC_BAD_ACCESS、除0运算):通常是Signal错误(软件中断,通常可以使用signal(SIGABRT,MySIGABRTHandler)来拦截信号中断),这类问题需要利用unix标准的signal机制,例如中断信号是SIGINT、SIGABRT(abort函数生成的信号,通常EXC_BAD_ACCESS错误就是系统最终调用了abort函数发出SIGABRT信号终止的),当然也有可能出现Mach Exception进而抛出EXC_BAD_ACCESS

  • 操作系统策略(WatchDog、OOM、OverHeat):因为系统CPU占用过高,内存过高、过热引起的系统中断,通常解决方式是找到大任务、内存泄漏或突然内存峰值过高的地方进行修复。

  • 异常情况程序主动退出(abort、assert):一般是开发者设置的屏障函数,避免因为没有满足必要条件而调用。

那么针对不同的异常(NSException、C++ Exception、Mach Exception和Signal)怎么debug呢?

捕获全局异常

大家在debug过程中常见的问题是出错了直接崩溃无法定位到相关出错代码,常用的方式就是在Xcode的breakpoint navigator中添加一个Swift Error BreakPoint(或者OC则添加All Objective-C Exceptions),当然用lldb命令也是可以解决的:

Swift Error BreakPointbr s -E swift
此时可以看到输出:Breakpoint x: where = swiftlibswiftCore.dylib`swift_willThrow, address = 0x00000001a1616aa0

All Objective-C Exceptionsbr s -E objc
此时可以看到输出:Breakpoint x: where = libobjc.A.dylib`objc_exception_throw, address = 0x0000000193b3008c

使用上面的命令在遇到swift或者oc异常抛出时则会停在断点处。

另一种方式是使用image寻址也,比如image lookup --address 0x0001b2666也可以输出具体的文件和函数等信息。

EXC_BAD_ACCESS

有些bug可以通过全局异常捕获就可以轻松定位到具体位置,EXC_BAD_ACCESS一般就无法定位到具体位置了,通常出错信息是这样的:MACH_Exception Crashed with mach exception EXC_BAD_ACCESS, fault_address: 0x0000000000000001,它根本上就是访问了野指针造成的,一般在iOS中就是访问了一个已经释放了的对象。但是Xcode提供了Zombie Objects检测功能,只要在Xcode中的Diagnostics启用即可。

Zombie Objects用来检测僵尸对象,关于Xcode检测僵尸对象的原理可以在CFRuntime.c源码中看到,其中定义了一个__CFZombifyNSObject(可以在Xcode中添加Symbolic breakpointSymbol输入**__CFZombifyNSObject即可)。它的实现原理就是swizzle,将NSObject的dealloc替换成了__dealloc_zombie,首先判断__CFZombieEnabled**是否开启,如果开启了,当对象引用计数器为0的时候将根据当前类名生成一个_NSZombie_%s的新类,然后使用object_setClass将当前对象类型替换成新的类型,之后一旦再给这个对象发送消息则抛出异常并打印相关信息。

比如使用下面的代码进行test:

NSObject *obj = [[NSObject alloc] init];
[obj release];
[obj release];

开启僵尸对象检测会打印:*** -[NSObject release]: message sent to deallocated instance 0x108b12df0并定位到出错位置。但是并不是所有的情况都可以用Zombie Objects检测出来,比如说下面的代码:

char *p = malloc(8);
p[9] = 'a';
NSLog(@"%c",p[9]);
free(p);

这段代码看起来有明显的越界访问内存错误,不过多数情况下并不会报错,可以正常运行,偶尔报错也会定位到main函数中。但是一旦启用了Diagnostics中的Address Sanitizer所有的问题迎刃而解:

iOS开发调试概览-LMLPHP

具体崩溃原因是Heap buffer overflow溢出了。

Address Sanitizer支持的检测类型包括下面几种:

  • Use-after-free
  • Heap buffer overflow
  • Stack buffer overflow
  • Global variable overflow
  • Overflows in C++ containers
  • Use-after-return

Address Sanitizer 的原理是当程序创建变量分配一段内存时,将此内存后面的一段内存也冻结住,标识poisoned memory。当程序访问到中毒内存时(buffer overflow),就会抛出异常,并打印出相应相关信息。如果对象释放了,对象所占的内存也会标识为中毒内存,这时候访问这段内存同样会抛出异常(Use-after-free),除此之外从Xcode 9开始支持use-after-scope/return,不过对于use-after-return记得打开检测时在Xcode中额外勾选Detect use of stack after return

Memory Leak

内存泄漏检测应该是iOS开发人员经常进行的一项调试工作,常见的就是Retain Cycle循环引用,比如说大家遇到最多的block引用。

首先最有效的办法就是使用Instrumentsleak checks进行检测,这种方式不会遗漏任何内存泄漏的情况,而且可以精准定位到泄漏处(通过切换到Cycles & Roots面板)。但是,这种方式自然有有它的缺点,那就是对于庞大的项目来说每次运行Instruments分析一次要花费数十分钟(毕竟要先archive一次)。

当然Xcode从iOS 8就提供了Debug Memory Graph可以更加快速的定位leak(可以通过Xcode左侧Debug Navigator面板下发的Show only leaked blocks过滤leak block)。当然如果你使用这个工具建议首先在scheme中打开Diagnostics中的Malloc Stack以便记录调用log方便在Xcode Memory Inspector中回溯。

但是即使如此每写一段代码就运行一次也不一定是所有开发者可以做到的,何况有些情况下Debug Memory Graph并不能检测到泄漏(这一点就不如Instruments了),不过相信即使每次都能检测到泄漏大家也不会每次编码都执行一次。那么如果有一种方式在运行app时给出泄漏的提示就最好了,这样就没有额外的检测成本了。当然也有一些第三方库可以帮助我们进行leak检测比如说MLeaksFinder就可以帮助我们检测没有释放的控制器(如果是其他对象需要编写一些代码),而它本身也可以使用facebook的FBRetainCycleDetector进行更加强大的检测功能。

其实crash修复无非也就几种情况一种是可以直接定位到具体代码上,这种通常堆栈信息比较完整,出错位置可以定位到具体代码而不是main函数(这种问题修复的主要路径是debug或者人为分析代码逻辑);另外一种堆栈信息几乎没有有用信息(基本都系统库调用没有其他额外信息)这种情况相对比较复杂。因为代码分析的范围并不确定,更不用说复现了,但是相信借助上面的工具还是可以解决绝大多数问题的。

Xcode 工具

编辑断点

其实前面更多的强调使用lldb进行debug,事实上Xcode提供了很多UI工具,比如说在一个breakpoint处点击编辑断点(如下图),可以在conditon中添加条件比如说a == 1,这样只有符合这个条件才会命中这个断点。也可以在下面的action中进行表达式输出,比如说程序已经运行了想要查看c变量的值而不用中断执行就可以勾选上Automatically continue after evaluation actions,同时在action中通过lldb命令e print(c)打印,当然如果你喜欢可以在这里使用任何lldb命令临时修改你的程序(比如返回前把c赋值成其它值,甚至修改某些界面元素)而不需要重新运行,这对你调试一些UI至关重要。

iOS开发调试概览-LMLPHP

汇编调用栈中打印函数实参

有些时候问题的调试并不能准确定位在某个断点,可能是某类调用,比如说你发现一个UILabel的text被莫名的修改了(有时会也可能是被hook修改了),前面也提到过这时可以使用Xcode的符号断点标记符号:-[UILabel setText:]每次命中这个符号就会断点到调用的位置。强大的是此时Xcode中支持在汇编调用栈中打印函数实参,方法是是用:po $arg1($arg2 etc),可以看到对应的参数信息:

iOS开发调试概览-LMLPHP

UI debugger

前面也提到了很多和UI有关的debug方法,包括打印视图层级、查看视图约束等,当然由此也产生了很多UI调试工具比如大名鼎鼎的Reveal应该是很多开发者必备的UI调试工具,当然它也有缺点,比如说复杂视图它经常会卡死(视频debug经常遇到,特别是出现OpenGL等渲染的地方)。当然苹果显然也意识到了这个问题,所以从Xcode 6开始在debug工具栏可以点击Debug View Hierarchy查看视图层级,当然它的缺点就是不如Reveal可以直接修改一些界面属性。

Main Thread Checker

相信升级了iOS 13 SDK的朋友都会有一个明显的感受,一旦出现异步线程操作UI必然crash,这个在之前的版本中可能会出现一些异常的情况,比如说MBProgress无法展示或者半天才出现等,但是在iOS 13 则会直接crash。事实上为了解决这类问题Xcode 9就加入了Main Thread Checker功能,只要在Diagnostics中启用即可。具体原理,其实是hook一个认为应该在主线程执行的方法,调用前进行线程检测,如果发现某个主线程操作现在在其他线程执行则会打印log:Main Thread Checker: UI API called on a background thread: -[UIView init]并断点到具体位置。其实现在Xcode 的Main Thread Checker本身也是一个breakpoint,可以在断点界面查看到有一类Runtime issue breakpoint,里面除了支持Main Thread Checker还支持Thread SanitizerUndefined BehaviorSystem Frameworks检测。

当然随着lldb的发展、Xcode的升级有很多新的调试功能和技巧在这里无法一一赘述,比如说Xcode 9就开始加入的无线调试,Xcode 11增加的热压力模拟、网络状态模拟,又比如关于SpriteKit、OpenGL的等一些偏渲染层面的调试就不在本文中介绍了。另外关于Instruments更是一个强大的调试工具集,例如其中的内存泄漏、内存占用、性能分析等都是比较常用的一些工具,其具体用法本身也是一个比较大的topic这里也不再具体介绍等有机会单独分析。

其他补充

网络调试利器-Charles

Charles应该是多数iOS开发者甚至其他mac开发者必备的网络调试工具,查看网络请求、响应内容,查看响应速度,查看ssl加密内容(前提是app没有进行证书验证),添加断点调试,模拟网络请求,模拟网络带宽,甚至做一些响应的转发(支持本地和远程转发),都可以大大帮助你提升调试效率。

Woodpecker

最后再说一下国内开发者开发的WoodPecker,这个mac应用实现了查看沙盒文件,监控App网络请求,查看UserDefaults,KeyChain数据等等功能,常常用来查看沙盒中的一些数据库或者文件,当然尽管这些操作使用其他命令也可以实现,但是毕竟一个直观的界面可以让你更高效。

11-29 11:00