Objective-C调试技巧——iOS开发者的Debug指南

16,032 阅读25分钟

前言

  开发者们在使用Objective-C开发过程中难免会遇到各种类型的bug或难题,而熟练使用IDE工具进行调试无疑会提高开发效率,这里我总结了常用的调试技巧。

  接下来我会从以下几个方面逐一介绍:首先简单介绍Xcode调试需要关注的工具条,这里断点工具条会被放入后续断点相关的内容中;接下来会叙述项目隐藏漏洞排查“利器”——代码静态分析;然后我们会将注意力集中在断点调试上;光有断点还不够,我还补充了Log的使用,在特定场景使用有意想不到的效果;作为静态分析的对照,我简单介绍了代码动态分析及其使用;最后本文会提供一些调试相关的杂项内容当做补充。后续我会将Objecttive-C简称为OC。因为涉及面较广,我更希望读者当它是一个索引,根据目录找到感兴趣的内容,给到读者一些思考启发。

1 Xcode工具条熟悉

  工欲善其事必先利其器,在介绍OC调试前,先熟悉Xcode中左上角的工具栏,后续内容会重点关注红框标注出的四个部分。

  • 项目导航器(Project navigator):管理项目中的文件资源;
  • 版本控制导航器(Source Control navigator):查看分支文件修改情况以及项目仓库信息,如分支名、暂存更改以及远程分支状态;
  • 符号导航器(Symbol navigator):项目中的可识别符号如类名、方法以及变量名都可以在这里查看;
  • 文件查找导航器(Find navigator):查找项目中的某个文件以及位置;
  • 问题导航器(Issue navigator):可以查看项目在构建或运行时出现的问题或者警告;
  • 测试导航器(Test navigator):项目运行时可以查看CPU、内存以及磁盘等的占用情况,方便后续优化,若添加断点还可以查看堆栈调用信息;
  • 断点导航器(Breakpoint navigator):方便管理项目中加入的断点,快速定位断点位置;
  • 报告导航器(Report navigator):查看项目在构建运行以及调试时的日志信息;

2 代码静态分析

  代码静态分析通过扫描代码,查找违反预编程规则的问题,如内存泄露和缓冲区溢出,可以有效提高代码质量,值得注意的是Xcode中的静态代码分析适用对象只有C、C++、Objective-C,除此之外如代码追踪和性能检测的工作应结合instruments使用,后面会介绍instruments及其使用。接下来我们会介绍如何打开代码静态分析的功能以及如何使用它解决常见问题。

2.1 使用方式

一般快捷键为 Command + Shift + B , 也可以通过Xcode 顶部工具栏选择Analyze。

执行之后便可以在问题导航区看到问题提示,双击后可以直接定位到代码处,可以看到右边红框的提示即为逻辑错误,这里漏掉了一个父类方法的调用。值得注意的是因为是静态分析,所以并不能检测出所有错误,且存在误报风险,比如这里的左边红框提示controller4并未使用,其实最后在运行时状态UI会将其从数组中取出并渲染;

2.2 静态代码分析可以解决的常见问题

  • 逻辑缺陷 可以看到这里返回了一个未初始化的值,与此同时IDE也会提示有变量并未初始化。

  • 内存管理错误

    这里检查到返回了一个空指针,但是函数并不希望返回一个空值。这里检查函数编写逻辑也存在错误,增加默认判断保证返回值不为空即可。

    当程序中包含潜在的内存泄露的问题时,静态代码分析也可以检测,这里CoreGraphics框架下的CGColor需要我们手动管理内存,使用 CGColorRelease 释放即可。

  • 无用存储逻辑
    这里主要是指永远不会访问的变量以及永不会执行的代码。

    这里提示 Value stored to 'cellNum' is never read 说明代码可以优化,直接返回需要的数值,可以去掉cellNum这个临时变量。

  • API滥用 这里的问题主要是忽略了一些OC对象的使用规则,数组中不允许添加空值,除此之外字典中也不允许设置key和value为空值。

  • Xcode13新增检查特性
    在Xcode13中静态代码分析器升级,可以捕获更多的逻辑错误如死循环、NSAssert副作用、无用的冗余代码等。这里我们以NSAssert副作用为例,编写代码后使用 Command + Shift + B 开启静态代码分析,可以看到提示condition中的语句会在release版本下丢弃,所以不应该将变量修改的代码放入condition中执行,这里将对number的修改代码移至NSAssert函数之外即可,不仅是Objective-C中的NSAssert函数,C和C++中的Assert函数也可以捕获异常。

3 LLDB常用指令与断点使用

  断点一般可以分为普通断点,条件断点,符号断点,异常断点,watch断点,线程断点。在叙述各个断点的使用功能之前先介绍调试区域工具栏以及常用LLDB指令。

3.1 断点工具栏

在此我们还是先熟悉一下断点相关的工具区及其用途。 图中各标签对应含义如下:

  • 1——断点开关(Deactivate breakpoint),当为蓝色时断点打开,点击后蓝色消失,断点关闭;
  • 2——继续执行(Continue program execution),点击后会跳转至下一个断点,若没有则程序进入运行状态;
  • 3——单步运行(Step over),单步执行程序,方便观察每一步的运行状况,当遇到子函数不会进入而是执行完进入下一条语句;
  • 4——跳入函数(Step into),进入子函数中继续执行;
  • 5——跳出函数(Step out),结束子函数执行跳转到上一级函数;
  • 6——层级视图(Debug View Hierarchy),查看程序当前页面的UI层级视图,定位视图组件层级关系;
  • 7——内存结构图(Debug Memory Graph),直观检查是否存在如内存泄漏的问题;
  • 8——调试器外观设置(Environment Overides),设置真机或模拟器的深浅色以及字体字号等外观元素;
  • 9——模拟定位(Simulate Location),设置真机或模拟器的定位位置;
  • 10——断点处实例变量信息(Variables View);
  • 11——LLDB终端,执行相关指令,清屏快捷键为 Command + K ;

3.2 常用LLDB指令

  • help指令——查看LLDB提供的指令及其作用

    当我们想查看某个具体指令的用法时可以使用 help commandName,可以看到其提示的诸多搭配用法以及含义。

  • command指令
    command指令主要用来管理自定义的一些LLDB指令,常常结合script或者source使用,command script import filePath 导入自定义的指令,而 command source 指令用于执行指定文件中的LLDB指令。LLDB会在几个特定位置预加载一些内容,而~/.lldbinit文件属于其中之一。若本地并无此文件可以自行创建,然后输入以下内容并保存,其中 fblldb.py 为facebook开源的chisel提供的定制LLDB指令集合的入口文件,详细内容以及安装方式点击这里,其中也包含如何添加个人定制的指令。

    command script import path/to/fblldb.py
    

    然后打开LLDB调试窗口,输入以下指令,如此便可以调用chisel提供的调试指令。除此之外,command source 指令还可以导入个人定制的.lldbinit文件,从而避免使用高达(Gundam)动态生成的.lldbinit文件,无法包含自定义配置项,而在.lldbinit文件中添加 command script import 指令也可以算作自定义配置的一种。

    command source ~/.lldbinit
    
  • p指令——打印基本数据类型

  • po(print object)指令——打印对象

    除此之外po指令还能和oc中的方法结合使用。

    当自定义一个类时,调用po指令。

    这时并没有显示我们需要的信息,需要重写KSEText类的debugDescription方法。

    再次执行po指令。

  • call指令——动态调用函数
    执行指定函数,在不重新编译的情况下更改视图,除此之外,call指令同样可以执行表达式

  • expr指令(expression)——执行指定的表达式
    若执行下述操作出现报错提示可以使用 expr videoController.tabBarItem.title = [NSString stringWithUTF8String:"短视频"] 来解决(出错原因)。

  • image指令——查找异常地址
    这里我们模拟数组越界的情况,接着使用image指令查找出错位置(当然后面会介绍使用异常断点直接定位),而 image list 可以查看当前项目所有引入的库,常用于查看某个插件是否注入自己的项目。 模拟代码如下。

    下面是出错的提示信息,可以看到前面的CoreFoundation和libobjc.A.dylib和我们关系不太大,可以直接从序号4开始查。

    执行 image lookup -a(image lookup --address),可以看到Summary部分提示了出错位置为AppDelegate.m文件中的53行18列。

  • bt指令——打印线程堆栈信息 bt all 打印所有线程堆栈信息

    当只需要部分堆栈信息时,可以在 bt 指令后面添加具体打印的行数信息。

3.3 断点使用

  • 普通断点
    在代码中某一行点击最前面的数字就可以添加普通断点,当程序执行至断点处便暂停执行,注意断点对应的程序语句是没有执行的。从下图中可以看到,程序目前暂停在了36行,且前面的断点标签即数字1所指的位置是亮蓝色,当再次点击图标会变暗,状态变为disable;

    数字2指向的按钮功能为跳转至下一个断点,当后续没有断点时,程序会进入运行状态,如下所示,后面的按钮也会进入禁用状态。

    当我们添加多个断点想要统一管理时,便可通过左上角的断点导航器查看,双击则跳转至断点处。普通断点可以结合LLDB指令使用,排查代码逻辑是否符合预期。

    而点击左上角的调试导航器图标则可以查看程序运行的内存CPU等资源的占用情况,除此之外还可以查看当前线程的函数调用栈;

    如下图所示,从左边的堆栈调用关系可以看到,main 函数间接调用了 KSECollectionViewController的方法 [collectionView:cellForItemAtIndexPath:],然后[collectionView:cellForItemAtIndexPath:] 方法调用 GTReuseableCell的 [initWithFrame:]方法。

  • 条件断点
    添加条件断点,我们可以在原先普通断点的基础上进行编辑修改使其成为条件断点。右键断点选择“Edit Breakpoint...”

    在Condition一栏添加我们设想的逻辑条件,然后运行程序,当符合断点条件时,程序便会停留在断点处。

    当然我们还可以在符合断点条件时执行指定的动作,点击Add Action进入选择界面。

    点击红框框选按钮,可以看到有六个选项,各自作用分别为:

    • AppleScript:执行脚本文件;
    • Capture GPU Workload:用于OpenGL ES调试,捕获断点处GPU当前绘制帧;
    • Debugger Command:和控制台中输入LLDB调试命令一致;
    • Log Message:输出自定义信息至控制台;
    • Shell Command:接收命令文件及相应参数列表,Shell Command是异步执行的,只有勾选“Wait until done”才会等待Shell命令执行完再执行调试;
    • Sound:断点触发时播放声音;

    除此之外,当我们在循环中设定断点后可以使用Ignore选项跳过前面N次,直接查看需要的信息。

    而下方的Options选项选中后断点不会终止程序执行。

  • 符号断点
    对一个方法下断点,当程序调用此方法时触发断点,这里可以是OC方法或者C++函数,常用于调试第三方库,给相应函数下断点,查看程序当执行流程。这里添加方法相比之前并不相同,这里先点击左上方的断点导航工具图标,进入断点查看界面。

    点击左下方的”+“按钮,然后选中Symbolic Breakpoint...进入符号断点添加界面。

    在输入符号时可以只输入函数名,也可以按[类名 函数名]的方式输入,还可以在前面加上类方法或者实例方法修饰符。

  • 异常断点(全局断点)
    用于快速定位由于程序抛出异常而导致退出,如常见的数组越界,添加方法同符号断点,选中断点导航器,点击左下角“+”号,选择Exception Breakpoint...,随后进入异常断点设置界面。

    这里我们主要配置两个地方,Exception和Break,Exception可以选择Objective-C、C++以及All,值得注意的是,当程序使用异常来组织框架逻辑,这时候选择All会频繁触发异常,所以这种情况更适合选择Objective-C;而Break主要选择在抛出异常还是捕获异常的时候断点。

    这里还是以数组访问越界的例子,演示异常断点如何定位问题位置。当我们添加异常断点后执行程序得到如下图所示结果,相比image指令,异常断点会直接跳转至错误代码位置并清晰指出异常触发原因。

  • 线程断点
    线程断点适用在调试多线程代码的时候,一段代码可能会被多个线程同时执行,设置方法为,首先在代码处添加普通断点,然后获取输入指令thread info 获取线程id,然后在LLDB中输入指令 breakpoint set -f filename -l line_number -t tid

    执行 breakpoint list 指令可以查看当前包含的所有断点,因为设置断点是逻辑断点,而一个逻辑断点对应一个或多个位置断点,当载入新的代码时也会同时更新位置断点,逻辑断点和位置断点使用“.”分割。

    当想禁用或者删除断点时可以执行指令breakpoint disable/delete breakpoint_number

  • Watch断点
    常用于跟踪某个变量发生的变化,当发生变化时触发断点。设置方法为,在变量第一次出现的位置新建普通断点,然后在Variables View窗口选中需要监控的变量并右键,选择Watch "_text",这时便可以在断点导航器中查看到新建的Watch断点。

    当被监控变量发生变化时便会触发断点,并在LLDB终端显示,并包含新旧值对比。

    继续执行,可以看到控制台输出后续变化的监测信息。

    这时的信息是十六进制,若需要查看其具体含义,则可以使用po指令。

    在实际开发中,可能需要使用逆向工程的方式来观测某个变量的变化,这个时候我们可以使用LLDB的命令行来添加Watch 断点,好处在于可以直接使用变量地址进行监测,指令格式为 watchpoint set expression address ,删除Watch断点可以使用 watchpoint delete <cmd-options> [<watchpt-id | watchpt-id-list] ,的可选参数为 -f (force)。

3.4 调试例子

这里我们尝试运行程序,使用 Command + R 指令,但程序构建失败,报错如下,我们找到和我们密切相关的部分,这里我们优先定位红框标出的部分。

使用image lookup -a 0x00000001049707ae 指令查找出错位置,信息如下。

感觉对我们帮助不大,这里main函数是主入口,也是项目初始化生成的部分。

我们尝试使用全局异常断点获得其他的异常信息。这里新建异常断点(Exception Breakpoint),参数使用默认值。再次运行程序得到以下信息,点击绿色断点信息将其展开,可以看到提示“UITableView dataSource returned a nil cell for row at index path:...",我们的TableView返回了一个空值导致了程序崩溃。这里我们可以自己定位代码中哪里有使用过TableView也可以直接使用静态代码分析。

因为程序构建就崩溃,我们优先尝试使用静态代码分析检查错误。这里使用 Command + Shift + B 指令,得到如下提示,也帮我们定位到了代码问题所在,在调用[tableView: cellForRowAtIndexPath:]方法时返回了空值。所以我们需要修改代码逻辑,在最后补充默认返回值但不能为空,增加一个else语句。

代码修改如下所示,执行程序,成功运行,但是最后TableView并没有显示我们想要的结果。

这里应该是存在内在逻辑错误,所以需要借助断点排查问题。这里我们分别在 [tableView:numberOfRowsInsection:] 和 [tableView:cellForRowsAtIndexPath:] 方法的返回处添加普通断点,因为这样我们方便查看最后的返回值,断点所在行的代码是还未执行的。除此之外我们还在 [tableView:cellForRowsAtIndexPath:] 方法的if语句开头处添加断点,方便我们查看indexPath变量的值。

我们先逐个排查返回值是否存在异常,这里添加断点后,程序便会停留在断点处,我们再使用po指令输出我们想查看的值。这里我们先查看cellNum的值为2,并无异常,我们再查看indexPath变量中的item属性发现为空,则需要在程序中添加兜底逻辑,让最后的cell返回非空值,这里在第二张图中也可以印证左边variable窗口cell最后依然是空值。图中为后续添加的else语句。

点击继续按钮,让断点往下执行,再次输出indexPath的item属性,并无异常。执行完断点后便可以看到tableview显示了一行结果。


但是上面一行还是没有信息填充肯定是不符合预期的,继续调试。

这里我们可以聚焦调试导航栏,查看程序调用的堆栈信息,发现这里它存在一个预生成逻辑,最初会生成一个全局的cell方便后面复用(TableView中的cell需要注册后使用,这样可以在原来cell的基础上修改少量显示信息便能展示新的cell,实现复用,极大提升性能)。

打印第一次返回的cell的信息发现返回的text属性都为空。

当继续执行断点,再次打印cell相关的text信息,可以得到以下结果。

结合代码可以知道,当indexPath.item值为1时,这里会使用懒加载生成cell然后赋值。

由此我们可以得到一个结论,第一次返回的是TableView创建的全局cell但是text属性值为空,这里我们需要给第一次创建的全局cell给一个合理的初值,然后执行代码,问题解决。

4 Log的使用

  虽然断点功能强大,但这里还是需要补充一样不可或缺的工具——Log。有的时候调试的时候bug无法复现,一旦关闭调试,bug便会出现。这一般是出现在多线程中,调试模式影响了多线程的执行。所以我们需要借助Log去检查程序执行顺序是否符合预期。这里我们需要借助pch(Precompiled header)文件设定我们的打印操作如何执行,至于pch文件是什么以及有何作用可以看这篇文章,Xcode6之后需要手动添加pch文件,添加方法看这里,完成第一步即可,这里着重介绍如何在pch文件中书写Log规则以及在项目中引入pch文件。

当我们在项目中生成了后缀为.pch的文件后,双击打开可以看到以下描述。

我们在#define和#endif中间添加需要的宏,这里我们添加以下内容。第11行的DEBUG标识可以帮助我们区分Debug和Release状态,这样在软件发布时日志信息便不会再打印。第12行中的KSELog为打印的方法名,可以自己定义。这里增加了文件名以及行数的信息打印,方便后续查看Log日志定位问题。

#ifndef KSELog_pch
#define KSELog_pch

#ifdef DEBUG
#define KSELog(...) \
NSLog(@"%@第%d行:%@\n---------------------------",[[NSString stringWithFormat:@"%s",__FILE__] componentsSeparatedByString:@"/"][[[NSString stringWithFormat:@"%s",__FILE__] componentsSeparatedByString:@"/"].count-1], __LINE__, [NSString stringWithFormat:__VA_ARGS__]);
#else
#define KSELog(...)
#endif

#endif /* KSELog_pch */

之后我们需要在项目中引入pch文件。首先我们需要打开项目的target找到Apple Clang - Language,这里可以使用过滤器快速定位。

然后我们需要将 Precompile Prefix Header 属性置为Yes,除此之外需要在Prefix Header部分添加pch文件的绝对路径,这里可以借助Finder和Terminal快速获取文件路径,将Finder中的文件拖入Terminal便可以得到打印的路径信息。这里需要双击箭头指向的部分,进入编辑模式,将复制的路径粘贴进去即可。

效果查看。

5 Instruments

  Instruments是功能强大的性能检测和代码追踪工具,常用于内存泄露检测,性能分析,支持多线程调试,可以将录制的图形界面操作和Instruments保存为模板,供以后访问使用。因为Instruments提供的功能较多,无法一一列举,这里主要介绍Leaks模版的使用。首先本文会简要说明Instruments提供了哪些主要功能,然后聚焦于Leaks模版的使用。打开方式为,点击顶部工具栏的Product,然后选择Profile即可。

5.1 主要功能(基于Xcode 13.4)

常用模板:

  • Allocations:用来检查内存分配,跟踪过程的匿名虚拟内存和堆的对象提供类名和可选保留/释放历史
  • Leaks:一般用来查看内存使用情况,检查泄漏的内存,并提供了所有活动的分配和泄漏模块的类对象分配统计信息以及内存地址历史记录
  • Time Profiler:分析代码的执行时间,执行对系统的CPU上运行的进程低负载时间为基础采样
  • Zombies:检查是否访问了僵尸对象

  • Blank:创建一个空的模板,可以从Library库中添加其他模板
  • Activity Monitor:显示器处理的CPU、内存和网络使用情况统计
  • Animation Hitches:动画监视,此模板通过时间分析来度量应用程序图形性能以及进程的CPU使用情况
  • App Launch:启动问题,可以用于查看App的启动过程,从而可以针对性的对启动速度进行优化
  • Core Data:监测读取、缓存未命中、保存等操作,能直观显示是否保存次数远超实际需要
  • CPU Counters:帮助开发者定位CPU占用高的线程和函数,来优化App的CPU性能问题
  • CPU Profiler:周期性的CPU负载分析器,使用硬件性能监测中断来提供可靠的性能监测,无论代码运行在高速还是节能的CPU上
  • File Activity:文件活动,此模板监视文件和目录活动,包括文件打开/关闭调用、文件权限修改、目录创建、文件移动等
  • Game Performance:方便开发者了解对游戏运行表现和帧率影响至关重要部分代码的运行情况
  • Logging:统一的日志管理系统,方便将日志和线索可视化。引入归档日志文件时的默认模版
  • Metal System Trace:金属系统跟踪,Metal System Trace通过提供来自应用程序、驱动程序和GPU层的跟踪信息,介绍了iOS、tvOS和macOS Metal应用程序的性能
  • Network:分析应用程序如何使用TCP / IP和UDP / IP连接使用连接仪器。就是检查手机网速的。(这个最好是真机)
  • SceneKit:概述应用程序对SceneKit的使用。确定进入每个帧的工作类型,例如动画、物理、场景选择和渲染
  • System Trace:系统跟踪,操作系统中发生的事情的一个全面的观点。了解如何跨cpu调度线程,了解系统调用和虚拟内存错误如何影响应用程序的性能

5.2 Leaks模版

如上所述,Leaks模板主要用来检测内存分配情况,查探是否发生了内存泄漏,一般和Allocations结合使用。这里以Xcode 13.4版本为例,演示Leaks模板的使用。首先我们在示例代码中增加一段会引发内存泄漏的代码,如下所示。我们简单定义一个结构体,然后模拟申请内存但不释放的情况,因为ARC只针对NSObject,所以这里的内存空间是需要手动回收的。

我们按照先前提示步骤打开Profile界面,选择Leaks,然后进入分析界面。注意此时代码是没有执行的,需要点击分析界面左上角的红色同心圆,开始程序的检查分析。

分析界面如下所示,上面一栏Allocations是分析代码的内存分配情况,下面一栏Leaks是检测是否发生了内存泄漏,绿色标识指明暂未发现内存泄漏,红色标识表明检测到发生了内存泄漏。这里我们以C语言中的结构体申请但不释放为例,演示Leaks模板的使用。

示例代码如下,重写KSEViewController的Init方法,添加内存申请代码。逻辑为在主界面点击跳转到KSEViewController对应的界面,然后返回,因为结构体Book为自定义结构,需要手动释放,若返回到主界面未释放内存则会被检测到内存泄漏。

注意使用Instruments模版需要先在Xcode中编译运行,然后按步骤打开模版检测。这里可以看到在Leaks一栏出现了红色标识,我们点击右下方的Stack Trace中的堆栈记录,跳转到对应含有内存泄漏风险的代码处,修复Bug。

6 调试补充

6.1 view hierarchy的使用

在进行UI界面开发时,常常需要观察页面层级关系以及位置关系,这时就可以使用Xcode中自带的Debug View Hierarchy,打开方法为运行项目程序后点击下方的Debug View Hierarchy,等待程序加载完毕后,就可以看到。

  • 1——Adjust the spacing between the views:调整视图间的间隙,一般是由2D视图转变为3D视图;
  • 2——Show/Hide Clipped Content:展示或者隐藏当前界面没有被展示的内容;
  • 3——Show/Hide Constraints:展示或者隐藏界面约束;
  • 4——Adjust View Mode:调整视图模式,可选仅内容展示、仅线框展示以及内容和线框同时展示;
  • 5——Change canvas background color:改变画布的背景颜色,深色模式和浅色模式,支持真机;
  • 6——Orient to 2D/3D:调整视图为2D或者3D模式
  • 7——Adjust the visible range views:调整可见的视图范围;

6.2 真机截图

 实际开发中,有的时候需要给产品或者测试人员展示界面效果,这个时候就可以使用Xcode中的截图功能,当我们选择顶部导航栏的 Debug 选项,然后找到 View Debugging ,最后点击 Take Screenshot of xxx的iPhone ,这样就能获得存放在桌面位置的真机截图。

总结

本文主要结合了个人的经验和查阅的资料,希望能对iOS的开发者们有所帮助,在这里找到需要的Debug技巧。如果内容有所不足,还请批评指正,有任何问题,也希望多多留言,共同探讨进步!

hi, 我是快手电商的小毅

快手电商无线技术团队正在招贤纳士🎉🎉🎉! 我们是公司的核心业务线, 这里云集了各路高手, 也充满了机会与挑战. 伴随着业务的高速发展, 团队也在快速扩张. 欢迎各位高手加入我们, 一起创造世界级的电商产品~

热招岗位: Android/iOS 高级开发, Android/iOS 专家, Java 架构师, 产品经理(电商背景), 测试开发... 大量 HC 等你来呦~

内部推荐请发简历至 >>>我们的邮箱: hr.ec@kuaishou.com <<<, 备注我的花名成功率更高哦~ 😘