原文:Intermediate Debugging with Xcode 8
作者:George Andrews
译者:kmyhy
更新说明: 本教程由 George Andrews 升级为 Xcode 8 和 Swift 3。原文作者为 Brain Moakley。
软件开发中唯一不变的主题就是 bug。让我们面对现实吧,没有人能够一次就能做对。从输出错误到不正确的假设,软件开发就好比是蟑螂屋里烤蛋糕——只不过制造蟑螂的人就是开发者自己。
幸运的是,Xcode 提供了大量防止这种事情发生的工具。虽然你热衷于 debugger,但通过这些工具你能够做的更好,而不仅仅是查看变量和单步执行!
本文针对中级 iOS 开发者,你将学到一些少为人知但又非常重要的调试技巧,比如:
我个人的目标是成为一个真正懒惰的开发者。我宁可麻烦在前,而享受在后。幸运的是,Xcode 为我节省下了“马丁尼时间”。它提供了许多工具,使我不再没日没夜地粘在我的电脑面前。
我们先来看看有哪些工具。拖过一把豆袋椅。打开饮料。让我们放松一下:]
本教程假设你熟悉 Xcode 调试器。如果你不知道如何在 Xcode 中进行调试,请先阅读新手调试教程。
我为本教程准备了一个示例 app。你可以在这里下载。
这个 app 叫 Gift Lister,它会记录你想为谁购买的礼物。就像 Gifts 2 HD,它获得了 2012 年印象最深刻的读者 App。Gift Lister 和 Gift 2 HD 很像……当然要差许多。
首先,它有许多 bug。开发者(也就是我,只不过穿着不同的衣服)雄心勃勃,尝试用传统的方式修复这些 bug……但是无功而返:]
本教程会教你如何在尽可能偷懒的同时修复这个 app。
好了,让我们开始吧——但也不必太过紧张。:]
打开项目,看一眼它的文件。你会注意到这个 app 有一个简单的前端和一个简单的 Core Data 数据库。
注意:如果你不熟悉 Core Data,也没关系!Core Data 是一个面向对象的持久化框架,它有现成的教程。在本教程中,我们不会过多深入这个框架,也不需要和 Core Data 对象进行任何有意义的交互,因此你也不需要了解得太多。只需要知道 Core Data 会为你加载对象、保存对象就可以了。
大概浏览完之后,我们开始打开调试器。
进行任何调试过程之前,首先需要打开 debugger 控制台。点击主工具栏上的这颗按钮:
这颗按钮很好找,每次调试会话开始时点击这颗按钮,将避免你的指尖的不必要的磨损 ;] 为什么不让 Xcode 为你多做一些工作呢?
然后,打开 Xcode 偏好设置,通过 [command + ,] 或者 Xcode\Preferences 菜单。点击 Behaviors 按钮(齿轮图标)。
点击左边的 Running\Starts。你会看到一堆选项。点击右边的第七个选项框,然后在最后边下拉列表中选择 Variables & Console View 。
在 Pauses 和 Generates Output 上重复同样动作,它们紧紧挨在 Starts 下边。
Variables & Console View 选项告诉调试器要显示本地变量清单,以及每当调试会话开始后显示控制台输出。如果你只想看到控制台输出,你可以选择 Console View。相反,如果只想看变量,则选择 Variable View。
Current Views 选项默认会显示最后一次调试的调试器视图。例如,如果你关闭了 Variables 仅仅显示控制台视图,那么下一次调试时就只会显示控制台视图。
关闭对话框,运行 app。
现在,每次编译运行 app 都会打开调试器——而不需要再点击那颗按钮了。尽管这个动作只需要 1 秒钟,但一周下来也会浪费你几分钟。毕竟,你的目标是做一个懒惰的程序员:]
在继续下一步之前,有一个重要的事情,就是回顾一下断点定义。
断点是程序中的一个时间点,允许你在运行程序的过程中执行某些动作。有时,程序可以在指定的某个点暂停,允许你查看程序状态或者单步执行代码。
你还可以运行代码,改变变量,让计算机引述莎士比亚语录。我们将在后面的内容中进行所有的这些动作。
注意:本教程将设计部分断点的高级用法。如果你还对诸如步进、步出、跳过之类的概念不太清楚,请阅读My App Crashed, Now What? 教程。
运行 app。然后,加一个新朋友,以便记录他的礼物。不出意外,当你添加新朋友时,app 崩溃了。我们来搞定它。
这是你第一次运行 app 的样子:
这个项目需要头脑清醒。现在,你无法看到编译错误的原因。要找到它,需要添加异常断点,以记录错误的原因。
切换到断点导航器:
然后,点击面板底部的 + 号按钮。在弹出菜单中,选择 Exception Breakpoint… 。
你会看到这个对话框:
Exception 字段允许你通过 O-C、C++ 或所有语言来触发断点。保持默认的 All 不改变。
Break 字段允许你在错误被抛出还是被捕捉时暂停。保持默认的 on throw(抛出时)不变。如果你是在自己的代码中进行了错误处理,则可以选择 On Catch(捕捉时)。对于本教程,请使用 on throw。
后面两个字段稍后介绍。点击对话框外任意地方,关闭对话框,然后运行 app。
这次调试器给出了更清晰的结果:
看一眼控制台——它打印了一些消息,其中大部分都是不需要的。
调试代码时日志是至关重要的。日志信息需要被过滤,否则控制台将被垃圾信息所占据。干扰信息会浪费你的时间,因此必须过滤掉它们,否则你会在一个问题上花去更多的时间。
打开 AppDelegate.swift,你会看到在 didFinishLaunchingWithOptions 方法中看到一大堆过时的消息。选中它们,然后删除。
然后搜索其他的日志输出语句。打开搜索栏,查找 in viewDidLoad。
点击搜索结果,这将打开 FriendSelectionViewController.swift 并跳到产生了日志的那句代码。
注意,这次使用的是 print 语句,而不是 NSLog 语句。通常在 Swift 中,标准输出使用 print,当然你也可以用 NSLog。
以 log 方式输出日志信息有一个重要的地方,当你在多线程中输出日志时,你不必自己保持同步。这两种方法都可以用在调试会话中,将信息输出到控制台。
这里,管理你的日志语句的工作开始逐步累积。它看起来不太多,但每分钟都会增加。到了项目后期,这种零零散散的时间加起来很容易就突破了几个小时。
硬编码日志语句带来的另一个“好处”是,每当你添加了一个语句到代码库中,就相当于添加了新的 bug 到代码中。只需要敲了几个键,再加上自动完成,以及稍微不注意——你以前正常的 app 就多了一个 bug。
是时候将这些日志语句移除代码中了,它们只属于断点。
首先,注释两条 print 语句。然后,在每条语句左边的边栏中左键,添加一个断点。
你的代码窗口看起来应该是这个样子:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '+entityForName: nil is not a legal NSManagedObjectContext parameter searching for entity name 'Friend''
Core Data 代码有问题。
浏览代码,你会看到实体对象是通过 persistentContainer 的 viewContext 来创建的。你的直觉会告诉你,可能是 persistenContainer 导致了问题。
仔细看控制台,你会找到这个调用栈:
Failed to load model named GiftList
CoreData: error: Failed to load model named GiftList
Could not fetch. Error Domain=Foundation._GenericObjCError Code=0 "(null)", [:]
这个信息告诉你 CoreData 无法加载一个数据模型,叫做: GiftList。如果你查看这个项目的数据模型,你会发现它实际上应该叫做 “GiftLister”。
看一眼 AppDelegate.swift 中的其它代码。
因为我的疏忽大意,我将 persistentContainer 的 name 参数写错了。我将 “GiftLister” 给写成了 “GiftList”。
将 “GiftList” 改成 “GiftLister”。
let container = NSPersistentContainer(name: "GiftLister")
运行 app。现在添加一个朋友。哈—— app 现在好像正常了。
看起来不错,但你可能注意到一点,断点输出的消息中不包含时间,对于调试来说这可是很有用的哦!幸好,通过断点表达式这个问题很好搞定!
注意:日期输出十分有用,但同时会让日志输出变慢,因为系统需要查询日期信息。记住哪怕是调用日志输出自身都会导致 app 性能降低。
让我们恢复原来的日志语句。右键(或 ctrl+左键)点击 FriendSelectionViewController.swift 中的第二个断点。点击 Edit Breakpoint。在 action 列表中将 Log Message 修改为 Debugger Command ,然后在文本框中输入:
expression NSLog("Loading friends...")
类似这个样子:
Debugger 命令将在运行时计算这个表达式。
运行 app。你会看到:
2012-12-20 08:57:39.942 GiftLister[1984:11603] Loading friends...
在断点中添加 NSLog 语句意味着你不需要为输出某些重要的数据而停止程序的执行,这会减少你产生新 bug 的机会,因为你根本没有碰代码——最好的一点是,你不需要在发布前争分夺秒地删除代码中的 debug 语句。
现在关闭 app 中的日志输出。非常简单,点击 debugger 视图中的断点按钮即可。
点击这个按钮,运行 app。现在控制台干净多了。你也可以在断点导航器中关闭某个断点。
现在,不得不一一注释代码中日志语句的日子一去不复返了!:]
接下来要做的事情是创建更多的朋友,这样你可以记录一张建议给他们的礼物清单。
运行 app。点击 Add a friend 行。app 会显示另一个 view controller,包含一个姓名输入框和一个日期选择器。输入名字,选择生日,然后点击 OK 按钮。
这将会回到根控制器,而你添加的朋友将显示在列表中。再次点击 Add a friend。
输入新朋友的名字,这次将生日设置为 2010 年 2 月 31 日。
在正常的日期选择器中,这样一个日期根本是不可能出现的。然而对于我们的这个神奇则不然。因为大脑抽风,我决定使用普通的 picker 来代替 date picker。这样我就不得不重写日期校验逻辑,同时也带来了几个 bug。
点击 OK 按钮。很不幸,这个无效的日期被保存了。让我们来看看这会导致什么样的错误吧。
打开 AddFriendViewController.swift ,在 saveFriend 方法开始处添加一个断点。
注意:在一个大文件中定位方法比较费事。比较麻烦的做法是逐行扫描代码,直到找到这个方法。另外一种方法是使用跳转栏,通过方法列表来查找。我最喜欢的方法是使用查找,当然不是在搜索栏中,而是在跳转栏中搜索。点击跳转栏,然后开始键入文本。你的方法名就会出现,就像你在搜索栏中所做的一样。
在模拟器中,点击 Add a friend 按钮,很之前一样,添加一个无效的生日。单步执行,知道你到达这一行:
if name.hasText, isValidDateComposedOf(month: selectedMonth, day: selectedDay, year: selectedYear) {
步进到 isValidDateComposedOf 方法。很显然,校验代码出错了——这里什么都没有!只有一个注释,表明以后会实现它。
注释是一种很好的描述代码块意图的方法,但你无法用它们来进行任务的管理。再小的项目,也有太多的任务,注释的任务经常会被遗忘。
真正防止它们被遗忘的方法是让它们非常显眼。其中一种方式就是让信息在跳转栏中显眼。
打开跳转栏,你会看到:
你还可以用 FIXME: 或者 MARK:。
代码中的 MARK:、TODO: 和 FIXME: 注释语句会显示在跳转栏中。此外,如果你在 MARK: 后面加一个连字符,比如 MARK: - UIPickerViewDataSource,跳转栏会在注释前面添加一个水平分割线,就更容易阅读了!
这些语句并不会让编译器当成警告或错误,但会比方法底部的普通注释更容易看到。这是让注释以及注释所标注的任务形成一个待办任务清单,从而突出于代码库。
但是,为什么不能让 Xcode 编译器为代码中出现的 TODO: 、 FIXME: 注释发出警告呢?我真心觉得这很不错!
要做到这一点,你需要在项目中添加一个 build 脚本,搜索代码中所有的 TODO: 和 FIXME: 注释,并将之作为编译器警告。
要创建 build 脚本,请在项目导航器中选中项目,然后点 Build Phases。点击 + 按钮添加一个新的 Run Script Phase。
然后,编写 build 脚本如下:
TAGS="TODO:|FIXME:"
echo "searching ${SRCROOT} for ${TAGS}"
find "${SRCROOT}" \( -name "*.swift" \) -print0 | xargs -0 egrep --with-filename --line-number --only-matching "($TAGS).*\$" | perl -p -e "s/($TAGS)/ warning: \$1/"
你的 Run Script 代码看起来是这样的:
编译项目,打开 issue 导航器:
现在 TODO: 注释被显示成一个 shell 脚本调用警告,这样你总没办法遗忘了吧?:]
现在,我们来看一个从 Xcode4.4 就有的小功能。
重新运行 app,保持在空的校验方法中的断点不变。现在,步出这段代码。打开 debugger 的 Variables 视图,你会看到:
显示返回值并不是多稀罕的功能,但它足以节省你很多时间。试想从这里调用这段代码:
if name.hasText, isValidDateComposedOf(month: selectedMonth, day: selectedDay, year: selectedYear) {
这句代码会调用 isValidDateComposedOf 方法并立即在表达式中使用它的返回值。
在这个功能出现之前,你需要离开这行代码,如果你想查看返回值的话,还必须打印它。现在,你可以简单地步出一个方法,然后在调试器中查看返回值。
有时,我们不得不以固定的间隔修改应用程序的状态。有时这种改变发生在大量时间序列之中,这使得正常的 debugging 变得十分困难。这就要使用到条件(conditions)了。
现在,app 中列出了几个好友,点击他们的名字将打开礼物界面。这是一个简单的分组表格,可以对表格进行排序,以决定某件礼物是否应该购买。
点击导航条上的 add 按钮添加一件礼物。名称输入 shoes,价格输入 88。然后点 OK 按钮。这样 shoes 就显示在礼物清单中了。
接着添加这些礼物:
哎呀,你突然想到,其实你想添加的应该是 PS4 而不是 XBox。你可以点击这一行进行修改,但为了方便我们演示的缘故,你可以通过 debugger 来进行修改。
打开 GiftListsViewController.swift 找到 cellForRowAtIndexPath。添加一个断点在这一行下面:.
if (gift) {
看起来是这个样子:
在这个断点上右键(或者 ctrl+左键),选择 Edit Breakpoint。
然后来添加条件。你可以把它当成一个简单的 if 语句。添加这个代码:
gift.name == "Xbox"
然后,点击 segmented 控件上的 Bought 按钮。表格会刷新,但断点不会被触发。
点击 segmented 控件的 Saved 按钮。这次会暂停了,同时所选中的礼物会高亮显示在控制台中。
在控制台中,输入下列代码:
expression gift.name = "PS4"
现在,点击 Run 按钮,表格会继续加载。PS4 会替换掉礼物中的 XBox。
你可以通过修改循环变量来达到同样效果。ctrl+左键(或右键)点击这个断点,选择 Edit Breakpoint。这次,将 Condition 栏清空,将 Ignore 设置为数字 2,然后点 Done。
现在,点击 segmeted 控件的 Bought 按钮,然后点击 segmented 控件的 Saved 按钮。这将触发同一个断点。
要确认这是我们需要的对象,可以输入:
(lldb) po gift
现在,和之前一样修改对象的状态:
(lldb) expression gift.name = "Xbox"
表格会显示出修改的结果。实时修改是不是更爽?
在开发数据驱动的 app 时,经常需要清除数据库。可以用很多办法去做这个事情,比如重置 iPhone 模拟器,或者在 Mac 上查找真实的数据库并进行删除。重复干这样的事让人厌倦,我们可以偷点懒,让 Xcode 为我们做这个。
一开始需要创建一个 shell 脚本。一个 shell 脚本是一个命令集合,让操作系统自动执行某些动作。要创建 shell 脚本,需要用 application 菜单创建一个新文件。点击 File\New\File 或 Command-N。在分类中,先选择 Other 然后选择 Shell Script 类型。
文件名设为 wipe-db.sh。
为了真正清除数据库,我们需要用 remove 命令以及数据库的完整路径(包括当前用户名)。你可以用 Finder 或者终端程序找到数据库所在位置,然后复制/粘贴它的路径到 shell 脚本中。但在 Xcode 8 中,保存数据库的文件夹在每次编译、运行 app 时总是不固定的。
要解决这个问题,我们可以使用 whoami 命令输出当前用户,用通配符 * 来代替会变的文件夹。
因此可以这样编写脚本:
rm /Users/$(whoami)/Library/Developer/CoreSimulator/Devices/*/data/Containers/Data/Application/*/Library/Application\ Support/GiftLister.sqlite
保存并关闭脚本。
默认情况下,shell 脚本是只读的。你可以在终端中将脚本设置为可执行。
如果你找不到终端程序,你可以在应用程序文件夹的工具中找到它。
打开终端,进入你的 home 目录,输入:
YourComputer$ cd ~
然后列出目录中的文件:
YourComputer$ ls
我们需要切到项目目录。如果你的项目目录位于桌面文件夹,你可以这样进入到项目目录:
YourComputer$ cd Desktop
YourComputer$ cd GiftLister
如果要向上返回一级目录,你可以用:
YourComputer$ cd ..
经过在终端中进行一番艰苦的摸索,我们终于可以看到我们的项目文件了。要将 shell 脚本修改为可执行,需要:
YourComputer$ chmod a+x wipe-db.sh
chmod 用于修改文件的权限。a+x 表示文件可以被所有用户、组和其它人执行。
哇……好麻烦。深呼吸。这是必需的。做这么多的工作目的就是为了偷懒。:]
关闭终端,回到 Xcode。打开 AppDelegate.swift。
在 didFinishLaunchingWithOptions 第一行打断点。右键(或 ctrl+左键)点击断点,选择 Edit Breakpoint,添加一个 action 并选择 Shell Command。在后面的对话框中,点击 Choose 然后选择我们所创建的脚本。勾选 Automatically continue after evaluating 选项,然后关闭对话框。
如果模拟器正在运行,请关闭它。
编译运行,数据库被删除了。
模拟器默认会缓存许多数据,因此最好是用 Xcode 的 Product/Clean 菜单执行一次“干净”的 build,然后再 build & run。否则,当你启动 app,停止、再次运行后。缓存的数据会和全新的数据库混在一起。
当 App 还在开发时,清空数据库只需要按一下按钮。如果不需要这个功能,只需要关闭这个断点。
注意:我们创建的脚本仅仅包含了一条简单的 Unix 命令,用于删除文件。你也可以在脚本中通过一个 PHP 文件来干同样的事情。你还可以打开一个 Java 程序、Python 脚本或者任意电脑上的其他程序。这样,你不需要学习 shell 脚本就能通过断点来操作底层操作系统。
此时,我们的 app 已经拥有不少数据了。是时候保存它们了。
对于这类 app,保存工作应当经常性的进行,以免丢失数据。
我们的 app 的情况有所不同,它只会在用户退出 app 时保存数据。
如果你现在没有看见根 View Controller,请点击导航条上的 Back 按钮返回根控制器。然后按下 Home 键。你可以点击模拟器菜单的 Hardware\Home 或者 Shift-Command-H 返回模拟器桌面。
从 Xcode 中终止程序,然后编译运行。table view 中是空的。这个 app 什么也没有保存。
打开 AppDelegate.swift。在 applicationDidEnterBackground 方法,你会看到问题在于 doLotsOfWork 方法。这个方法无法按时完成,iOS 就终止了 app,无论它有没有完成收尾的工作。这导致 saveData 方法还没有被调用。
首先需要确保数据被保存。将 saveContext 方法调用放到 doLotsOfWork 方法调用之前:
saveContext()
doLotsOfWork()
现在在 doLotsOfWork 这行打断点。右键(或 ctrl+左键)点击断点,然后选择 Edit Breakpoint。在 Action 中选择 sound action 以及 Submarine 声效。在使用 sound action 时,尽量避免使用系统声音,以免断点被忽略。
然后,勾选 Automatically continue after evaluating。
最后,点击 build & run。
当 app 再次启动,添加一个新的好友,然后在模拟器中按下 Home 键。当 app 关闭后,你会听到有海底泉的声音,表明我们的数据已经保存。
用 Xcode 停止程序,按下 Run。你会看到所有的数据都显示了。通过声音,我们能够知道某些代码已经被执行,不需要我们查看日志。你还可以提供自定义生效,比如在某种严重崩溃时播放爆炸声音。
这需要将你的声音文件放到这个文件夹:
YOUR_HOME_DIRECTORY/Library/Sounds
在使用这些声音之前你必须重启 Xcode,但注意这可能成为某些人的恶作剧:]
还有一个有趣的地方。找到 FriendSelectionViewController 中的第一个断点,右键(或 ctrl+左键)点击这个断点,选择 Edit Breakpoint,在对话框中,点击 + 按钮,这里可以添加更多的动作,而不仅仅是断点本身。
选择 Log Message 动作,这次,我们输入 To be, or not to be。勾选 Speak Message 单选框,然后点击 Done。对话框最终显示成这个样子:
build & run,好好乐一下子吧!
注意:除了新奇,这个功能也是很有用的!语音消息在调试复杂的网络代码等情形时尤其有用。
你可以在这里下载已完成的项目。
如你所见,Xcode 调试工具在面对日常开发中的挑战时,非常灵活。例如,LLDB 提供了动态查看和修改代码的能力,而不会增加更多的 bug。
无论如何,这只是一个开始。LLDB 还提供了许多其它特性,比如自定义变量概览、动态断点、用 Python 定义调试脚本等。
当然,将 NSLog() 和调试代码删除会是一个麻烦,但最终你会发现你的项目变得更健壮。你不需要在发布的前一晚为移除所有的调试代码而忧心忡忡,也不需要为创建清晰的调试环境而编写复杂的宏代码。Xcode 为你提供了能够让你轻松度过最后一天的一整套工具。
如果你想了解更多,可以从 LLDB 开始。一个学习 LLDB 的好去处就是 Brian Moakley 录制的视频教程:Using LLDB in iOS。
在 WWDC 2016 大会中也介绍了LLDB 的新特性:Debugging Tips and Tricks。
有任何问题和建议,请在下面留言!