更优雅地调试SwiftUI—借助LLDB

概述

你是否写过这样的代码:
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 行加上断点:
notion image
接着 command + R 运行程序,此时屏幕右下角的 debug console 就会进入 LLDB 模式:
notion image
这里我们就可以输入一些 LLDB 指令来进行调试了。
有哪些命令可以用呢,可以随时在这里输入help获得可用的命令列表和帮助,再输入help 命令名查看某一个命令更多的帮助。
notion image
首先,对于调试标志右侧的 4 个按钮—ContinueStep overStep intoStep out,我们可以直接输入命令 continuenextstepfinish
执行与按下按钮相同的操作,当然也可以输入简写的指令 cnsfin代替(finish的简写指令不是f是因为与其他指令的简写冲突了)。
frame info也是一个非常常用的命令,如图:
notion image
我们分别输入help frame infoframe info,先是获得了frame info的介绍,紧接着如帮助所说,获得了当前线程下的栈帧信息,包括当前调用的函数名和断点所在文件名及行数等。
💡
每当函数被调用时,都会在调用栈上创建一个新的栈帧,用来保存函数的局部变量和参数,并记录函数的返回地址。栈帧也包含了函数调用的上下文信息,例如函数的调用者和函数的参数值。

打印变量

print 命令

notion image
如上图,在 debug console 的左侧可以直接看到局部变量,使用 LLDB 当然也可以打印出来这些信息(我们还会做到更多)。
话不啰嗦,打印的命令是print,简写为p,让我们试着打印出myStringmyArray
notion image
看起来不错,但是你可能会好奇$R0$R1是什么意思,实际上这是寄存器的名称,这是 LLDB 为myStringmyArray分配的临时变量名。
接着尝试打印self,即contentView结构体本身:
notion image
同样地,我们看到了contentView的全部信息,LLDB 也为它分配了$R2的临时名称。
目前为止都很顺利,但是我们在打印一些更加复杂的对象时,print命令可能并不是一个最优选,事实上,LLDB 提供了一个专门打印对象的命令print object,简写po。相对于前者,后者可以更好地显示对象的内容,而前者有时会只显示一些内存地址。
尝试一下po命令:
notion image
确实比刚刚的打印信息好看多了,富有层次,一目了然。

frame variable 命令

使用 frame variable 命令可以打印出当前栈帧的所有变量,简写为 frame v ,单个字母 v 也可以:
notion image

运行代码

在 LLDB 中,你可以直接运行 C、C++、Objective-C 和 Swift 的命令,因为 LLDB 不仅是一个调试器,还拥有一个编译器的副本,这便是 LLDB 的第一个强大之处(但是不能创建新函数、类、和块等)。
命令 expression [expr]就是用来运行我们想要的代码的,其简写为e [expr]
简单地对myArray尝试一下:
notion image
我们成功地向 myArray 中新增了一个元素 … … 但事实真的如此吗?
在 Objective-C 中,调试器中的变量和代码运行时的变量确实是同一个变量,指向同一个内存地址,因此在 LLDB 中修改变量自然与在代码中修改变量等价。
而 Swift 中许多基本类型在底层都是由更加复杂的类型实现的,即使我们在 Build Settings 中将 Optimization Level 设为 No Optimization,编译器仍在幕后做了很多优化操作。考虑以下代码:
notion image
我们在第 13 行的断点处进入 LLDB,依次执行以下命令:
notion image
在调试器确实改变了 a 的值,然而从 16 行打印的结果可以看出,程序继续运行后 a 的值依然是 1。看来在调试器中对变量的修改没有影响到实际运行时的变量,考虑到函数在 return 前没有代码计算过 a 的值,这几行代码很可能已经被编译器优化过了。
可以猜想 LLDB 此时得到的是变量 a 的一个只读副本,或者说是所谓的“影子变量”。
首先修改代码以“避开”编译器的优化行为:
notion image
return 之前插入一些“难以”被编译器优化的代码后,果然成功修改了实际变量,函数返回了我们修改后的结果。
进一步验证“影子变量”的猜想,可以借助 Unmanaged<AnyObject>.passUnretained(myObject as AnyObject).toOpaque() 得到某一个变量的内存地址:
notion image
在调试器中得到 a 的地址为 0x82113d58ab217fdb ,而在程序运行中得到的地址为 0x82113d58ab2174db,地址不同,果然此 a 非彼 a
LLDB 注入代码的功能固然强大,但是如果我们无从知晓在 LLDB 中操纵的变量是实际的变量还是那个“影子变量”,无法明确判断编译器何时做出了优化,这个功能无疑成为了一把达摩克利斯之剑。在实际调试代码时,要小心使用类似 setter 行为的方法,相比之下,使用 getter 之类的方法帮助我们监听一些信息更加可靠。

伪造函数返回值

使用thread return [expr]命令可以立即跳出当前函数和栈帧,并且thread return后面的表达式将会作为当前函数的返回值返回。毫无疑问,又是一项强大的功能。
不幸的是,如前文所说,在 SwiftIntFloatBool 等常见的类型实际都是复杂对象,LLDB 不支持返回这些基本数据类型,遑论更加复杂的类型。这个命令只适合在 Objective-C 中使用,对于 Swift 几乎不可用。此外,因为 Swift 的 ARC 机制,贸然使用这个命令也是非常危险的。

编辑断点

好了,你可能会说,LLDB 是很有用,但是每次都得打上断点,等程序中断了再运行这些命令,这不比直接写一个print麻烦得多?
言之有理,幸而我们有办法在不中断程序的情况下运行这些 LLDB 命令。
第一步,command + 8 打开 Xcode 中的 Breakpoint navigator:
notion image
这里会显示我们在项目中打上的所有断点,紧接着第二步,右键 body 右侧的断点标志,并选择 Edit Breakpoint…
notion image
我们可以在弹窗中的 Condition 中输入一个条件表达式,当表达式为真时才会启用这个断点。这又是一个方便调试的选项。
重点是 Automatically continue after evaluating actions 选项,勾选上后,我们便轻松实现了在不中断程序的情况下运行 LLDB 命令!
事不宜迟,让我们马上在断点处加上动作吧。简单来讲就是把之前的那些 LLDB 命令移到断点编辑处的 Action 里。点击 Add Action ,做出如下修改:
notion image
运行程序,控制台上的信息说明命令运行得一切正常:
notion image

取代 print

万事俱备,只欠新建一个断点。
先在 LLDB 中尝试打印 mySize 的值:
notion image
LLDB 报错,原因是 LLDB 无法直接解析 @State包装的属性。解决办法是通过 _myString 访问编译器默认为该属性生成的实例变量,打印 _myString
notion image
可见mySize的实际值存储在临时变量_value中。这样一来,通过命令po _myString._value就可以得到我们关心的值了:
notion image
接下来删掉 20 行的print语句,设置好一个新的断点:
notion image
运行程序:
notion image
成功了,现在我们可以随时监视字体大小了。

用私有 API 监视 View 的渲染时机

你可能注意到,每次在控制台上打印mySize的时候,myString也被同时打印出来了。这是因为我们设置第一个断点的位置,是在 View 中声明 body 属性的地方,每次 View 重新渲染时,body 属性会自动返回一个新的值(准确地说在渲染之前,因为计算新值之后需要重新布局,布局完成再进行渲染)。而当 State 属性mySize发生变化时,驱动 View 进行更新,由此会触发到第一个断点。
利用这一点,我们就可以在 View 即将渲染前,监视当前栈帧的信息。还可以更进一步,利用一个私有 API ._printChanges() 来查看具体是哪一个变量或属性驱动 View 更新发生重绘。
在不借助 LLDB 的情况下使用 ._printChanges()
notion image
运行程序并点击两次 Add Size 按钮后,控制台上的输出:
notion image
第 1 行是视图第一次加载到视图树上时,其自身和 id 属性的初始化驱动了视图的第一次渲染。2、3 行是我们分别点击两次 Add Size 按钮后,mySize 发生了变化,驱动 View 进行更新。当一个视图相当复杂时,有很多属性可能导致 View 的刷新,利用这个 API 就可以非常方便地找到那个(或那些)驱使 View 重绘的“元凶”了。
让我们借助 LLDB 再实现同样的操作;
notion image
上图中添加的 Action 为 po Self._printChanges(),运行程序:
notion image
很完美,在要发布的 App 中使用私有 API 肯定不是明智之举,借助 LLDB 正好帮我们避免了这点,Xcode 在打包时会排除掉开发者设置的断点信息。

总结

LLDB 还有很多强大的功能,再次建议官网过一遍基本教程,也可以在控制台里用 help 仔细看一遍所有命令的帮助,如使用 breakpoints 命令,可以在指定文件行数添加断点、甚至使用正则表达式添加断点等,篇幅所限,本文没有一一介绍。
掌握了 LLDB,就等于掌握了一项高效的调试利器。当然要小心那把达摩克利斯之刃。
 
 
本文已在个人公众号上同步发表
本文已在个人公众号上同步发表
 
你觉得这篇文章怎么样?
YYDS
比心
加油
菜狗
views

Loading Comments...