美团 iOS 工程 zsource 命令背后的那些事儿

zsource 命令是什么?

美团 App 在 2015 年就已经基于 CocoaPods 完成了组件化的工作。在组件化的改造过程中,为了能够加速整体工程的构建速度,我们对需要集成进美团 App 的组件进行了二进制化,同时提供一个叫做 cocoapods-binary 的 CocoaPods 插件来支持本地工程使用二进制。因此,美团 App 的开发者在集成开发时,除了自己正在开发的组件,其他的组件都以二进制的形式存在。

使用二进制,虽然会给工程带来构建速度的提升,但是会带来一个新的问题:在调试工程时,那些使用二进制的组件,无法像源码调试那样看到足够丰富的调试信息。例如,如果程序在二进制组件的代码中崩溃,我们只能看到该组件的堆栈信息和一些不明所以的汇编代码:

和业界大多的组件化方案类似,美团 App 的组件化方案也提供了将一个组件从二进制切换到源码的机制。美团工程的开发者能够使用一系列配置和命令来切换组件的源码和二进制状态,但每次切换都需要重新执行 pod install 。这种方式在组件化的初期是没有什么问题的。但随着美团 App 的组件数量不断增长,即便是只切换一个组件的状态,单次 pod install 的时间也增长到了分钟级。而且这种方式每切换一次就必须重新编译运行一次 App,在追查一些偶现崩溃问题时,开发体验非常不友好,也不利于崩溃问题的快速定位分析。

为了解决以上提到的这些问题,我们利用 CocoaPods 的插件机制,为 CocoaPods 的 pod 命令增加了 zsource 子命令,开发者可以在使用二进制构建工程的同时,非常快速地将一个组件调出源码进行调试,具体的使用效果可以看一下如下的屏幕录制:

zsource 命令的开发始末

在推出 zsource 功能后,很多同学都对 zsource 背后的技术原理十分感兴趣。其实 zsource 整个功能的开发流程也十分的有趣,就像小说一样,分为几个不同的时期:

  • 原理猜想
  • 查阅资料
  • 简单粗暴的尝试
  • 柳暗花明
  • 工程化

原理猜想

如果让我们猜想 Xcode 断点调试功能的实现原理,可能大部分人都会猜这样一种可能:Xcode 在编译 Debug 版本的二进制过程中,在二进制中某个字段存储了该二进制所对应的源码的文件地址。当我们在 Xcode 中打断点进行调试的时候,Xcode 会根据二进制中这个字段中存储的源码文件地址,打开对应的源码文件,并在 UI 上展示该源码文件。

道理好像没有什么问题,但是事实是这样吗?在某次团建回国的航班上,我们组成威和志宇两位同学在提出这种猜想后,拿出电脑,做了一个这样的小实验:

实验中,他们分别创建了两个 Xcode 工程 A 和 B,工程 A 会产出一个二进制 libA.a。工程 B 中会接将 A 的产出 libA.a 拖到工程中,然后设置 A 中代码的符号断点,然后编译运行。结果发现,当断点断在 A 中的代码时,Xcode 会直接跳转到 A 的源文件中,并且可以继续增加断点以及正常的单步调试。

通过这个实验,成威和志宇同学确定了猜想的正确性。那么接下来需要做的,就是确定二进制中,这个源文件地址信息具体藏在哪一个字段中。

查阅资料

我们都知道苹果的 Mach-O 二进制文件使用的是 DWARF 这种格式来存放调试相关的数据的。但因为我们很难从这个问题中提炼几个精确的关键词在搜索引擎中检索,所以很难通过简单的几次检索就获取到我们想要的答案:二进制这个字段的名称,在初期甚至无法确定这个字段应该是从 Mach-O 的资料中检索还是从 DWARF 的资料中检索。

在没有太好的搜索结果的情况下,我们一度曾经想尝试去从头去啃一啃找到的一些二进制相关的文档:

简单粗暴的尝试

然而,由于对二进制格式不是那么熟悉,也不太了解二进制相关的词汇和概念,所以阅读文档的速度就非常缓慢。

不过,技术的有趣之处就在于,有时候你可以基于我们的猜想,任意去尝试,跳过艰辛的文档阅读过程。在文档阅读遇到挫折后,我们猜想,二进制中很有可能也是用字符来存储这些源码信息的,那么如果我们就把二进制当做字符来看,是不是能搜到一些东西呢?

于是我们试着做了一个比较简单的二进制文件,二进制文件中仅仅包含一个 ZSCViewController,然后用 xxd 这个命令尝试读取二进制中的内容,考虑到 xxd 的输出会折行,我们选取了 ZSCViewController 字符串的子串进行过滤:

xxd ./libZSource.a | grep -C 5 'ZSCViewControlle'

果真得到了一些结果:

通过这个实验,我们确定了二进制中源码文件的路径确实是用普通的字符来存储的;紧接着,我们用 MachOViewer 来查看二进制文件,以获取到更友好的二进制信息。利用 MachOViewer,我们可以发现这些信息都存在了二进制的 “__debug_str” Section 中。

虽然还是不确定这个地址所对应的字段叫什么,但研究到这里,我们还是有所进展的,最起码我们可以假定这个路径一定是紧跟在 “Apple LLVM version 10.0.0 ” 字符后面的,然后利用一些读取 Mach-O 的 Ruby 库,比如 ruby-macho ,基于这个假定来读取这个路径,为这个特性的工具化提供一丝可能性。

柳暗花明

简单的尝试没有得到想要的答案,但透过 Section 的名字,可以确定源码文件的路径信息和 DWARF 有关。

长时间和 CI 打交道的经验告诉我们,对于每一种二进制格式,苹果公司都会提供一个可以专门用于解析的命令行工具,所以我们就尝试找了找有没有解析二进制中 DWARF 格式的命令行工具。

功夫不负有心人,我们找到了 dwarfdump ,那么用它来看看之前的那个二进制文件:

dwarfdump ./libZSource.a | grep 'ZSCViewContro'

果然有了更好的输出:

这里我们注意到了 AT_name 这个字段名。拿着这个字段名,去前面给出的 DWARF 1.1.0 Reference 文档中查阅,我们可以得知:

An AT_name attribute whose value is a null-terminated string containing the full or relative path name of the primary source file from which the compilation unit was derived.

进一步查询,我们可以找到另一个和他类似的字段 —— AT_comp_dir:

An AT_comp_dir attribute whose value is a null-terminated string containing the current working directory of the compilation command that produced this compilation unit in whatever form makes sense Forelax the host system.

看起来,这两个字段就是我们所苦苦追寻的答案了。

工程化

通过实验,以及找到的这两个字段的描述,我们基本可以确定,即便工程是使用二进制构建,只要二进制 AT_name 字段中的路径存在对应的源码文件,App 一样可以使用源码进行断点调试。这种调试方式除了修改源码再次构建不能生效以外,其他的调试场景都和直接使用源码构建无异。考虑到我们日常的调试场景绝大多数都只需要查看其他组件的源码,并不需要修改,把这个功能工程化还是非常有意义的。

那接下来的事情就比较简单了:

dwarfdump

幸运的是,查看完美团 App 的几百个组件后,我们发现只有少数近一年内没有制作过二进制的组件路径比较不同,其他都相同,因此可以先忽略这一小部分组件。如果这部分组件需要支持该功能,只要再制作一次二进制即可。

确定方案以后,写代码就很简单了,最终我们利用 CocoaPods,提供了 zsource 的三个命令:

总结

zsource 功能整体的开发过程基本上都是基于一个个的猜想和实验来完成的,整体的开发上线过程实际上只花了两个晚上。但如果在没有基础知识的情况下,选择把上文中提到的参考资料都看懂后再动手,可能会花费更多的时间。这一个有趣的验证过程也充分说明,有时候我们可以不拘泥于冗长的文档以及资料,通过类似逆向工程的方式,非常快速地拿到我们需要的答案。此时我们再回过头去看文档,可能会获得比直接看文档更好的效果。

最后,非常感谢成威老师和志宇同学对技术的崇高追求,即便在飞机上,也愿意拿出电脑验证自己的猜想,为 zsource 后续的工程化落地提供了更多的可能。

作者简介

  • 宇杰,美团 iOS 工程师,2016 年加入美团,先后参与美团 App 持续集成平台建设、美团 App ReactNative 平台化等工作。目前在参与美团 App 工程效率提升和 Flutter 应用的相关工作。