WWDC 2018:创建自定义的 Instrument

6,471 阅读24分钟

Instruments 自 2010 年发布之后,一直不温不火,去年也没有任何更新值得去关注。但在一年的沉淀后,Instruments 团队在今年终于有了个可以令人期待的发布。本文是针对 Session 410:Creating Custom Instruments 的解读。

Instruments 10

Instruments 是一款强大且灵活的性能分析工具,集成在 Xcode 的开发者工具集中。我们能够用不同的 Instrument 来分析测试各种各样的性能问题,比如 Leaks 来查内存泄漏问题,Time Profiler 来分析 App 的页面卡顿问题等等。那么今年苹果对 Instruments 做了哪些更新呢?从官方的 Xcode 10 Release Notes 里我们可以看到有这几点:

  • 开发者能够根据不同需求,灵活快速地基于 os_signpost 创建和发布属于自己的 Instrument;
  • Instruments 能够自动展示你代码里通过 OSLog signposts 标记的数据;
  • System Trace 里为线程分析新增了一个图像模式;
  • 由于 Instrument 10 中所有的自定义工具都会基于 os_signpost 来完成,原本基于 dtrace 的自定义 Instrument 将不被继续支持。

其中最重要的一点便是自定义自己的 Instrument 了,虽然以往的版本中也可以根据自己的需要去创建,但步骤麻烦且图形化支持简陋,并且因为数据采集是基于 DTrace 的,导致真机上并不能够使用,苹果自身也并不鼓励大家去做这个自定义。但 Instruments 10 针对这点这次可谓下了苦功——全新基于 os signpost 的架构能够支持所有平台,『标准界面(Standard UI)』『分析核心(Analysis Core)』 使得自定义 Instruments 变得更加灵活而且便利。这个 Session 围绕如何创建一个自定义的组件,分为以下四个大部分展开:

  • 为什么要创建自定义的工具
  • Instruments 10 的架构体系
  • Instruments 自定义工具的初级、中级以及高级应用
  • 自定义工具的最佳实践

其中将会涉及到一些关于专家系统(Expert System)CLIPS 语言的知识,如果有兴趣的话,可以先了解下。本文相对于 App 端开发者,可能会更适合一些测试人员,尤其是对性能测试方面比较感兴趣的同学。

为什么要创建自定义的工具

我们都知道 Instruments 中已经内置很多方便好用的工具了,这些工具苹果已经通过文档模板创建好并集成在 Instruments 的启动选择界面里。比如上文中提到的 Leaks 和 Time Profiler 相信很多人都有使用过,下图的就是 Instruments 10 的启动界面,与以前版本差别不大。

但是这些内置工具的使用其实很大程度上是基于我们对自己代码的充分了解,那么如何让不懂这部分代码的人也能够很快了解 App 的运行数据呢,比如测试 App 的网络层性能数据?这时候,一个良好的 Instruments 自定义工具或许就是那个答案,它能够出色地描述 App 运行情况。另外,如果想为内置的 Instruments 工具换个界面,或者系统提供的工具不够用了,这些问题也能够通过创建自定义 Instruments 工具来得到解决。

Instruments 10 的架构体系

架构的演变

介绍 Instrument 10 的架构之前,我们先回顾下以往苹果是怎么维护 Instruments 组件的。

最早一个版本的 Instruments 拖拽不同的 Library 到 Instruments 点击 Record 的后便可以执行一系列的性能工具,但这些基础库并没有提供良好的方式来继续更新维护。早期苹果通过继承现有的工具完成迭代,但每个工具都有自己的数据记录和分析模式,他们不得不设计一个自定义的存储机制来获取追踪到的数据,然后再设计一个自定义的界面来整合其他新增的应用。这种方式随着不断地迭代,维护成本越来越高,每添加一个新的功能,就必须要修改之前最原始的那几个工具库,这样在旧的架构上完成自定义 Instrument 的将会变得极其痛苦,最终苹果放弃了旧的架构,从而也才有了现在的 Instruments 10。
有了前车之鉴,苹果在新的架构设计上就考虑到了创建新的 Instrument 怎么才能够更易维护的问题。全新的 Instruments 架构分为 『标准界面(Standard UI)』『分析核心(Analysis Core)』 两个标准组件,二者分工不同但却紧密连接。『标准界面』组件负责用户交互,而『分析核心』负责数据存储和统计分析。现在 Instruments 10 中的工具库已经都是基于这个所创建,我们甚至完全可以自己基于这两个标准组件做一个与苹果内置工具一模一样的东西。

界面介绍

从上图可以看出,Instruments 10 的界面变化并不大,与以往的版本结构差不多。但正如前面提到的,架构变化使得现在的界面数据都将由『分析核心』提供,我们有必要来了解下其数据结构是怎么设计的。下图就是一张数据表的格式。
『分析核心』以表的形式将数据传递给『标准界面』,而这些表可以通过一个简单的 Table Schema 定义。Table Schema 就和我们 OC 或者 Swift 中的类一样,描述了这个 Table 是什么。也正是出于这种设计,我们接下来才能够使用 Table Schema 来完成自定义 Instrument 的创建。

Instruments 自定义工具的初级、中级以及高级应用

初级应用

有了足够的理论知识后,我们可以尝试下简单的自定义工具创建了。

Instruments 10 创建自定义工具和创建工程的步骤几乎一样,只是最后 Project 模板需要切换到 macOS 栏下,选择 Instruments Package,并填写好工程名等信息即可。
工程创建完成后,在左侧导航中已经自动生成了一个后缀为 .instrpkg 的 XML 文件,所有的配置都会在这个 XML 文件中完成,其中默认已经帮我生成了包含一些基础信息。接着我们要做的就是按照我们的需要去写这个配置文档了。

1、导入需要使用的 Schema

    <!-- MARK: 导入你需要使用的 schema -->
    <import-schema>tick</import-schema>

2、完成 Instrument 的『标准界面』和『分析核心』配置

<!-- MARK: 导入你需要使用的 schema -->
    <import-schema>tick</import-schema>
    <instrument>
        <!-- MARK: 这个 Instrument 的基本信息 -->
        <id>com.Parsifal.TicksDemo</id>
        <title>Ticks</title>
        <category>Behavior</category>
        <purpose>Instrument drawing ticks every 100ms</purpose>
        <icon>Generic</icon>
        
        <!-- MARK: 描述的表数据,将会由 分析核心 最终完成存储和解析提供给 标准界面 模块 -->
        <create-table>
            <id>tick-table</id>
            <!-- 定义了每列的数据 -->
            <schema-ref>tick</schema-ref>
        </create-table>
        
        <!-- MARK: 图形视图上面展示(可选) -->
        <graph>
            <title>Ticks</title>
            <lane>
                <title>Lane</title>
                <!-- 这里就是上面你定义的 table id-->
                <table-ref>tick-table</table-ref>
                <plot>
                    <value-from>time</value-from>
                </plot>
            </lane>
        </graph>
        
        <!-- MARK: 这里描述你需要展示在详情视图的数据 -->
        <list>
            <title>Ticks</title>
            <!-- 这里就是上面你定义的 table id-->
            <table-ref>tick-table</table-ref>
            <column>time</column>
        </list>
    </instrument>

至此我们便已经完成了所有的编码工作了,在编写过程中,我们还会发现苹果为我们准备了很多代码片段,来帮助完成这些配置,并且编译期间还会对代码进行检查,报出的错误信息也很方便我们对其进行调试。编译运行后,在弹出的 Instruments 选择窗口里选择 Blank 就可以在测试界面的 Library 中发现我们自己定义的工具了,直接拖入 Instruments 里就能够像使用其他内置工具一样运行。

另外,我们还能够在 Instruments -> Preferences -> Packages 里找到我们生成的自定义工具包。

中级应用

这一部分我们会介绍一些『标准界面』和『分析中心』里更详细的内容。

标准界面

『标准界面』模块里为我们提供了很多简单又好用的元素,使用这些元素能够让我们创建出非常酷炫又实用的 Instruments 工具。接下来列举一些常用的元素进行介绍,更多的元素还待苹果正式发布 Instruments 10 后大家一起探索。

图形通道面板

  • <plot>:为图形视图上面划出一个单独的数据通道,需要提供一个值来定位纵列,如我们 Ticks 例子中的 time;
  • <plot-template>:这个和 plot 差不多,区别在于它会自动为每个 instance-by 值创建一个数据通道;
  • <histogram>:为给定的时间片段生成柱状图,如 System Trace 组件中使用的那样;

详情面板

  • <list>:创建一个列表,常见的各种内置工具都会有的;
  • <aggregation>:创建一个总计视图,统计总和和平均数等,使用这个元素的时候,纵列就是各种函数了,如 sumaveragecount等,另外这个元素还有个 hierarchy 属性,能够为不同的纵列设置外轮廓,非常适合于大量数据的展示;
  • <calltree>:这个就顾名思义了,调用栈的视图;
  • <narrative>:展示一个描述工程类型(engineering type)的视图;

分析核心

这一部分我们首先会重点谈谈『分析核心』是如何收集数据和处理数据的,这一过程主要包含以下三个步骤。然后会介绍一些『分析核心』中的相关概念以助于我们在配置表中使用它们。

1、简化

在开始测试记录之前,『分析核心』会先处理我们配置好的各种表并且为其申请存储空间,有相同的 Schema 、属性并且定义为相同数据的表将会被映射为同一个 store。

2、搜索

接着每个 store 将会开始尝试寻找数据的提供者。

有时候能够数据流直接找到。
但有时候需要使用 Modeler 进行合成,Modeler 可以要求他们自己的输入信号,而这些输入信号也能够被作为 Modeler 的输出信号或者通过数据流被直接记录。

3、优化

当我们从各个 store 中获取到数据源后,在『分析核心』中就开始了一项称为『Binding Solution』的工作,第三步就是优化这个工作流。

上图展示了 Instruments 的这一工作流程,其中 Thread Narrative 便是它的『Binding Solution』。

一些重要的概念:

Binding Solution:Instruments 里是通过 Thread Narrative 实现的,它有以下两个有点

  • trace-wide;
  • Instruments 会在我们将工具拖入测试界面的时候,开始计算寻找最佳可能的记录方案来最小化在目标上的影响;

Schemas:我们创建表的时候就必须要指定一个 Schema,比如我们第一个 Demo 中的 tick

  • 目前 Instruments 里已经定义有超过 100 个 Schema 供我们导入使用了;
  • 包含在 Instruments 包中;
  • 可以通过编译设置链接其他的 Instruments 包,在编译期会进行类型校验;
  • 提供了不同的构建模块;

Modelers:前面提到过,Modeler 可以帮助我们合成不同的数据,关于它的有以下几个常用的元素可在 XML 配置文件中使用。

  • <modeler>:创建一个 Modeler,帮助我们做数据类型转换的工作;
  • <point-schema>:定义一个可以用来存储点(无时间片段,即某个时间点的数据)的 schema;
  • <interval-schema>:定义一个可以用来存储定距数据(有时间片段,一段时间内的数据)的 schema;

PS:Modelers 其实是一个由 CLIPS 编写,非常强大而且高级的小型专家系统(Expert System)。它可以指定自己需要哪些输入信号来告知 Binding Solution 怎么完成剩下数据图形的填充工作。关于这一部分我们将会在“高级应用”里详细说明。

最后,具备定义一个 schema 的能力是很重要的。今年新发布的 OS signpost API 赋予了我们一个很棒的把数据导入到 Instruments 中的方式,而且苹果为我们创建了一些快捷方式来使用它,比如在 XML 配置表中敲下 <os-signpost-interval-schema> 元素,就会自动生成如下代码片段。

<os-signpost-interval-schema>:定义了一个存储定距数据的 schema,而且数据由 os_signpost API 提供。这就意味着我们可以代码的任意地方使用 os_signpost API 来将我们需要被测试的数据直接导入到 Instruments 中。在创建这个元素的时候, Xcode 会自动生成相关的代码片段以帮助我们来完成 Modeler 的创建。 这个 API 和 os_signpost 结合使用起来就如下:

//使用这个 os_signpost 可以再我们代码的任意地方将你的数据传递给 Instruments,但必须记得 begin 和 end 成对使用
os_signpost(.begin, log: parsingLog, name: "Parsing", "Parsing started SIZE:%ld", data.count)

// Decode the JSON we just downloaded

let result = try jsonDecoder.decode(Trail.self, from: data)

os_signpost(.end, log: parsingLog, name: "Parsing", "Parsing finished")
<!-- MARK: 使用这个元素就能创建从 os_signpost 获取数据的 schema -->
<os-signpost-interval-schema>

<id>json-parse</id>

<title>Image Download</title>

<subsystem>"com.apple.trailblazer"</subsystem>

<category>"Networking"</category>

<name>”Parsing"</name>

<start-pattern>
<!-- MARK: 这里根据我们设置好的条件输出信息 -->
<message>"Parsing started SIZE:" ?data-size</message>

</start-pattern>

<column>
<!-- MARK: Engineering Type 对应的助记词 -->
<mnemonic>data-size</mnemonic>

<title>JSON Data Size</title>
<!-- MARK: 填写的是 Engineering Type 里定义的数据类型,比如这边看的是内存相关的,就会有 size-in-bytes -->
<type>size-in-bytes</type>
<!-- MARK: 由 CLIPS 语言编写的表达式作为这个列的值 -->
<expression>?data-size</expression>

</column>

</os-signpost-interval-schema>

在中级这一部分,苹果还为我们演示了一个 os_signpost API 使用示例。该示例中对一个展示图片的列表页做了图片测试,监控了每一个 cell 图片的下载情况。由于涉及到的知识点上面都已经描述过了,具体示例的完成过程这边就不再赘述,相信通过视频大家能看得更直观。其中演示过程中有提到几点比较值得注意的,这里重点抽出来说明下。

Instrument Inspector:如果有查看数据存储、Modeler 和 Schema 需求的话(调试我们自定义工具的时候可能就会用到),这个功能就能够满足你,在 『Instrument-> Instrument Inspector』里或者 『cmd+I』 就能打开。

高级应用

这一部分将会着重介绍我们怎么去创建和定义 Modelers,并且简单介绍下怎么用 CLIPS 搭建基本的专家系统。

探秘 Modeler 的内部世界

从概念上说,Modeler 是在 Instruments 中是接受一系列输入数据并转化产生输出数据的一个简单机器。Modeler 的输入数据往往是按时间排序的。当我们把不同的输入数据表给 Modeler 时,这些数据将会先被按时间排序然后合并进一个时间排序队列里。时间队列将会依次把数据再传递给 Modeler 的工作内存,Modeler 就会根据工作内存的进展推理要产生什么样的数据并写入到输出表中。

我们通过一个简单的例子来探秘 Modeler 世界。我们模拟这样一种情况,我的代码里有部分危险的操作容易触发程序问题,我们的目标就是在程序出现问题的时候,找到是哪个操作导致的。那么我们可以定义下图中的三个 schema,前两个作为输入项,最后一个是输出项。

将这个流程,我们已一个更加形象直观的时序图来展示,其中虚线代表着 Modeler 自己的时钟:

其中,图上我按照时间顺序,标注除了 4 个值得注意的节点:

(1)这时的 App 出于正常运行状态,没有任何数据传输给 Modeler 的工作内存中,Modeler 的时钟并没有开始走;

(2)此时虚线到达我们的第一个 input schema 触发的节点(开始有危险操作的节点),在这里 Modeler 的工作内存中正式开始接收到数据,Modeler 的时钟从这个点开始计时。

(3)这是第二个 input schema 的触发节点(我们 App 出现问题的节点)。这里值得一提的是,Modeler 是很机智的,它有自己的逻辑,它能区分出在这个时间节点之前的危险操作数据意义不大,而这个节点开始到 app-on-fire 这个节点结束前的数据才是我们所需要的。

(4)到这最后一个节点,所有的输入数据都已经传输完毕了,Modeler 的时钟与这些输入数据没有交集, 它推断出这些输入数据已经不再被需要了,因而把它们从工作内存里移除并且产生最终的输出数据

可以回顾整个过程,可以总结出两点:

  • Modeler 的时钟起始时间总是第一个输入数据触发的时间
  • 只有与 Modeler 当前时钟有交集的输入数据才会被 Modeler 保留在工作内存

这样一种机制能够帮助我们更清晰地定位到问题的所在,而不被那些无意义的旧数据干扰。这样一种机制是怎么实现的呢?答案就是我们接下来要说明的 『生产系统(Production System)』

生产系统

Modeler 中对『工作内存(Working Memory)』的逻辑支持是来自我们定义的 『生产系统』。『生产系统』可以由一系列的 『规则(Rules)』 来生成,它在『工作内存』中为 facts(CLIPS 语言中的专业术语,可以理解为字面意思事实,推理得到的事实) 服务。『规则』由三部分组成—— LHS => RHS ,左边部分 + 操作符(=>)+ 右边部分。左边部分是一个在『工作内存』中激活规则的条件,右边部分则是激活后要执行的行为。右边的行为包含为输出的数据表创建新的一行,也可以是在建模时推断出一个新的『fact』到『工作内存(Working Memory)』里。结合之前上面的时序图,我们可以想到『facts』 来源于两个地方。一个是我们看到的数据输入表,这些是根据『规则』自动推理出的。另一个可以是来自于右边部分的行为,这些是通过 CLIPSassert 命令主动推理的。如果我们打算创建我们自己的『facts』,CLIPS 提供了 fact 模板,模板允许为『fact』提供数据结构和做一些基础类型的检查。接下来就是介绍怎么来定义规则了。

规则和 CLIPS

我们可以使用 CLIPS 语言定义一些规则。先回顾下上面那个例子的时序我们是怎么通过 CLIPS 设置规则实现的:

(defrule MODELER::found-cause//规则名
    //LHS,左边部分指定规则激活的条件
    (playing-with-matches (start-time ?t1) (who ?object))

    (app-on-fire (start-time ?t2))

    (test (< ?t1 ?t2))

    =>
    //RHS,右边部分为推理产生一个 fact
    (assert (cause-of-fire (who ?object)))

)

(defrule RECORDER::record-cause//规则名
    //LHS,左边部分未设定激活的条件
    (app-on-fire (start-time ?start))

    (cause-of-fire (who ?object))

    (table (table-id ?t) (side append))

    (table-attribute (table-id ?t) (has schema started-a-fire))

    =>
    //RHS,右边部分为产生输出数据
    (create-row ?t)

    (set-column time ?start)

    (set-column who ?object)
    
)

其中,record-cause 这条规则定义了,如果满足以下三个条件,则这次生产会推理出一个 『fact』并被压入『工作内存(Working Memory)』里。

  • 一个对象 t1 这个时间在 playing-with-matches 中产生;
  • app-on-fire 在 t2 这个时间被触发了;
  • t1 的发生时间早于 t2;

record-cause 这条规则定义了,如果满足了以下四个条件:

  • App 在一些起始时间“着火”;
  • 知道“着火”的原因和相关的对象(规则 1 里可以拿到);
  • 有一张绑定了 Modeler 输出侧的数据表;
  • 这张数据表关联着我们之前定义的 start-a-fire schema;

则在输出数据表中创建一行数据,并且设置时间和设置『左边部分』捕获到的中引起“着火”的值。

通过以上两个简单的规则,我们便基本上创建了最早的『专家系统(Expert System)』。使用定义好的两条规则,就可以用来寻找我们 App 内存在的一些问题。或许你也已经注意到,这些规则要么是为 Modeler 预先设置的,要么是为 Recorder。CLIPS 里把他们叫做『模块』,并且支持把规则分组和控制规则的执行顺序。比如说,如果你所有的规则都定义在了记录模块里生产输出数据表,那么你将不会在 Modeler 模块推断的时候写入任何的输出数据。因为在 Modeler 模块 里的规则必须在记录模块里的规则之前执行。

逻辑支持

在前面探秘 Modeler 内部世界的时候,我们提到过逻辑支持。逻辑支持一般与纯推理规则关联在一起。比如说,如果 a 和 b,那么 c。通过我们的『生产系统』中说,就是如果 a 和 b 不存于『工作内存』了,那么 c 就要被自动地被回收。这样我们就可以说 c 是被 a 和 b 在逻辑上支持了。这样的一种能力对于『专家系统』将『工作内存』维持在较低的水平很重要,因为它能很好地控制资源开销。同时,把无效的 facts 从『工作内存』里及时移除也是很重要的。如果 a 和 b 失效了,那么 c 也应该被移除。这样的需求在 CLIPS 里实现会很简单,只要通过 logical 命令就可以,如下面的代码。

(defrule MODELER::found-cause
    //通过 logical 命令实现逻辑支持
    (logical (playing-with-matches (start-time ?t1) (who ?object))

    (app-on-fire (start-time ?t2))

)

    (test (< ?t1 ?t2))

    =>

    (assert (cause-of-fire (who ?object)))

)

最佳实践

前面洋洋洒洒说了那么多,最后这一部分我们主要谈一下在开发自定义 Instruments 工具时有哪些最佳实践。

多写一个 Instrument

这句话不是建议我们去不断练习写自定义的 Instrument 工具,它说的是我们应该把 Instrument 功能做得细粒度化。举个例子,我们已经有了一个自定义的 Instrument 工具,但这个工具的功能并不能满足现在的需求,我们需要在它的基础上去增删一些详情或者图形的数据展示。这样一种场景,我们会有两个方式去做,第一个在原有的基础上继续迭代,第二个是重新再写一个满足我们当前需求的 Instrument。如果选择了第一种方案,这会导致这个 Instrument 组件变得不再纯粹,虽然功能是更多了,但相应的 Instruments 也变得更加复杂。所以苹果会更推荐我们用第二种方案,重新写一个符合我们当前需求的 Instrument。如果我们需要组合使用不同的 Instrument,在 Instrument 组件库中拖拽对应的 Instrument 到我们的文档中即可。如果这类组合使用场景很多,我们也可以用 “File -> Save As Template” 保存为模板以供接下来使用。保存模板的将会展示在我们 Instruments 的启动页面,比如内置的 Leaks、Activity Monitor 和 Time Profiler 等一样。另外这些模板,也能够很方便的在我们的包中复用,使用 <template> 元素即可。

实时模式很困难

实时模式指的是数据的获取、分析、到最终通过 Instruments 的界面能够实时地被展示出来。苹果现在很难完成这种实时交互主要有两个原因。第一个原因是,实时模式的完成需要一些额外的支持,但现在苹果并没有足够充裕的时间去做;第二个更重要的原因便是定距型数据(Interval Data,统计学上的概念,具有间距特征的数据,可作加减计算)。定距变量只有起始和结束这个阶段完成时,才能被加到数据表中和被『分析核心』所获取。在启动测试记录后,Instruments 将会收到一群定距型变量的开区间,当定距变量没完成闭区间时,Modeler 的时钟并不会往前走(Modeler 里的数据是按时间排序的)。这样的机制就会导致了一个问题,如果某个定距变量的开闭区间拉的很长,那么 Modeler 就会一直停滞在那儿等待。但如果这个时候用户点击了停止记录按钮,所有的开区间定距变量就会关闭,一切都会恢复正常,数据将会被填充到 Modeler 里。应该能感觉到,这是一个很不好的用户体验。一旦我们遇到这种情况时,我们有两个选择。第一个选择是配置我们的 Instrument 使它不支持这种实时模式,这个可以通过 <limitations> 元素实现。第二个选择避免数据出现这种长时间的开区间,例如使用 <os-signpost-interval-schema> 替代 <interval-schema>

使用『最后 5 秒记录模式』

当创建一个用来测试含有大量输入数据的 Instrument 时,使用『最后 5 秒记录模式』则是我们目前最好的选择。这一选项我们可以在 “File -> Recording Options” 里找到。如下图:

选择这一模式意味着将会有一个缓存池来存储数据,而不是实时地将数据传输给 Instruments。缓存池机制极大地提高了效率,在 5 秒记录模式下甚至能把 signpost 类型数据传输速度提升 10 倍。这个机制相应的代价是,我们只能看到最后 5 秒的数据。但对于大批量数据的 Instruments 而言,这依然是一个不错的选择。

总结

Instruments 10 提供了太多创建自定义 Instrument 的可能性了,不过这同样需要我们花点时间来学习掌握新一套的编写方式。对于大多数客户端开发者来说,或许并不会用到上面谈的这部分技能,但对于测试团队来说,这无疑为 iOS App 的性能测试又打开了一扇窗。相信在未来的一年里,圈子内会陆陆续续地有高质量自定义 Instrument 的产出,让我们一起期待。

查看更多 WWDC 18 相关文章请前往 老司机x知识小集xSwiftGG WWDC 18 专题目录