Swift Debug 日常: 消灭手写 print ?

过去调试 Swift 代码基本靠手写 print 这种非常原始的手法,作为一个有进取心的青年,觉得该好好修炼调试技能了。打开上次学习 LLDB 的 Demo,发现是三个月前建立的;Objc.io 有一期 Debug 的专题,老实说,我看过几遍了,实战的次数少得可怜,至今依然没有记住几个命令。为何会这样,学习资料可以说是汗牛充栋,光上就有一堆入门文章 @ 大 V 转发后收获了相当可观的红心数,但 Swift Debug 对初学者来说依然是件很困难的事情。一是官方的 Debug 文档并没有针对如何在 Swift 下使用进行过说明;二是几乎所有的相关文章都是针对 Objective-C 的,当你试图在 Swift 上使用时往往不灵,这实在是令人沮丧;三是这些文章和官方文章真的只是功能性文档,实际中如何应用往往一筹莫展。这三点足以让 OC 时代仅仅依靠 NSLog 调试的我望而却步,在 Swift 里依然使用 print 这种低效的手法。不管怎样,这种局面需要改变了,第一步就从摆脱 print 开始吧。我在调试中使用 print 的主要目的是查看变量和跟踪调用流程,那么使用 Xcode 和集成的 LLDB 调试工具如何做到这点?不过,不要期待会看到其他地方看不到的东西,这里讲的都是其他文章里说过的,唯一不同的是切换到 Swift 里了,差别大吗?不大,但这点差别足以让你放弃,而这篇文章就是让你重拾信心。

为何要使用断点(break point)?

通过添加 print(在 OC 中是 NSLog)调试时,每次都需要重新编译,而且在实际项目中还需要把这些 print 调用清除,或许你可以使用条件编译来避免这点,仅就我个人来说,正式发布的项目里不清除 print 很难受。在 OC 中调试时如果测试的应用对性能敏感,建议不要使用 NSLog,见 NSLog效率低下的原因及尝试lldb断点打印Log。在 Swift 中 print 是否也有此隐患我未做调查,不过显然使用断点好处多多。调试时使用 print 的痛点在于每次添加一条 print 语句都要重新编译,如果只是个小 Demo 或许没什么,几秒钟的事情,稍大一点的工程或者电脑配置已经跟不上,编译太花时间,继续使用这种方式就有点浪费生命了,而断点则完全没有这种不便。iOS 应用是事件驱动的,在应用运行时,断点可以随时加入或取消,这些操作都是立即生效的,无需重新编译。当你在源文件左侧行数列表处点击时添加了一条什么都不做只是暂停应用运行的断点,此时 LLDB 无缝切入应用的运行状态,在控制台利用 LLDB 能做的事比 print 强多了。

p and po in Swift

在控制台使用ppo来替代 print,想必你知道ppo除了输出的格式稍有不同外,都是expression命令的别名,在 LLDB 环境下使用help查询这两个命令可以看到:

'p' is an abbreviation for 'expression --'
'po' is an abbreviation for 'expression -O  -- '

-O选项的意义是:

-O ( --object-description ) Display using a language-specific description API, if possible.

上面的 description API 调用的是NSObject协议中descriptiondebugDescription属性,这两个属性默认打印对象的内容。但对于 Swift 对象,po命令会忽略对象的description属性,你也可以直接打印对象的这个属性。

要我说两者的差别主要在于,对于 Int, String, Arrar, Dictionary 这些值类型,p输出的内容中类型信息多,占用行数少,所以下面的断点设置面板示例里我采用p而不是po;而对于类这种引用类型,p会打印出内部详细的变量类型和值,po则只输出干巴巴的内存地址外加换行符,浪费屏幕空间,不能忍。

LLDB 语言切换

调试 Swift 代码时尽力切换至 Swift 语法,不过基本上只有 expression 命令需要使用 Swift(OC) 代码,但有时候你会发现即使在命令中切换至 Swift 语法也不起作用,比如从与调试器共舞 - LLDB 的华尔兹教了这样的实用技巧来打印应用的视图层次:

po [[[UIApplication sharedApplication] keyWindow] recursiveDescription]

但是你这个命令转化为对应的 Swift 代码却出现这样的错误:

error: :1:44: error: value of type 'UIWindow' has no member 'recursiveDescription'

你访问了私有变量,因此出错了。但是这个技巧在 OC 代码是正常的,可能和 UIKit 框架是 OC 语言有关,解决办法是在 LLDB 中切换到 OC 语言环境使用这样的命令:

expr -l objc++ -O -- [[[UIApplication sharedApplication] keyWindow] recursiveDescription]

注意,这个切换只对当前这条命令有效,解决方案来自这里,不过我在看 Advanced Swift Debugging in LLDB 这个 session 时没提到为何这里指定语言是 objc++ 而不是 objc,对 LLDB 的底层还没什么了解,暂且记住切换到 OC 环境使用 objc++ 而不是 objc 就好了。语言选项还包括 objc 和 swift。

如果调试 Swift 代码时直接使用po [[[UIWindow keyWindow] rootViewController] _printHierarchy]这样的 OC 语法,会遇到error: expected ',' separator这样的错误;另外使用 Swift 语法很多时候会遇到可选链,由于这里没有自动补全,往往你敲了一大堆 LLDB 告诉你没有对可选值进行封装不能执行,这无疑很让人苦恼,这时候统统切换到 OC 环境就能解决。

expr -l objc++ -O -- [[[UIWindow keyWindow] rootViewController] _printHierarchy]

F 家出品的 Chisel 这个 LLDB 插件合集自定义了很多方便的命令,但文档上写的通过brew来安装的版本在 Swift 环境下很多命令不兼容,不过有人添加了对 Swift 的支持,可以直接下载 Github 上的版本手动安装来解决 Swift 环境的兼容问题。

断点面板

命令交互只能驻留在某处,有时候你想要了解多处的状态,就需要添加多个断点,并在运行到这些地方时输出信息,这时候就需要设置断点了。

调试代码片段,「抄袭」自上面文章里的例子
Swift Debug 日常: 消灭手写 print ?_第1张图片
典型的断点设置面板

这个画面你必然不陌生,暂且只看第3点:
1.Condition:输入框内添加 Bool 表达式,使用 Swfit 的语法,使用的变量仅限于断点所在类以及所在函数栈中的变量。如果不添加约束条件(Condition 后面的输入框内为空),则每次循环时都会执行添加的动作。
2.Ignore:跳过符合条件的前几次触发,注意,这里很容易犯下错误,这里的跳过次数是指在应用的整个生命周期内,也就是说这只是一次性有效。比如上面的 for 循环所在的函数即使多次执行,设定的忽略次数在用完后就完了,而不是每次 for 循环执行时跳过指定的次数。这个参数是一次性有效,而第1点的条件约束则是永久有效。
3.Action:想要取代 print 的我暂时只需要第3和4个选项,一个断点可以添加多个动作,这个才是我这篇的重点。
4.Options:如果不需要在断点处暂停,勾选最后一个选项「Automatically continue after evaluation actions」,执行操作后继续运行;否则,应用将会暂停,此时可以在控制台与应用进行交互,实际上 LLDB 此时让你直接介入应用的运行,你可以像写代码一样修改变量,执行其他动作,以 LLDB 的形式。

看了上面4点估计直接晕了,我只是想 print 下而已,这种强大而麻烦的功能会直接吓跑初学者。暂时只关注 Action,除了勾选最后一个选项让断点不要暂停应用的执行,其他都不要管,立马出结果才是初学者想要的,然而 Action 也比较麻烦,想要在控制台 print 有两个选择:Debugger Command 和 Log Message。

Swift Debug 日常: 消灭手写 print ?_第2张图片
两种语法示例

控制台的输出(在下面的输出中,LLDB 调试 Swift 类时以$R16之类的变量存储输出结果,OC 类中则是$16之类的变量):

(String) $R16 = "Debug: i:2, sum:1"
Log: i: 2, sum: 1
(String) $R20 = "Debug: i:3, sum:3"
Log: i: 3, sum: 3
(String) $R24 = "Debug: i:4, sum:6"
Log: i: 4, sum: 6

就是这样简单,加上无需重新编译的先天优势,使用断点岂不是完爆 print ?但三个月前我为什么没有爱上断点呢?因为语法让我受挫,事实上说出这个原因让我有点羞愧,竟然是这个原因。当你看着各种入门 LLDB 的文章时,第一知道的肯定是ppo这两个 print 命令,在这些文章的示例里都是在交互状态下使用这两个命令;而在 Debugger Command 的输入框里要按照 Swift 的格式化字符串的语法来编写命令;Log Message 的输入框的语法以上图中的简单规则来处理,输出的字符不需要""来引用,对变量的访问使用@variable@来引用。这给当时的我带来了一点困扰,尽管现在看来这不算什么难事,然而我确定当时放弃的原因就是因为这个,太特么分裂了。除了语法问题,写代码有自动补全,添加一行 print 几秒的事情,而添加一个断点以及编写没有自动补全加持的输出语句,比起前者尽管需要重新编译依然让人没有动力切换。有时候太舒服了就不愿意挪窝。

那么这两个动作如何选择呢?Debugger Command 和 Log Message 两者对普通的变量的输出没有什么差别,但是对数组、字典之类的对象的输出差别很大,后者无法输出此类对象的内容。对于如下两个变量:

var testArray: [Int] = [1,2]
var testDic: [String: Int] = ["seedante": 18, "iOS": 9]

Debugger Command 中使用p "testArray: \(testAray) testDic: \(testDic)"的输出结果如下:

(String) $R52 = "testArray: [1, 2] testDic: [\"iOS\": 9, \"seedante\": 18]"

Log Message 中使用等效的命令testArray: @testArray@ testDic: @testDic@的输出结果为:

testArray: 2 values testDic: 

有时候也会是这样的结果:

testArray: 2 values testDic: 1 key/value pair

Log Message 丢失了很多信息,而且使用正确的键访问testDic中的内容也无法得到结果,Debugger Command 则没有这个问题。

在输出简单的提示信息时,Debugger Command 和 Log Message 没有什么差别;需要访问复杂的变量时,后者会丢失很多信息,这时候应该使用前者。

总体来说,使用断点来替代 print 并不是高成本的事情,而且很灵活,不过有时候写个 print 真的就是很顺手的事情啊。

如果仅仅只是查看某个变量值,print 或许更方便;但如果需要同时查看该函数栈里其他变量的状态,断点就方便多了:设置该断点时不要勾选最后一个选项,这样运行到该断点时应用便会暂停,控制台左侧的变量视图则会展示出该函数栈中的所有变量以及属于该类的变量,而且还支持数据预览,另外在右边的控制台中此时可以和应用进行交互,甚至可以修改变量,这样的优势是 print 无法比拟的。

代码片段

Swift Debug 日常: 消灭手写 print ?_第3张图片
上图断点处的变量视图 Variable View 和控制台 Console

不过,稍有瑕疵,变量视图里 testDic的消息有误,和 Log Message 的错误一样。

强大而难用的符号断点(Symbolic breakpoint)

除了跟踪变量状态,print 另外一大用途是跟踪函数的调用,符号断点可以在指定的函数被调用时执行动作,这是「开发者的大事,大快所有人心的大好事」,print 可以抛弃了,为什么我没能早点学到(T▽T)。添加符号断点的过程如下:

Swift Debug 日常: 消灭手写 print ?_第4张图片

Swift Debug 日常: 消灭手写 print ?_第5张图片

添加符号断点的 文档中添加符号的语法如下:
Swift Debug 日常: 消灭手写 print ?_第6张图片
symbol format

但是我高兴得太早了,上面的语法直接换成 Swift 也会有各种不兼容,这里开始才是对 Swift 进行调试真正困难的地方,官方文档至今没有针对 Swift 进行更新,而且关于调试的文档整体上都缺乏语法细节,所以在 Swift 应用中 Symbol 的写法只能靠猜。总结如下:Swift 的部分用 Swift 语法,Objective-C 的部分维持不变。有点玄乎,拆开细说。

**A method name: **
只指定方法,而不关心是哪个类执行的,只要有同名的方法执行就能触发断点动作。但子类没有重写父类方法的话则子类不会触发该符号断点,这样一来很鸡肋,不重写就无法触发,就像需要另外一只手电筒才能点亮的手电筒。在 OC 中这个问题可以使用 Chisel 的bmessage命令解决,该命令可以为子类中未重写的方法添加符号断点,这样一来在视图控制器子类中不用重写viewDidxxxviewWillxxx这两个系列的方法就可以获取调用的顺序,然而这个命令在 Swift 中无效。

Arguments:
  ; Type: string; Expression to set a breakpoint on, e.g. "-[MyView setFrame:]", 
  "+[MyView awesomeClassMethod]" or "-[0xabcd1234 setFrame:]"

这是bmessage命令的参数文档,实际使用发现三种使用方式的前两种都没有效,最后一种必须使用内存中的地址,可以使用pvc打印出结构来找出信息,不过这样一来使用很受限制,也多大用处了。

彻底消灭 print 变成了不可能,接下来谈谈语法细节。

Swift 里拥有相同函数名的方法是靠参数来区分的,但 LLDB 作为调试工具做不到这一点,比如下面的几个方法在 LLDB 的眼里都是methodDemo

symbol format

所以由 Swift 实现的类(比如你写的或是 Swift 标准库)中的方法,无论是重写父类的方法,或是新添加的方法,无论是否带参数,一律不带:(),只写函数名:methodName

由 Objective-C 类实现的方法(现行的 iOS 框架还是由 OC 实现的),则按照文档中给出的语法书写,如下图所示,UIViewController中的实例方法,在符号断点中就按照这样的格式书写,不带-


Swift Debug 日常: 消灭手写 print ?_第7张图片

比如无参数方法func viewDidLoad(),在符号断点中写viewDidLoad;带参数方法:

func prepareForSegue(_ segue: UIStoryboardSegue, sender sender: AnyObject?)`

在符号断点中写作performSegueWithIdentifier:sender:。如果在 Swift 子类中重写了这两类方法,在符号断点中分别写作viewDidLoadperformSegueWithIdentifier,如上面说的那样,LLDB 无法区分 Swift 方法的参数,只接受函数名。判断输入的 Symbol 是否被接受,在填写后回车,断点处就会显示出会被检测到的方法列表,没有的话就表示当前格式不可接受或者没有这个方法。下图中添加了performSegueWithIdentifier:sender:的符号断点,第一个匹配 UIKit 框架中的方法,第二个匹配我用 Swift 实现的UIViewController子类重写的该方法。

Swift Debug 日常: 消灭手写 print ?_第8张图片
OC 方法和 Swift 方法的书写区别

可以看到匹配 Swift 中重写的方法的信息超长,而且一个方法有两种匹配信息,这意味着添加的动作会被执行两次。而且子类重写方法的符号断点里对方法或是类相关的变量的访问有点问题。以viewDidAppear:为例,添加的动作如下:

p " \(self) viewDidAppear: \(animated)"

控制台的输出:

error: :1:5: error: use of unresolved identifier 'self'
" \(self) viewDidAppear: \(animated)"
^~~~
:1:28: error: use of unresolved identifier 'animated'
" \(self) viewDidAppear: \(animated)"
                       ^~~~~~~~
(String) $R17 = "  viewDidAppear: true"

所有针对子类重写方法的符号断点的输出都有一条重复,可能和上面截图中的@objc有关。事实上此时在 LLDB 中也无法访问self或是自身变量,而子类中新添加的方法则没有这个问题,对变量的访问也是正常的。

小结:针对方法添加的符号断点只会被实现了该方法的类触发,无论是用 Swift 或 Objective-C 实现的。针对 OC 类,Chisel 的bmessage该命令可以为子类中未重写的方法添加符号断点,不支持 Swift 类。为 Swift 类中的方法(只能针对已经实现了的)添加符号断点时,只接受方法名(不带参数, :()),因此无法区分同名但参数不同的方法;Swift 中的 Struct 和 Enum 也支持方法,匹配原则与 Swift 类一样;为 Objective-C 类的方法添加符号断点则需要给出完整的方法原型名(包括参数名和:,不带())。 由于现存的框架基本上还是用 OC 实现的,所以匹配现有框架的方法还是用 OC 的语法。

A method of a particular class
在前一种符号断点的基础上再添加类别的约束条件,只需要在前一种的基础上添加作为前缀的 Class 名,在 Swift 中则需要添加 Class/Struct/Enum 名。

func viewDidLoad()
func viewDidAppear(animated: Bool)
func performSegueWithIdentifier(identifier: String, sender: AnyObject?)

以下为用 Swift 实现的ViewController类中重写的以上三个方法添加类别约束的符号断点:

Swift Debug 日常: 消灭手写 print ?_第9张图片

而为UIViewController这种 OC 类添加类别符号断点时,则要回到 OC 的语法:

Swift Debug 日常: 消灭手写 print ?_第10张图片

注意:这种符号断点针对的是 Class 本身,因此添加的动作里输出的格式化字符串中无法使用self或自身属性等变量。

A function name
因为这里没有 Swift 的事,所以这个跟文档的说明没有区别,只接受函数的完整原型名(带参数和:,没有()),比如_objc_msgForwardperformSelector:withObject:afterDelay:

符号断点小节结束,知道这个小节取名的原因了吧。Debugging in Swift: How Hard Can It Be?这个演讲发布有大半年了,别的不说,光搞清楚断点的用法对初学者来讲都是极困难的事情,忽然觉得 print 真是个好东西。

小结

总体而言,使用 LLDB 进行调试还是比较辛苦的,print 依然是个性价比高的趁手小工具,但仅限于此了,掌握 LLDB 这一利器是进阶的必经之路,学习它,好好利用它。

据主观统计,我目前看到的有关 LLDB 入门的中文文章 99.99% 取材自《与调试器共舞 - LLDB 的华尔兹》这篇文章,所以入门还是去看这篇吧,同时这期专题的其他文章也值得反复阅读,比如像我还看不懂。

推荐阅读:

  1. Objc.io 19期专题:调试
  2. Advanced Swift Debugging in LLDB

你可能感兴趣的:(Swift Debug 日常: 消灭手写 print ?)