概述
你是否写过这样的代码:
struct ContentView: View {
@State private var mySize: CGFloat = 15.0
var myString: String = "Hi LLDB"
var myArray: [Int] = [1, 2, 3]
var body: some View {
VStack {
Text("Hello World")
.font(.system(size: mySize))
Button("Add Size") {
mySize += 0.5
print("mySize = \(mySize)")
}
.padding()
}
}
}
我们没有超人的眼力,比如从屏幕上的文字就一眼看出它字体尺寸的数值,所以每当我们点击按钮放大时,就使用
print
函数在控制台上打印出这个值,这种调试的方式看起来合情合理。每个 SwiftUI 新手都可能写过类似的代码,稍有经验一些的开发者也许还会在print
语句前加上#if DEBUG。
但是,借助LLDB这项强大的工具,我们可以更优雅地实现同样的调试行为。首先它可以让你的代码更美观,不用在代码中间插入大量的
print
语句,此外它还提供了更多更强大的调试功能。摘录官网上对于 LLDB 的介绍:
LLDB is a next generation, high-performance debugger. It is built as a set of reusable components which highly leverage existing libraries in the larger LLVM Project, such as the Clang expression parser and LLVM disassembler.
你可以直接访问https://lldb.llvm.org查看更详细的介绍和完全的教程,也可以在https://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1.html上了解调试器的基本原理。本文的讨论范围限定在使用 SwiftUI 编写 App 时运用 LLDB 进行调试,稍后将介绍 LLDB 的一些基本指令,你可以在阅读完本文后再去阅读前面两个网站。很快你就会见识到 LLDB 的强大之处。
初步使用
要进入 LLDB 很简单,让我们在第 15 行加上断点:
接着 command + R 运行程序,此时屏幕右下角的 debug console 就会进入 LLDB 模式:
这里我们就可以输入一些 LLDB 指令来进行调试了。
有哪些命令可以用呢,可以随时在这里输入
help
获得可用的命令列表和帮助,再输入help 命令名
查看某一个命令更多的帮助。首先,对于调试标志右侧的 4 个按钮—Continue、Step over、Step into 和 Step out,我们可以直接输入命令
continue
、next
、step
和 finish
执行与按下按钮相同的操作,当然也可以输入简写的指令
c
、n
、s
和 fin
代替(finish
的简写指令不是f
是因为与其他指令的简写冲突了)。frame info
也是一个非常常用的命令,如图:我们分别输入
help frame info
和 frame info
,先是获得了frame info
的介绍,紧接着如帮助所说,获得了当前线程下的栈帧信息,包括当前调用的函数名和断点所在文件名及行数等。每当函数被调用时,都会在调用栈上创建一个新的栈帧,用来保存函数的局部变量和参数,并记录函数的返回地址。栈帧也包含了函数调用的上下文信息,例如函数的调用者和函数的参数值。
打印变量
print 命令
如上图,在 debug console 的左侧可以直接看到局部变量,使用 LLDB 当然也可以打印出来这些信息(我们还会做到更多)。
话不啰嗦,打印的命令是
print
,简写为p
,让我们试着打印出myString
和myArray
:看起来不错,但是你可能会好奇
$R0
和$R1
是什么意思,实际上这是寄存器的名称,这是 LLDB 为myString
和myArray
分配的临时变量名。接着尝试打印
self
,即contentView
结构体本身:同样地,我们看到了
contentView
的全部信息,LLDB 也为它分配了$R2
的临时名称。目前为止都很顺利,但是我们在打印一些更加复杂的对象时,
print
命令可能并不是一个最优选,事实上,LLDB 提供了一个专门打印对象的命令print object
,简写po
。相对于前者,后者可以更好地显示对象的内容,而前者有时会只显示一些内存地址。尝试一下
po
命令:确实比刚刚的打印信息好看多了,富有层次,一目了然。
frame variable 命令
使用
frame variable
命令可以打印出当前栈帧的所有变量,简写为 frame v
,单个字母 v
也可以:运行代码
在 LLDB 中,你可以直接运行 C、C++、Objective-C 和 Swift 的命令,因为 LLDB 不仅是一个调试器,还拥有一个编译器的副本,这便是 LLDB 的第一个强大之处(但是不能创建新函数、类、和块等)。
命令
expression [expr]
就是用来运行我们想要的代码的,其简写为e [expr]
。简单地对
myArray
尝试一下:我们成功地向 myArray 中新增了一个元素 … … 但事实真的如此吗?
在 Objective-C 中,调试器中的变量和代码运行时的变量确实是同一个变量,指向同一个内存地址,因此在 LLDB 中修改变量自然与在代码中修改变量等价。
而 Swift 中许多基本类型在底层都是由更加复杂的类型实现的,即使我们在 Build Settings 中将 Optimization Level 设为 No Optimization,编译器仍在幕后做了很多优化操作。考虑以下代码:
我们在第 13 行的断点处进入 LLDB,依次执行以下命令:
在调试器确实改变了
a
的值,然而从 16 行打印的结果可以看出,程序继续运行后 a
的值依然是 1。看来在调试器中对变量的修改没有影响到实际运行时的变量,考虑到函数在 return
前没有代码计算过 a
的值,这几行代码很可能已经被编译器优化过了。可以猜想 LLDB 此时得到的是变量
a
的一个只读副本,或者说是所谓的“影子变量”。首先修改代码以“避开”编译器的优化行为:
在
return
之前插入一些“难以”被编译器优化的代码后,果然成功修改了实际变量,函数返回了我们修改后的结果。进一步验证“影子变量”的猜想,可以借助
Unmanaged<AnyObject>.passUnretained(myObject as AnyObject).toOpaque()
得到某一个变量的内存地址:在调试器中得到
a
的地址为 0x82113d58ab217fdb
,而在程序运行中得到的地址为 0x82113d58ab2174db
,地址不同,果然此 a
非彼 a
。LLDB 注入代码的功能固然强大,但是如果我们无从知晓在 LLDB 中操纵的变量是实际的变量还是那个“影子变量”,无法明确判断编译器何时做出了优化,这个功能无疑成为了一把达摩克利斯之剑。在实际调试代码时,要小心使用类似
setter
行为的方法,相比之下,使用 getter
之类的方法帮助我们监听一些信息更加可靠。伪造函数返回值
使用
thread return [expr]
命令可以立即跳出当前函数和栈帧,并且thread return
后面的表达式将会作为当前函数的返回值返回。毫无疑问,又是一项强大的功能。不幸的是,如前文所说,在
Swift
中 Int
、Float
和 Bool
等常见的类型实际都是复杂对象,LLDB 不支持返回这些基本数据类型,遑论更加复杂的类型。这个命令只适合在 Objective-C 中使用,对于 Swift 几乎不可用。此外,因为 Swift 的 ARC 机制,贸然使用这个命令也是非常危险的。编辑断点
好了,你可能会说,LLDB 是很有用,但是每次都得打上断点,等程序中断了再运行这些命令,这不比直接写一个
print
麻烦得多?言之有理,幸而我们有办法在不中断程序的情况下运行这些 LLDB 命令。
第一步,command + 8 打开 Xcode 中的 Breakpoint navigator:
这里会显示我们在项目中打上的所有断点,紧接着第二步,右键 body 右侧的断点标志,并选择 Edit Breakpoint…:
我们可以在弹窗中的 Condition 中输入一个条件表达式,当表达式为真时才会启用这个断点。这又是一个方便调试的选项。
重点是 Automatically continue after evaluating actions 选项,勾选上后,我们便轻松实现了在不中断程序的情况下运行 LLDB 命令!
事不宜迟,让我们马上在断点处加上动作吧。简单来讲就是把之前的那些 LLDB 命令移到断点编辑处的 Action 里。点击 Add Action ,做出如下修改:
运行程序,控制台上的信息说明命令运行得一切正常:
取代 print
万事俱备,只欠新建一个断点。
先在 LLDB 中尝试打印
mySize
的值:LLDB 报错,原因是 LLDB 无法直接解析
@State
包装的属性。解决办法是通过 _myString
访问编译器默认为该属性生成的实例变量,打印 _myString
:可见
mySize
的实际值存储在临时变量_value
中。这样一来,通过命令po _myString
._
value
就可以得到我们关心的值了:接下来删掉 20 行的
print
语句,设置好一个新的断点:运行程序:
成功了,现在我们可以随时监视字体大小了。
用私有 API 监视 View 的渲染时机
你可能注意到,每次在控制台上打印
mySize
的时候,myString
也被同时打印出来了。这是因为我们设置第一个断点的位置,是在 View 中声明 body
属性的地方,每次 View 重新渲染时,body
属性会自动返回一个新的值(准确地说在渲染之前,因为计算新值之后需要重新布局,布局完成再进行渲染)。而当 State 属性mySize
发生变化时,驱动 View 进行更新,由此会触发到第一个断点。利用这一点,我们就可以在 View 即将渲染前,监视当前栈帧的信息。还可以更进一步,利用一个私有 API
._printChanges()
来查看具体是哪一个变量或属性驱动 View 更新发生重绘。在不借助 LLDB 的情况下使用
._printChanges()
:运行程序并点击两次 Add Size 按钮后,控制台上的输出:
第 1 行是视图第一次加载到视图树上时,其自身和
id
属性的初始化驱动了视图的第一次渲染。2、3 行是我们分别点击两次 Add Size 按钮后,mySize
发生了变化,驱动 View 进行更新。当一个视图相当复杂时,有很多属性可能导致 View 的刷新,利用这个 API 就可以非常方便地找到那个(或那些)驱使 View 重绘的“元凶”了。让我们借助 LLDB 再实现同样的操作;
上图中添加的 Action 为
po Self._printChanges()
,运行程序:很完美,在要发布的 App 中使用私有 API 肯定不是明智之举,借助 LLDB 正好帮我们避免了这点,Xcode 在打包时会排除掉开发者设置的断点信息。
总结
LLDB 还有很多强大的功能,再次建议官网过一遍基本教程,也可以在控制台里用
help
仔细看一遍所有命令的帮助,如使用 breakpoints
命令,可以在指定文件行数添加断点、甚至使用正则表达式添加断点等,篇幅所限,本文没有一一介绍。掌握了 LLDB,就等于掌握了一项高效的调试利器。当然要小心那把达摩克利斯之刃。
Loading Comments...