[转载] 美团 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的资料中检索。
在没有太好的搜索结果的情况下,我们就想尝试从头去啃一啃文档。于是,找到了如下的一些二进制相关文档:
osx-abi-macho-file-format-reference
Introduction to the DWARF Debugging Format
简单粗暴的尝试
但是,我们不太熟悉二进制格式,也不太了解二进制相关的词汇和概念,所以阅读文档的速度就非常缓慢。
不过,技术的有趣之处就在于,有时候你可以基于猜想,任意去尝试,甚至可以跳过艰辛的文档阅读过程。在文档阅读遇到挫折后,我们就猜想,二进制中很有可能也是用字符来存储这些源码信息的。那么如果我们就把二进制当做字符来看,是不是能搜到一些东西呢?
于是我们试着做了一个比较简单的二进制文件,二进制文件中仅仅包含一个ZSCViewController,然后用xxd这个命令尝试读取二进制中的内容,考虑到xxd的输出会折行,我们选取了ZSCViewController字符串的子串进行过滤:
1 | 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。赶紧用它来看看之前的那个二进制文件:
1 | dwarfdump ./libZSource.a | grep 'ZSCViewContro' |
果然有了更好的输出:
注意,这里有个AT_name的字段名,在[DWARF 1.1.0 Reference]{.underline}文档中查阅:
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一样可以使用源码进行断点调试。这种调试方式除了修改源码再次构建不能生效以外,其他的调试场景都和直接使用源码构建无异。考虑到我们日常的调试场景绝大多数都只需要查看其他组件的源码,并不需要修改,把这个功能工程化还是非常有意义的。
那接下来的事情就比较简单了:
首先,需要确定大部分美团使用的组件,其二进制的编译目录是相同的。这样就方便我们在本地某个路径下统一管理下载的源码文件。
接下来,通过
dwardump
这个命令获取源码文件应该在的路径,然后通过给CocoaPods增加命令,将源码文件下载并放置在对应的路径中。
幸运的是,查看完美团App的几百个组件后,我们发现只有少数近一年内没有制作过二进制的组件路径比较不同,其他都相同,因此可以先忽略这一小部分组件。如果这部分组件需要支持该功能,只要再制作一次二进制即可。
确定方案以后,写代码就很简单了,最终我们利用CocoaPods,提供了zsource的三个命令:
总结
zsource功能的开发基本上都是基于一个个的猜想和实验来完成的,从开发到上线实际只花了两个晚上。但如果在没有基础知识的情况下,选择把上文中提到的参考资料都看懂后再动手,可能会花费更多的时间。这一个有趣的验证过程也充分说明,有时候我们可以不拘泥于冗长的文档以及资料,通过类似逆向工程的方式,非常快速地拿到我们需要的答案。此时我们再回过头去看文档,可能会获得比直接看文档更好的效果。