Qusic

从 Trace 文档中读取数据

Apr 16 2017
development

背景

这个问题对我来说也有点历史了,最开始是 15 年在淘宝实习的时候,有个需求想记录帧率然后把低于 60 FPS 时的程序执行情况记录下来,当时我就提出说能不能用 Instruments 的命令行工具来自动记录,然后读数据出来分析,但是当时觉得还是要一个在程序里实时监控的组件比较好,这样内部测试版的用户在使用的时候就可以采集样本,相对来说也更接近用户使用情况,同时还能覆盖不少不同的机型。大概就是可以在一个单独的线程监控掉帧的情况,然后用 signal 来抢占并获取主线程的调用栈,我觉得网上应该是有现成的库的,可以尝试搜索一下。

不过后来一方面想找点有趣的事做,另外大概也比较闲,就开始尝试做一下 Trace 文档的读取。当时也在网络上搜索了下,现有的解决方案一般是利用了 Trace 文档里数据大部分是用 NSKeyedArchiver 来序列化的情况,直接解包出来看,一层一层地找。可以说这样的方式除了能用可以原谅外,根本没有什么优点,代码很脆弱,同时也很难理解和维护。

后来就想到了能不能直接调用 Instruments 的代码来读取数据,最开始写的时候是 Xcode 7,只写了 Time Profiler。后来到 Xcode 8,因为苹果的一大波重构,整个项目完全坏掉了,这次重新写的同时,也为更多的 Instruments 写了示例程序。所以就想要写这篇文章,记录对 Instruments 逆向的过程和成果,同时也作为 Instruments 部分私有 API 的文档,希望能对这个项目的用户在定制自己的数据分析程序时有所帮助。

项目代码

https://github.com/Qusic/TraceUtility

代码里有很多有用的注释,本文也不是代码分析,所以还请读者两边的内容一同参考。

Trace 文档的基本结构

其实文档结构的逻辑也能从 Instruments 的交互逻辑看出来,一份文档有一个目标设备,一个监测的进程和一组 profiling 模版,然后可以使用同一组模版进行多次 profile。而对应到 Instruments 的代码中,一份文档就是一个 XRTrace 对象,除了一些元数据,它还包含一个 XRInstrument 对象的数组,其中每一个,又包含了一个 XRRun 的数组。

所以接下去要做的事情就是,找到从不同模版的 XRInstrument 中读取 XRRun 对象中的数据的方法,然后依次遍历即可。

新旧版本的 XRInstrument

Xcode 7 到 Xcode 8 的过程中,苹果开始尝试对 Instruments 的模版进行重构,比如 Time Profiler 的旧版本是在 PlugIns/SamplerPlugin.xrplugin,而新版本放在了 Packages/Sampling.instrdst。新旧版本同时存在,但是有不同的 uuid,Instruments 通过文档中记录的 uuid 来选择合适的模版来处理数据,所以最新版本的 Instruments 是可以同时支持新旧版本的 Instruments 产生的数据的。

旧版本数据的读取相对来说更加直观,不同模版对应 XRInstrumentXRRun 的不同的子类,而且数据通常都可以在 XRRun 的某些成员变量中找到线索,只需要运行时判断一下类型然后参考这些类的声明(使用 class-dump 一类的工具)就差不多了。

因为旧版本中的数据通常是不同的模版自己处理的,所以有些模版的数据保存为了方便就使用了 NSKeyedArchiver 相关的序列化反序列化方法来做,这也是为什么之前有一些相关项目可以很容易地不调用 Instruments 的代码读取部分数据。我估计苹果可能也觉得这样的做法让代码不好维护,性能堪忧,而且难以重用,所以开始了这些重构工作。

新版本数据读取和保存的代码主要在 InstrumentsAnalysisCore.framework 这个框架里,存储格式也用了 SQLite,把各种读取写入查询都抽象了出来,比如用户在 Instruments 里用鼠标选了一段时间来看这段时间的数据,这样的过滤操作就不需要在每个模版里单独实现了。同时,因为数据的结构种类也比较有限,比如调用栈树,样本列表,所以新版本里数据的展示也和这些 profiling 模版解耦了。

这样看来,新版本模版中的 XRInstrument 的子类并不需要读写数据,就不会有具体数据的引用,也不需要过滤、搜索、格式化或者展示数据,就也不会有具体视图的引用(比如作为 tableView 的 dataSource 和 delegate)。所以也就没有 subclass XRInstrument 的必要了。这也是为什么新版本的模版的数据读出来全都是 XRInstrument 这个类的对象,里面也找不到有用的东西。

不过直接通过 InstrumentsAnalysisCore.framework 来读取数据并不是很容易,我个人看下来感觉这个框架挺复杂,设计也不是很浅显易懂,所以折腾了一段时间并没有取得什么进展,最后还是从更高层的地方来入手了。

XRContext

在 Instruments 里,XRContext 就体现在模版列表下面的这个导航条。它是树结构的,每次选中显示一个 context,那从根结点到这个 context 就是一条 context path。XRContextContainer(通常是 view controller)保存着 context 的引用,叫做它的 contextRepresentation,而 XRContext 也引用一个真正展示它数据的 XRContextContainer(通常是 view),叫做它的 container

不同的模版当然会有不同的 contexts,但是有时候一个模版也会包含多个不同的 contexts,所以在用户选择不同的模版查看数据,或者从那个导航条里切换当前模版的不同的数据视图的时候,当前的 context 就会变化,新的 context 的 -[XRContext display] 方法被调用,然后这个 context 会通过 -[XRContextContainer displayContext:] 传给相对应的 XRContextContainer,然后这个 container(通常是 view controller)就可以加载数据,刷新视图了。

也就是说在文档中的大部分数据只有在真正需要显示的时候才会被读取,从 Instruments 用户的角度来看很容易理解,不过对于 TraceUtility 的用户来说,就是需要知道,如果自己读取的数据都是 nil,或者空数组之类的,就可以看看是不是忘记调用这个 -[XRContext display] 方法了。

Search Paths

虽然说要调用 Instruments 的代码只需要链接上它的 framework 就可以了,不过这里还有几个值得一提的地方。

首先是编译器搜索我们要链接的 framework 的目录。参考 Xcode 项目配置的 FRAMEWORK_SEARCH_PATHS 变量。

  • /Applications/Xcode.app/Contents/SharedFrameworks
  • /Applications/Xcode.app/Contents/Applications/Instruments.app/Contents/Frameworks

如果这个没有设置对的话,链接的时候会报错,没法生成可执行程序。

然后是编译好的程序在启动过程中搜索这些动态链接的 framework 的目录。参考 Xcode 项目配置的 LD_RUNPATH_SEARCH_PATHS 变量。

  • /Applications/Xcode.app/Contents/SharedFrameworks
  • /Applications/Xcode.app/Contents/OtherFrameworks
  • /Applications/Xcode.app/Contents/Developer/Library/Frameworks
  • /Applications/Xcode.app/Contents/Developer/Library/PrivateFrameworks
  • /Applications/Xcode.app/Contents/Applications/Instruments.app/Contents/Frameworks

如果这个没有设置对的话,程序还是可以编译,但是程序运行的时候会报错。另外,这里不仅包含上一个配置里的目录,还多了别的,是因为我们链接的 framework 还依赖了别的 framework,这些 framework 也需要在运行的时候由 dyld 一起加载。

最后是 Instruments 自己搜索 Packages 的目录,这些目录都在 Instruments.app 的安装目录下,而函数 NSString *PFTInstrumentsAppContents() 就是用来获取这个安装目录的。它通过调用 +[NSBundle mainBundle] 来确定当前进程是 Instruments 的主 App 还是附加的命令行工具,然后返回正确的结果。

然而在我们的进程中,+[NSBundle mainBundle] 返回的结果并不能被这个函数正确识别和处理,所以我们需要 hook 这个方法来返回 Instruments.app 所对应的 bundle,从而使得 Instruments 能够正确找到需要加载的包。

结语

上面我大概总结了一些在项目代码中没有明显体现出来的逆向成果,如果还有其它方面的问题也可以联系我,我会尝试补充更多内容。不过毕竟我没有看过 Instruments 真正的代码或者文档,也没有研究完 Instruments 的所有组件,有些地方可能会以偏概全,甚至完全不对,读者还需要自行判断,当然能帮忙指正就更好了。最后,希望这些工作能对大家的自动化测试设施建设有所帮助!