WWDC 2018:建立自定义的 Instrument

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

Instruments 10

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

  • 开发者可以根据不一样需求,灵活快速地基于 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 围绕如何建立一个自定义的组件,分为如下四个大部分展开:swift

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

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

为何要建立自定义的工具

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

可是这些内置工具的使用其实很大程度上是基于咱们对本身代码的充分了解,那么如何让不懂这部分代码的人也可以很快了解 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 文件中完成,其中默认已经帮我生成了包含一些基础信息。接着咱们要作的就是按照咱们的须要去写这个配置文档了。

一、导入须要使用的 Schema架构

<!-- MARK: 导入你须要使用的 schema -->
    <import-schema>tick</import-schema>
复制代码

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

<!-- 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 里就可以像使用其余内置工具同样运行。 ide

另外,咱们还可以在 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)的视图;

分析核心

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

一、简化

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

二、搜索

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

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

三、优化

当咱们从各个 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 专题目录

相关文章
相关标签/搜索