阅读视图

发现新文章,点击刷新页面。

第 17 章 图像和 UI:示例(iOS和macOS性能优化)

这一部分的文章是我早年间参与iOS和macOS性能优化这本书翻译时的原稿, 当时的水平有限, 翻译的可能会有一些纰漏, 还请多多指教.
将文章同步到个人博客主要是为了同步和备份.

本章将通过两个具体示例来详述如何优化大型应用程序:一款以图像为核心的天气应用和 Wunderlist 3 任务管理器(译者注:一款生活实用类软件)。

优美的天气应用

几年之前,我与一家刚刚成立的柏林创业公司进行了接触,迄今为止主要业务是开发益智类应用程序,并且取得了相当优秀的成绩。他们的目标是构建一款最优美的天气应用,现在已经基本完成了 UI 设计(参见图 17.1),但是同时遇到了一些阻碍,延缓了项目的交付。

IMAGE

这款应用程序依赖大量图片资源,以致于经常因为内存错误而引发崩溃。此外性能问题也令人堪忧。我已经在前面几章中讲述了一些经验教训,但是最明显的一点仍然是内存警告与线程之间恼人的交互:若主线程运行内存消耗大的程序,进程会收到内存警告并处理,这将阻塞当前进程,甚至可能会被销毁,即使原则上你可以做些事情来挽救它。另一方面,如果后台线程上运行内存消耗大的程序,那么即使主线程尝试处理内存警告,但是后台线程可能仍会继续占用内存,从而导致进程被销毁。

解决方案如下:通过间断性发送消息的方式,后台线程向主线程“登记”申请内存,然后等待主线程返回的结果,特别是在分配大量内存之前。这样做的结果就是让主线程有时间对所有内存警告作出回应,并且在必要的时候停止后台线程。

更新

然而,本章所述的主要任务是对应用进行相应的更新。截至目前,iOS 设备的屏幕尺寸和分辨率数目又有所增加,并且为了同时兼容旧设备与新设备,我们需要处理新设备与旧设备之间存在的巨大功能差异。当然,设计团队可能还需要更多的动画、更逼真的图像以及更强的视差效果。

当我介入这个项目的时候,可能需要花费几分钟的时间才能够启动应用,单单高分辨率版本的图像资源本身就占用了 491 MB,并且这还不是全部的资源。为每个设备添加优化过的资源以及所缺少的图像,很容易就能让应用大小超过 1 GB,但是 Apple 仅允许 100 MB 以下的应用程序使用空中下载技术进行购买与更新(译者注:OTA Over-The-Aire 的简称,是通过移动通信(GSM或CDMA)的空中接口对SIM卡数据及应用进行远程管理的技术,可以简单理解为2G 3G 4G网络)。

所以当务之急是将资源的容量减少五分之四以上,并且还要在不增加资源的基础上支持当前所有 iOS 设备,此外还得大大缩短加载时间。我不得不承认,这似乎是个不可能完成的任务。

探索 PNG

应用初始版本使用的是 PNG 图像,尽管这些图像有着媲美照片的真实画质。我当时对这个选择已经有所质疑,但是在版本发布的前一夜再来做任何事情就真的太晚了,而且这个团队似乎也知道他们在做什么。

此外,虽然这些图像看起来与真实照片无异,但实际上是通过合成得到,而且 PNG 通常认为比 JPEG 更适合进行图像合成。因为 PNG 进行了充分的优化,它使用了 8 位 / 256 色的调色板图像来进行有损压缩,甚至可以将调色板颜色减少到 8 位以下,以为了空间分辨率而牺牲色彩分辨率。

原则上来说,牺牲色彩分辨率是一个好主意,因为人类视觉对于亮度变化的分辨要远远强于对色彩变化的分辨,但是这种做法可能会有所偏差:使用某种交互工具将原始尺寸的图像进行多次压缩,并调整相应的参数,直到图像达到最小尺寸,也就是实现一种“看起来还行”的效果。这种方法的问题在于,由于图像将会被放大显示,空间上的缺陷将会超过色彩上的缺陷。解决方案是同时“增加”颜色分辨率,但是我不知道该如何实现。

原型应用试图避免为每个设备配置多个版本的资源,这种方式也是有问题的——它对某些特定出现问题的设备保存了优化过的资源版本(实际上,这是为了将其降低到适当的分辨率上)。为了不影响现有的渲染代码,这种二次采样和保存操作是在正常加载代码之前完成的(参见图 17.2),这导致第一次启动的用户体验非常糟糕:应用会在加载屏幕停留好几分钟,不停的转圈圈,无法进行交互,并且设备会变得很烫手。

IMAGE

此外,iOS 的 PNG 写入代码不像外部工具那样具备相应的优化机制,因此图像将被保存为 32 位的 RGBA,显然会比原始文件要大很多,尽管图像的分辨率很低。

头脑风暴

就我而言,很显然,“至少” PNG 格式需要重新考虑是否继续使用。我的第一个想法是围绕类似金字塔 (pyramidal) 编码方案进行,或者直接使用诸如 JPEG 2000 之类的小波 (wavelet) 编码。金字塔编码方案的优点是,它通过不停提取图像的较低分辨率版本来压缩图像,并且只存储这些版本之间的差距。这意味着解压缩操作将会自动提取较低分辨率的版本,因此可以仅仅只提供一个图像文件,就可以实现多个分辨率。

不过,iOS 和 Mac OS X 对 JPEG 2000 的支持相当慢,所以使用 JPEG 2000 并不是一个好选择。还有相当多的证据表明,这种性能上的缺陷并不是没有人尝试去修复,而是被现有的格式和技术手段所制约住了。所以这种方法似乎相当令人生畏,尽管我们的需求允许使用较少层次的金字塔简单实现,也可以使用其他的压缩机制来编码这些基本图像。

我们还研究了 PNG 当中所使用的 flate 压缩的替代方法。flate 压缩是一个非常好用的通用无损压缩器,但是我们的需求并不是很广泛,而是非常具体的。例如,我们只需要压缩图像,并且比起压缩速度而言,我们更关注解压缩速度(flate 有时会平衡两者的速度)。我们看到的一个替代方案是 LZ4,它的解压缩速度比起最差压缩比的 flate 速度至少要快 10 倍。

此外,我们其实还有其他神奇的选择,比如说 Apple 的移动芯片组直接支持的预压缩 PVRTC 结构格式。该数据格式不需要 CPU 解码;它可以直接馈送到 GPU,从而得到最佳的性能。另一方面,它的压缩率和质量都很一般。我还尝试使用 MPEG 影片格式,它也有一个硬件解码器,但是在 iOS 上一次只能存在一个 MPEG,而且它很难与场景中的其他对象组合在一起。

JPEG 数据点

直至最后,我们的面前仍然摆放了很多选择,而且依旧不清楚什么是正确的选择。我们需要相关的数据,所以我开始进行试验,从最普通的 JPEG 和 PNG 图像格式开始。

毫无疑问,JPEG 格式的文件在尺寸大小方面遥遥领先,对于 491 MB 的资源来说,使用非常保守的 0.7 质量设置就能够压缩到 87 MB,并且图片质量没有明显的损失。更令人惊讶的是,Apple 推荐将 Xcode “优化过的” PNG 作为默认格式,但是 JPEG 压缩文件的解码速度明显更快,至少在我运行测试的 Mac 上是这样的。

我们决定更加深入研究 JPEG 解码,得到了更多的好消息——使用 TurboJPEG 库能够将速度额外提升 20% 到 200%。更重要的是,对于我们在第 16 章“为何绝对不要绘制缩略图”一节中提到的需求而言,CGImageSourceCreateThumbnailAtIndex() 函数变得完全无用,也就是从 JPEG 2000 当中快速提取低分辨率图像!

测量时的小错误

当然,这里我犯了一个严重错误,没有实际在设备上运行这些测试,真正运行测试的时候就出现了一个巨大的断层:性能变得更加糟糕,特别是相对于 PNG 而言,而 PNG 格式现在竟然变得更加迅速了!这种性能差异对我来说没有任何意义,因为 CPU 是非常相似的,即便设备上的速度要稍微慢一些,但是两个解编码器之间的性能差异也不应该像我们测量的那么大才对。随后我对 Independent JPEG Group 的 libjpeg 软件的其中一个版本,也就是 JPEG 的官方参考实现,发现得到了比 Apple 标准库更好的结果,事情变得棘手起来。

其原因在于,Apple 实际上在 iPhone 系统集成芯片 (SOC) 中包含了一个 JPEG 解码硬件。这个解码硬件的工作效率实际上可能比软件还要慢,但是所使用的功率要更少一些,所以即便它的工作效率较慢,Apple 仍然更加偏好它。此外使用 Mach IPC 接口与硬件通信也会产生一些性能开销。

幸运的是,事实证明这些性能开销是固定的,因为我对一些小图像进行了测量。图 17.3 至 17.5 展示了一组更具代表性的测量结果,我们对不同的尺寸和不同的二次采样设置的图像进行了测量。

IMAGE

IMAGE

IMAGE

对于较大的图像而言,在对更多数据进行解码的时候,固定的性能消耗将会被摊销,此时 Apple JPEG 轻松地击败了 TurboJPEG,这个替代品的性能位于第二位,而 PNG 的速度依旧慢得让人难以接受,因为 JPEG 解码器确实能够只进行部分解码来获得性能的大幅度提升。

最后,尽管小图像相对性能的下降非常显著,但是由于图像很小,绝对性能的下降并没有那么大。一个中等大小的 JPEG 所“耗费”的性能抵得上将近一百个小图像,并且如果有一个非常大的图像,那么结果就显而易见了。此外,硬件性能的下降因模型而异,我所测试的是最慢的设备之一。

所以实际上,这个结果比我们一开始用设备测试后得出的结果要好,如果其他的方案都失败了,那么我们就可以回到 libjpeg 或者使用 TurboJPEG。因此,看起来我们似乎不需要那种高精尖的技术,JPEG 能够满足我们所需的一切。

JPNG 与 JPJP

现在只剩最后一道障碍了:我们需要将许多资源组合在一起,从而形成最终的场景,并且还是用了大量的透明度设置,然而 JPEG 不支持透明度设置。幸运之神再次眷顾了我们,有人曾经遇到并解决了这个特殊问题:Nick Lockwood 提出了 JPNG 文件格式,也就是将 JPEG 和 PNG 组合成一个单独的文件,JPEG 提供颜色信息,PNG 提供 alpha 遮罩。

首先,使用 PNG 来提供 alpha 通道似乎听起来有些矛盾。没错,PNG 支持 alpha,但是我们并不会使用 alpha 值来编码图像,我们只需要编码出一个简单的灰度图,这样就与对另一个图像应用 alpha 通道的表现类似。另一方面,alpha 通道通常比图像更具备块状特性,因此 flate 压缩的效果应该也会很不错。尽管如此,实际上并没有一个很好的理由让 alpha 通道以 PNG 的形式提供,对于我们来说这同样是一个严重的限制,因为这意味着对于最高分辨率而言,必须要砍掉四分之一的数据。

相反,我们决定更新 JPNG 格式,以便它也可以使用(灰度)JPEG 图像来作为 Alpha 通道。此外,我们还会修改库的 API,以允许指定图像的分辨率/大小,并且还可以使用 CGImageSourceCreateThumbnailAtIndex() 来提取较低分辨率的图像。

优美的启动

最后,我们实现了这个看似不可能完成的任务。让这个应用保持在 100 MB 的限制之内,并且还可以支持所有的新设备,设计师对于他们可以添加新图形和动画感到十分满意。用户呢?他们非常喜欢,应用在美国和德国的 App Store 上都获得了 4.5+ 的评分,并且很多评论表示早上打开这个应用还能够驱赶瞌睡。

使用 JPEG 子集化机制的一个好处就是:即便是在非常老旧的设备上,我们仍然可以非常快速地加载这些分辨率显著降低的图像(分辨率只有之前的四分之一甚至八分之一),以便在高分辨率图片正在加载的过程中,用户仍然能够看到最终的场景。

我们能做的还有很多。首先我们并没有集成 JPEG 软解码,所有的解码都要通过硬件解码器完成。显然,从 CPU Profile 中可以看到,CPU 的利用率显著低于 100%。虽然硬件通常要更快一些,但是添加两个软件解码器似乎可以让解码吞吐量至少增加一倍,特别是如果我们还设法对图像进行排序,以便让硬件解码器优先解码较大的图像,软件解码器优先解码较小的图像。我们还可以添加一些 PVRTC 格式的图像,使用这种压缩还能够进一步提升性能,此外或许还可以从 MPEG 视频中解码某些动画序列。这种想法是尽可能多地利用可用的硬件资源,只要它们不会互相干扰即可。

但是这是之后的优化目标了。

Wunderlist 3

2013 年底,Wunderkinder 团队请我去帮忙推出 Wunderlist 3,在这期间,我们一起推出了名为 Objectice-C 客户端:Mac 和 iOS 的架构(译者注:没搞明白),它的表现格外突出,至今仍然无与伦比。这个团队以及参与的这款产品给我留下了深刻印象,。

一年半后,微软对这个团队和他们所构建的产品表现出了浓厚的兴趣,与此同时收购了这个公司,这意味着忠实的 Apple 程序员现在已被邪恶的 Redmond 帝国所雇佣了。并且还深深喜爱上了它!

Wunderlist 2

Wunderlist 2.0 版本就许多方面而言是一款非常优异的产品,大部分用户都非常喜欢,但是它的性能和稳定性仍然亟待改善。当我首次下载并启动应用的时候竟然直接闪崩了,直至最后版本稳定下来之前,依旧连续崩溃了好几次。

Mac 和 iOS 客户端的数据模型使用 Core Data 来构建,此外也用来关联 UI 组件。正如我们在第 12 章所看到的那样,对于少量数据和简单用例而言,Core Data 的表现还行,毕竟性能的要求并不高。但是在处理中等或者大量数据的时候,保持高性能就变成了极大的挑战,开发团队发现他们得创建更复杂、同时也更加脆弱的权衡措施,才能够保证 Core Data 不会由于 I/O 而阻塞主线程。

整体架构

Wunderlist 3 Objective-C 客户端的整体架构如图 17.6 所示。这个架构并没有什么特别的地方。其中包含了一个内存模型 (in-memory model),它会在启动的时候从持久化(硬盘)存储中进行初始化。内存模型将会与 UI (双向)和后端(也是双向)保持同步。我们还会让磁盘存储与内存模型保持同步,但是由于磁盘存储对于应用而言是无法直接看见的,因此这个同步是单向访问。

IMAGE

然而,正是这种简单的架构才可能成就优秀的性能:通过明确界定不同子系统之间的界限,使得责任分工清晰明了。例如,模型对象和内存中数据库都不需要知道存储或者网络 I/O 中的任何内容,这样就不会有出人意料的数据交互发生。它们最多知道如何将自身转换成字典,也就是让外部代码可以将其序列化为某种持久化的数据格式。

我们用另一种方法来表示该架构,如示例 17.1 所示,这次是用代码的形式来表示的。|==|= 运算符(类似图 17.6 当中的实线箭头)表示数据流约束 (dataflow constraints),其行为与 Excel 公式非常类似,并且可以视作永久分配 (permanent assignment),因此它的行为与正常分配类似,只不过系统会维护它们之间的关系。

1
2
3
4
memory-model := persistence.
persistence |= memory-model.
ui =|= memory-model.
backend =|= memory-model.

URI 与进程中 REST

内存模型和持久化存储的基础架构模型是 In-Process REST,这是一种适用于应用当中的 REST 架构风格。所有的实体都由标识符对象所引用,这些标识符对象则是发挥 URI (通用资源标识符,Uniform Resource Identifier) 的作用;在 Wunderlist 中,指的是 WLObjectReference 类的实例,它会将实体类型 (entity type)容器 ID (container id)对象 ID (object id) 进行编码。容器 ID 是封闭对象 (enclosing object) 的 ID,比如说任务所属列表的列表 ID。并非所有对象都有明确的容器;例如,列表或者已登入用户是直接位于 URI 根结构下面的。示例 17.2 展示了用字符串 URI 表示的 WLObjectReferences 实例:

示例 17.2 内部 URI

1
2
3
4
task://container/2/id/3
list://id/2/
task://container/2/
task://id/3

URI 是结构化的。例如,示例 17.2 中的第一个 URI 引用了 id 2 列表中的 id 3 对象所表示的任务。第二个 URI 表示 id 2 列表对象。第三个 URI 是一个数组,表示包含在 id 2 列表当中的所有任务。最后一个 URI 指示表示 id 为 3 的一个任务,并没有提供任何列表 id。在我们目前的实现中,这会在所有列表中搜索满足此条件的任务。

数据存储被组织成一系列对象,其行为类似于 Web 服务器,只是它们不会使用 HTTP 协议来进行通信,不过这与示例 17.3 所示的标准 Objective-C 消息协议类似。如你所见,消息会一一与 GET、PUT 和 DELETE 这几个动词相对应,唯一的区别是,我们通常传入对象数组,而非单个对象。

1
2
3
4
5
6
7
@protocol WLStorage <NSObject>

- (NSArray*)objectsForReference:(WLObjectReference*)ref;
- (void)removeObjectsForReference:(WLObjectReference*)ref;
- (void)setObjects:(NSArray*)new forReference:(WLObjectReference*)ref;

@end

内存存储、硬盘存储和表示 REST 后端的对象都遵循相同的协议,因此大部分的存储操作都是可以互相替换的。为了进行测试,我们可以将第二个内存存储替换为磁盘存储,或者替换为后端同步,也可以同时实现两者的功能,这样就加快了测试的速度。事实上,这个协议非常简单,这同样意味着可以相互组合。例如,我们将计算实体 (computed entities) 的匹配器 (filter) 添加到存储层次结构中,这样就可以使用相同的方式来对其进行访问了,或者也可以针对某个特定的实体,将其放到多种持久化存储当中。

我们可以独立于 WLObjectReferences 所引用的对象来执行相关的计算。例如,我们可以确定磁盘路径和后端 URL。正如我们在示例 17.2 当中所看到的那样,我们还可以剔除 URI 的最后一个部分来确定对象所属的组。

最终一致的异步数据存储

回想我们之前使用 Core Data 的经验,让数据存储保持简洁、快速是初期设计中的优先考虑事项之一。我认为我们成功做到了这一点:我们的 CTO 很喜欢在讲座中震惊听众,他这样说:我们将数据以单独的 JSON 文件形式存储在磁盘上。这种做法其实非常有效——如果您回想一下第 12 章的内容,使用 Foundation 方法来对 JSON 格式进行编码和解码是最快的,恰好 JSON 也是我们与后端通信的数据格式,因此让存储格式和后端通讯格式保持相同,已经证明对于调试而言是非常不错的方法。

我们已经证明,这种简单的机制所带来的性能提升是非常惊人的。我们的其它客户端使用了数据库,或者其他复杂的序列化格式,但是 Objective-C 客户端在性能上始终遥遥领先,特别是在处理压力测试的时候,考虑到一个理智的——呃,不,一个不理智的用户很有可能会创建很多个列表和任务。尽管从事实上来说,使用这种格式就很多方面而言存在很多不足。例如,我们可能会写入太多的小文件。然而,每当我以为某个问题需要换用更复杂的方法才能解决时(否则会很伤脑筋),但实际上最后我会发现这个问题是由一个简单的错误所引起的,并且解决起来非常简单。

数据存储之所以如此简单,完全要归功于我们的后端,它由一个松散的微服务集合所组成,可以保证不同的实体之间最终能够保持一致。这意味着我们的一致性需求并不只满足于存储的规范,因此将 YES 传递给 NSDatawriteToFile:atomically: 方法来保证独立文件的一致性,这个做法非常有效。

所有写入到磁盘的操作都是与主线程异步的;不过,这些操作都是在一个负责写入磁盘的后台线程中同步循环执行的。主线程只会将需要保存到后台写入线程的 对象 URI,通过队列发送出去。当后台写入线程在队列中获取到特定的 URI 后,就会从内存存储中获取当前的最新条目,然后将其序列化到磁盘当中。

由于磁盘写入器总是保存当前最新版本,所以可以简单地剔除队列中重复的写入请求 URI,以合并多个写入请求。这有助于减少磁盘子系统的负载。

RESTOperation 队列

在上一节中,我提到写入请求将通过队列发送到文件写入模块当中。这个队列就是 WLRESTOperationQueue,这个实例我们在整个系统中用来异步连接代理实体 (acting entities)。可以这么说,这是让 Wunderlist 在处理网络交互和管理持久化的同时,仍然可以响应用户操作的秘密武器。

顾名思义,WLRESTOperationQueue 由一个 REST 操作队列组成,而每个操作又由一个 WLObjectReference 和一个 REST 动词(GET、PUT、DELETE)组成,该动词用于告知目标应该对引用执行何种操作。操作的含义取决于具体的目标。对于磁盘存储而言,如果收到了 PUT 就意味着要将这个由 URI 指定的对象存储到磁盘;对于 Web 接口而言,则是意味着发送 HTTP PUT 请求给后端。

可以从任意线程中来添加队列,并且队列可以维护子集的工作线程,以便为条目进行服务。队列可以选择将结果传递给指定的目标线程,这个线程与服务线程不同;比如说主线程。与 GCD 相比,让每个队列都具备单独的工作线程,可以大大减少线程的数量,以及相应的资源消耗。

WLRESTOperationQueue 对象通过自动拒绝重复条目 (entry),来支持合并操作。要实现这个功能,最关键的一点取决于:队列当中的条目只能是引用。我们花了很多时间来证明这一点,因此 WLRESTOperationQueue 目前的版本历经了大约一年左右的完善才得以出现。

如果我们尝试使用实际对象指针的任意一种变体,都会出现不合意的结果(比如说以这个写入磁盘的示例应用为例)。

  • 将可变对象写入到队列当中,并在写入之后对其进行修改,因此这种对象在保存的时候仍有可能会被修改。这是一个很糟糕的想法,如果要解决这个问题,那么就需要添加数不胜数的锁,即便如此仍可能允许进行有冲突的修改。
  • 将副本发在队列当中可能意味着:每当对其进行修改,那么所有相同对象都会执行一次写入。这在高负载的情况下会导致性能严重下降,特别是您需要保证性能良好的时候。
  • 清除最新添加的对象(通过 URI 的方式)意味着:只有第一条更新操作会被写入;后面执行的所有更新都会丢失,直到对象再次执行更改。
  • 清除最老添加的对象,很容易导致所修改的对象永远无法写入到磁盘的情况出现。

借助 URI 队列,即便是在高负载的情况下也仅仅只意味着会有多余的更改操作累积下来,但是磁盘子系统仍会尽可能保持最高的吞吐量。到目前为止,我们已经很少遇见磁盘写入速率跟不上系统写入速率的情况,而这些由小错误引起的问题也很容易进行修复。

流畅、反应灵敏的 UI

对于 UI 而言(图 17.7),实际上我们使用了一种经典的 MVC 方法,在 Wunderlist 的架构中可以表示为 ui =|= model。在经典的 MVC 中,当 UI 准备好进行更新并需要数据时,UI 便会去模型中拉取数据,对应到 Apple 的 MVC,其特征是控制器负责将数据从模型推送到 UI 中。

IMAGE

让 UI 在准备就绪后,就自行完成更新其实才是 MVC 的基本准则,而这在目前流行的大部分 ViewController 编程实践中往往会被忽略,但是当在动画运行的时候,需要通过异步操作来为其添加更多数据,从而协调动画的运行,因此这个准则变得至关重要,因为尝试依赖模型推送来协调几乎是不可能实现这一点的,并且还可能会导致各种复杂解决方案的出现,例如说 FRP 和 React,但是传统的 MVC 对此就有解决方案:我们只需要通知用户界面(无需推送数据),并让决定何时该自行更新即可。

在我们的示例中,UI 元素将由 URI 进行参数化,URI 当中包含了 UI 元素所应该展示的对象。之后,可以使用这个 URI 来发送 objectsForReference: 消息,从内存存储中获取最新的对象版本。

这个 URI 同样也可以用在更新通知当中。我们可以使用 Cocoa 最基本的 NSNotificationCenter 方法,将对应的 URI 与其一同进行参数化。随后,UI 元素可以将此 URI 与其内部维护的 URI 进行比较,以确定是否需要更新自身。

如前所述,URI 还会互相关联,因此假设有一个 URI 为 task://container/2/id/3 的任务被修改了,那么展示该列表 task://container/2 的列表视图同样也会进行更新。

我们可以使用 WLRESTOperationQueue 对象分离 UI 线程和任何可能会发生模型修改的线程。当模型对某个特定对象进行修改后,它会将该对象的 URI 发布到队列当中,并将配置为表示“模型已更改”的 NSNotification 传递到 UI 线程上的默认 NSNotificationCenter 当中。

队列的合并行为巧妙地解决了这样一个问题:我们尽可能降低每个单独更新的等待时间,同时避免在发生大量连续更改时频繁重载 UI 。此外,这种行为同样也不会丢失任何更新。

对于 UI 而言,我们实际上还需要添加一项功能:自动组合。在正常操作下,我们希望每个单独元素会立即独立进行更新。然而,随着负载的增加,这种做法变得越来越无意义。当您将数百条新列表任务发送给设备时,让每个列表都执行一遍动画效果不仅毫无意义,并且还会让用户感到非常烦人、感觉非常混乱。

自动合并的工作方法就是监视队列的深度。对于进入到队列当中的 URI 而言,随着队列越来越长,合并级别也将逐步增加,URI 后面的元素移除得也越来越多。如果将合并级别设置为默认值,那么诸如 task://container/2/id/3 之类的 URI 将原样进入到队列当中,并且合并只会影响到特定任务的更改。

如果将自动合并的合并级别设置为 1,那么就会从 URI 后面移除一个元素,仅仅只留下前面的容器:task://container/2。这会造成两种影响:一方面,当前 UI 中的整个列表会全部刷新,而不是针对某个特定任务进行更新;另一方面,这还会将列表当中所有独立的项目更新给合并在一起。因此,这就不会对列表当中的项目进行多次更新,因为我们对整个列表执行了更新。

最后,如果更新的频率仍然超出了用户界面展示的能力,那么合并级别 2 将会移除 URI 末尾的所有内容,只留下一个通用的 “UI 需要更新”的消息,并且还会将所有的 UI 更新请求合并成一个,以让 UI 自行进行刷新。

通过这种机制,我们就再也不用担心 UI 在大量更新的过程中跟不上变化,或者出现无响应的情况。除非我们引入了新的 BUG。

简评 Wunderlist

这里所展现的架构元素显然不是 Wunderlist 3 之所以性能如此强劲的全部原因。我们还有一个庞大的后端团队,为我们提供快速的 HTTP 和 WebSocket 接口,此外整个团队还进行了深入、详细的性能调查,并根据需求适当进行调整。不过,架构元素会确保审查的次数并不会很多,并且目的也十分明确,因此所做的调整都是非常微小、简单的,而不是时刻都在奋战性能问题。

我的意思是,这并不是实现高性能的唯一途径,换句话说:那些我们没有使用的技术并不是不能实现高性能。我认为,应用这些技术和基本原则,不仅可以在使用其他技术的同时实现高性能,并且还可以任意使用我们所提供的工具来直接获得出人意料的性能。优秀的性能是应用每月获取 500 万活跃用户,并且在 App Store 中获取 4.5 到 5 星评价的关键因素。

总结

在本章中,我们讨论了两个示例,通过将所有调优手段“结合在一起”,从而开发出优秀的、高性能的应用。优美天气应用是一个很极端的例子,因为它将调优目标推动到了一个非常具体的调优方向(即加载并显示大型图像集),此外还要实现一些看起来完全不可能的功能,最后还得为应用预留充足的空间。我们需要仔细分析硬件和软件的功能,并恰当调整需求,并根据需求作出适当调整,此外还需要一些来自外界的支持……我们自定义一种图像文件格式,它是另一种自定义图像格式的改版。

Wunderlist 是当代移动应用的一个典型示例,其中混杂了数据存储、实时网络访问以及 UI 的频繁更新等功能。我们将之前章节中所学到经验教训结合在了一起——例如,都要尽可能在内存中处理大部分工作,避免使用数据库引擎,无论性能是否与之相关,都尽可能使用简单、快速的存储机制。我们将第 16 章当中所述的更新机制进行了概括,将 UI 更新限制在某个架构元素当中,从而用于协调和简化应用的所有部分:网络层、数据存储、内存模型和 UI。

这两个示例都展示了如今手机的无限可能,并且最后,我们还是需要将硬件的能力推向极致,从而才能实现非凡的性能。

第 16 章 图像和 UI:陷阱和技巧(iOS和macOS性能优化)

这一部分的文章是我早年间参与iOS和macOS性能优化这本书翻译时的原稿, 当时的水平有限, 翻译的可能会有一些纰漏, 还请多多指教.
将文章同步到个人博客主要是为了同步和备份.

实际上,图形程序设计本身就是一个完整成熟的领域,有许多值得一读的书籍,网上资料也非常丰富,所以本书不可能涵盖并详述所有知识点。比如,我们会粗略了解下 OpenGL,学习非游戏、用户端应用程序中常见的图形技巧,仅此而已。

陷阱

响应性方面,最大的陷阱之一是在主线程中执行较长或不可预测的操作。所有 I/O 都存在该问题,我们从之前章节中了解到,即使是最小的 I/O 操作也可能耗费很长时间。另一方面,单纯将这些操作放到后台线程执行,而不想办法提升 I/O 响应性,这只会更加糟糕,关于这点我们之前也见过不少了:用户可以操作所有界面,但没有任何反应,并且也没有显示旋转光标(译者:系统卡主时出现的进度指示器,俗称“风火轮”)。在这种情况下,操作放置在主线程执行更合适一些,至少主线程操作会触发系统繁忙的旋转光标,告知用户发生了什么。

对于图形来说,预先渲染所有图形资源并以位图格式进行传递是提供图形最普遍的技术之一,同时这也是最大和最明显的错误。最极端的例子就是 iPad 上一些杂志类应用程序,用 Adobe 发布工具直接发布到应用里,杂志的每一页都是预先渲染好的整页位图,同时适配横屏和竖屏。由于 I/O 是现代计算机体验中最慢的一部分,不管是使用(蜂窝数据)无线数据还是直接从硬盘读取,哪怕是固态硬盘,用户体验都差强人意。视网膜高清屏出来以后,预先渲染的位图在高清屏上看起来就会非常模糊,效果更差了。

随着高分辨率视网膜显示屏的出现,让一切都看起来像是艺术品,任何数字生成的东西都应该可以直接作为矢量图使用,除去那些直接用代码绘制的部分。而位图问题显而易见:本身太大了而且无法缩放,必须适配每个分辨率版本,不过 “像素完美” 的理念依然会经久不衰。

这个理念将一直延续下去。

iPhone 6 Plus 有 1,920×1,080 个面板,但是实际渲染是分辨率的 3 倍,即 2,208×1,242 像素,由于两个分辨率不匹配,因此像素对显示的分辨率下采样 1.15 倍。无论是通过下采样的手法(Quartz 和合适的设备会自动处理)还是单独一个步骤专门对整个渲染帧缓冲区进行下采样并不(很)重要,不管采取哪种方式,都不符合 “像素完美” 的预渲染设计理念。其中这也不重要 —— 400 dpi 的屏幕分辨率,也没人会在单个像素点上较真,用户十分喜爱 6 Plus 的屏幕。

如果必须要使用位图格式的图形,也存在多种优化技术供我们选择。即使是 Xcode 生成的 “优化过” 的 PNG 文件,也能借助这些优化技术减少文件大小以及文件加载的时间。我们将在第 17 章中详细讨论这些。

在绘图方面,最大的问题往往是过度绘制(多次绘制相同的像素)和重绘没有变化的屏幕区域。幸运的是,第 15 章介绍的那些工具将助你诊断和解决这些问题,请使用这些工具!

技巧

要获得良好的图形性能,通常意味着要从需要展示的屏幕和像素入手,进行逆向操作,而不是从更改模型作为开始并将其展示在屏幕上。

一方面,绘制过程涉及到 AppKit、UIKit 提供的脏矩形,另一方面,每当 UI 层收到改动通知都需要设置这些矩形。“过多通信导致安装缓慢” 一节包含了一个庞大的示例,展示为了实现较好的效果,在项目中需要克服的一些障碍,以及如何将性能从难以忍受的缓慢提升到几乎无法测量的快速。

译者注:脏矩形就是每一帧绘制图形界面的时候,需要重新绘制、有变化的区域。

大型复杂的路径一直是 Quartz 的巨大难题,由于需要计算自相交以实现抗锯齿效果,所使用的几何图形算法是分段数的二次项(检查每段上的相交点,对比其他的段)。底层算法已经改进了不少,所以这也不再是一个大问题了,但是还是要考虑路径的长度,最好是使用中等长度的路径。

不再重复绘制形状,使用 Quartz 图样设备。对渐变来说都是一样的:使用内置的内容。在绘制性能方面,CGLayer 并没有太大的帮助。自 NextStep 起图像绘制问题就没有得到改善,而 CGImage 重写了基本图形绘制部分,尽可能加快了绘制速度。不过,CGLayer 在生成 PDF 时仍具有优势,得益于保留了重复元素的向量信息。如果你打算打印或者生成 PDF 里有重复的元素,那么 CGLayer 是个好东西。

考虑到 15 章里介绍的 OpenGL 潜在的性能优势,以及现在的游戏在高帧率下呈现的超级复杂场景,使用 OpenGL 加速绘制绝对是无脑行为。虽然概念上讲得通,实际上实现类似加速绘制的行为绝对是无脑行为。苹果公司曾多次尝试在系统级上将 OpenGL 的加速机制融入 Quartz 中,据我所知,所有的尝试都以失败告终。额外的执行操作将会吃掉图形加速所获的所有好处。还有一个问题非常明显,Quartz API 的调用/返回性质,能很好的映射到 OpenGL API 上,但是不能很好的映射到实际的硬件接口上(参见第 14 章 Metal 章节里的 OpenGL 和 Metal 的讨论。)

另外一个原因是图形原语不同:Quartz 使用填充和描边的贝塞尔路径,这些路径必须经过昂贵的细分,将其转换成阴影三角形供图形硬件使用,同时开放应用程序接口暴露给外部调用。 如果形状被重复使用,那么转换成本可以被其他使用者均摊,不过接口并未真正提供该功能。

过多通信导致安装缓慢

一项调查结果出乎意料:Mac OS X 新版本下,某个安装程序按照预期的步骤执行安装数千个小文件,竟然出现了进度减慢的神秘现象。在新版本下,安装程序速度减缓了几倍,几乎降了一个数量级。I/O 速率比磁盘子系统慢了很多,CPU 的使用率可以忽略不计,所以看起来好像没什么瓶颈可以用来解释这种性能下降的情形。

节流显示

在使用了大量工具和抓耳挠腮之后,终于发现安装器提供显示了状态更新信息。它正在显示每个安装文件名称,为了确保用户有机会看到每一个文件名,每次都会刷新屏幕上的内容。这样能满足用户了解过程进度的渴望,却与 Mac OS X 图形子系统的节流机制相冲突,限制图形的更新频率,现在设置成了大约 60 Hz。试着更频繁的刷新屏幕,就能轻而易举地阻塞你的程序,正如示例 16.1 所示。

示例 16.1 在 60 Hz 下运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import Cocoa
class AppController: NSObject, NSApplicationDelegate {
var mainWindow: NSWindow?
func applicationDidFinishLaunching(n: NSNotification) {
let window = NSWindow(contentRect: NSMakeRect(0, 0, 320, 200),
styleMask: NSTitledWindowMask,
backing: NSBackingStoreType.Buffered,
defer: false)
window.orderFrontRegardless()
self.mainWindow = window
NSApp.activateIgnoringOtherApps(true)
dispatch_async(dispatch_get_main_queue()) {
for i in 1...60 {
self.drawSomething(i)
}
NSApp.terminate(true)
}
}
func drawSomething( i:Int ) {
let window=self.mainWindow!
window.contentView?.lockFocus()
NSColor.redColor().set()
NSBezierPath.fillRect( NSMakeRect( 10,10,200,120 ))
let labels=String(i)
let label:NSString=labels
NSColor.blackColor().set()
label.drawAtPoint( CGPoint(x:20,y:20), withAttributes:nil)
window.contentView?.unlockFocus()
window.flushWindow()
} }
NSApplication.sharedApplication()
let controller = AppController()
NSApp.delegate = controller
NSApp.run()

在 8 核 的 Mac Pro 上执行这段代码 600 次只花费了 10.0 秒,MacBook Pro 同样如此 ,两个系统 CPU 的空闲超过了 90%。

注意一下,Quartz 足够聪明可以分辨是否有任何绘图行为,因此只要在循环中调用 flushWindow() 就会立即得到返回结果,因为在这种情况下实际并未产生刷新行为。我们需要实际地绘制一些东西,但绘制内容并非总是不同。

使用节流显示

虽然节流显示的使用方式有很多,例如游戏里使用 OpenGL 来获得更高的刷新频率,请牢记以下要点:实际状态更新的频次可能比用户知悉的要多得多。比如,我通过 NSURLSession DataTask 的代理方法进行测试,在家用 6-MBit/s 的 DSL 光缆条件下,显示状态更新次数为每秒 273 次。

我相信用户并不关心,也不想知道每秒进行了 273 次状态更新 —— 换做我也是如此 —— 实际上我们也无法读取刷新如此频繁的字节数。就字节计数文本而言,每秒一次足矣,当然如果加快更新速率会使得进度条更加平滑,例如每秒钟刷新10次。

至此,我已经使用了三种技术来避免过高的 UI 刷新频率,其中有两种是有效的。第一种技术适合用在连续的进度监视上,比如下载或磁盘进度。使用了大约 10 Hz 的计时器来查询进度并更新显示。除了解决进度显示的平滑问题以及一些限制,该技术避免了模型→视图通信,从架构角度而言是可取的。 定时器技术的缺点是它必须独立于底层操作启动和停止。

第二种技术是批量处理更新请求,避免引入显示计时器,但不能避免 模型→视图 通信。实现批量处理更新请求的一种方式是预先缓存,然后发送更新消息,在某个点比如 0.1 秒之后及时地传递该更新消息,如在下章节示例 16.4 中展示了这种操作。

第三种技术我本以为会有效的,结果还是图样图森破了,performSelector:afterDelay: 取消了之前的请求,实际上我曾多次见过该项技术,哎,实际上没起作用,至少如果有足够的负载使间隔时间短于延迟,这样的话,能一直取消更新信息的发送直到更新停止为止。

今日安装程序和进度报告

考虑到这个特定问题是如此糟糕,你可能认为该情况很少见,甚至根本不可能发生。那你就错了—— 在 2016 年 1 月,据报道,当呈现进度条时,节点包管理器 npm 速度减慢了 50% 到 200%,微软的自动更新仍然占用了 150% 的 CPU 资源(这是在双核机器上使用 top 命令测量得到的结果),归结原因是在更新过程中开启了进度显示条。

在 OS X 10.9 和 iOS 7 中,苹果引入了 NSProgress 类和 NSProgressReporting 协议,明确支持长时间运行任务的报告进度。基本实现思路如下:让对象汇报每个活动的进度,然后将这些单独的进度汇总在一起,组成总的进度。在上世纪 90 年代末期就实现了相似的系统,干得好!

哎,谈及进度报告,苹果实际上完全失误了,或许就是熟视无睹。指示 UI 进度的推荐方法实际上就是监听 NSProgress 对象的 percentCompleted 属性。这并不能解决我们遇到的问题,还会引入 KVO 通知的问题,KVO 传递通知的线程和改变进度条状态使用的线程是同一个。

实际上,苹果公司警告过:“不要在紧密循环中更新 completedUnitCount”,看来苹果公司知悉该问题,意识到了实际上并没有解决问题。

iPhone 无法承受之重(Overwhelming an iPhone)

几年前,有人请我帮忙开发一款新闻类应用程序,管理一些类似 RSS 里的 Feed 条目,使用常见的 UITableView 进行展示。每个条目对其所处的不同状态都显示不同的 UI:尚未下载,下载了一些元数据,缩略图已接收,有无音频。另外,在下载音频数据时需要展示下载进度,这些文件可能要很久才能下载完毕。

只要我们使用一个 feed 且条目数量小于 10,就不会出现什么问题。但是一旦超过 10 个 feed 或者 30 个条目,应用程序就会遇到明显的性能问题。我们尝试了很多常见的优化措施,比如,将长时间运行的操作(如生成缩略图)放到后台线程上,避免不必要的工作,例如为每个条目的状态更改都同步到数据库中,这些措施都无济于事。

具体的症状表现为:UI 界面可能会很长一段时间僵住不动,然后才会恢复使用。应用程序第一次启动时最容易发生,因为此时正在读取、更新 feed 下的所有条目。之后启动应用程序就不太会遇到该问题了,因为数据已经缓存在应用里了。即便如此,也给用户的第一印象造成了不好的影响。

分析表明,UI 界面僵住主要是 UIKit 花时间在执行绘制代码(绘制文本、table 布局),Cell 的复用机制如期运行,创建 Cell 没有什么额外的消耗。这里也没有什么可优化的对吧?

不对!实际上问题出现在示例 16.2 的这段代码里。

示例 16.2 模型改变时通知视图代码

1
2
3
4
5
6
-(void)notifyChanged
{
[[NSNotificationCenter defaultCenter]
postNotificationName: @"UserStatusChanged"
object:nil];
}

尽管这段代码看起来没什么问题,和很多 NSNotificationCenter 示例代码都相似,实际上会产生两个问题。首先,缺少之前章节讨论的更新节流(update-throttling),其次未给出具体细节:只说有些东西改变了,却没有明确指出是什么。缺少这么重要的上下文信息,UI 元素(这个例子中的 table view)收到通知后,无奈只能更新所有的 UI 元素。不仅要更新可见的元素,还要更新不可见的元素,有些甚至不在当前的界面里!

有个非常简单的方法可以解决该问题,就是在通知中指明当前的对象,幸运的是 NSNotification 可以实现该需求。示例 16.3 显示了实现的代码:

示例 16.3 模型改变时通知视图,并传递上下文

1
2
3
4
5
6
-(void)notifyChanged
{
[[NSNotificationCenter defaultCenter]
postNotificationName: @"UserStatusChanged"
object:self];
}

接收通知的代码能够从通知中获取有问题的对象,看一下是否相关(属于当前的 table),接着只更新这一行。示例 16.4 的代码在之前章节所说的批量更新中,将根据上下文内容进行了单独的更新。假设 table view 只能显示一屏的 “条目”,客户端调用 -refreshItemsFromBackground:,例如,通过 NSNotification 确定该条目的索引值,然后使用该值。

示例 16.4 批量更新 table view 条目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@property (retain)  NSMutableSet  *indexesToRefresh;
-(void)refreshAccumulatedItems
{
NSSet *items=nil;
@synchronized(self) {
itemIndexes=[self indexesToRefresh];
[self setIndexesToRefresh:nil];
}
[tableview reloadRowsAtIndexPaths:[itemIndexes allObjects]
withRowAnimation:UITableViewRowAnimationNone];
}
-(void)triggerRefresh
{
[self performSelector:@selector(refreshAccumulatedItems)
withObject:nil afterDelay:0.2];
}
-(void)refreshItemFromBackground:item
{
NSIndexPath* index=[self indexPathForItem:item];
if ( index ) {
@synchronized(self) {
if ( !indexesToRefresh ) {
[self setIndexesToRefresh:[NSMutableSet setWithObject:index]];
[self performSelectorOnMainThread:@selector(triggerRefresh)
withObject:nil waitUntilDone:NO];
} else {
[indexesToRefresh addObject:index];
}
}
}
}

批量更新代码基本上按照如下步骤执行:如果没有可供刷新的批量集合,则新建一个然后再安排刷新;如果存在,则只需要将结果添加到批量操作的队列中。启动过程也很简单 —— 获得需要更新的批量集合,清除并刷新 table view。请注意,如果发生任何事情导致这些索引失效,你将要清除当前更新批次。

一切都是假象

关于处理 UI 性能问题,我学到最重要的技巧之一就是伪造,如果你要做的事情执行速度实在太慢,你可以向用户展示一个旋转图标,告知用户你正在努力完成剩下的工作,提示用户完成进度。或者用动画效果表达。

使用动画效果掩饰延迟是非常聪明的办法,让 iPhone 看起来运行速度非常快,反应迅速。尽管硬件性能相对有限,且竞争对手推出了更高性能的硬件,更好的硬件加速处理,动画效果仍让 iPhone 保持了领先地位。

例如,打开 PDF 文件渲染第一页(或头两页)会花一些时间,然而,借助动画效果将文件从缩略图大小切换成全屏的这个过程,用户的注意力被动画效果所吸引,同时系统忙于处理打开 PDF 的工作。动画可以由 GPU 处理,所以不会占据 CPU 的资源。

图像的缩放和剪切

在上世纪 90 年代,我开发了多款 NeXT 软件,以输出设备的驱动为主,从 NeXT 彩色打印机到成本高达数万美元的高端彩色激光复印机,都可以使用我开发的软件。其中一个软件是 eXTRASLIDE(见图 16.1),可驱动宝丽来 CI-5000S 数字调色板录像机。

IMAGE

从工程师的角度来说,程序的核心部分是底层驱动,使用 SCSI 端口将由 Display PostScript 渲染的高清分辨率位图传送到设备上,设备往往使用自定义且缺乏文档说明的协议。

良好的性能对上面这些软件来说至关重要。就拿宝丽来录像机来说,有 4000 行分辨率,图像大小 48 M,对传送图像有实时性要求。1 GB 内存的手机看起来貌似有点不够用,但是当时我们最高端的盒子是佳能 object.station 41,32 MB 的 DRAM,100 MHz 的 486 处理器。频率钟显示此设备的 CPU 和现代 CPU 之间的差异是 10 到 20 倍,基准测试显示是 100 倍,简而言之,该设备比当今中等配置的手机要慢得多。那时候,我们还认为它“超级快”呢。

就 eXTRASLIDE 而言,还有另外一个问题:需要前端界面对原材料进行定位、缩放、剪切(见图 16.1)。小菜一碟,除了需要硬件需要花费大量的时间重绘原素材,实时重绘是无法实现的。于是,标准做法是绘制矩形轮廓辅助定位,定位后重新一次性绘制整张图片。

这时候 NSImage 就派上用场了。不管你的原素材是什么,它都能为不同的屏幕分辨率自动创建并缓存预览界面以及渲染。这样会导致一些问题,尽管类名称已经暗示和图片有关(NSBitmapImageRep 是处理位图的类),有时候人们依旧会忘记正在处理一个包装器,而不是一张图片,但在这种情况下正是我们所需要的。eXTRASLIDE 的预览图片足够小一遍满足界面交互的所需性能。不管原始图片有多大,从截图中也可能看出,预览图片非常小。

最后一个问题就是 NSImage 只会考虑屏幕的有效缓存,如果分辨率和屏幕正好匹配的话。否则,就会从原始的展示中生成缓存。哎,在缩放的时候的确会发生这样的事情,每次缩放都会导致分辨率不再匹配屏幕,因而触发重绘机制。这本是 “正确” 行为,然而反复采样缩略图会引发严重的质量问题,也意味着不可能实时缩放。

关键的技巧如图 16.2 所示,只用最初的 NSImage 缓存创建新的 NSImage 实例,在这里,NSImage 没得选,只能缩放低分辨率位图,因此可以实现实时缩放。缩放低质量缩略图没什么问题,图片在移动的时候,人眼无法分辨细节,一旦实施缩放结束(按钮松开),就切回原来的 NSImage,重新缓存新的屏幕位图。

IMAGE

我得到的经验教训就是,凡是交互形式的程序,只要不被抓住,就可以作弊。在这里的例子中,只要能缩放就行,哪怕是只能缩放低分辨率的位图。虽然不如缩放源文件效果好,图片在运动过程中,这两者之间的区别不是很大,也不会引人注意,客户还是很喜欢这个特性的。

缩略图绘制

在开发获奖软件 Livescribe Desktop 时,图片,特别是缩略图,再次使用了作弊手段未被发现,结果证明这样的处理是合理的。Livescribe Smartpen 使用红外摄像机精准地捕捉位置,正如你在纸上书写不规则的点图案一样。桌面应用展示并整理这些捕捉到的手写或绘制页面。

每个笔记的预览模式,应该展示缩略图,展示捕捉到的纸张背景的向量笔触,很明显这不是笔捕捉的图像。这些背景有两个问题,意识高分辨率的 PNG 图像渲染速度非常慢,包含笔触的文件格式在读取时需要执行很多初始化的工作。

如何不绘制缩略图

绘制缩略图的第一个方案,如图 16.3 所示,以 “缩略图” 为主。一张缩略图实际上就是一张从源文件中生成的小图片,我们的 Windows 客户端直接为每个页面创建了缩略图图片,保存在硬盘里。

Mac 团队认为他们应该也这样做,不过效果更好,因为 OS X 支持高质量的 PDF,苹果也引入了 CGImageSource CreateThumbnailAtIndex() 方法,专门用于从硬盘中加载缩略图。那么最可能出什么纰漏呢?

IMAGE

额怎么说呢,使用该方法后所有东西都出错了:因为 PDF 是一种与分辨率无关的格式,每个 PDF “缩略图” 包含了全分辨率的 PNG,绘制到 PDF 需要解压 PNG,生成 PDF 后再重新压缩。这些 PDF 由于包含了太多细节信息,因此 “缩略图” 渲染速度也很慢,为每个 PDF 生成新的线程解决进程自身执行缓慢的问题。

要尝试这个办法,需要重启机器,因为 200 个线程对 CPU 和内存的消耗会产生大量持续的交换。

如何真的不画缩略图

把 PDF 写到硬盘后再去渲染明显就是一个典型的 烂注意 TM,我们仍然坚持认为一张缩略图就是一个特定的图像,我们刚刚接受了位图在低分辨率下够用的想法。苹果公司的 ImageKit (新出的)和 IKImageBrowser View 提供了答案:快速(OpenGL 加速!)、可用的图片视图。很完美吧?

应用程序接口也非常简单,我们需要做的就是提供数据源,然后 API 返回给我们任何图片格式的图片。

哎!可惜结果和理想效果差距甚远,如图 16.4 所示:缩略图生成后,速度就会变的超级快。但是初始的加载却非常缓慢,一张一张渲染缩略图的过程简直辣眼睛。更糟糕的是,打开过程中发生的延迟导致我们无法绘制任何东西。

IMAGE

这也导致我们收到了亚马逊客户无爱的评论:

如果笔记有数百页之多,每次打开软件的时候都要重新加载缩略图,这也太奇怪了。如果你愿意等那么长的时间,只要软件仍处于打开状态,这些缩略图总会存下来的。但是等的时间也太长了吧!如果这时候你关闭软件,然后重新打开,猜猜会发生什么?你又要重新等待缩略图加载!我不知道这个问题是否出现在 Windows 版本的软件中,不过在 Mac 版里确实出现了。

基本上和之前遇到的问题一样:无法获得 IKImageBrowserView 想要展示的缩略图。我们使用的是大尺寸、共享且渲染缓慢的背景图和每个数据源里的向量数据,必须要将这两者用 IKImageBrowserView 结合在一起,这自然拖慢了进程速度。

如何绘制非缩略图

庆幸的是,导致问题发生的架构给出了解决方案。不再作为原子单元和实际图片传递每张缩略图,而是使用古老的 Quartz/AppKit 绘制所有的屏幕元素,使用 ThumbView 类里的 -drawRect: 方法。

与 eXTRASLIDE 图片缩放相似,NSImage 可用于缓存已优化的、屏幕大小分辨率的 PNG 格式背景图片,每台笔记本仅需缓存一次,之后为每张缩略图绘制 NSImage 背景图。这使得绘制背景操作瞬间完成成为可能。

然而,我们还遗留一个问题需要解决:从文件格式中提取笔触数据略微有些耗时。解决方案还是有点取巧嫌疑:不再像过去那样等到所有的笔触数据都可用时才操作,而是在渲染完背景图后立即绘制所有的缩略图,就可以看见需要绘制的一些东西,即便不是最终的图片。

结果如图 16.5 所示,首先绘制所有背景图,同时检索笔触数据。在下一步中,所有的笔触数据都会在同一时间出现。

在屏幕的效果非常动态:缩略图似乎是立即出现了,感觉像是活的可以触摸的对象。原因就是缩略图立即出现然后缓慢的改变,比一个接连一个出现最终完整的缩略图的效果要好得多。该解决方案给用户一种立即响应的感觉,感觉像是正在直接操作屏幕上的项目,而不是等待计算机响应操作。

这种技术类似于苹果要求 iOS 应用程序的启动页快速响应,看似响应及时,“实则因为它出现在界面后,立即被第一个屏幕置换掉了“。

IMAGE

想要达到这种效果的关键在于不要将缩略图当做一个独立的单位,活用缩略图(背景图+笔触数据)的结构优势:首先,考虑到背景图绘制缓慢,所以我们一次只绘制一页,不要每个页面都绘制,然后,在等待解码笔触数据的时候可以显示这些背景图。效果对比如图 16.6 所示。

实际上我们不需要更快的图形程序或借助 OpenGL 图形程序接口。事实证明,使用 OpenGL 内部的方法(IKImageBrowserView)实现该需求时速度不增反降,不如使用 Quartz 和 AppKit 进行绘制。通常情况下,数据结构优势远远超过了 API 的消耗。

iPhone 上绘制直线

本章 “一切都是假象”一节中讨论的 Livescribe 软件还使用了另外一个方案:称之为“Paper Replay”:可以用笔记录音频和笔画,播放这段音频时,笔画的动画效果会和音频同步浮现。更具体地说,所谓的未来墨迹 (即尚未书写的笔记) 呈现灰色,随着音频播放书写过的墨迹显示绿色。该效果可以从手写文本(或图片)处找到,看起来就好像是第一次书写时那样。

对于 Mac 客户端来说,这真的是小菜一碟。直接使用 Quartz 代码绘制笔触,调用 PageView’s drawRect: 方法。对于 Paper Replay 功能,只需添加 time-fromtime-to 参数(每一笔都有自己的时间戳),然后绘制笔触两遍:设置笔触颜色为灰色,设置 time-to 为录音中的当前时间后,绘制一遍;设置笔触颜色为绿色,设置 time-from 为录音中的当前时间后,再绘制一遍。

IMAGE

当我们把这段代码移植到 iPhone 上后,我们决定使用 Core Animation,也是苹果推荐使用的高效应用程序接口,毕竟我们用了动画效果。具体来说,用 CATiledLayer 支持缩放功能,CATiledLayer 内部甚至支持多线程,所以速度应该会更快。只可惜凡事都有意外:在复杂的图形上,Instruments 显示每秒只有 3-4 帧,CPU 使用率已达 100%。在动画工程中还要实现重绘,重绘命令也会占用 CPU,使得应用程序彻底瘫痪。Paper Replay 功能没法用了,更别提还会有更复杂的页面内容。

到底做错了什么?我们并非忽略了性能问题,实际上,我们所做的一切都是为了提升性能。为了提高速度,我们甚至研究了笔触的最佳长度,可是不管长度如何,性能上只提升了 10% - 20% ,实在是微不足道。于是我们查看 OpenGL,问题依然萦绕我的思绪,这个问题本不应该这么难解决的。

IMAGE

当然了,问题的确不难解决,我们只是被技术细节蒙蔽了双眼,却没有真正地思考问题。图 16.7 显示了 Paper Replay 的两帧,学习飞行课程的笔记——两种不同的方式输入空白区。该区域实际上需要重绘,才能让第一张图的矩形所示区域变成第二张图的样子。

笔是由手控制的实体物品,在一定的时间内移动速度是有限的,六十分之一秒内,一帧所移动的距离不可能很远。所以只有屏幕的一小部分需要在两帧之间做出改变,也只有这一小部分需要重绘。

之前一味地关注 Core Animation,从而忽视了这点。Core Animation 只允许一次替换整个 layer 的位图。AppKit 和 UIkit 的视图机制,换句话说,都允许使用 drawRect: 方法只绘制一部分区域(或多个矩形子区域),用 setNeedsDisplayInRect: 让视图中一部分矩形无效。在 iOS 中,这些矩形最终绘制写入作为整个 layer 的位图。

在了解到应该使用 UIKit 而不是 Core Animation 后,我们立即修改了代码。添加了 PageView,放到绘制代码这里,在设置时间的代码里,添加了一些程序,从一组时间中获取更改后的矩形,接着针对特定的几帧让一部分矩形无效。效果非常明显:之前只能每秒处理 3-4 帧,就到达了 CPU 的极限,现在每秒可以处理 60 帧,而 CPU 的使用率只有 2% - 3%,很大程度上独立于页面内容的复杂度。更棒的是,UI 的动画直接从 UI 界面程序,没有了被音频推出来的感觉,所以永远都不会出现音频和动画不同步的情况了。

总结

在本章,我们了解了图形性能和响应性。尽管底层绘制性能更容易测量,一直是开发者热衷讨论的话题,我觉得架构模式和特定领域的优化有更深远的影响。实际上,阻止高级优化的底层的技术限制,常常比单纯的低级优化有更深远的影响,在 model-view-controller 通信机制中更为明显,也和我们网络连接的设备更息息相关,因为我们可以快速更改 model 无需用户输入。我们会在下一章中寻找一种更容易理解的解决方案。

第 15 章 图像和 UI:测量和工具(iOS和macOS性能优化)

这一部分的文章是我早年间参与iOS和macOS性能优化这本书翻译时的原稿, 当时的水平有限, 翻译的可能会有一些纰漏, 还请多多指教.
将文章同步到个人博客主要是为了同步和备份.

正如其他领域的性能一样,如果不清楚导致缓慢的原因,优化图形通常是毫无意义的,更为重要的是你不知道刚才所做的优化对性能是有益还是有害。

在各方面而言,测量图形性能和响应性会比测量其他类型的性能更加困难。一般来说,相关的操作会涉及到一些完全封闭的系统库,未必有权限接触的进程,甚至可能是完全无法感知的硬件。

更重要的是,涉及到图像性能,你需要真正的关心单个事件的时间,而这个时间的数量级会降低到几十毫秒的水平上。在之前的章节中,我们通过测量大量的单个事件,然后进行分割以获得单个事件的的“时长”。这其实并不完全正确,因为获取的实际上是 平均 时长。

正如在第 14 章中所见的,平均数在测量图像时长中的作用非常有限:例如现在有两组数据,第一组数据中,在 1 秒的时间内,每一帧都能在保证在 16.6 ms 后刷新,第二组数据中,同样是在 1 秒的时间内,前 50 帧在 1ms 内完成刷新,而后 10 帧以每帧 90ms 的速度刷新,虽然第二组数据的平均数比第一组数据要好,但是在视觉上很难接受这样的效果,还是第一种方法在视觉上更平滑一些。

幸运的是,我们还是有解决办法的:系统和相应的工具给出了更好的解决方案,可以让开发者直观地观察到是否高效达成了目标。例如,如果你正用同样的值重绘一个像素,这个无效的操作指令就会被标注。

本章将会介绍这些特定的工具以及它们报告出来的信息在整个图形管线(graphics pipeline)中的意义,我们还会说说如何将这些工具和之前介绍的通用工具结合起来使用。

CPU 分析仪

在之前的章节中,主要介绍了基于 CPU 绘制图像的 Quartz 框架和基于硬件绘制图像 OpenGL 框架,以及这两个框架在性能上的区别。图 15.1 展示了时间分析仪(time profile)在 Quartz 示例下表现。

时间分析仪里的前 11 个条目包含了开发者自己编写的代码,但可以看到,这些代码只占用了总时间的 3.3%,最后一个开发者编写的代码条目是个闭包,这个闭包在 -[GLBenchView drawOn:inRect:] 中定义,被 -[MPWAbstractContext ingsave:] 方法调用。剩下 96.7% 的运行时间都花在了 Quartz 的函数 CGContextDrawPath() 上。这个看起来很有意思,其中 50% 都花费在了 CGSColorMaskSover ARGB8888() 里,而且很显然支持 SSE 的函数 CGSColor MaskSoverARGB8888_sse() 在这里并没起到什么作用。

事实上,你花了大量的时间绘制路径,也许算是有用的信息,但是它并没有告诉你为什么会这样。是路径太复杂了吗?还是进行了多余的绘制?或者你正在尝试的操作对系统来说太复杂了?如何是后者的话,那我们可以肯定这是不应该发生的,因为即便是 Quartz 也能在理论上以动画帧速率(animation frame rate)填充屏幕上的每个像素点。

当使用硬件加速时,问题更严重,因为现在 CPU 几乎处于闲置状态,只是在等 GPU 的结果,图 15.2 显示了基准程序在同样时间下的数据图,不过这次用的是 OpenGL 代码,用 CPU 进行绘制。

IMAGE

IMAGE

鉴于 Quartz 示例下,CPU 花费了 2176 ms,而在 OpenGL 示例下,CPU 仅仅花费了6 ms,而这6 ms 中真正用于绘图的代码只占用了 16% 的时间。

当使用硬件协助绘图时,CPU 的分析结果不会告诉你的应用程序瓶颈在哪里。

Quartz 调试

在 Mac OS X 上,有个专门用来调试图形性能的工具,叫做 Quartz Debug。图 15.3 显示了其主要菜单选项和帧率表盘。Quartz Debug 可以调试 Mac OS X 图形堆栈中的全局元素。它不仅会检测你的程序,还会检测所有正在运行的程序,包括 Quartz Debug 本身,最好在测试之前关闭或者隐藏其他正在运行的程序。

我个人觉得最有用的选项是 Flash identical screen updates,差不多在菜单的中间位置,开启该选项后,Quartz 会用红色矩形块标注屏幕中重复刷新相同内容的区域,这表示红色区块的绘制操作是多余的,应该被删除。很显然我们没有必要绘制同样的内容。

下一步是 Flash screen updates 设置,这个设置和前面说的选项的很像,不过它会在有更新的界面上闪动一个黄色矩形框。该选项能让你区分出那些刷新次数过于频繁的地方。打开该选项可能会产生一些干扰数据。

IMAGE

Autoflush drewing 选项会关闭合并内存的访问模式(coalescing),所以每个绘制操作都会直接展示在屏幕上(如果开启前面所说的两个选项,可能会造成闪烁),这会产生更多干扰信息,但是这样会将绘制过程划分的更细,展示的矩形越多,就越能让开发者了解是系统是如何绘制的、以及绘制过程发生了哪些改变。

最后,应该注意随着 Quartz Debug 的运行,应用程序与平常的运行状态有一点区别,绘制这些额外的矩形会造成不小的开销,你甚至可以感觉到屏幕刷新的过程中有一些延迟。开启一个 Flash 选项,然后尝试拖拽窗口,这时不仅能看到有很多的闪烁效果,拖拽的过程也会变得迟钝。关闭延迟能让性能恢复正常,但闪烁会导致肉眼难以识别屏幕上的情况。

Core Animation 工具

iOS 有一个更高级的调试工具,这也许是因为 iOS 上的图像架构更复杂,但手机的硬件性能不够强大,所以这就要求调试更加严格和精准。总之,在 iOS 上你需要这些高级调试工具!

最主要的调试工具是 Core Animation,它属于 Instruments 一部分,并非像 Quartz Debug 那样的独立工具。它和 Instruments 集成在一起非常有用,你可以将多种工具结合起来调试,并由此寻找问题的根源。

图 15.4 显示了在开发 Wunderlist 3 时,我们遇到的一个动画性能问题。

测试针对的 iPhone 5s,但在 iPhone 4s 上动画性能下降地更为明显。通过关注性能下降区域,然后切换到 CUP 调试工具上,我们可以弄清楚发生了什么 —— 一段程序反复调用 valueForKeyPath::进行计算。回顾下第 3 章提到的,使用键值访问比直接访问或发送信息要慢得多。

IMAGE

简单的解决方案是日常工作不再使用 valueForKeyPath:方法,而使用循环和发送消息,这样计算速度更快。如果此路不通,还有一个办法,就是延迟计算操作,稍等片刻再计算,在后台线程上执行,而不是主线程上,或者逐步计算。

当 CPU 不再是问题

在之前的几个例子中,我们足够幸运,发现问题都出现在 CPU 上,当然这是使用了第 2 章所说的分析工具确定了问题所在。不过要是问题不在 CPU 上怎么办?iOS 的 Core Animation instrument 有一组和 Quartz Debug 工具类似的选项,除了应用广泛,也适用于一些特殊的 iPhone / iPad 环境。

正如在第 14 章中解释的那样,iOS 将最为笨重且低效的位图作为标准,虽然这样通过充分利用 GPU 来抵消性能上的文档,但这也意味着,在遇到大量数据时,数据读取效率低的缺点会格外明显。图 15.5 Core Animation 的选项表里列举出了一些虽然看起来不显眼,但有可能造成性能问题的情况,这些选项的显示结果和 Quartz Debug 的显示结果相似。

具体来说,这个调试工具支持以下功能:

  • Color Blended Layers——将目标色和来源色混合意味着需要同时读取目标色值和源色值,不混合就意味着能少读取一次数据,工具会将进行混合的图层层用红色标注,不需要混合的图层用绿色标注。
    IMAGE
  • Color Hits Green and Misses Red——这里指的是支持 shouldRasterize 标识的系统缓存。通常状况下 Core Animation 会在每次更新后复制/混合整个视图层级树,当视图设置了 shouldRasterize 标识时,这就意味着它告诉 Core Animation 去缓存该整个视图的栅格图像。然而,系统并不会保留所有视图的栅格图,它是一个全局缓存,在有限的时间内缓存有限数量的位图。
  • Color Copied Images——用于标注图像是否能直接被 GPU 使用,或者图像是否需要通过 CPU进行一次 复制/转换(开启选项后,系统会为需要转换的情况标注颜色)。
  • Color Misaligned Image——该标记用于优化内存的访问模式。当图像边界以字(word)的方式对齐时,所有的内存只需要访问一个完整的字。当图像没有对齐时,需要读取多个字里的内容进行计算,之后 GPU 会根据计算结果对原始数据进行一些必要的处理。根据 GPU 的智能程度,额外的内存访问可能发生在每个字或者图像的边缘上。不管哪种方式,最好避免不对齐的图像,开启这个选项会标注出没有对齐的图像。
  • Flash Updated Regions——这个功能与名为 Quartz Debug 的功能相似:当屏幕上的某个区域改变后,该区域会以独特的颜色进行闪烁。目前我还没有在 iOS 的调试工具中找到与 Quartz Debug 里 “flash identical regions” 功能相似的选项。
  • Color OpenGL Fast Path Blue——开启改功能后,调试工具会标注出屏幕上哪些区域需要避免合成,应该直接使用 OpenGL 进行渲染。
  • Color Offscreen Rendered Yellow——开启该功能后,调试工具会显示哪些区域是先进行离屏渲染,再将渲染内容复制到屏幕上的,很明显额外的复制操作会存在潜在的性能问题。

和 Quartz Debug 需要注意的一样,这些选项会全局影响设备的渲染,而不是仅仅影响正在使用 Instruments 的应用。

为了更形象的描述这些选项,让我们看一下 iPad 的 PostScript/PDF 预览界面的缩略图。图 15.6 显示了看起来正常的缩略图,没有开启颜色标识。滚动的时候会有一点延迟的感觉,所以在这里我们使用刚才所说 debug 选项来找找里面的问题。

图 15.7 显示了开启了 Color Blended Layers 选项后的缩略图。每个单独的缩略图都被 Instruments 工具标红了(受纸质书籍的打印问题,这些红色区域在书中会显示为深灰色),意味着这里使用了混合功能。由于这里完全不需要显示缩略图后的背景,所以这里不应该有 blending 才对。

首当其冲的想法就是 UIImageView 没有被设置成不透明色,所以才有了阴影,阴影需要混合。然而,将视图设置成不透明色、并移除阴影后,结果还是没什么变化,由于使用了混合,所有的缩略图仍然是红色的。

IMAGE

IMAGE

经过检查,问题的原因是缩略图本身包含一个 alpha 通道。 从视觉角度上来说,这个 alpha 通道是不需要的,因为所有的像素都是不透明的,但这并不是 GPU 或者 API 可以理解的,所以它们还是会进行混合(blending)操作,这样就会重新计算目标对象的像素。

这些缩略图是参考 PDF 文件并通过 Core Graphics 位图上下文创建的,所以解决方案是在 CGBitmapContextCreate() 方法中,使用 kCGImageAlphaNoneSkipLast,而不是 kCGImageAlphaPremultiplied Last。使用 kCGImageAlphaNone 似乎是一个显而易见的去掉透明图层的办法,但是它并会不起任何作用,如果这样设置的话,函数在运行时返回了一个错误信息。

图 15.8 显示了解决方案的结果:现在屏幕上所有的东西都成了绿色(这些颜色在纸质书中显示为浅灰色),现在没有图层混合的问题了。但是你会惊奇的发现,性能几乎没有什么提升,不过这就是争取到的最好结果了,毕竟你也没费多少精力,但是优化仍然是值得的。

最后,我还查看了没有对齐的图像(如图 15.9),在图 15.9 显示 app 中没有对齐的缩略图。由于这些图片应该居中且宽度没法控制,所以不太好解决图像对齐的问题,所以就目前来讲,该问题仍未得到优化解决。

如果图像未对齐真的会造成明显的性能问题,那么我们可以修改缩略图的生成过程,让图片的宽度取整,让其接近一个几乎 “对齐安全” 的数值,接着居中绘制实际文档的大号缩略图。然而,这需要让图片的边缘透明才能保证精准还原图片,但这又会导致图层混合问题,从而增大计算开销。当然另外一种选择是在图片还原度上妥协,用小的白色边框绘制缩略图或将其缩放到略微不同的尺寸。

我在测量什么?

我们在考虑是使用静态的图像资源,还是使用代码绘制图像。其中一个考量因素就是不同技术的性能表现如何。示例 15.10 是绘制的渐变色效果。

在示例 15.1 的代码中,想绘制渐变效果,要么用 CoreGraphics,要么加载一张预渲染渐变效果的 PNG 或 JPEG 图片文件,并测量一下每种方法的时间。

IMAGE

IMAGE

IMAGE

示例 15.1 尝试记录加载图像的时间和生成图像的时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-(void)timeImageDrawingAndLoading
{
CGFloat drawnTime, PNGTime, JPGTime;
CFTimeInterval startTime, endTime;
startTime = CACurrentMediaTime();
self.drawnImageView.image = [self drawnImage];
endTime = CACurrentMediaTime();
drawnTime += 1000*(endTime - startTime);
What Am I Measuring? 321
startTime = CACurrentMediaTime();
self.PNGImageView.image = [UIImage imageNamed:@"Image.png"];
endTime = CACurrentMediaTime();
PNGTime += 1000*(endTime - startTime);
SEL flusher = NSSelectorFromString(@"_flushSharedImageCache");
[[UIImage class] performSelector:flusher];
startTime = CACurrentMediaTime();
self.JPGImageView.image = [UIImage imageNamed:@"Image.jpg"];
endTime = CACurrentMediaTime();
JPGTime += 1000*(endTime - startTime);
[[UIImage class] performSelector:flusher];
NSLog(@"Drawing %f, PNG %f, JPG %f", drawnTime, PNGTime, JPGTime);
}

测量出来的时间是:绘制用了 11.1 毫秒,PNG 3.89 毫秒,JPG 0.51 毫秒,所以这是一个能够说明问题的例子,不是么?我们可以粗略地解释这些数字:正如在第 14 章中见到的,Core Animation 基于位图,所以绘制是一个额外步骤,然而图片的加载或存储可以简单的理解为是直接从后备存储器(backing storage)中获取的。

先别急着下结论!

因为这些时长是模拟器的测量结果(数值可疑),另外,JPG 的读取时间比 PNG 快了 8 倍?这有点奇怪啊。

首先,打开 Instruments,了解一下大体发生了什么,timeImageDrawing AndLoading 方法中没有图片解码,相反稍后在实际绘制这些视图时,会看到一些 PNG 的解码操作。我没有看到 JPEG 的解码过程,整个过程发生的太快了。这验证了我们对 iOS 中图片加载和解码的理解:它是一种懒加载的模式,只在万不得已才会加载/解码,比如绘图时。

一些书籍声称,当为 UIImageViewimage 属性赋值或者 CALayerimage 属性赋值时,解码是会被强制执行的,不过我在实践中并没有发现这点。

在真机上运行代码得到以下结果:绘制 3.26 毫秒,PMG 67.2 毫秒,JPG 49.1 毫秒。这次,结果反转了,绘制比 PNG 解码快 20 倍,比 JPG 解码快 15 倍。然而,对该结果也令人困惑——因为在这里 iOS 实际上并没有解码图片(通过 Instrument 工具可以确认这点),那么,到底发生了什么?

如果仔细观察一下 Instruments,可以在当前的例子中,JPG、PNG 的“解码”时长是包含了各自解码器的初始时间成本,所以“加载”图片的副本可能会快很多倍:绘制 3.50 毫秒,PNG 2.54 毫秒,JPG 1.91 毫秒。但是这些并不是解码图片的真正时长,现在得到的时长只是从硬盘中读取图片元数据并用于将来解码用。

为了将在视图中绘制图像与解码过程区分出来(当然这很难区分),我们需要在位图上下文中“绘制”图像,使用此方法获得以下时长:绘制 3.41 毫秒,PNG 7.91 毫秒,JPG 8.39 毫秒。这是目前最接近的真实时间,尽管很有点小误差,我们不得不把解码和绘制时间合在了一起。尽管苹果公司声称该操作与纯解码基本上是一样的,但我们还是不能百分之百确定。

另一个异常现象是,尽管现在可以清楚地在 CPU 调试工具中追踪到 PNG 的解码时间,但还是没法追踪 JPEG 的解码时间。运行 CPU Profile Instrument 工具,打开 “Record Waiting Threads” 选项,我们可以看到 JPEG 解码位于 mach_msg_trap() 中,这意味着它在等待一个不会被 CPU 调试工具显示任何信息的进程完成任务。答案很显然是 iPhone 有一个 JPEG 的硬件解码器,使用该解码器时,不会在 CPU 上显示任何信息。

硬件解码器在处理大图片时非常快,但对于小图片而言,这个开销就比较大了,即便针对 PNG 使用相对缓慢的 zlib/flate 解压器,总体速度依然很快,而且类似 TurboJPEG 这样专门的 JPEG 库甚至可以快好几倍。

总结

本章展示了在衡量图形性能过程中会遇到各种错综复杂的情况,除了要考虑其他子系统的影响外,实际操作中的延迟也会影响测量结果。获取这些操作的平均值是无法得到有意义的结果。

然而,还是有一些测量的途径的——比如,Mark 1 eyeball 是个非常好的测量装置,特别是当它配备一个性能良好的电子秒表(带有机械按钮的那种大秒表,不是 iPhone 上的那种)时,效果更佳。当我们使用手动测量结果作为性能评估的关键指标是,我们只花费了十分之一秒就诊断出问题所在。

在下一章中,我们将关注如何解决发现的问题。

第 14 章 图像和 UI:原理(iOS和macOS性能优化)

这一部分的文章是我早年间参与iOS和macOS性能优化这本书翻译时的原稿, 当时的水平有限, 翻译的可能会有一些纰漏, 还请多多指教.
将文章同步到个人博客主要是为了同步和备份.

在之前的章节中,我们已经学习了不少底层系统性能方面的知识,例如 CPU、内存和 I/O ,现在我们可以将学到的知识应用到搭建一个高性能的用户界面中。这意味着应用程序既能快速绘制用户界面,又能高效响应用户请求。

响应能力

当提及高性能的用户界面(high-performance user interface)时,通常也意味着它是一个响应式的界面,也就是说它能快速响应用户的操作。这里的快速响应到底得多快呢?一般说来,就是能多快就多快,不过我们还是设定了一些阈值来区分用户的感知程度。具体的数值请见表 14.1。

下表中最值得关注的两个阈值分别为响应用户操作的 100 毫秒和保证动画流畅所需的 60 Hz/16(2/3)毫秒,如果你不想让用户在向计算机发出指令后等待一段时间才获得反馈,而是希望让用户有一种在屏幕直接操控物体的感觉,那么这两个阈值就显得及其重要。下表提及的 25 Hz(也有说是 30 Hz)也是一个常见的阈值,举个例子来说,它可以应用在模拟电影(analog film,用模拟胶片拍摄的电影,需要进行数字化后才能在电子设备上播放)的重制过程中,如果想让整个电影画面平稳过渡的话,它还依赖模拟媒介提供的集成效果,例如运动模糊等。而对于数字屏幕上的动画,60 Hz 将成为我们的目标。

表 14.1 响应时间阈值表

响应阈值 效果
10 秒 能够引起用户的注意
1 秒 能够保证用户持续关注
100 毫秒 能够造成直接操作对象的感觉
40 毫秒(25 Hz) 能够将多个帧合成动画
16(2/3)毫秒(60 Hz) 能够让动画变得平缓细腻
1 毫秒 能够追踪到快速的手势操作

请见http://www.youtube.com/watch?v=vOvQCPLkPt4.

相比之前看到的阈值而言,60-Hz,即 16.67 毫秒,可不是一个多么长的时间。对于一些基本的计算密集型(CPU-bound)操作来说,这个时间是没问题的,但如果你还需要在磁盘搜索上花费 7ms,那你就不得不考虑下花费如何使用剩下的时间来做点其他的事儿,更何况图形系统还要占用一些剩余的时间。

所以 60 HZ 是一个极限值了么?尽管表 14.1 的最后一项不是 60 Hz,但受限于当前的软硬件水平,60 HZ 的确是极限了。虽然 60 HZ 能够保证动画非常流畅,但实际上仍然有提升的可能性。我们举个例子,当你用光笔(pen or stylus)在屏幕上进行绘制或者写作的时候,假设笔尖移动的速度大约是 5 cm/s(大约 2 英寸/每秒),而屏幕在 60 HZ 的刷新率下,暂且认为它会以每 16.7 ms 移动 1 mm 的速度延伸,屏幕渲染出来的“印记”和光笔的笔尖之间有着明显的距离差,这会产生一种“滞后”的感觉。当你在屏幕上拖拽物体的时候,也会产生同样的效果。假如你快速地移动指针设备(手指,鼠标等设备),系统实际上是无法做到精准追踪的效果。

幸运的是,对于鼠标的光标这一类物体实在是太小了,小到以至于它快速移动时,人眼根本无法精准定位,因此它带来的滞后感不易被察觉。但是要拖拽一个比较大的物体时,人眼就能察觉到这种滞后感。在当下科技水平无法从本质上解决这一实际问题时,充分利用人类感知的极限是一种保持响应能力的常见技术手段。

软件和 API

在 Mac OS X 和 iOS 中,大部分的人机交互都由高层级的框架完成,如 iOS 上的 UIKit,Mac OS X 上的 AppKit。这些框架读取用户的输入并将其转换成事件传递到程序中,同时它们还负责绘制用户界面。图 14.1 从一个较高的视角对图形相关的 API 进行了分层。

IMAGE

这些高层级框架提供了大量现成的控件,如按钮(buttons)、复选框(checkboxes)、表格(tables)和文本编辑器(text editors)。除个别控件外,它们中的绝大多数不光完备性强,还拥有快速响应的能力,所以它们的性能并没有什么问题。

这些控件基于 OS X 的 NSView 类和 iOS 的 UIView,它允许用户扩展视图的层次结构。一个视图(view)表示屏幕上的一个矩形区域,可用drawRect:方法渲染,可处理用户的输入事件。这些高层级框架主要通过 Core Graphic 的 API 进行绘制方面的工作,Core Graphic 有时候也被叫做 Quartz。

另外一个与图形相关的主要 API 是 OpenGL,它主要用于 3D 图形应用中,例如一些 3D 游戏,由于这套 API 拥有对图形加速硬件的访问权限,也常常被其他的 API 所使用。除此之外,还有一些处理图片或者视频的 API,如 Core Image 和 Core Video,以及视频播放相关的 AVFoundation 或 Quick Time X,最新的框架还包括了用于 2D 游戏开发的框架 SpriteKit 和用于处理 3D 图形的高级框架 SceneKit。

在显示列表(display list)被图像系统持有且直接驱动显像管(CRT)成像的时代,保留模式(retained-mode)即时模式(immediate-mode)的意义在于它们区分了两种不同风格的图形 API。在即时模式中,程序通过主动调用绘图 API 的方式进行渲染。而在保留模式中,程序不会主动调用绘图相关的 API ,而是通过更新 API 创建的对象这种方式来实现渲染的效果。

图 14.2 以三个几何图形为例来阐述二者之间的不同。图表上方是保留模式风格的 API,程序首先生成三个图形,这些图形会以某种数据结构或着数据库的形式被 API 所持有。如果想要改变粉色矩形的坐标,程序必须要记住该矩形,并且告诉 API 要移动该矩形。接着 API 就会担负起刷新屏幕并渲染该矩形到新的位置上的工作。图表下方是即时模式风格的 API,它没有保留模式那么复杂,它只需绘制两遍场景(scence)即可,粉色矩形一次是在旧的坐标上,一次是新的坐标上。

Quartz 和 OpenGL 这两个重要的图形 API 都是即时模式风格的 API,绘图命令可以直接发起并立即执行。虽然 OpenGL 将自己内部的 API 划分为了即时模式和保留模式,但从广义上来说,OpenGL 的 API 应该都是即时模式风格的 API。SceneKit 和 SpriteKit 属于保留模式风格的 API;你需要创建 nodes(节点)并将其添加到场景中,之后的过程中,我们只需要对节点进行操作即可。

乍一看,保留模式风格的 API 似乎更简单一些,尤其在图形对象的层级关系满足应用需求的时候。因此在许多场景中,一个简易的图形编辑器可以是这些 API 轻度封装后的产物。然而,现实中的大多数应用都有特定的应用场景和特定的数据模型,这也就是说通过业务对象模型(domain-model)和相关算法得到的图元也会缺乏共性。在这种情况下,我们可以选择继续抽象相关算法以便它能够处理不同类型的数据模型,也可以选择放弃保留模式,而投入到即时模式的怀抱中。

IMAGE

UIKIt/AppKit 是个混合体:视图本身是保留模式风格的 API,但是绘制的时候使用的是即时模式风格的 API, 想想 drawRect: 方法。在这种混合模式下,你可以定义视图的相关特性并在发出重新绘制视图的命令后,把视图的移动,缩放和相关变换的工作交给系统来完成,但你同样保留了灵活修改视图内容的能力。图 14.2 中的三个几何图形,既可以用三个独立的视图对象表示(保留模式),也可以用 drawRect: 方法统一绘制(即时模式)。

Core Animation 和上面的情况有点相似,我们会在后面用另外一种方式来讨论它。

Quartz 和 PostScript 图像模型

Quartz 看起来很像当今计算机环境下的产物,但它的前身可以追溯到施乐公司 Palo Alto 研究中心(PARC)的 Bravo 系统,后来面向打字机的 PostScript 页面描述语言(page description language)和 Quartz 走的很近,借助 NeXT 公司开发的视窗和绘图系统( windowing and drawing system) ,Adobe 公司的 PDF,以及阉割掉编程功能的 PostScript,Quartz 最终演变成了 Mac OS X 的 Quartz 框架。

PDF 和 Quartz 是基于 PostScript 的产物,而 PostScript 这门页面描述语言的一个特性就是它拥有精确定义的图像模型。PostScript 语言操作的对象会被定义成栅格图像(raster image),我们根据其来源将这些被操作的对象分成三种类型的图元:

  1. 栅格图像
  2. 路径(path),filled 类型或 stroked 类型
  3. 文本

这三种图元可以进行仿射变换,任意裁剪以及着色,在之后的版本中,还可以使用渐变色或其他非常量类型的着色方式。早期图像模型的构成方式决定了在栅格化之后用画家算法(Painter’s Algorithm)着色的结果,这意味着对于某个具体的像素点而言,后添加的图像模型会最终决定其着色方式,不论这个像素上之前的图像模型是什么样子的。Quartz 和当前版本下的 PDF 使用了新的图像构成方式,这种方式具备了 alpha 混合(alpha-blending)的能力,这意味着先添加的图像模型和后添加的图像模型会共同影响最终的生成结果。抗锯齿(anti-aliasing)技术可以看做是 alpha 混合的一种变体,它是将原始图像模型的边缘和其相对应的背景色进行了混合,从而产生抗锯齿的效果。

最重要的是在这种设计模式下,每个像素的信息都可以通过图像模型获取,而不依赖具体的实现方式,苹果公司非常认同并坚持这种方式。

在图像模型中,所有的对象都会被转换为 filled 类型的路径。文本字符通过编码映射转换成字形(glyph),然后再通过字体程序变成 filled 类型的路径。stroked 类型的路径可以理解为由自身轮廓线围成的 filled 类型的路径;路径的顶点样式(cap)和拐点样式(line)也会以几何图形的形式添加进路径中。所有曲线在高分辨率下会被转换成一个个连接着的直线段,之后就是栅格处理了。

平整的栅格图像是由矩形网格组成的,所以栅格图像本身也可以像其他矩形一样进行缩放和旋转。举个例子,我们可以绘制一个编号从 0 到 255 号, 256 像素宽、1 像素高的栅格图形,它的效果与我们绘制一个灰度值介于 0 到 1 之间,共有 256 个矩形的效果一样。事实上,将图像进行栅格化就是一个渐变过程,这种渐变和我们常说的渐变有一些不同:用一个路径(clip path)描绘出需要绘制的形状,将对应的图像绘制到这个形状中。

图 14.3 用 Times Roman 字体的小写字母 a 来演示上面的内容。

IMAGE

注意这个转换步骤的每一步都会导致数据量的增加,有时候增长的非常显著。字母本身可以用一个字节表示,例如 ASCII 码中 97 就代表小写字母 a。字形也可以是表明某个特定字体的数字或名称。然而,字母 a 的轮廓有 3 个直线段和 34 个曲线段组成,总计 210 个浮点坐标,将曲线拟合成直线会成倍地增加坐标数量,当然这主要取决于分辨率(在当前的测试环境下会生成 484 个坐标点)。最终,网格化后的字形有 24 point 的尺寸,在 retina 屏幕下会产生大约 2 KB 的黑白栅格数据,或者带有 alpha 信息的 8 KB 的全彩色栅格数据。如果你还想使用 super-sampling 技术进行抗锯齿处理,那么栅格属性还会继续增大。

在现实生活中我们完全可以不这样做,也不应该这样做,否则文本和光栅图形的处理会非常慢。虽然精准的图像模型能够更好的还原图像本身,但这也意味着我们需要能够保存更多信息的图像模型。

OpenGL

Mac OS X 平台上的第二个基础图形 API 就是 3D OpenGL API(iOS 上也叫 OpenGL ES)。OpenGL 最早是由 Silicon Graphics 开发出来的,它是一种与语言无关的、跨平台的 3D 图形 API,但是在后来它变成了一个开源标准。远远超越了现在的 PHIGS 开源标准,而其中的原因就是 OpenGL 与 Postscript,Quartz 一样,都是即时模式风格的图形框架,相比较与 PHIGS 这种保留模式风格的图形框架,OpenGL 的使用更加简单。

OpenGL 和 Quartz 支持的图形模式和图元大不相同,OpenGL 可以通过顶点数组(vertex array)实现对多边形(polygon meshe)、折线(polyline)或点云(point cloud)使用。在 OpenGL 中,不能直接使用图像(image)进行渲染,但可以通过在物体表面覆盖纹理贴图(texture)的方式达到同样的效果。另外 OpenGL 不支持文本类型,所以它会将文本转换成多边形或者位图。

尽管绘制图像本身是即时模式的,但 OpenGL 也可以将纹理贴图或顶点数组上传到图形硬件中,以便图形硬件在之后可以多次使用。在 OpenGL 的领域内,这就是保留模式。这将保留模式与另外一种数据被逐步指定的模式区分开来。

OpenGL 有这样一个问题,它自身提供的是一种过程式(procedural)/返回式(call return)的 API,这使得 OpenGL 与更加面向批量处理的现代化图形硬件格格不入。这种格格不入体现在,看起来非常“自然”的 API 并不高效,而高效的 API 看起来非常别扭且容易出错。

Metal

针对 OpenGL 与现代化图形硬件格格不入的情况,苹果公司给出了自己的解决方案。那就是 Metal API。与 Khronos 组织的 Vulcan API 非常相似, Metal 是一个面向底层的 API。

在 Metal 的世界中,应用程序不仅管理着命令缓冲区(comman buffer),也负责将缓冲区中的指令发送到 GPU 中,这与让程序直接调用 API 中去更新状态或者绘制视图的方式不太一样。

图形硬件加速

尽管计算机图形学中的一些术语,如 “Display List(显示列表)”会让我们回想曾经还使用过向量显示器(vector display)(光束在 CRT 的作用下可以绘制出特定的线段),但事实上,现在所有的显示器都是栅格显示器。栅格显示器定义由图像元素(像素)组成的矩形网格,这就像栅格图像和原始图像的关系。

虽然现代固态显示器经历了诸如 LCD、OLED、等离子屏幕等技术的发展,但最终显示的像素矩阵仍然是由硬件预设:这也就是说在硬件上的每一个独立元素会对应着帧缓冲区(frame buffer)的每一个元素。

针对前面所说的情况,即使考虑到视网膜(retina)屏幕的存在。我们需要提供的像素数量也可以说是相对稳定的,iPhone 屏幕的像素数量在七十万到二百万之间,iPad 屏幕需要的像素数量接近三百万,iPad Pro 或者笔记本电脑的像素数量接近五百万。因此无论应用程序多么复杂,只要在每次刷新的过程中能够处理这么多的像素数,就代表你可以改变整个屏幕的内容。在大 O 表示法中,它的复杂度是 O(k)。

虽然像素的数量是一个常量,我们从算法复杂度的角度上来说可以忽略它的存在,但由于像素的数量非常大,在实际的计算过程中我们还是无法完全忽视它。大多数情况下,绘制图形是当今非服务器型(non-server)计算机中计算量最大的任务。

随着时间的推移,市面上出现了许多能够增强图形计算的硬件架构,最常见的几种如图 14.4 所示。架构(1)中没有任何额外的硬件支持,CPU 在内存中进行绘制,而且内存还会被当做用于刷新屏幕的帧缓冲器。

IMAGE

CPU 绘图模型(CPU-drawing model)的优点是简单通用。从 Xerox Alto 到 Apple II,以及早期的 Macintosh 和 NeXT 机器,都使用了这个模型。Quartz 就是使用 CPU 绘图模型的一个例子 ,即 Quartz 操作 CPU 在内存中进行绘制。

从几何计算到绘制像素中,图形处理器(graphics processing units, GPUs)可以在绘图的各个过程中发挥作用。在架构(2)中,独立出来的 GPU 和 CPU 一样,都可以直接访问内存,这种架构在二十世纪八九十年代的家用电脑中非常普遍,不过现在已被弃用。

如今,图形硬件的架构大体可以分为两类,一种是架构(3)中的独立显卡模式,GPU 配有独立的显存(VRAM);另一种是架构(4)中的集成显卡模式,GPU 和 CPU 在同一个芯片上,通过公用总接线口访问同样的 RAM。

特定硬件协助通用硬件更好的完成工作,这种模式似乎看起来有点像历史的重演,但随着这种特定硬件的普及,现在的 GPU 还可以用在 OpenCL 的计算中。例如英特尔的 “Larrabee” 显卡由大量的通用 x86 内核组成。最终,集成显卡的架构会越来越像最初的 CPU 配置,可以看做是前者的一种变体。

IMAGE

尽管这些架构看起来大同小异,但在性能上还是存在不小的差异。举个例子,如图 14.5 所示,我在 2D 平面下绘制了许多三角形进行测试,测试设备是一台 13 英寸、拥有视网膜显示屏、英特尔 HD Graphics 4000 集成显卡的 MacBook Pro。

首先我尝试用 OpenGL 进行实验,显而易见,我们将用到图形硬件,详情见示例 14.1

示例 14.1 用 OpenGL 进行三角形绘制的基准测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-(void)drawRect:(NSRect)dirtyRect
{
int iterations=10000;
glClearColor(1.0, 0.5, 0.5, 0.5);
glEnable (GL_BLEND);
glBlendFunc (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glPushMatrix();
glScalef(0.4, 0.4, 1.0);
for (int i=0;i<iterations;i++) {
float a = -0.5 + (float)i / (float)iterations;
glColor4f(1.0f, 0.85f, 0.35f + a,0.4);
glBegin(GL_POLYGON);
{
a*=2;
glVertex3f( 0.0 + a, 0.6, 0.0);
glVertex3f( -0.2 + a, -0.3, 0.0);
glVertex3f( 0.2 + a , -0.3 ,0.0);
glVertex3f( 0.0 + a, 0.6, 0.0);
}
glEnd(); }
glPopMatrix();
glFinish();
}

在执行效率上,示例 14.1 里 OpenGL 的代码比示例 14.2 里 Quartz 的代码快了十多倍,我在一台 2007 年产,拥有独立显卡的 Mac Pro 上看到了明显的对比结果。

示例 14.2 用 Quartz 进行三角形绘制的基准测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-(void)drawRect:(NSRect)dirtyRect
{
int iterations=10000;
CGContextRef context=[[NSGraphicsContext currentContext]
graphicsPort];
CGContextSetRGBFillColor(context, 1.0,0.5,0.5,1.0 );
CGContextAddRect( context, dirtyRect);
CGContextFillPath( context );
CGContextScaleCTM( context, [self frame].size.width,
[self frame].size.height );
CGContextTranslateCTM( context, 0.25 , 0.5);
CGContextScaleCTM( context,0.2, 0.2);

for (int i=0;i<iterations;i++) {
float a = (float)i / (float)iterations;
CGContextSetRGBFillColor(context, 1.0f, 0.85f, 0.35f + a,0.4);
a*=2;
CGContextMoveToPoint( context, 0.0 + a, 0.6);
CGContextAddLineToPoint( context, -0.2 + a, -0.3);
CGContextAddLineToPoint( context, 0.2 + a , -0.3);
CGContextClosePath(context);
CGContextFillPath( context );
}
}

在使用 Quartz 时,CPU 已经达到了百分之百的利用率,而在使用 OpenGL 时,将对 GPU 操作的指令传递到命令缓冲器(command buffer)后,CPU 就空闲下来,等待这些命令执行完毕。上面两种情况都符合低功耗 CPU 的标准。

GPU 不仅在架构上拥有一定的优势,它还可以在图形计算中充分发挥自身并行计算的能力,这是以芯片上有大量的晶体管为前提做保障的。虽然 CPU 也有相似的晶体管数量,但是 CPU 却并不具备相同的并行能力。

所以,相比于 CPU 设计者需要采取更加精密的方案才能让 CPU 在执行大量串性指令的时候得到小幅的性能提升,而利用额外的硬件资源就会让整体性能的提升近似于线性增长,例如让 GPU 分担一部分的计算量。GPU 和 CPU 在处理串行指令上的区别是造成它两性能差距的主要原因之一(在适当的计算负载下),但是 GPU 的性能提升曲线更为陡峭,意味着 CPU 和 GPU 的性能差距每年都会扩大。

从 Quartz 到 Core Animation

Mac OS X 和 iOS 的图像 API 最开始只有基于 CPU 渲染的 Quartz,随后 Quartz Extreme,Quartz GL,以及 Core Animation 等图像框架逐渐加入到这个大家庭中。驱动这些 API 演变的不只是基于 OpenGL 的游戏,还有充分利用现代 GPU 潜能进行系统级别图形渲染的目标。

让 Quartz 利用 GPU 进行绘制的最直接方式就是将 Quartz 的图元映射为 GPU 的命令。这个方案的问题在于图形硬件实现了与 OpenGL 兼容的图元,而 OpenGL 的图元与 Quartz 的图元并不兼容。OpenGL 并没有 filled path 类型的图元;它只有三角形或四边形组成的多边形网络,并没有曲线的概念,与 stroke path 类型的图元相对应的是折线。尽管这种映射是可行的,例如我们将 filled path 转换为多边形,但这种转换的代价相当大。另外,OpenGL 对渲染模型的定义非常松散(loosely),也就是说,这意味着硬件在解释命令方面有很大的回旋余地。这种松散的特性不仅与 Quartz 严格定义的成像模型冲突,也和苹果公司对图形质量的要求相冲突。

然而,有一个图元可以很容易的进行转换,那就是栅格图像(raster image)。有一个名为窗口管理者(Window Mnanager)的隐藏子系统会处理栅格图像,它允许不同进程所拥有的窗口对象可以与单个物理屏幕进行映射,这样每个进程可以只关注自己的窗口对象,无需关注其他进程的操作。

由于窗口管理者的存在,不管是即时模式还是保留模式,Mac OS X 上的每个图形 API 最后都变成了保留模式的 API,具有窗口位图和保留状态,具备保留模式 API 的所有优势:窗口可以随意移动,移动的过程可视化,完全由 Window Manager 控制。这和之前的视窗系统(windowing systems)不同,也不像是 AppKit 或 UIKit 中的视图层级结构,无需客户端代码来重绘显示的部分,因此即使进程不响应,窗口的操作总是非常平滑的。

在 Mac OS X 10.2 引入了 Quartz Extreme 库,这个库为系统添加了一些图形硬件加速的能力。系统中的每个窗口都变成了 OpenGL 里的矩形,窗口的内容(由 Quartz 或其他图形 API 提供)都变成了 OpenGL 的纹理,这些纹理被映射到了相应的矩形中。每个窗口的内容都由合适的 API 进行绘制,然后再使用 OpenGL 和图形硬件将内容整合到位图窗口(bitmap window)中。

这种改变不仅更好地利用了显卡的性能,对于许多操作而言,也完全消除了 CPU 的负载:为了移动窗口,视窗管理者(window server)只需改变矩形的坐标,想要将窗口置前或置后,只需调整矩形的 z 值。

Core Animation 将这种架构从 Window Server 引入到客户端程序中,每个 CALayer 不再是绘制到一个共享的后台存储(shared backing store)里,而是维护自己的栅格图像,然后通过硬件支持的单独进程(iOS 上的渲染服务器)将这些图像组合在一起。其实,每个 CALayer 就像是 Quartz Extreme 中的窗口,我们可以改变 CALayer 的位置、透明度和旋转等属性,通过操作 CALayer 映射的 OpenGL 图元的几何形状,我们也可以将实际的位图组合加载到图形硬件中。

图 14.6 展示了从 Window Server,扩展了 Quartz Extreme 的 Window Server,到 Core Animation 的发展过程。如您所见,硬件加速的增长和图形架构中后置缓冲区(backing buffer)的增长相对应。在图形资源极端的情况下,除了解码这些资源外,根本就没有软件渲染;整个管道的其余部分都是硬件加速的。所谓的开销就是内存,资源的分辨率,以及图片资源解码所需的时间,这可能也是最重要的。

就像独立 Window Server 进程能够让窗口的操作平滑过渡一样,这种架构也可以在任意的客户端达到动画运行平滑的效果。一旦设置完所有的图层,动画就会独立于调用程序而运行,它会在一个单独的进程中执行,并且能够得到硬件的支持。

这是一种极其不明显的性能优化方法,因为该方法的基础是处理最宝贵的图元,即栅格图像。栅格图像使用了大量的内存和内存带宽,并用于最后的图像合成。在其他条件相同的情况下,Core Animation 的架构会导致速度变慢,但是实际上其他的条件不可能相同,因为这些操作可以从图形硬件中获得不少好处。

另外,我们在简单性和相应的可预测性上获得了收益,使得操作可以单独执行,只需给图形硬件发送指令流即可。

IMAGE

大大减少了 CPU 的工作,将不同的层复合在一起,为这些组合创建动画空间。实际上动画由独立的进程控制,在高性能的 GPU 上执行,这意味着比起客户端程序,该系统可以保证更高的平滑度和性能。

性能保证了动画可以成为用户体验的核心,除了能够让用户体验更加“立体”和真实之外,动画还能极大地提高感知响应性。

如果用户的操作触发了一个动画,即使系统没有给出最终的结果,用户也会认为这个应用是具有响应性的。只要找到合适的动画效果,这个动画所花费的时间都可用于处理用户的操作。

总结

在本章中,我们主要了解了应用程序响应用户操作的基本心理学,学习了图形编程的特点和需要权衡的问题,接着我们还了解了图形硬件和 Mac OS X 以及之后 iOS 的图像 API 的协同进化史。

这种协同进化给我们留下了许多的关于图形编程的方法和需要权衡的地方,我们将在后面的章节进行详细的探讨。

iOS系统中导航栏的转场解决方案与最佳实践

这篇文章是我早年在美团技术博客上发布的一篇文章, 部分内容可能已经过时, 请开发者注意.
将文章同步到个人博客主要是为了同步和备份.

背景

目前,开源社区和业界内已经存在一些 iOS 导航栏转场的解决方案,但对于历史包袱沉重的美团 App 而言,这些解决方案并不完美。有的方案不能满足复杂的页面跳转场景,有的方案迁移成本较大,为此我们提出了一套解决方案并开发了相应的转场库,目前该转场库已经成为美团点评多个 App 的基础组件之一。

在美团 App 开发的早期,涉及到导航栏样式改变的需求时,经常会遇到转场效果不佳或者与预期样式不符的“小问题”。在业务体量较小的情况下,为了满足快速的业务迭代,通常会使用硬编码的方式来解决这一类“小问题”。但随着美团 App 业务的高速发展,这种硬编码的方式遇到了以下的挑战:

  1. 业务模块的不断增加,导致使用硬编码方式编写的代码维护成本增加,代码质量迅速下降。
  2. 大型 App 的路由系统使得页面间的跳转变得更加自由和灵活,也使得导航栏相关的问题激增,不但增加了问题的排查难度,还降低了整体的开发效率。
  3. App 中的导航栏属于各个业务方的公用资源,由于缺乏相应的约束机制和最佳实践,导致业务方之间的代码耦合程度不断增加。

从各个角度来看,硬编码的方式已经不能很好的解决此类问题,美团 App 需要一个更加合理、更加持久、更加简单易行的解决方案来处理导航栏转场问题。

本文将从导航栏的概念入手,通过讲解转场过程中的状态管理、转换时机和样式变化等内容,引出了在大型应用中导航栏转场的三种常见解决方案,并对美团点评的解决方案进行剖析。

重新认识导航栏

导航栏里的 MVC

在 iOS 系统中, 苹果公司不仅建议开发者遵循 MVC 开发框架,在它们的代码里也可以看到 MVC 的影子,导航栏组件的构成就是一个类似 MVC 的结构,让我们先看看下面这张图:

01导航栏组件关系图.png

在这张图里,我们可以将 UINavigationController 看做是 C,UINavigationBar 看做是 V,而 UIViewController 和 UINavigationItem 组成的 Stack 可以看做是 M。这里要说明的是,每个 UIViewController 都有一个属于自己的 UINavigationItem,也就是说它们是一一对应的。

UINavigationController 通过驱动 Stack 中的 UIViewController 的变化来实现 View 层级的变化,也就是 UINavigationBar 的改变。而 UINavigationBar 样式的数据就存储在 UIViewController 的 UINavigationItem 中。这也就是为什么我们在代码里只要设置 self.navigationItem 的相关属性就可以改变 UINavigationBar 的样式。

很多时候,国内的开发者会将 UINavigationBar 和 UINavigationController 混在一起叫导航栏,这样的做法不仅增加了开发者之间的沟通成本,也容易导致误解。毕竟它们是两个完全不一样的东西。

所以本文为了更好的阐明问题,会采用英文区分不同的概念,当需要描述笼统的导航栏概念时,会使用导航栏组件一词。

通过这一节的回顾,我们应该明确了 NavigationItem、ViewController、NavigationBar 和 NavigationController 在 MVC 框架下的角色。下面我们会重新梳理一下导航栏的生命周期和各个相关方法的调用顺序。

导航栏组件的生命周期

大家可以通过下图获得更为直观的感受,进而了解到导航栏组件在 push 过程中各个方法的调用顺序。

02push过程中的方法调用顺序图.png

值得注意的地方有两点:

第一个是 UINavigationController 作为 UINavigationBar 的代理,在没有特殊需求的情况下,不应该修改其代理方法,这里是通过符号断点获取它们的调用顺序。如果我们创建了一个自定义的导航栏组件系统,它的调用顺序可能会与此不同。

第二个是用虚线圈起来的方法,它们也有可能不被调用,这与 ViewController 里的布局代码相关,假设跳转到新页面后,新旧页面中的控件位置会发生变化,或者由于数据改变驱动了控件之间的约束关系发生变化,这就会带来新一轮的布局,进而触发 viewWillLayoutSubviewviewDidLayoutSubview 这两个方法。当然,具体的调用顺序会与业务代码紧密相关,如果我们发现顺序有所不同,也不必惊慌。

下面这张图展示了导航栏在 pop 过程中各个方法的调用顺序:

03pop过程中的方法调用顺序图.png

除了上面说到的两点,pop 过程中还需要注意一点,那就是从 B 返回到 A 的过程中,A 视图控制器的 viewDidLoad 方法并不会被调用。关于这个问题,只要提醒一下,大多数人都会反应过来是为什么。不过在实际开发过程中,总会有人忘记这一点。

通过这两个图,我们已经基本了解了导航栏组件的生命周期和相关方法的调用顺序,这也是后面章节的理论基础。

导航栏组件的改变与革新

导航栏组件在 iOS 11 发布时,获得了重大更新,这个更新可不是增加了一个大标题样式(Large Title Display Mode)那么简单,需要注意的地方大概有两点:

  1. 导航栏全面支持 Auto Layout 且 NavigationBar 的层级发生了明显的改变,关于这一点可以阅读 UIBarButtonItem 在 iOS 11 上的改变及应对方案

  2. 由于引进了 Safe Area 等概念,topLayoutGuidebottomLayoutGuide 等属性会逐渐废弃,虽然变化不大,但如果我们的导航栏在转场过程中总是出现视图上下移动的现象,不妨从这个方面思考一下,如果想深究可以查看 WWDC 2017 Session 412

导航栏组件到底怎么了?

经常有人说 iOS 的原生导航栏组件不好使用,抱怨主要集中在导航栏组件的状态管理和控件的布局问题上。

控件的布局问题随着 iOS 11 的到来已经变得相对容易处理了不少,但导航栏组件的状态管理仍然让开发者头疼不已。

可能已经有朋友在思考导航栏组件的状态管理到底是什么东西?不要着急,下面的章节就会做相关的介绍。

导航栏的状态管理

虽然导航栏组件的 push 和 pop 动画给人一种每次操作后都会创建一遍导航栏组件的错觉,但实际上这些 ViewController 都是由一个 NavigationController 所管理,所以你看到的 NavigationBar 是唯一的。

04导航栏示例图.png

在 NavigationController 的 Stack 存储结构下,每当 Stack 中的 ViewController 修改了导航栏,势必会影响其他 ViewController 展示的效果。

例如下图所示的场景,如果 NavigationBar 原先的颜色是绿色,但之后进入 Stack 里的 ViewController 将 NavigationBar 颜色修改为紫色后,在此之后 push 的 ViewController 会从默认的绿色变为紫色,直到有新的 ViewController 修改导航栏颜色才会发生变化。

05导航栏push状态.png

虽然在 push 过程中,NavigationBar 的变化听起来合情合理,但如果你在 NavigationBar 为绿色的 ViewController 里设置不当的话,那么当你 pop 回这个 ViewController 时,NavigationBar 可就不一定是绿色了,它还会保持为紫色的状态。

06导航栏pop状态.png

通过这个例子,我们大概会意识到在导航栏里的 Stack 中,每个 ViewController 都可以永久的影响导航栏样式,这种全局性的变化要求我们在实际开发中必须坚持“谁修改,谁复原”的原则,否则就会造成导航栏状态的混乱。这不仅仅是样式上的混乱,在一些极端状况下,还有可能会引起 Stack 混乱,进而造成 Crash 的情况。

导航栏样式转换的时机

我们刚才提到了“谁修改,谁复原”的原则,但何时修改,何时复原呢?

对于那些存储在 Stack 中的 ViewController 而言,它其实就是在不断的经历 appear 和 disappear 的过程,结合 ViewController 的生命周期来看,viewWillAppear:viewWillDisappear: 是两个完美的时间节点,但很多人却对这两个方法的调用存在疑惑。

苹果公司在它的 API 文档中专门用了一段文字来解答大家的疑惑,这段文字的标题为《Handling View-Related Notifications》,在这里我们直接引用原文:

When the visibility of its views changes, a view controller automatically calls its own methods so that subclasses can respond to the change. Use a method like viewWillAppear: to prepare your views to appear onscreen, and use the viewWillDisappear: to save changes or other state information. Use other methods to make appropriate changes.
Figure 1 shows the possible visible states for a view controller’s views and the state transitions that can occur. Not all ‘will’ callback methods are paired with only a ‘did’ callback method. You need to ensure that if you start a process in a ‘will’ callback method, you end the process in both the corresponding ‘did’ and the opposite ‘will’ callback method.

07视图管理器的状态转换.jpg

这里很好的解释了所有的 will 系列方法和 did 系列方法的对应关系,同时也给我们吃了一个定心丸,那就是在 appearing 和 disappearing 状态之间会由 will 系列方法进行衔接,避免了状态中断。这对于连续 push 或者连续 pop 的情况是及其重要的,否则我们无法做到 “谁修改,谁复原”的原则。

通常来说,如果只是一个简单的导航栏样式变化,我们的代码结构大体会如下所示:

1
2
3
4
5
6
7
8
9
- (void)viewWillAppear:(BOOL)animated{
[super viewWillAppear:animated];
// MARK: change the navigationbar style
}

- (void)viewWillDisappear:(BOOL)animated{
[super viewWillDisappear:animated];
// MARK: restore the navigationbar style
}

现在,我们明确了修改时机,接下来要明确的就是导航栏的样式会进行怎样的变化。

导航栏的样式变化

对于不同 ViewController 之间的导航栏样式变化,大多可以总结为两种情况:

  1. 导航栏的显示与否
  2. 导航栏的颜色变化

导航栏的显示与否

对于显示与否的问题,可以在上一节提到的两个方法里调用 setNavigationBarHidden:animated: 方法,这里需要提醒的有两点:

  1. 在导航栏转场的过程中,不要天真的以为 setNavigationBarHidden:setNavigationBarHidden:animated: 的效果是一样的,直接使用 setNavigationBarHidden: 会造成导航栏转场过程中的闪现、背景错乱等问题,这一现象在使用手势驱动转场的场景中十分常见,所以正确的方式是使用带有 animated 参数的 API。
  2. 在 push 和 pop 的方法里也会带有 animated 参数,尽量保证与 setNavigationBarHidden:animated: 中的 animated 参数一致。

导航栏的颜色变化

颜色变化的问题就稍微复杂一些,在 iOS 7 后,导航栏增加了 translucent 效果,这使得导航栏背景色的变化出现了两种情况:

  1. translucent 属性值为 YES 的前提下,更改导航栏的背景色。
  2. translucent 属性值为 NO 的前提下,更改导航栏的背景色。

对于第一种情况,我们需要调用 UINavigationBar 的 setBackgroundColor: 方法。

对于第二种情况我们需要调用 UINavigationBar 的 setBackgroundImage:forBarMetrics: 方法。

对于第二种情况,这里有三点需要提示:

  1. 在设置透明效果时,我们通常可以直接设置一个 [UIImage new] 创建的对象,无须创建一个颜色为透明色的图片。
  2. 在使用 setBackgroundImage:forBarMetrics: 方法的过程中,如果图像里存在 alpha 值小于 1.0 的像素点,则 translucent 的值为 YES,反之为 NO。也就是说,如果我们真的想让导航栏变成纯色且没有 translucent 效果,请保证所有像素点的 alpha 值等于 1。
  3. 如果设置了一个完全不透明的图片且强行将 NavigationBar 的 translucent 属性设置为 YES 的话,系统会自动修正这个图片并为它添加一个透明度,用于模拟 translucent 效果。
  4. 如果我们使用了一个带有透明效果的图片且导航栏的 translucent 效果为 NO 的话,那么系统会在这个带有透明效果的图片背后,添加一个不透明的纯色图片用于整体效果的合成。这个纯色图片的颜色取决于 barStyle 属性,当属性为 UIBarStyleBlack 时为黑色,当属性为 UIBarStyleDefault 时为白色,如果我们设置了 barTintColor,则以设置的颜色为基准。

分清楚 transparenttranslucentopaquealphaopacity 也挺重要

在刚接触导航栏 API 时,许多人经常会把文档里的这些英文词搞混,也不太明白带有这些词的变量为什么有的是布尔型,有的是浮点型,总之一切都让人很困惑。

在这里将做了一个总结,这对于理解 Apple 的 API 设计原则十分有帮助。

transparenttranslucentopaque 三个词经常会用在一起,它用于描述物体的透光强度,为了让大家更好的理解这三个词,这里做了三个比喻:

  • transparent 是指透明,就好比我们可以透过一面干净的玻璃清楚的看到外面的风景。
  • translucent 是指半透明,就好比我们可以透过一面有点磨砂效果的塑料墙看外面的风景,不能说看不见,但我们肯定看不清。
  • opaque 是指不透明,就好比我们透过一个堵石墙是看不见任何外面的东西,眼前看到的只有这面墙。

这三个词更多的是用来表述一种状态,不需要量化,所以这与这三个词相关的属性,一般都是 BOOL 类型。

08transparent-translucent-opaque的区别.png

alphaopacity 经常会在一起使用,它要表示的就是透明度,在 Web 端这两个属性有着明显的区别。

在 Web 端里,opacity 是设定整个元素的透明值,而 alpha 一般是放在颜色设置里面,所以我们可以做到对特定对元素的某个属性设定 alpha,比如背景、边框、文字等。

1
2
3
4
5
6
7
div {
width: 100px;
height: 100px;
background: rgba(0,0,0,0.5);
border: 1px solid #000000;
opacity: 0.5;
}

这一概念同样适用于 iOS 里的概念,比如我们可以通过 alpha 通道单独的去设置 backgroudColorborderColor,它们互不影响,且有着独立的 alpha 通道,我们也可以通过 opacity 统一设置整个 view 的透明度。

但与 Web 端不一致的是,iOS 里面的 view 不光拥有独立的 alpha 属性,同时也是基于 CALayer,所以我们可以看到任意 UIView 对象下面都会有一个 layer 的属性,用于表明 CALayer 对象。view 的 alpha 属性与 layer 里面的 opacity 属性是一个相等的关系,需要注意的是 view 上的 alpha 属性是 Web 端并不具备的一个能力,所以笔者认为:在 iOS 中去说 alpha 时,要区分是在说 view 上的属性,还是在说颜色通道里的 alpha

由于这两个词都是在描述程度,所以我们看到它们都是 CGFloat 类型:

9alpha-opacity的区别.png

转场过程中需要注意的问题和细节

说完了导航栏的转场时机和转场方式,其实大体上你已经能处理好不同样式间的转换,但还有一些细节需要你去考虑,下面我们来说说其中需要你关注的两点。

translucent 属性带来的布局改变

translucent 会影响导航栏组件里 ViewController 的 View 布局,这里需要大家理清 5 个 API 的使用场景:

  1. edgesForExtendedLayout
  2. extendedLayoutIncluedsOpaqueBars
  3. automaticallyAdjustScrollViewInsets
  4. contentInsetAdjustmentBehavior
  5. additionalSafeAreaInsets

前三个 API 是 iOS 11 之前的 API,它们之间的区别和联系在 Stack Overflow 上有一个比较精彩的回答 - Explaining difference between automaticallyAdjustsScrollViewInsets, extendedLayoutIncludesOpaqueBars, edgesForExtendedLayout in iOS7,我在这里就不做详细阐述,总结一下它的观点就是:

如果我们先定义一个 UINavigationController,它里面包含了多个 UIViewController,每个 UIViewController 里面包含一个 UIView 对象:

  • 那么 edgesForExtendedLayout 是为了解决 UIViewController 与 UINavigationController 的对齐问题,它会影响 UIViewController 的实际大小,例如 edgesForExtendedLayout 的值为 UIRectEdgeAll 时,UIViewController 会占据整个屏幕的大小。
  • 当 UIView 是一个 UIScrollView 类或者子类时,automaticallyAdjustsScrollViewInsets 是为了调整这个 UIScrollView 与 UINavigationController 的对齐问题,这个属性并不会调整 UIViewController 的大小。
  • 对于 UIView 是一个 UIScrollView 类或者子类且导航栏的背景色是不透明的状态时,我们会发现使用 edgesForExtendedLayout 来调整 UIViewController 的大小是无效的,这时候你必须使用 extendedLayoutIncludesOpaqueBars 来调整 UIViewController 的大小,可以认为 extendedLayoutIncludesOpaqueBars 是基于 automaticallyAdjustsScrollViewInsets 诞生的,这也是为什么经常会看到这两个 API 会同时使用。

这些调整布局的 API 背后是一套基于 topLayoutGuidebottomLayoutGuide 的计算而已,在 iOS 11 后,Apple 提出了 Safe Area 的概念,将原先分裂开来的 topLayoutGuidebottomLayoutGuide 整合到一个统一的 LayoutGuide 中,也就是所谓的 Safe Area,这个改变看起来似乎不是很大,但它的出现确实方便了开发者。

10safe-area示例图.jpg

如果想对 Safe Area 带来的改变有更全面的认识,十分推荐阅读 Rosberry 的工程师 Evgeny Mikhaylov 在 Medium 上的文章 iOS Safe Area,这篇文章基本涵盖了 iOS 11 中所有与 Safe Area 相关的 API 并给出了真正合理的解释。

这里只说一下 contentInsetAdjustmentBehavioradditionalSafeAreaInsets 两个 API。

对于 contentInsetAdjustmentBehavior 属性而言,它的诞生也意味着 automaticallyAdjustsScrollViewInsets 属性的失效,所以我们在那些已经适配了 iOS 11 的工程里能看到如下类似的代码:

1
2
3
4
5
if (@available(iOS 11.0, *)) {
self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
} else {
self.automaticallyAdjustsScrollViewInsets = NO;
}

此处的代码片段只是一个示例,并不适用所有的业务场景,这里需要着重说明几个问题:

  1. 关于 contentInsetAdjustmentBehavior 中的 UIScrollViewContentInsetAdjustmentAutomatic 的说明一直很“模糊”,通过 Evgeny Mikhaylov 的文章,我们可以了解到他在大多数情况下会与 UIScrollViewContentInsetAdjustmentScrollableAxes 一致,当且仅当满足以下所有条件时才会与 UIScrollViewContentInsetAdjustmentAlways 相似:

    • UIScrollView 类型的视图在水平轴方向是可滚动的,垂直轴是不可滚动的。
    • ViewController 视图里的第一个子控件是 UIScrollView 类型的视图。
    • ViewController 是 navigation 或者 tab 类型控制器的子视图控制器。
    • 启用 automaticallyAdjustsScrollViewInsets
  2. iOS 11 后,通过 contentInset 属性获取的偏移量与 iOS 10 之前的表现形式并不一致,需要获取 adjustedContentInset 属性才能保证与之前的 contentInset 属性一致,这样的改变需要我们在代码里对不同的版本进行适配。

对于 additionalSafeAreaInsets 而言,如果系统提供的这几种行为并不能满足我们的布局要求,开发者还可以考虑使用 additionalSafeAreaInsets 属性做调整,这样的设定使得开发者可以更加灵活,更加自由的调整视图的布局。

backIndicator 上的动画

苹果提供了许多修改导航栏组件样式的 API,有关于布局的,有关于样式的,也有关于动画的。backIndicatorImagebackIndicatorTransitionMaskImage 就是其中的两个 API。

backIndicatorImagebackIndicatorTransitionMaskImage 操作的是 NavigationBar 里返回按钮的图片,也就是下图红色圆圈所标注的区域。

11backIndicator示例图.png

想要成功的自定义返回按钮的图标样式,我们需要同时设置这两个 API ,从字面上来看,它们一个是返回图片本身,另一个是返回图片在转场时用到的 mask 图片,看起来不怎么难,我们写一段代码试试效果:

1
2
self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"];
self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrowMask"];

代码里的图片如下所示:

12mask图片示例图1.png

也许大多数人在这里会都认为,mask 图片会遮挡住文字使其在遇到返回按钮右边缘的时候就消失。但实际的运行效果是怎么样子的呢?我们来看一下:

13mask动态效果图1.gif

在上面的图片中,我们可以看到返回按钮的文字从返回按钮的图片下面穿过并且文字被图片所遮挡,这种动画看起来十分奇怪,这是无法接受的。我们需要做点修改:

1
2
self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"];
self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrow"];

这一次我们将 backIndicatorTransitionMaskImage 改为 indicatorImage 所用的图片。

14mask图片示例图2.png

到这里,可能大多数人都会好奇,这代码也能行?让我们看下它实际的效果:

15mask动态效果图2.gif

在上面的图中,我们看到文字在到达图片的右边缘时就从下方穿过并被完全遮盖住了,这种动画效果虽然比上面好一些,但仍然有改进的空间,不过这里我们先不继续优化了,我们先来讨论一下它们背后的运作原理。

iOS 系统会将 indicatorImage 中不透明的颜色绘制成返回按钮的图标, indicatorTransitionMaskImage 与 indicatorImage 的作用不同。indicatorTransitionMaskImage 将自身不透明的区域像 mask 一样作用在 indicatorImage 上,这样就保证了返回按钮中的文字像左移动时,文字只出现在被 mask 的区域,也就是 indicatorTransitionMaskImage 中不透明的区域。

掌握了原理,我们来解释下刚才的两种现象:

在第一种实现中,我们提供的 indicatorTransitionMaskImage 覆盖了整个返回按钮的图标,所以我们在转场过程中可以清晰的看到返回按钮的文字。

在第二种实现中,我们使用 indicatorImage 作为 indicatorTransitionMaskImage,记住文字是只能出现在 indicatorTransitionMaskImage 里不透明的区域,所以显然返回按钮中的文字会在图标的最右边就已经被遮挡住了,因为那片区域是透明的。

那么前面提到的进一步优化指的是什么呢?

让我们来看一下下面这个示例图,为了更好的区分,我们将 indicatorTransitionMaskImage 用红色进行标注。黑色仍然是 indicatorImage。

16mask图片示例图3.jpg

按照刚才介绍的原理,我们应该可以理解,现在文字只会出现在红色区域,那么它的实际效果是什么样子的呢,我们可以看下图:

17mask动态效果图3.gif

现在,一个完美的返回动画,诞生啦!

此节所用的部分效果图出自 Ray Wenderlich 的文章 UIAppearance Tutorial: Getting Started

导航栏的跳转或许可以这么玩儿

前两章的铺垫就是为了这一章的内容,所以现在让我们开始今天的大餐吧。

这样真的好么?

刚才我们说了两个页面间 NavigationBar 的样式变化需要在各自的 viewWillAppear:viewWillDisappear: 中进行设置。那么问题就来了:这样的设置会带来什么问题呢?

试想一下,当我们的页面会跳到不同的地方时,我们是不是要在 viewWillAppear:viewWillDisappear: 方法里面写上一堆的判断呢?如果应用里还有 router 系统的话,那么页面间的跳转将变得更加不可预知,这时候又该如何在 viewWillAppear:viewWillDisappear: 里做判断呢?

现在我们的问题就来了,如何让导航栏的转场更加灵活且相互独立呢?

常见的解决方案如下所示:

  1. 重新实现一个类似 UINavigationController 的容器类视图管理器,这个容器类视图管理器做好不同 ViewController 间的导航栏样式转换工作,而每个 ViewController 只需要关心自身的样式即可。
    18常见的导航栏转场方案1示例图.png

  2. 将系统原有导航栏的背景设置为透明色,同时在每个 ViewController 上添加一个 View 或者 NavigationBar 来充当我们实际看到的导航栏,每个 ViewController 同样只需要关心自身的样式即可。
    19常见的导航栏转场方案2示例图.png

  3. 在转场的过程中隐藏原有的导航栏并添加假的 NavigationBar,当转场结束后删除假的 NavigationBar 并恢复原有的导航栏,这一过程可以通过 Swizzle 的方式完成,而每个 ViewController 只需要关心自身的样式即可。
    20常见的导航栏转场方案3示例图.png

这三种方案各有优劣,我们在网上也可以看到很多关于它们的讨论。

例如方案一,虽然看起来工作量大且难度高,但是这个工作一旦完成,我们就会将处理导航栏转场的主动权牢牢抓在手里。但这个方案的一个弊端就是,如果苹果修改了导航栏的整体风格,就好比 iOS 11 的大标题特效,那么工作量就来了。

对于方案二而言,虽然看起来简单易用,但这需要一个良好的继承关系,如果整个工程里的继承关系混乱或者是历史包袱比较重,后续的维护就像“打补丁”一样,另外这个方案也需要良好的团队代码规范和完善的技术文档来做辅助。

对于方案三而言,它不需要所谓的继承关系,使用起来也相对简单,这对于那些继承关系和历史包袱比较重的工程而言,这一个不错的解决方案,但在解决 Bug 的时候,Swizzle 这种方式无疑会增加解决问题的时间成本和学习成本。

我们的解决方案

在美团 App 的早期,各个业务方都想充分利用导航栏的能力,但对于导航栏的状态维护缺乏理解与关注,随着业务方的增加和代码量的上升,与导航栏相关的问题逐渐暴露出来,此时我们才意识到这个问题的严重性。

大型 App 的导航栏问题就像一个典型的“公地悲剧”问题。在软件行业,公用代码的所有权可以被视作“公地”,因为不注重长期需求而容易遭到消耗。如果开发人员倾向于交付“价值”,而以可维护性和可理解性为代价,那么这个问题就特别普遍了。如果是这种情况,每次代码修改将大大减少其总体质量,最终导致软件的不可维护。

所以解决这个问题的核心在于:明确公用代码的所有权,并在开发期施加约束。

明确公用代码的所有权,可以理解为将导航栏相关的组件抽离成一个单独的组件,并交由特定的团队维护。而在开发期施加约束,则意味着我们要提供一套完整的解决方案让各个业务方遵守。

这一节我们会以美团内部的解决方案为例,讲解如何实现一个流畅的导航栏跳转过程和相关使用方法。

设计理念

使用者只用关心当前 ViewController 的 NavigationBar 样式,而不用在 push 或者 pop 的时候去处理 NavigationBar 样式。

举个例子来说,当从 A 页面 push 到 B 页面的时候,转场库会保存 A 页面的导航栏样式,当 pop 回去后就会还原成以前的样式,因此我们不用考虑 pop 后导航栏样式会改变的情况,同时我们也不必考虑 push 后的情况,因为这个是页面 B 本身需要考虑的。

使用方法

转场库的使用十分简单,我们不需要 import 任何头文件,因为它在底层通过 Method Swizzling 进行了处理,只需要在使用的时候遵循下面 4 点即可:

  • 当需要改变导航栏样式的时候,在视图控制器的 viewDidLoad 或者 viewWillAppear: 方法里去设置导航栏样式。
  • setBackgroundImage:forBarMetrics: 方法和 shadowImage 属性去修改导航栏的背景样式。
  • 不要在 viewWillDisappear: 里添加针对导航栏样式修改的代码。
  • 不要随意修改 translucent 属性,包括隐式的修改和显示的修改。

隐式修改是指使用 setBackgroundImage:forBarMetrics: 方法时,如果 image 里的像素点没有 alpha 通道或者 alpha 全部等于 1 会使得 translucent 变为 NO 或者 nil。

基本原理

以上,我们讲完了设计理念和使用方法,那么我们来看看美团的转场库到底做了什么?

从大方向上来看,美团使用的是前面所说的第三种方案,不过它也有一些自己独特的地方,为了更好的让大家理解整个过程,我们设计这样一个场景,从页面 A push 到页面 B,结合之前探讨过的方法调用顺序,我们可以知道几个核心方法的调用顺序大致如下:

  1. 页面 A 的 pushViewController:animated:
  2. 页面 B 的 viewDidLoad or viewWillAppear:
  3. 页面 B 的 viewWillLayoutSubviews
  4. 页面 B 的 viewDidAppear:

在 push 过程的开始,转场库会在页面 A 自身的 view 上添加一个与导航栏一模一样的 NavigationBar 并将真的导航栏隐藏。之后这个假的导航栏会一直存在页面 A 上,用于保留 A 离开时的导航栏样式。

等到页面 B 调用 viewDidLoad 或者 viewWillAppear: 的时候,开发者在这里自行设置真的导航栏样式。转场库在这里会对页面布局做一些修正和辅助操作,但不会影响导航栏的样式。

等到页面 B 调用 viewWillLayoutSubviews 的时候,转场库会在页面 B 自身的 view 上添加一个与真的导航栏一模一样的 NavigationBar,同时将真的导航栏隐藏。此时不论真的导航栏,还是假的导航栏都已经与 viewDidLoad 或者 viewWillAppear: 里设置的一样的。

当然,这一步也可以放在 viewWillAppear: 里并在 dispatch main queue 的下一个 runloop 中处理。

等到页面 B 调用 viewDidAppear: 的时候,转场库会将假的导航栏样式设置到真的导航栏中,并将假的导航栏从视图层级中移除,最终将真的导航栏显示出来。

为了让大家更好地理解上面的内容,请参考下图:

21KMNavigationBarTransiton的原理图-push流程.png

说完了 push 过程,我们再来说一下从页面 B pop 回页面 A 的过程,几个核心方法的调用顺序如下:

  1. 页面 B 的 popViewControllerAnimated:
  2. 页面 A 的 viewWillAppear:
  3. 页面 A 的 viewDidAppear:

在 pop 过程的开始,转场库会在页面 B 自身的 view 上添加一个与导航栏一模一样的 NavigationBar 并将真的导航栏隐藏,虽然这个假的导航栏会一直存在于页面 B 上,但它自身会随着页面 B 的 dealloc 而消亡。

等到页面 A 调用 viewWillAppear: 的时候,开发者在这里自行设置真的导航栏样式。当然我们也可以不设置,因为这时候页面 A 还持有一个假的导航栏,这里还保留着我们之前在 viewDidLoad 里写的导航栏样式。

等到页面 A 调用 viewDidAppear: 的时候,转场库会将假的导航栏样式设置到真的导航栏中,并将假的导航栏从视图层级中移除,最终将真的导航栏显示出来。

同样,我们可以参考下面的图来理解上面所说的内容:

22KMNavigationBarTransiton的原理图-pop流程.png

现在,大家应该对我们美团的解决方案有了一定的认识,但在实际开发过程中,还需要考虑一些布局和适配的问题。

最佳实践

在维护这套转场方案的时间里,我们总结了一些此类方案的最佳实践。

判断导航栏问题的基本准则

如果发现导航栏在转场过程中出现了样式错乱,可以遵循以下几点基本原则:

  • 检查相应 ViewController 里是否有修改其他 ViewController 导航栏样式的行为,如果有,请做调整。
  • 保证所有对导航栏样式变化的操作出现在 viewDidLoadviewWillAppear: 中,如果在 viewWillDisappear: 等方法里出现了对导航栏的样式修改的操作,如果有,请做调整。
  • 检查是否有改动 translucent 属性,包括显示修改和隐式修改,如果有,请做调整。

只关心当前页面的样式

永远记住每个 ViewController 只用关心自己的样式,设置的时机点在 viewWillAppear: 或者 viewDidLoad 里。

透明样式导航栏的正确设置方法

如果需要一个透明效果的导航栏,可以使用如下代码实现:

1
2
[self.navigationController.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault];
self.navigationController.navigationBar.shadowImage = [UIImage new];

导航栏的颜色渐变效果

如果需要导航栏实现随滚动改变整体 alpha 值的效果,可以通过改变 setBackgroundImage:forBarMetrics: 方法里 image 的 alpha 值来达到目标,这里一般是使用监听 scrollView.contentOffset 的手段来做。请避免直接修改 NavigationBar 的 alpha 值。

还有一点需要注意的是,在页面转场的过程中,也会触发 contentOffset 的变化,所以请尽量在 disappear 的时候取消监听。否则会容易出现导航栏透明度的变化。

导航栏背景图片的规范

请避免背景图里的像素点没有 alpha 通道或者 alpha 全部等于 1,容易触发 translucent 的隐式改变。

如果真的要隐藏导航栏

如果我们需要隐藏导航栏,请保证所有的 ViewController 能坚持如下原则:

  1. 每个 ViewController 只需要关心当前页面下的导航栏是否被隐藏。
  2. viewWillAppear: 中,统一设置导航栏的隐藏状态。
  3. 使用 setNavigationBarHidden:animated: 方法,而不是 setNavigationBarHidden:

转场动画与导航栏隐藏动画的一致性

如果在转场的过程中还会显示或者隐藏导航栏的话,请保证两个方法的动画参数一致。

1
2
3
- (void)viewWillAppear:(BOOL)animated{
[self.navigationController setNavigationBarHidden:YES animated:animated];
}

viewWillAppear: 里的 animated 参数是受 push 和 pop 方法里 animated 参数影响。

导航栏固有的系统问题

目前已知的有两个系统问题如下:

  1. 当前后两个 ViewController 的导航栏都处于隐藏状态,然后在后一个 ViewController 中使用返回手势 pop 到一半时取消,再连续 push 多个页面时会造成导航栏的 Stack 混乱或者 Crash。
  2. 当页面的层级结构大体如下所示时,在红色导航栏的 Stack 中,返回手势会大概率的出现跨层级的跳转,多次后会导致整个导航栏的 Stack 错乱或者 Crash。

23引发导航栏栈错乱的视图层级.png

导航栏内置组件的布局规范

导航栏里的组件布局在 iOS 11 后发生了改变,原有的一些解决方案已经失效,这些内容不在本篇文章的讨论范围之内,推荐阅读UIBarButtonItem 在 iOS 11 上的改变及应对方案,这篇文章详细的解释了 iOS 11 里的变化和可行的应对方案。

总结

本文涉及内容较多,从 iOS 系统下的导航栏概念到大型应用里的最佳实践,这里我们总结一下整篇文章的核心内容:

  • 理解导航栏组件的结构和相关方法的生命周期。
    • 导航栏组件的结构留有 MVC 架构的影子,在解决问题时,要去相应的层级处理。
    • 转场问题的关键点是方法的调用顺序,所以了解生命周期是解决此类问题的基础。
  • 状态管理,转换时机和样式变化是导航栏里常见问题的三种表现形式,遇到实际问题时需要区分清楚。
    • 状态管理要坚持“谁修改,谁复原”的原则。
    • 转换时机的设定要做到连续可执行。
    • 样式变化的核心点是导航栏的显示与否与颜色变化。
  • 为了更好的配合大型应用里的路由系统,导航栏转场的常见解决方案有三种,各有利弊,需要根据自身的业务场景和历史包袱做取舍。
    • 解决方案1:自定义导航栏组件。
    • 解决方案2:在原有导航栏组件里添加 Fake Bar。
    • 解决方案3:在导航栏转场过程中添加 Fake Bar。
  • 美团在实际开发过程中采用了第三种方案,并给出了适合美团 App 的最佳实践。

特别感谢莫洲骐在此项目里的贡献与付出。

参考链接

作者简介

思琦,美团点评 iOS 工程师。2016 年加入美团,负责美团平台的业务开发及 UI 组件的维护工作。

招聘

美团平台诚招 iOS、Android、FE 高级/资深工程师和技术专家,Base 北京、上海、成都,欢迎有兴趣的同学投递简历到zhangsiqi04@meituan.com

❌