保证应用高性能的方法(WWDC 2018 session 407)

WWDC 2018 session 407: Practical Approaches to Great App Performance

简介

在本 Session 中,苹果的工程师 Jon Hess 和 Matthieu Lucas 分享了如何针对 App 性能进行有效的优化,以及如何在设计上针对性能进行优化的理论与实践。其中 Xcode 团队工程师 Jon Hess 现场演示了如何在 Instrument 工具的帮助下进行性能优化,而另一位苹果的工程师 Matthieu Lucas 则分享了官方的 Photos 应用在性能优化方面相关的经验。

测量

首先,如果要做性能优化,应用程序内所有的性能表现都应该基于测量。在开始解决性能问题之前,您应该进行测量,建立一个基线。当您迭代解决一个性能问题时,您应该测量它的每一步,以确保性能产生了您预期的变化。当您解决完性能问题后,您应该再次进行测量,以便可以与原始基线进行毕肖,并对您提高app性能的程度进行量化。

当您考虑提高用户性能时,您需要考虑到一个我称之为总体性能影响的东西。如果您将应用程序内某个区域的功能和性能提升了50%,但只有1%的用户会遇到这种情况,那么这并没有将所有用户都在用的一些其他功能提高10%的性能划算。因此,您没有必要边缘情况,确保您的更改正在影响所有用户。

如何修复性能问题

思考一下,我们如何修复普通的问题。一般都是收到用户的bug报告开始,我们确定是一个问题之后,找到一些方法来复现该问题。找到之后,我们会在程序中进行调试,来找到问题的根本所在。之后我们修改并验证问题修复,并且没有引入其他新的问题,必要的时候可能需要重复多次验证,直到我们完全解决了这个问题。
image
修复性能问题也是同样的思路。在这个过程中不仅仅会用到调试器,而且会用到测量、分析工具。然后找到了复现步骤,通过分析工具进行分析。找到问题后,进行修改代码,并根据需要重复的进行测量、分析,直到结果满意为止。
image

性能问题的类型

image

主要衰退

有的时候会遇到性能衰退。一些进展顺利,但是有人写了一些东西,性能下降了。现在我们就必须回去找出导致该问题的原因。如果性能降低的特别明显,或者一个我认为不久的将来不会再次出现性能降低的地方,我可以通过测试工具手动测试它。然而,性能优化的胜利是来之不易的,并且它们很容易会因为性能慢慢降低的而失去。我非常鼓励大家编写自动化性能测试来捕捉应用的性能,这样就可以确保他不会随着时间的推移而退化。

偏离目标

另外一个场景,应用程序打开时间越长,性能变得越差。在一些绘制测试中,它可能以每秒45帧的速度运行,但我们预期它该以每秒60帧的速度运行。这需要改进,我们可以通过现场修复和增量更改来实现这一目标。这种情况下,我还可能已经进行自动化测试了,因为我了解我程序的性能。

设计差

第三种情况,我们的应用程序知识设计的不好,性能比应该的差了几个数量级。这种情况,我们无法通过简单的现场修复来改进它,因为我们过去曾经尝试过,但性能仍然特别差。这种情况下,您需要进行全面的性能检测,重新设计功能的某些核心部分或相关算法,从而使性能成为主要约束。

性能测试

无论我们要解决上述的哪一种性能问题,都离不开测试。性能测试打开氛围两种:单元测试(Unit Test)和集成测试(Integration Test)

性能单元测试

image
在性能单元测试中,您的目标是把应用的某些特性孤立出来进行测试。您可以模拟(mock)他的依赖关系,也可以在它被隔离的环境中启动它。举个例子,假设我们要对 Xcode 的自动补全功能进行性能的单元测试,大概需要三个测试用例:

  • 测试与编译器通信,并获取原始的补全候选数据的性能
  • 测试对原始候选数据进行分析并选出最适合的数据的性能
  • 测试把准备好的数据展示到 UI 层的性能

这样,通过这一系列的单元测试,我们便能够对 Xcode 代码自定补全功能的进行全面的性能测试。

性能集成测试

image
在性能集成测试中,你的目标是测量用户正常使用应用程序是的性能。同样以 Xcode 的代码补全功能为例,对此功能的集成测试应聚焦于其真正的使用场景上面 —— 用 Xcode 打开一个源文件,输入一些代码并显示补全信息,然后不断重复此工作。在这一过程中,我们可以利用如 Instrument 等分析工具进行性能分析。您的性能调查应该要从集成测试开始,这才是真正意义上用户使用您的应用的性能表现。

实战:利用 Instrument 进行性能分析

这里讲到的是在Xcode 9和Xcode 10之间修复的性能问题(视频11:30开始)。问题是Xcode打开打开新标签页(Command+T)时会屏幕闪烁,出现黑屏,创建新的标签需要花费几秒钟。
首先打开Instruments,里面有各种各样的分析工具,可以测量图形利用率,内存消耗,IO,以及时间。如果你实现有限只能学习一种工具的话,那一定是Time Profiler。打开Time Profiler,选择被测程序为Xcode之后,开始录制,返回Xcode创建一个新标签,然后停止录制。这样我们就可以追踪这段时间内程序都干了什么。Time Profiler是每毫秒为间隔来采样的。我们可以看到主线程里面所有代码的时间花费。查看这些数据尝试找到我们可以更快完成或者冗余、不必要的操作,这是我们提高程序性能的主要方法。

正如你想象的那样,我们每秒捕获一千条回溯轨迹。现在有大量的数据可以浏览。我给您的主要建议是,您要尽可能多的过滤这些数据,这样方便您找到更多的性能优化线索,而不是关注于细节。

在刚才录制的结果中,我想知道CPU利用率是如何辩护的,在哪里它发生了变化,而在刚刚创建新标签的时候,我也注意到了就是这里,发生了变化,所以我点击并拖动,选中这个区域,来看这个区域内的追踪数据。
image
窗口的底部区域,展示了它收集的所有痕迹。在最下面有几个过滤器,其中有一个是通过线程分开,默认是开启的。如果关闭了它,所有线程都将按其顶级入口点进行分类,而不是线程ID。看了下其他线程都不怎么活动,那么我们可以在顶部追踪视图上点击主线程按钮,来只关注主线程。
image
这样我们就可以关注右下角的调用层次结构了。在这里面就可以去找一下自己的代码。因为我熟悉这个API,所以点击,查看左侧发现这个功能耗费了1.19秒,占用了45%的时间。
image
这远远超出了我的期望,但是很难确定到底发生了什么。这些调用层次结构大概有30-40的深度,不容易观察。那么可以右键只关注这个函数。
image
image
然后我们再一层一层打开,打开到最后发现有一个objc_msgSend的方法,右击,选择Charge 'libobjc.A.dylib' to callers
image
这将告诉Instruments采取所有来自libobjc的样本,并从调用数据中删除他们,但将时间归因于调用跟他们的父帧。因为我很少会尝试优化他们,所以我只想将它们从数据中删除,这样我就可以更专注于我要做的事情了。
同样设置Call Tree Constraints中设置时间范围,可以让我们进一步筛选出我们需要关注的代码。这里我设置大于20。然后定位到[NSOutlineView expandItem:expandChildren:]方法中。
image
很多人到这里就停止了,因为他看到这是系统的函数,我再这里花了很多时间,但不是我的问题吧。我没有办法优化这里。并不是这样。

例如,系统框架可能会花费所有的时间,因为它对您提供的数据进行操作。这可能需要很长的时间,以内您可能会成千上万次的调用这个方法。他可能需要很长时间,因为它通过委托重新调用您的代码。

这里通过向下扩展这个树状的层次结构,查看正在调用的函数名称,您可以深入了解系统框架正在做什么。这正是我学会修复这个bug的方法。
image
当我展开后,我发现调用了这两个方法。批量展示包含项目条目的项目,展开子项目,并在结束更新后工作。这些对我来说是个很大的线索,通过批处理可能有一些效率提高的机会。思考之后,发现确实是做了很多多余的工作,计算位置只需要计算一次,而这正是想要提高性能时需要消除的东西。

通用的解决方案

最后,Jon 总结了一系列性能优化的通用方案:

  • 延迟工作(Defer)
  • 批量工作(Batch)
  • 共享计算结果(Share Result)

    避免进行重复的工作,例如布局不变的情况下,frame只计算一次,缓存下来,而不是每次都去重新计算。

  • 使用更少视图(Fewer Views)

    对于代码组织来说,使用很小的视图和很小的功能集,并将它们组合成为更大的片段是非常好的。但视图越多,渲染和布局就会越慢。较小的视图通常会让您拥有更多的细粒度缓存,这对性能也是有好处的。一般来说,您可以调整视图的数量,来提高性能。但拥有较少的视图并不总是最好的。

  • 使用直接观察模式(Direct observartion)

    避免两个对象通过某个中间者进行通信时,其通信过程不透明造成额外的性能消耗。

  • 使用字典记录(Prefer records to dictionaries)

在随后的部分中,我们可以看到这些解决方案在 Photos 应用的性能优化的实践中是如何应用的。

Photo性能优化之路

分享的优化实践主要集中于两个部分:

  • “时刻” 页面的加载时长优化
  • “年度” 页面构建及布局的优化

“时刻”页面的加载时长优化

首先,在分析启动性能之前,要确定我们本次需要优化的启动过程属于哪一类型。启动分三种,冷启动、暖启动和热启动。

image

  • 冷启动

    应用处在关闭状态后,第一次启动它。因此基本上没有缓存任何东西,可能需要创建进程,加载一些库。测试冷启动的正确方式是重启设备后,点击图标启动app。

  • 暖启动

    当您关闭一个应用程序后,过几秒种你重新启动它,这几乎确定您触发了暖启动。之所以称之为暖启动,是因为资源或者依赖库仍然在缓存中,所以启动高速度更快。

  • 热启动

    当您的应用在后台运行时,被带回前台的启动,城市为热启动。

当您开始测量启动时,您应该首先测量暖启动。暖启动时间比冷启动时间短,并且你不需要重启设备就可以进行多次测试。测量启动的方法是从主屏幕上点击图标到应用可以响应用户操作所需的时间。一种常见的做法是屏幕上展示一个正在加载的旋转进度条,这样并不会使应用更快的变为可用,所以我们应该试图避免这种情况。

优化Photos有三个目标:

  • 即时性
  • 不阻塞交互
  • 不展示占位符

其中,我们重点关注一下 “即时性”。到底该如何界定加载的即时性,这里给出了以下几个条件:

  • 加载所耗时间与从主屏幕打开的缩放动画时长一致
  • 具体时间范围是500~600毫秒
  • 无缝转场:启动动画结束后,就可以进行操作了

这些条件是我们推荐的最低标准,适用于各个应用。

目标加载时间解析

如果我们深入了解启动,你会发现主要有两部分。

  • dyld(时长请保证在100ms之内)

    这是一个加载器,它将加载和链接所有依赖库,并且它也将运行静态初始化器。您对这部分的控制是有限的,但也不是什么都做不了。如果想了解关于这一部分的内容,请查看WWDC 2017 启动时间的过去、现在和将来

  • main/UIApplicationMain(时长请保证在500ms之内)

    1.willFinishLaunching

    2.didFinishLaunching

    3.主页布局
    didFinishLaunching调用完毕,会马上进行主页的布局,主页布局完毕,这基本上意味着您的应用可以开始使用了。

优化前的准备

在优化之前,我们需要确定一下优化的一些原则——实际上这次优化也是基于这些原则的。

  • 慵懒

    将某些耗时操作尽可能的延迟进行

  • 积极主动

    保证后面的工作在预期内执行,并且能够及时的获取反馈

  • 常量时间

    无论加载的数据量多少,加载时间总能保持在常量时间

加载步骤分析

接下来,我们着手于分析 Photos 应用在启动加载时所做的工作有哪些。Matthieu 给出了一副这样的柱状图,包含了所有的启动加载工作以及所耗时间:

image

需要注意的是,数据是一直都在增长的,因为人们每天都会拍照。

优化每个步骤
1.数据库初始化

通常来说,在第一次查询执行的时候数据库才会初始化和加载。曾设想过把数据库的初始化过程尽可能早地放在子线程中执行,但这样其实并不可行——有些语句可能更早地在主线程中执行。因此,确保查询都尽可能高效,尽可能避免重复查询。可以建立一些数据库索引,来加速数据库的查询。

具体步骤 优化前 优化后
(1) 尽可能早地加载好数据库
(2) 启动前完成查询操作
(3) 确保查询高效,避免重复
80ms 30ms
2.视图准备

首先,应用导航栏中有四个选项卡代表四个主要功能。所以要做的第一件事是最大限度的减少在三个不可见的控制器里面的初始化做的事情。所以这里的原则是最小化不可见控制器的准备工作,视图初始化时尽可能做少的工作,只加载可见的视图。

具体步骤 优化前 优化后
(1) 最小化不可见视图的准备工作
(2) 尽可能精简视图的初始化工作
(3) 只加载可见的视图
600ms 120ms
3.配置数据源

上一步的视图准备优化工作同时会影响到配置数据源的工作,由于我们只需要加载可见的视图,因此我们的数据源并不需要把所有的内容都准备好。相反地,我们只需要把源数据准备好,而只配置并加载需要的内容(即可见的内容),异步去处理安排其他内容的加载(即不可见内容)。在 “时刻” 中,通常每次加载所需要的内容大概只有7~10个。这样的加载策略同样能够把加载时间控制在常量时间内。

具体步骤 优化前 优化后
(1) 仅同步加载可见内容
(2) 异步地安排剩余内容的预处理工作
500ms 100ms
4.加载图片资源

关于加载图片资源的优化最重要的一个环节,因为在优化之前此部分的加载已经缓慢到需要几秒钟的时间。如此长的加载时间意味着期间必定进行了大量冗余的工作,因此首要工作便是评估真正需要加载的图片数量——在 “时刻” 的页面中能展示出的图片数量是60个左右(包含了一部分为了保证交互顺滑的所需要的预加载数量)。然后在这个页面只去加载低质量的缩略图,这样内容中加载更少的像素,效率更高。

具体步骤 优化前 优化后
(1) 评估真正需要加载的图片数量
(2) 加载低质量的缩略图
2000ms 200ms
5.获取iCloud状态

关于iCloud状态获取(在“时刻”中的场景为底部的一个统计信息)这一部分的工作相对比较简单。首先要做的是评估此类信息是否需要在加载时立即显示,而后的优化可以基于此评估结果来进行——运用缓存机制、App 后台刷新机制等。

具体步骤 优化前 优化后
(1) 评估此类信息是否需要在加载时即刻显示
(2) 缓存信息
(3) 运用 App 后台刷新机制(如果1的答案是需要)
400ms 0ms
优化结果

最终优化结果为450ms,符合之前说的500ms的预期。
image

总结一下关于 “时刻” 的优化工作,大概有这几点需要注意:
- 思考内容准备工作的耗时
- 测量内容准备工作的耗时
- 争取常量时间

“年度” 页面构建及布局的优化

“时刻”页面可以无缝的切换到“年度页面”,相比于时刻页面来说,年度页面有非常复杂的层次结构。该页面可能会展示数千张图片,同时支持实时更新。还需要支持动画效果以及手势的响应。

而我们的优化目标还是三点:

  • 不阻塞交互
  • 不展示占位符
  • 丝滑的动画

优化的原则,跟之前说的类似:

  • 慵懒
  • 积极主动
  • 常量时间

  • 尽可能减少时间

    只有与8~16ms的渲染时间,确保不要超过这个时间,否则就要丢帧了

视图层级优化

我们想要这种便携式视图,里面包含切片以及微型单元。
image
原生的UICollectionView已经提供了这样的布局样式,但这样的布局结果会存在一些问题:

  • 太多的视图/图层
  • 布局复杂
  • 过多的内存消耗

为了解决这些问题,我们需要在这里进行创新,我们在使用集合视图的同时,通过大幅限制视图的数量来解决这几个问题。这里使用了在电子游戏中非常常见的技术,叫做地图集(atlasing)。

想法是将一行缩略图整合成为一张大图,这样就会少了很多渲染的工作。每行以单图的形式展示,及时的生成以及缓存,以便更灵活的使用。
image
最重要的优化就是把多张图片合成一张图片再渲染到屏幕上,这减少了大量的元素图层对象的生成。但换取了高性能的同时,也对一些其他功能带来了不便,其中较为显著的就是长按/3D-Touch 预览功能中手势识别变得更复杂,还需要需要保存单个图片到单行大图的映射关系。

为什么要试试生成以及缓存?因为我们需要支持实时更新,以及横屏、竖屏下不同的视图尺寸。

为什么不将整个切片的区域整体生成一张图片呢?答案是我们的设计记录是做这样很酷的动画,在哪里你可以看到这些收藏正在扩展到他们自己的部分或者折叠成群组,反之亦然。

“年度” 优化总结
  • 思考内容视图层级带来的布局上的消耗
  • 测量内容视图层级带来的布局上的消耗
  • 您应该多考虑应用的性能表现

结语

在本session中,两位功臣是分享了许多与性能优化相关的理论知识以及实现。虽然没有设计太多底层知识,但是为开发者提供了分析问题的思路,以及他们的见解,让我们队性能分析及优化有了更清楚的认识和理解。

猜你喜欢

转载自blog.csdn.net/TuGeLe/article/details/81384771