普通视图

发现新文章,点击刷新页面。
昨天以前SketchK's Blog

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

作者 SketchK
2022年8月30日 02:51

这一部分的文章是我早年间参与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性能优化)

作者 SketchK
2022年8月30日 02:51

这一部分的文章是我早年间参与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性能优化)

作者 SketchK
2022年8月30日 02:48

这一部分的文章是我早年间参与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性能优化)

作者 SketchK
2022年8月30日 02:45

这一部分的文章是我早年间参与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 的协同进化史。

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

从预编译的角度理解 Swift 与 Objective-C 及混编机制

作者 SketchK
2022年8月30日 01:00

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

TL;DR

文章涉及面广,篇幅长,阅读完需要耗费一定的时间与精力,如果你带有较为明确的阅读目的,可以参考以下建议完成阅读:

  • 如果你对预编译的理论知识已经了解,可以直接从【原来它是这样的】的章节开始进行阅读,这会让你对预编译有一个更直观的了解。
  • 如果你对 search path 的工作机制感兴趣,可以直接【关于第一个问题】的章节阅读,这会让你更深刻,更全面的了解到它们的运作机制,
  • 如果您对 Xcode Phases 里的 Header 的设置感到迷惑,可以直接从【揭开 Public,Private,Project 的真实面目】阅读,这会让你理解为什么说 Private 并不是真正的私有头文件
  • 如果你想了解如何通过 hmap 技术提升编译速度,可以从关于【基于 hmap 优化 Search Path 的策略】的章节开始阅读,这会给你提供一种新的编译加速思路。
  • 如果你想了解如何通过 VFS 技术进行 Swift 产物的构建,可以从 【关于第二个问题】开始阅读,这会让你理解如何用另外一种提升构建 Swift 产物的效率。
  • 如果你想了解 Swift 和 Objective-C 是如何找寻方法声明的,可以从 【Swift 来了】的章节阅读,这会让你从原理上理解混编的核心思路和解决方案。

概述

随着 Swift 的发展,国内的技术社区出现了一些关于如何实现 Swift 与 Objective-C 混编的文章,这些文章的主要内容还是围绕着指导开发者进行各种操作来实现混编的效果,例如在 Build Setting 中开启某个选项,在 podspec 中增加某个字段,鲜有文章对这些操作背后的工作机制做剖析,大部分核心概念也都是一笔带过。

正是因为这种现状,很多开发者在面对与预期不符的行为时,又或者各种奇怪报错时,会无从下手,而这也是由于对其工作原理不够了解所导致的。

笔者自身在美团平台负责 CI/CD 相关的工作,这其中也包含了 Objective-C 与 Swift 混编的内容,出于让更多开发者能够进一步理解混编工作机制的目的,笔者编写了这篇技术文章。

该文章从预编译的基础知识入手,由浅至深的介绍了 Objective-C 和 Swift 的工作机制,并通过这些机制来解释混编项目中使用到的技术和各种参数的作用,由此来指导开发者如何进行混编。

好了废话不多说,我们开始吧!

预编译知识指北

#import 的机制和缺点

在我们使用某些系统组件的时候,我们通常会写出如下形式的代码:

1
#import <UIKit/UIKit.h>

#import 其实是 #include 语法的微小创新,它们在本质上还是十分接近的。#include 做的事情其实就是简单的复制粘贴,将目标 .h 文件中的内容一字不落地拷贝到当前文件中,并替换掉这句 #include,而 #import 实质上做的事情和 #include 是一样的,只不过它还多了一个能够避免头文件重复引用的能力而已。

为了更好的理解后面的内容,我们这里需要展开说一下它到底是如何运行的?

从最直观的角度来看:

假设在 MyApp.m 文件中,我们 #importiAd.h 文件,编译器解析此文件后,开始寻找 iAd 包含的内容(ADInterstitialAd.hADBannerView.h),及这些内容包含的子内容(UIKit.hUIController.hUIView.hUIResponder.h),并依次递归下去,最后,你会发现 #import <iAd/iAd.h> 这段代码变成了对不同 SDK 的头文件依赖。

01.png

如果你觉得听起来有点费劲,或者似懂非懂,我们这里可以举一个更加详细的例子,不过请记住,对于 C 语言的预处理器而言, #import 就是一种特殊的复制粘贴。

结合前面提到的内容,在 AppDelegate 中添加 iAd.h

1
2
3
4
#import <iAd/iAd.h>
@implementation AppDelegate
//...
@end

然后编译器会开始查找 iAd/iAd.h 到底是哪个文件且包含何种内容,假设它的内容如下:

1
2
3
4
/* iAd/iAd.h */
#import <iAd/ADBannerView.h>
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

在找到上面的内容后,编译器将其复制粘贴到 AppDelegate 中:

1
2
3
4
5
6
7
#import <iAd/ADBannerView.h>
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

@implementation AppDelegate
//...
@end

现在,编译器发现文件里有 3 个 #import 语句 了,那么就需要继续寻找这些文件及其相应的内容,假设 ADBannerView.h 的内容如下:

1
2
3
4
5
6
7
8
/* iAd/ADBannerView.h */
@interface ADBannerView : UIView
@property (nonatomic, readonly) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end

那么编译器会继续将其内容复制粘贴到 AppDelegate 中,最终变成如下的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface ADBannerView : UIView
@property (nonatomic, readonly) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

@implementation AppDelegate
//...
@end

这样的操作会一直持续到整个文件中所有 #import 指向的内容被替换掉,这也意味着 .m 文件最终将变得极其的冗长。

虽然这种机制看起来是可行的,但它有两个比较明显的问题:健壮性和拓展性。

健壮性

首先这种编译模型会导致代码的健壮性变差!

这里我们继续采用之前的例子,在 AppDelegate 中定义 readonly0x01,而且这个定义的声明在 #import 语句之前,那么此时又会发生什么事情呢?

编译器同样会进行刚才的那些复制粘贴操作,但可怕的是,你会发现那些在属性声明中的 readonly 也变成了 0x01,而这会触发编译器报错!

1
2
3
4
5
6
7
8
9
10
11
@interface ADBannerView : UIView
@property (nonatomic, 0x01) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end

@implementation AppDelegate
//...
@end

面对这种错误,你可能会说它是开发者自己的问题。

确实,通常我们都会在声明宏的时候带上固定的前缀来进行区分。但生活里总是有一些意外,不是么?

假设某个人没有遵守这种规则,那么在不同的引入顺序下,你可能会得到不同的结果,对于这种错误的排查,还是挺闹心的,不过这还不是最闹心的,因为还有动态宏的存在,心塞 ing。

所以这种靠遵守约定来规避问题的解决方案,并不能从根本上解决问题,这也从侧面反应了编译模型的健壮性是相对较差的。

拓展性

说完了健壮性的问题,我们来看看拓展性的问题。

Apple 公司对它们的 Mail App 做过一个分析,下图是 Mail 这个项目里所有 .m 文件的排序,横轴是文件编号排序,纵轴是文件大小。

IMAGE

可以看到这些由业务代码构成的文件大小的分布区间很广泛,最小可能有几 kb,最大的能有 200+ kb,但总的来说,可能 90% 的代码都在 50kb 这个数量级之下,甚至更少。

如果我们往该项目的某个核心文件(核心文件是指其他文件可能都需要依赖的文件)里添加了一个对 iAd.h 文件的引用,对其他文件意味着什么呢?

这里的核心文件是指其他文件可能都需要依赖的文件

这意味着其他文件也会把 iAd.h 里包含的东西纳入进来,当然,好消息是,iAd 这个 SDK 自身只有 25KB 左右的大小。

IMAGE

但你得知道 iAd 还会依赖 UIKit 这样的组件,这可是个 400KB+ 的大家伙

IMAGE

所以,怎么说呢?

在 Mail App 里的所有代码都需要先涵盖这将近 425KB 的头文件内容,即使你的代码只有一行 hello world

如果你认为这已经让人很沮丧的话,那还有更打击你的消息,因为 UIKit 相比于 macOS 上的 Cocoa 系列大礼包,真的小太多了,Cocoa 系列大礼包可是 UIKit 的 29 倍……

所以如果将这个数据放到上面的图表中,你会发现真正的业务代码在 file size 轴上的比重真的太微不足道了。

所以这就是拓展性差带来的问题之一!

很明显,我们不可能用这样的方式引入代码,假设你有 M 个源文件且每个文件会引入 N 个头文件,按照刚才的解释,编译它们的时间就会是 M * N,这是非常可怕的!

备注:文章里提到的 iAd 组件为 25KB,UIKit 组件约为 400KB, macOS 的 Cocoa 组件是 UIKit 的 29 倍等数据,是 WWDC 2013 Session 404 Advances in Objective-C 里公布的数据,随着功能的不断迭代,以现在的眼光来看,这些数据可能已经偏小,在 WWDC 2018 Session 415 Behind the Scenes of the Xcode Build Process 中提到了 Foundation 组件,它包含的头文件数量大于 800 个,大小已经超过 9MB。

PCH(PreCompiled Header)是一把双刃剑

为了优化前面提到的问题,一种折中的技术方案诞生了,它就是 PreCompiled Header。

我们经常可以看到某些组件的头文件会频繁的出现,例如 UIKit,而这很容易让人联想到一个优化点,我们是不是可以通过某种手段,避免重复编译相同的内容呢?

而这就是 PCH 为预编译流程带来的改进点!

它的大体原理就是,在我们编译任意 .m 文件前, 编译器会先对 PCH 里的内容进行预编译,将其变为一种二进制的中间格式缓存起来,便于后续的使用。当开始编译 .m 文件时,如果需要 PCH 里已经编译过的内容,直接读取即可,无须再次编译。

虽然这种技术有一定的优势,但实际应用起来,还存在不少的问题。

首先,它的维护是有一定的成本的,对于大部分历史包袱沉重的组件来说,将项目中的引用关系梳理清楚就十分麻烦,而要在此基础上梳理出合理的 PCH 内容就更加麻烦,同时随着版本的不断迭代,哪些头文件需要移出 PCH,哪些头文件需要移进 PCH 将会变得越来越麻烦。

其次,PCH 会引发命名空间被污染的问题,因为 PCH 引入的头文件会出现在你代码中的每一处,而这可能会是多于的操作,比如 iAd 应当出现在一些与广告相关的代码中,它完全没必要出现在帮助相关的代码中(也就是与广告无关的逻辑),可是当你把它放到 PCH 中,就意味组件里的所有地方都会引入 iAd 的代码,包括帮助页面,这可能并不是我们想要的结果!

如果你想更深入的了解 PCH 的黑暗面,建议阅读 4 Ways Precompiled Headers Cripple Your Code ,里面已经说得相当全面和透彻。

所以 PCH 并不是一个完美的解决方案,它能在某些场景下提升编译速度,但也有缺陷!

Clang Module 的来临

为了解决前面提到的问题,Clang 提出了 module 的概念,关于它的介绍可以在 Clang 官网 上找到。

简单来说,你可以把它理解为一种对组件的描述,包含了对接口(API)和实现(dylib/a)的描述,同时 module 的产物是被独立编译出来的,不同的 module 之间是不会影响的。

在实际编译之时,编译器会创建一个全新的空间,用它来存放已经编译过的 module 产物。如果在编译的文件中引用到某个 module 的话,系统将优先在这个列表内查找是否存在对应的中间产物,如果能找到,则说明该文件已经被编译过,则直接使用该中间产物,如果没找到,则把引用到的头文件进行编译,并将产物添加到相应的空间中以备重复使用。

在这种编译模型下,被引用到的 module 只会被编译一次,且在运行过程中不会相互影响,这从根本上解决了健壮性和拓展性的问题。

module 的使用并不麻烦,同样是引用 iAd 这个组件,你只需要这样写即可。

1
@import iAd;

在使用层面上,这将等价于以前的 #import <iAd/iAd.h> 语句,但是会使用 clang module 的特性加载整个 iAd 组件。如果只想引入特定文件(比如 ADBannerView.h),原先的写法是 #import <iAd/ADBannerView.h.h>,现在可以写成:

1
@import iAd.ADBannerView;

通过这种写法会将 iAd 这个组件的 API 导入到我们的应用中,同时这种写法也更符合语义化(semanitc import)。

虽然这种引入方式和之前的写法区别不大,但它们在本质上还是有很大程度的不同,Module 不会“复制粘贴”头文件里的内容,也不会让 @import 所暴露的 API 被开发者本地的上下文篡改,例如前面提到的 #define readonly 0x01

此时,如果你觉得前面关于 clang module 的描述还是太抽象,我们可以再进一步去探究它工作原理, 而这就会引入一个新的概念 – modulemap。

不论怎样,module 只是一个对组件的抽象描述罢了,而 modulemap 则是这个描述的具体呈现,它对框架内的所有文件进行了结构化的描述,下面是 UIKit 的 modulemap 文件

1
2
3
4
5
framework module UIKit {
umbrella header "UIKit.h"
module * {export *}
link framework "UIKit"
}

这个 module 定义了组件的 umbrella header 文件(UIKit.h),需要导出的子 module(所有),以及需要 link 的框架名称(UIKit),正是通过这个文件,让编译器了解到 Module 的逻辑结构与头文件结构的关联方式!

可能又有人会好奇,为什么我从来没看到过 @import 的写法呢?

这是因为 Xcode 的编译器能够将符合某种格式的 #import 语句自动转换成 module 识别的 @import 语句,从而避免了开发者的手动修改。

画板.png

唯一需要开发者完成的就是开启相关的编译选项。

IMAGE

对于上面的编译选项,需要开发者注意的是:

Apple Clang - Language - ModulesEnable Module 选项是指引用系统库的的时候,是否采用 module 的形式。

Packaing 里的 Defines Module 是指开发者编写的组件是否采用 module 的形式。

说了这么多,我想你应该对 #importpch@import 有了一定的概念。当然,如果我们深究下去,可能还会有如下的疑问:

  • 对于未开启 clang module 特性的组件,clang 是通过怎样的机制查找到头文件的呢?在查找系统头文件和非系统头文件的过程中,有什么区别么?
  • 对于已开启 clang module 特性的组件,clang 是如何决定编译当下组件的 module 呢?另外构建的细节又是怎样的,以及如何查找这些 module 的?还有查找系统的 module 和非系统的 module 有什么区别么?

为了解答这些问题,我们不妨先动手实践一下,看看上面的理论知识在现实中的样子。

原来它是这样的

在前面的章节中,我们将重点放在了原理上的介绍,而在在这个章节中,我们将动手看看这些预编译环节的实际样子。

#import 的样子

假设我们的源码样式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#import "SQViewController.h"
#import <SQPod/ClassA.h>

@interface SQViewController ()
@end

@implementation SQViewController
- (void)viewDidLoad {
[super viewDidLoad];
ClassA *a = [ClassA new];
NSLog(@"%@", a);
}

- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
}
@end

想要查看代码预编译后的样子,我们可以在 Navigate to Related Items 按钮中找到 Preprocess 选项

IMAGE

既然知道了如何查看预编译后的样子,我们不妨看看代码在使用 #import, PCH 和 @import 后,到底会变成什么样子?

这里我们假设被引入的头文件,即 ClassA 中的内如如下:

1
2
3
4
@interface ClassA : NSObject
@property (nonatomic, strong) NSString *name;
- (void)sayHello;
@end

通过 preprocess 可以看到代码大致如下,这里为了方便展示,将无用代码进行了删除。这里记得要将 Build Setting 中 Packaging 的 Define Module 设置为 NO,因为其默认值为 YES,而这会导致我们开启 clang module 特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@import UIKit;
@interface SQViewController : UIViewController
@end

@interface ClassA : NSObject
@property (nonatomic, strong) NSString *name;
- (void)sayHello;
@end

@interface SQViewController ()
@end

@implementation SQViewController
- (void)viewDidLoad {
[super viewDidLoad];
ClassA *a = [ClassA new];
NSLog(@"%@", a);
}

- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
}
@end

这么一看,#import 的作用还就真的是个 copy & write。

pch 的真容

对于 CocoaPods 默认创建的组件,一般都会关闭 PCH 的相关功能,例如笔者创建的 SQPod 组件,它的 Precompile Prefix Header 功能默认值为 NO。

IMAGE

为了查看预编译的效果,我们将 Precompile Prefix Header 的值改为 YES,并编译整个项目,通过查看 build log,我们可以发现相比于 NO 的状态,在编译的过程中,增加了一个步骤,即 Precompile SQPod-Prefix.pch 的步骤。

画板.png

通过查看这个命令的 -o 参数,我们可以知道其产物是名为 SQPod-Prefix.pch.gch 的文件

IMAGE

这个文件就是 PCH 预编译后的产物,同时在编译真正的代码时,会通过 -include 参数将其引入

画板.png

又见 clang module

在开启 Define Module 后,系统会为我们自动创建相应的 modulemap 文件,这一点可以在 Build Log 中查找到

IMAGE

它的内容如下:

1
2
3
4
5
6
framework module SQPod {
umbrella header "SQPod-umbrella.h"

export *
module * { export * }
}

当然,如果系统自动生成的 modulemap 并不能满足你的诉求,我们也可以使用自己创建的文件,此时只需要在 Build Setting 的 Module Map File 选项中填写好文件路径,相应的 clang 命令参数是 -fmodule-map-file

画板.png

最后让我们看看 module 编译后的产物形态。

这里我们构建一个名为 SQPod 的 module ,将它提供给名为 Example 的工程使用,通过查看 -fmodule-cache-path 的参数,我们可以找到 module 的缓存路径

画板.png

进入对应的路径后,我们可以看到如下的文件

IMAGE

其中后缀名为 pcm 的文件就是构建出来的二进制中间产物。

现在,我们不仅知道了预编译的基础理论知识,也动手查看了预编译环节在真实环境下的产物,现在我们要开始解答之前提到的两个问题了!

打破砂锅问到底

关于第一个问题

对于未开启 clang module 特性的组件,clang 是通过怎样的机制查找到头文件的呢?在查找系统头文件和非系统头文件的过程中,有什么区别么?

在早期的 clang 编译过程中,头文件的查找机制还是基于 header search path 的,这也是大多数人所熟知的工作机制,所以我们不做赘述,只做一个简单的回顾。

header seach path 是构建系统提供给编译器的一个重要参数,它的作用是在编译代码的时候,为编译器提供了查找相应头文件路径的信息,通过查阅 Xcode 的 Build System 信息,我们可以知道相关的设置有三处 header search path,system header search path,user header search path。

IMAGE

它们的区别也很简单,system header search path 是针对系统头文件的设置,通常代指 <> 方式引入的文件,user header search path 则是针对非系统头文件的设置,通常代指 "" 方式引入的文件,而 header search path 并不会有任何限制,它普适于任何方式的头文件引用。

听起来好像很复杂,但关于引入的方式,无非是以下四种形式:

1
2
3
4
#import <A/A.h>
#import "A/A.h"
#import <A.h>
#import "A.h"

我们可以两个维度去理解这个问题,一个是引入的符号形式,另一个是引入的内容形式

  • 引入的符号形式:通常来说,双引号的引入方式(“A.h” 或者 "A/A.h")是用于查找本地的头文件,需要指定相对路径,尖括号的引入方式(<A.h> 或者 <A/A.h>)是全局的引用,其路径由编译器提供,如引用系统的库,但随着 header search path 的加入,让这种区别已经被淡化了。

  • 引入的内容形式:对于 X/X.hX.h 这两种引入的内容形式,前者是说在对应的 search path 中,找到目录 A 并在 A 目录下查找 A.h,而后者是说在 search path 下查找 A.h 文件,而不一定局限在 A 目录中,至于是否递归的寻找则取决于对目录的选项是否开启了 recursive 模式

画板.png

在很多工程中,尤其是基于 CocoaPods 开发的项目,我们已经不会区分 system header search path 和 user header search path,而是一股脑的将所有头文件路径添加到 header search path 中,这就导致我们在引用某个头文件时,不会再局限于前面提到的约定,甚至在某些情况下,前面提到的四种方式都可以做到引入某个指定头文件。

header maps

随着项目的迭代和发展,原有的头文件索引机制还是受到了一些挑战,为此,Clang 官方也提出了自己的解决方案。

为了理解这个东西,我们首先要在 build setting 中开启 Use Header Map 选项。

IMAGE

然后在 build log 里获取相应组件里对应文件的编译命令,并在最后加上 -v 参数,来查看其运行的秘密:

1
clang <list of arguments> -c SQViewController.m -o SQViewcontroller.o -v

在 console 的输出内容中,我们会发现一段有意思的内容:

画板.png

通过上面的图,我们可以看到编译器将寻找头文件的顺序和对应路径展示出来了,而在这些路径中,我们看到了一些陌生的东西,即后缀名为 .hmap 的文件。

那 hmap 到底这是个什么东西呢?

当我们开启 Build Setting 中的 Use Header Map 选项后,会自动生成的一份头文件名和头文件路径的映射表,而这个映射表就是 hmap 文件,不过它是一种二进制格式的文件,也有人叫它为 header map,总之,它的核心功能就是让编译器能够找到相应头文件的位置。

为了更好的理解它,我们可以通过 milend 编写的小工具 hmap 来查其内容。

在执行相关命令(即 hmap print)后,我们可以发现这些 hmap 里保存的信息结构大致如下:

IMAGE

需要注意,映射表的键值并不是简单的文件名和绝对路径,它的内容会随着使用场景产生不同的变化,例如头文件引用是在 "..." 的形式,还是 <...> 的形式,又或是在 Build Phase 里 Header 的配置情况。

IMAGE

至此我想你应该明白了,一旦开启 Use Header Map 选项后,Xcode 会优先去 hmap 映射表里寻找头文件的路径,只有在找不到的情况下,才会去 header search path 中提供的路径遍历搜索。

当然这种技术也不是一个什么新鲜事儿,在 Facebook 的 buck 工具中也提供了类似的东西,只不过文件类型变成了 HeaderMap.java 的样子。

查找系统库的头文件

上面的过程让我们理解了在 header map 技术下,编译器是如何寻找相应的头文件的,那针对系统库的文件又是如何索引的呢?例如 #import <Foundation/Foundation.h>

回想一下上一节 console 的输出内容,它的形式大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
#include "..." search starts here:
XXX-generated-files.hmap (headermap)
XXX-project-headers.hmap (headermap)

#include <...> search starts here:
XXX-own-target-headers.hmap (headermap)
XXX-all-target-headers.hmap (headermap)
Header Search Path
DerivedSources
Build/Products/Debug (framework directory)
$(SDKROOT)/usr/include
$(SDKROOT)/System/Library/Frameworks(framework directory)

我们会发现,这些路径大部分是用于查找非系统库文件的,也就是开发者自己引入的头文件,而与系统库相关的路径只有以下两个:

1
2
3
#include <...> search starts here:
$(SDKROOT)/usr/include
$(SDKROOT)/System/Library/Frameworks.(framework directory)

当我们查找 Foundation/Foundation.h 这个文件的时候,我们会首先判断是否存在 Foundation 这个 framework。

1
$SDKROOT/System/Library/Frameworks/Foundation.framework

接着,我们会进入 framework 的 Headers 文件夹里寻找对应的头文件

1
$SDKROOT/System/Library/Frameworks/Foundation.framework/Headers/Foundation.h

如果没有找到对应的文件,索引过程会在此中断,并结束查找。

以上便是系统库的头文件搜索逻辑。

framework search path

到底为止,我们已经解释了如何依赖 header search path,hmap 等技术寻找头文件的工作机制,也介绍了寻找系统库(system framework)头文件的工作机制。

那这是全部头文件的搜索机制么?答案是否定的,其实我们还有一种头文件搜索机制,它是基于 Framework 这种文件结构进行的。

IMAGE

对于开发者自己的 Framework,可能会存在 “private” 头文件,例如在 podspec 里用 private_header_files 的描述文件,这些文件在构建的时候,会被放在 Framework 文件结构中的 PrivateHeaders 目录。

所以针对有 PrivateHeaders 目录的 Framework 而言,clang 在检查 Headers 目录后,会去 PrivateHeaders 目录中寻找是否存在匹配的头文件,如果这两个目录都没有,才会结束查找。

1
$SDKROOT/System/Library/Frameworks/Foundation.framework/PrivateHeaders/SecretClass.h

不过也正是因为这个工作机制,会产生一个特别有意思的问题,那就是当我们使用 Framework 的方式引入某个带有 “private” 头文件的组件时,我们总是可以以下面的方式引入这个头文件!

画板.png

怎么样,是不是很神奇,这个被描述为 “private” 的头文件怎么就不私有了?

究其原因,还是由于 clang 的工作机制,那为什么 clang 要设计出来这种看似很奇怪的工作机制呢?

揭开 Public,Private,Project 的真实面目

其实你也看到我在上一段的写作中,将所有 private 单词标上了双引号,其实就是在暗示,我们曲解了 private 的含义。

那么这个 “private” 到底是什么意思呢?

在 Apple 官方的 Xcode Help - What are build phases? 文档中,我们可以看到如下的一段解释:

Associates public, private, or project header files with the target. Public and private headers define API intended for use by other clients, and are copied into a product for installation. For example, public and private headers in a framework target are copied into Headers and PrivateHeaders subfolders within a product. Project headers define API used and built by a target, but not copied into a product. This phase can be used once per target.

总的来说,我们可以知道一点,就是 Build Phases - Headers 中提到 Public 和 Private 是指可以供外界使用的头文件,且分别放在最终产物的 Headers 和 PrivateHeaders 目录中,而 Project 中的头文件是不对外使用的,也不会放在最终的产物中。

如果你继续翻阅一些资料,例如 StackOverflow - Xcode: Copy Headers: Public vs. Private vs. Project?StackOverflow - Understanding Xcode’s Copy Headers phase,你会发现在早期 Xcode Help 的 Project Editor 章节里,有一段名为 Setting the Role of a Header File 的段落,里面详细记载了三个类型的区别。

Public: The interface is finalized and meant to be used by your product’s clients. A public header is included in the product as readable source code without restriction.
Private: The interface isn’t intended for your clients or it’s in early stages of development. A private header is included in the product, but it’s marked “private”. Thus the symbols are visible to all clients, but clients should understand that they’re not supposed to use them.
Project: The interface is for use only by implementation files in the current project. A project header is not included in the target, except in object code. The symbols are not visible to clients at all, only to you.

至此我们应该彻底了解了 Public,Private,Project 的区别,简而言之,Public 还是通常意义上的 Public,Private 则代表 In Progress 的含义,至于 Project 才是通常意义上的 Private 含义。

那么 CocoaPods 中 Podspec 的 Syntax 里还有 public_header_filesprivate_header_files 两个字段,它们的真实含义是否和 Xcode 里的概念冲突呢?

这里我们仔细阅读一下官方文档的解释,尤其是 private_header_files 字段。

IMAGE

我们可以看到,private_header_files 在这里的含义是说,它本身是相对于 public 而言的,这些头文件本义是不希望暴露给用户使用的,而且也不会产生相关文档,但是在构建的时候,会出现在最终产物中,只有既没有被 public 和 private 标注的头文件,才会被认为是真正的私有头文件,且不出现在最终的产物里。

其实这么看来,CocoaPods 对于 public 和 private 的理解是和 Xcode 中的描述一致的,两处的 Private 并非我们通常理解的 Private,它的本意更应该是开发者准备对外开放,但又没完全 ready 的头文件,更像一个 In Progress 的含义。

所以,如果你真的不想对外暴露某些头文件,请不要再使用 Headers 里的 Private 或者 podspec 里的 private_header_files 了。

至此,我想你应该彻底理解了 Search Path 的搜索机制和略显奇怪的 Public,Private,Project 设定了!

基于 hmap 优化 Search Path 的策略

在查找系统库的头文件的章节中,我们通过 -v 参数看到了寻找头文件的搜索顺序:

1
2
3
4
5
6
7
8
9
10
11
12
#include "..." search starts here:
XXX-generated-files.hmap (headermap)
XXX-project-headers.hmap (headermap)

#include <...> search starts here:
XXX-own-target-headers.hmap (headermap)
XXX-all-target-headers.hmap (headermap)
Header Search Path
DerivedSources
Build/Products/Debug (framework directory)
$(SDKROOT)/usr/include
$(SDKROOT)/System/Library/Frameworks(framework directory)

假设,我们没有开启 hmap 的话,所有的搜索都会依赖 header search path 或者 framework search path,那这就会出现 3 种问题:

  • 第一个问题,在一些巨型项目中,假设依赖的组件有 400+,那此时的索引路径就会达到 800+ 个(一份 public 路径,一份 private 路径),同时搜索操作可以看做是一种 IO 操作,而我们知道 IO 操作通常也是一种耗时操作,那么,这种大量的耗时操作必然会导致编译耗时增加。
  • 第二个问题,在打包的过程中,如果 header search path 过多过长,会触发命令行过长的错误,进而导致命令执行失败的情况。
  • 第三个问题,在引入系统库的头文件时,clang 会将前面提到的目录遍历完才进入搜索系统库的路径,也就是 $(SDKROOT)/System/Library/Frameworks(framework directory),即前面的 header search 路径越多,耗时也会越长,这是相当不划算的。

那如果我们开启 hmap 后,是否就能解决掉所有的问题呢?

实际上并不能,而且在基于 CocoaPods 管理项目的状况下,又会带来新的问题。下面是一个基于 CocoaPods 构建的全源码工程项目,它的整体结构如下:

首先,Host 和 Pod 是我们的两个 Project,Pods 下的 target 的产物类型为 static library。

其次,Host 底下会有一个同名的 Target,而 Pods 目录下会有 n+1 个 target,其中 n 取决于你依赖的组件数量,而 1 是一个名为 Pods-XXX 的 target,最后,Pods-XXX 这个 target 的产物会被 Host 里的 target 所依赖。

整个结构看起来如下所示。

画板.png

此时我们将 PodA 里的文件全部放在 Header 的 Project 类型中。

IMAGE

在基于 Framework 的搜索机制下,我们是无法以任何方式引入到 ClassB 的,因为它既不在 Headers 目录,也不在 PrivateHeader 目录中。

可是如果我们开启了 Use Header Map 后,由于 PodA 和 PodB 都在 Pods 这个 Project 下,满足了 Header 的 Project 定义,通过 Xcode 自动生成的 hmap 文件会带上这个路径,所以我们还可以在 PodB 中以 #import "ClassB.h" 的方式引入。

而这种行为,我想应该是大多数人并不想要的结果,所以一旦开启了 Use Header Map,再结合 CocoaPods 管理工程项目的模式,我们极有可能会产生一些误用私有头文件的情况,而这个问题的本质是 Xcode 和 CocoaPods 在工程和头文件上的理念冲突造成的。

除此之外,CocoaPods 在处理头文件的问题上还有一些让人迷惑的地方,它在创建头文件产物这块的逻辑大致如下:

  • 在构建产物为 Framework 的情况下:
    • 根据 podspec 里的 public_header_files 字段的内容,将相应头文件设置为 Public 类型,并放在 Headers 中
    • 根据 podspec 里的 private_header_files 字段的内容,将相应文件设置为 Private 类型,并放在 PrivateHeader 中
    • 将其余未描述的头文件设置为 Project 类型,且不放入最终的产物中
    • 如果 podspec 里未标注 public 和 private 的时候,会将所有文件设置为 public 类型,并放在 Header 中
  • 在构建产物为 Static Library 的情况下:
    • 不论 podspec 里如何设置 public_header_filesprivate_header_files,相应的头文件都会被设置为 Project 类型
    • Pods/Headers/Public 中会保存所有被声明为 public_header_files 的头文件
    • Pods/Headers/Private 中会保存所有头文件,不论是 public_header_files 或者 private_header_files 描述到,还是那些未被描述的,这个目录下是当前组件的所有头文件全集
    • 如果 podspec 里未标注 public 和 private 的时候,Pods/Headers/PublicPods/Headers/Private 的内容一样且会包含所有头文件。

正是由于这种机制,还导致了另外一种有意思的问题。

在 Static Library 的状况下,一旦我们开启了 Use Header Map,结合组件里所有头文件的类型为 Project 的情况,这个 hmap 里只会包含 #import "A.h" 的键值引用,也就是说只有 #import "A.h" 的方式才会命中 hmap 的策略,否则都将通过 header search path 寻找其相关路径。

而我们也知道,在引用其他组件的时候,通常都会采用 #import <A/A.h> 的方式引入。至于为什么会用这种方式,一方面是这种写法会明确头文件的由来,避免问题,另一方面也是这种方式可以让我们在是否开启 clang module 中随意切换,当然还有一点就是,Apple 在 WWDC 里曾经不止一次的建议开发者使用这种方式引入头文件。

接着上面的话题来说,所以说在 Static Library 的情况下且以 #import <A/A.h> 这种标准方式引入头文件时,开启 Use Header Map 并不会提升编译速度,而这同样是 Xcode 和 CocoaPods 在工程和头文件上的理念冲突造成的。

画板.png

这样来看的话,虽然 hmap 有种种优势,但是在 CocoaPods 的世界里显得格格不入,也无法发挥自身的优势。

那这就真的没有办法解决了么?

当然,问题是有办法解决的,我们完全可以自己动手做一个基于 CocoaPods 规则下的 hmap 文件。

举一个简单的例子,通过遍历 PODS 目录里的内容去构建索引表内容,借助 hmap 工具生成 header map 文件,然后将 Cocoapods 在 header search path 中生成的路径删除,只添加一条指向我们自己生成的 hmap 文件路径,最后关闭 Xcode 的 Ues Header Map 功能,也就是 Xcode 自动生成 hmap 的功能,如此这般,我们就实现了一个简单的,基于 CocoaPods 的 header map 功能。

同时在这个基础上,我们还可以借助这个功能实现不少管控手段,例如

  • 从根本上杜绝私有文件被暴露的可能性。
  • 统一头文件的引用形式

目前我们已经自研了一套基于上述原理的 cocoapods 插件,它的名字叫做 cocoapods-hmap-prebuilt,是由笔者与同事 @宋旭陶 共同开发的,

说了这么多,让我们看看它在实际工程中的使用效果!

经过全源码编译的测试,我们可以看到该技术在提速上的收益较为明显,以美团和点评 App 为例,全链路时长能够提升 45% 以上,其中 Xcode 打包时间能提升 50%。

关于第二个问题

对于已开启 clang module 特性的组件,clang 是如何决定编译当下组件的 module 呢?另外构建的细节又是怎样的,以及如何查找这些 module 的?还有查找系统的 module 和非系统的 module 有什么区别么?

首先,我们来明确一个问题, clang 是如何决定编译当下组件的 module 呢

#import <Foundation/NSString.h> 为例,当我们遇到这个头文件的时候:

首先会去 Framework 的 Headers 目录下寻找相应的头文件是否存在,然后就会到 Modules 目录下查找 modulemap 文件

画板.png

此时,Clang 会去查阅 modulemap 里的内容,看看 NSString 是否为 Foundation 这个 Module 里的一部分,

1
2
3
4
5
6
7
8
9
10
11
12
13
// Module Map - Foundation.framework/Modules/module.modulemap
framework module Foundation [extern_c] [system] {
umbrella header "Foundation.h"
export *
module * {
export *
}

explicit module NSDebug {
header "NSDebug.h"
export *
}
}

很显然,这里通过 umbrella header,我们是可以在 Foundation.h 中找到 NSString.h 的。

1
2
3
4
5
6
// Foundation.h

#import <Foundation/NSStream.h>
#import <Foundation/NSString.h>
#import <Foundation/NSTextCheckingResult.h>

至此,clang 会判定 NSString.h 是 Foundation 这个 module 的一部分并进行相应的编译工作,此时也就意味着 #import <Foundation/NSString.h> 会从之前的 textual import 变为 module import

Module 的构建细节

上面的内容解决了是否构建 module,而这一块我们会详细阐述构建 module 的过程!

在构建开始前,clang 会创建一个完全独立的空间来构建 module,在这个空间里会包含 module 涉及的所有文件,除此之外不会带入其他任何文件的信息,而这也是 module 健壮性好的关键因素之一。

不过,这并不意味着我们无法影响到 module 的唯一性,真正能影响到其唯一性的是其构建的参数,也就是 clang 命令后面的内容,关于这一点后面还会继续展开,这里我们先点到为止。

当我们在构建 Foundation 的时候,我们会发现 Foundation 自身要依赖一些组件,这意味着我们也需要构建被依赖组件的 module

画板.png

但很明显的是,我们会发现这些被依赖组件也有自己的依赖关系,在它们的这些依赖关系中,极有可能会存在重复的引用。

画板.png

此时 module 的复用机制就体现出来优势了,我们可以复用先前构建出来的 module,而不必一次次的创建或者引用,例如 Drawin 组件,而保存这些缓存文件的位置就是前面章节里提到的保存 pcm 类型文件的地方。

先前我们提到了 clang 命令的参数会真正影响到 module 的唯一性,那具体的原理又是怎样的?

clang 会将相应的编译参数进行一次 hash,将获得的 hash 值作为 module 缓存文件夹的名称,这里需要注意的是,不同的参数和值会导致文件夹不同,所以想要尽可能的利用 module 缓存,就必须保证参数不发生变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ clang -fmodules —DENABLE_FEATURE=1 …
## 生成的目录如下
98XN8P5QH5OQ/
CoreFoundation-2A5I5R2968COJ.pcm
Security-1A229VWPAK67R.pcm
Foundation-1RDF848B47PF4.pcm

$ clang -fmodules —DENABLE_FEATURE=2 …
## 生成的目录如下
1GYDULU5XJRF/
CoreFoundation-2A5I5R2968COJ.pcm
Security-1A229VWPAK67R.pcm
Foundation-1RDF848B47PF4.pcm

这里我们大概了解了系统组件的 module 构建机制,这也是开启 Enable Modules(C and Objective-C) 的核心工作原理。

神秘的 Virtual File System(VFS)

对于系统组件,我们可以在 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.2.sdk/System/Library/Frameworks 目录里找到它的身影,它的目录结构大概是这样的

IMAGE

也就是说,对于系统组件而言,构建 module 的整个过程是建立在这样一个完备的文件结构上,即在 Framework 的 Modules 目录中查找 modulemap,在 Headers 目录中加载头文件。

那对于用户自己创建的组件,clang 又是如何构建 module 的呢?

通常我们的开发目录大概是下面的样子,它并没有 modules 目录,也没有 headers 目录,更没有 modulemap 文件,看起来和 framework 的文件结构也有着极大的区别。

IMAGE

在这种情况下,clang 是没法按照前面所说的机制去构建 module 的,因为在这种文件结构中,压根就没有 Modules 和 Headers 目录。

为了解决这个问题,clang 又提出了一个新的解决方案,叫做 Virtual File System(VFS)。

简单来说,通过这个技术,clang 可以在现有的文件结构上虚拟出来一个 Framework 文件结构,进而让 clang 遵守前面提到的构建准则,顺利完成 module 的编译,同时 VFS 也会记录文件的真实位置,以便在出现问题的时候,将文件的真实信息暴露给用户。

为了进一步了解 VFS,我们还是从 Build Log 中查找一些细节!

画板.png

在上面的编译参数里,我们可以找到一个 -ivfsoverlay 的参数,查看 help 说明,可以知道其作用就是向编译器传递一个 VFS 描述文件并覆盖掉真实的文件结构信息。

1
-ivfsoverlay <value>    Overlay the virtual filesystem described by file over the real file system

顺着这个线索,我们去看看这个参数指向的文件,它是一个 yaml 格式的文件,在将内容进行了一些裁剪后,它的核心内容如下,:

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
{
"case-sensitive": "false",
"version": 0,
"roots": [
{
"name": "XXX/Debug-iphonesimulator/PodA/PodA.framework/Headers",
"type": "directory",
"contents": [
{ "name": "ClassA.h", "type": "file",
"external-contents": "XXX/PodA/PodA/Classes/ClassA.h"
},
......
{ "name": "PodA-umbrella.h", "type": "file",
"external-contents": "XXX/Target Support Files/PodA/PodA-umbrella.h"
}
]
},
{
"contents": [
"name": "XXX/Products/Debug-iphonesimulator/PodA/PodA.framework/Modules",
"type": "directory"
{ "name": "module.modulemap", "type": "file",
"external-contents": "XXX/Debug-iphonesimulator/PodA.build/module.modulemap"
}
]
}
]
}

结合前面提到的内容,我们不难看出它在描述这样一个文件结构:

借用一个真实存在的文件夹来模拟 framework 里的 Headers 文件夹,在这个 Headers 文件夹里有名为 PodA-umbrella.hClassA.h 等的文件,不过这几个虚拟文件与 external-contents 指向的真实文件相关联,同理还有 Modules 文件夹和它里面的 module.modulemap 文件。

通过这样的形式,一个虚拟的 framework 目录结构诞生了!此时 clang 终于能按照前面的构建机制为用户创建 module 了!

Swift 来了

没有头文件的 Swift

前面的章节我们聊了很多 C 语言系的预编译知识,在这个体系下,文件的编译是分开的,当我们想引用其他文件里的内容时,就必须引入相应的头文件。

画板.png

而对于 Swift 这门语言来说,它并没有头文件的概念,对于开发者而言,这确实省去了写头文件的重复工作,但这也意味着,编译器会进行额外的操作来查找接口定义并需要持续关注接口的变化!

为了更好的解释 Swift 和 Objective-C 是如何寻找到彼此的方法声明的,我们这里引入一个例子,在这个例子由三个部分组成:

  • 第一部分是一个 ViewController 的代码,它里面包含了一个 view,其中 PetViewController 和 PetView 都是 Swift 代码。
  • 第二部分是一个 App 的代理,它是 Objective-C 代码。
  • 第三个部分是一段单测代码,用来测试第一个部分中的 ViewController,它是 Swift 代码。
1
2
3
4
5
import UIKit
class PetViewController: UIViewController {
var view = PetView(name: "Fido", frame: frame)

}
1
2
3
4
#import "PetWall-Swift.h"
@implementation AppDelegate

@end
1
2
3
@testable import PetWall
class TestPetViewController: XCTestCase {
}

它们的关系大致如下所示:

画板.png

为了能让这些代码编译成功,编译器会面对如下 4 个场景:

首先是寻找声明,这包括寻找当前 target 内的方法声明(PetView),也包括来自 Objective-C 组件里的声明(UIViewController 或者 PetKit)。

然后是生成接口,这包括被 Objective—C 使用的接口,也包括被其他 target (Unit Test)使用的 Swift 接口。

第一步 - 如何寻找 Target 内部的 Swift 方法声明

在编译 PetViewController.swift 时,编译器需要知道 PetView 的初始化构造器的类型,才能检查调用是否正确。

此时编译器会加载 PetView.swift 文件并解析其中的内容, 这么做的目的就是确保初始化构造器真的存在,并拿到相关的类型信息,以便 PetViewController.swift 进行验证。

画板.png

编译器并不会对初始化构造器的内部做检查,但它仍然会进行一些额外的操作,这是什么意思呢?

与 clang 编译器不同的是,swiftc 编译的时候,会将相同 target 里的其他 swift 文件进行一次解析,用来检查其中与被编译文件关联的接口部分是否符合预期。

同时我们也知道,每个文件的编译是独立的,且不同文件的编译是可以并行开展的,所以这就意味着每编译一个文件,就需要将当前 target 里的其余文件当做接口,重新编译一次。 等于任意一个文件,在整个编译过程中,只有 1 次被作为生产 .o 产物的输入,其余时间会被作为接口文件反复解析。

画板.png

不过在 Xcode 10 以后,Apple 对这种编译流程进行了优化!

在尽可能保证并行的同时,将文件进行了分组编译,这样就避免了 group 内的文件重复解析,只有不同 group 之间的文件会有重复解析文件的情况。

画板.png

而这个分组操作的逻辑,就是刚才提到的一些额外操作。

至此,我们应该了解了 Target 内部是如何寻找 Swift 方法声明的了。

第二步 - 如何找到 Objective-C 组件里的方法声明

回到第一段代码中,我们可以看到 PetViewController 是继承自 UIViewController,而这也意味着我们的代码会与 Objective-C 代码进行交互,因为大部分系统库,例如 UIKit 等,还是使用 Objective-C 编写的。

在这个问题上,Swift 采用了和其他语言不一样的方案!

通常来说,两种不同的语言在混编时需要提供一个接口映射表,例如 JavaScript 和 TypeScript 混编时候的 .d.ts 文件,这样 TypeScript 就能够知道 JavaScript 方法在 TS 世界中的样子。

然而,Swift 不需要提供这样的接口映射表, 免去了开发者为每个 Objective-C API 声明其在 Swift 世界里样子,那它是怎么做到的呢?

很简单,Swift 编译器将 clang 的大部分功能包含在其自身的代码中,这就使得我们能够以 module 的形式,直接引用 Objective-C 的代码。

画板.png

既然是通过 module 的形式引入 Objective-C,那么 framework 的文件结构则是最好的选择,此时编译器寻找方法声明的方式就会有下面三种场景:

  • 对于大部分的 target 而言,当导入的是一个 Objective-C 类型的 framework 时,编译器会通过 modulemap 里的 header 信息寻找方法声明

  • 对于一个既有 Objective-C,又有 Swift 代码的 framework 而言,编译器会从当前 framework 的 umbrella header 中寻找方法声明,从而解决自身的编译问题,这是因为通常情况下 modulemap 会将 umbrella header 作为自身的 header 值。

  • 对于 App 或者 Unit Test 类型的 target,开发者可以通过为 target 创建 briding header 来导入需要的 Objective-C 头文件,进而找到需要的方法声明。

不过我们应该知道 Swift 编译器在获取 Objective-C 代码过程中,并不是原原本本的将 Objective—C 的 API 暴露给 Swift,而是会做一些 “Swift 化” 的改动,例如下面的 Objective-C API 就会被转换成更简约的形式。

画板.png

这个转换过程并不是什么高深的技术,它只是在编译器上的硬编码,如果感兴趣,可以在 Swift 的开源库中的找到相应的代码 - PartsOfSpeech.def

当然,编译器也给与了开发者自行定义 “API 外貌” 的权利,如果你对这一块感兴趣,不妨阅读我的另一篇文章 - WWDC20 10680 - Refine Objective-C frameworks for Swift,那里面包含了很多重塑 Objective-C API 的技巧。

不过这里还是要提一句,如果你对生成的接口有困惑,可以通过下面的方式查看编译器为 Objective-C 生成的 Swift 接口。

IMAGE

第三步 - Target 内的 Swift 代码是如何为 Objective-C 提供接口的

前面讲了 Swift 代码是如何引用 Objective-C 的 API,那么 Objective-C 又是如何引用 Swift 的 API 呢?

从使用层面来说,我们都知道 Swift 编译器会帮我们自动生成一个头文件,以便 Objective-C 引入相应的代码,就像第二段代码里引入的 PetWall-Swift.h 文件,这种头文件通常是编译器自动生成的,名字的构成是 组件名-Swift 的形式。

画板.png

但它到底是怎么产生的呢?

在 Swift 中,如果某个类继承了 NSObject 类且 API 被 @objc 关键字标注,就意味着它将暴露给 Objective-C 代码使用。

不过对于 App 和 Unit Test 类型的 target 而言,这个自动生成的 header 会包含访问级别为 public 和 internal 的 API,这使得同一 target 内的 Objective-C 代码也能访问 Swift 里 internal 类型的 API,这也是所有 Swift 代码的默认访问级别。

但对于 framework 类型的 target 而言,Swift 自动生成的头文件只会包含 public 类型的 API,因为这个头文件会被作为构建产物对外使用,所以像 internal 类型的 API 是不会包含在这个文件中。

注意,这种机制会导致在 framework 类型的 target 中,如果 Swift 想暴露一些 API 给内部的 Objective-C 代码使用,就意味着这些 API 也必须暴露给外界使用,即必须将其访问级别设置为 public 。

那么编译器自动生成的 API 到底是什么样子,有什么特点呢?

画板.png

上面是截取了一段自动生成的头文件代码,左侧是原始的 Swift 代码,右侧是自动生成的 Objective-C 代码,我们可以看到在 Objective-C 的类中,有一个名为 SWIFT_CLASS 的宏,将 Swift 与 Objective-C 中的两个类进行了关联。

如果你稍加注意,就会发现关联的一段乱码中还绑定了当前的组件名(PetWall),这样做的目的是避免两个组件的同名类在运行时发生冲突。

当然,你也可以通过向 @objc(Name) 关键字传递一个标识符,借由这个标识符来控制其在 Objective-C 中的名称,如果这样做的话,需要开发者确保转换后的类名不与其他类名出现冲突。

画板.png

这大体上就是 Swift 如何像 Objective-C 暴露接口的机理了,如果你想更深入的了解这个文件的由来,就需要看看第四步。

第四步 - Swift Target 如何生成供外部 Swift 使用的接口

Swift 采用了 Clang Module 的理念,并结合自身的语言特性进行了一系列的改进。

在 Swift 中,module 是方法声明的分发单位,如果你想引用相应的方法,就必须引入对应的 module,之前我们也提到了 swift 的编译器包含了 clang 的大部分内容,所以它也是兼容 clang module 的。

所以我们可以引入 Objective-C 的 module,例如 XCTest,也可以引入 Swift Target 生成的 module,例如 PetWall

1
2
3
4
5
6
7
8
import XCTest
@testable import PetWall
class TestPetViewController: XCTestCase {
func testInitialPet() {
let controller = PetViewController()
XCTAssertEqual(controller.view.name, "Fido")
}
}

在引入 swift 的 module 后,编译器会反序列化一个后缀名为 .swiftmodule 的文件,并通过这种文件里的内容来了解相关接口的信息。

例如,以下图为例,在这个单元测试中,编译器会加载 PetWall 的 module,并在其中找寻 PetViewController 的方法声明,由此确保其创建行为是符合预期的。

画板.png

这看起来很像第一步中 target 寻找内部 Swift 方法声明的样子,只不过这里将解析 swift 文件的步骤,换成了解析 swiftmodule 文件而已。

不过需要注意的是,这个 swfitmodule 文件并不是文本文件,它是一个二进制格式的内容,通常我们可以在构建产物的 Modules 文件夹里寻找到它的身影。

IMAGE

在 target 的编译的过程中,面向整个 target 的 swiftmodule 文件并不是一下产生的,每一个 swift 文件都会生成一个 swiftmodule 文件,编译器会将这些文件进行汇总,最后再生成一个完整的,代表整个 target 的 swiftmodule,也正是基于这个文件,编译器构造出了用于给外部使用的 Objective-C 头文件,也就是第三步里提到的头文件

画板.png

不过随着 Swift 的发展,这一部分的工作机制也发生了些许变化。

我们前面提到的 swiftmodule 文件是一种二进制格式的文件,而这个文件格式会包含一些编译器内部的数据结构,不同编译器产生的 swiftmodule 文件是互相不兼容的,这也就导致了不同 Xcode 构建出的产物是无法通用的,如果对这方面的细节感兴趣,可以阅读 Swift 社区里的两篇官方 Blog:Evolving Swift On Apple Platforms After ABI StabilityABI Stability and More,这里就不展开讨论了。

为了解决这一问题,Apple 在 Xcode 11 的 Build Setting 中提供了一个新的编译参数 Build Libraries for Distribution,正如这个编译参数的名称一样,当我们开启它后,构建出来的产物不会再受编译器版本的影响,那它是怎么做到这一点的呢?

为了解决这种对编译器的版本依赖,Xcode 在构建产物上提供了一个新的产物,swiftinterface 文件。

IMAGE

这个文件里的内容和 swiftmodule 很相似,都是当前 module 里的 API 信息,不过 swiftinterface 是以文本的方式记录,而非 swiftmodule 的二进制方式。

这就使得 swiftinterface 的行为和源代码一样,后续版本的 swift 编译器也能导入之前编译器创建的 swiftinterface 文件,像使用源码的方式一样使用它。

为了更进一步了解它,我们来看看 swiftinterface 的真实样子,下面是一个 .swift 文件和 .swiftinterface 文件的比对图。

画板.png

在 swiftinterface 文件中,有以下点需要注意

  • 文件会包含一些元信息,例如文件格式版本,编译器信息,和 Swift 编译器将其作为模块导入所需的命令行子集。
  • 文件只会包含 public 的接口,而不会包含 private 的接口,例如 currentLocation
  • 文件只会包含方法声明,而不会包含方法实现,例如 Spacesship 的 init,fly 等方法
  • 文件会包含所有隐式声明的方法,例如 Spacesship 的 deinit 方法 ,Speed 的 Hashable 协议

总的来说,swiftinterface 文件会在编译器的各个版本中保持稳定,主要原因就是这个接口文件会包含接口层面的一切信息,不需要编译器再做任何的推断或者假设。

好了,至此我们应该了解了 Swift Target 是如何生成供外部 Swift 使用的接口了。

这四步意味着什么?

此 module 非彼 module

通过上面的例子,我想大家应该能清楚的感受到 swift module 和 clang module 不完全是一个东西,虽然它们有很多相似的地方。

clang module 是面向 C 语言家族的一种技术,通过 modulemap 文件来组织 .h 文件中的接口信息,中间产物是二进制格式的 pcm 文件。

swift module 是面向 Swift 语言的一种技术,通过 swiftinterface 文件来组织 .swift 文件中的接口信息,中间产物二进制格式的 swiftmodule 文件。

画板.png

所以说理清楚这些概念和关系后,我们在构建 Swift 组件的产物时,就会知道哪些文件和参数不是必须的了。

例如当你的 Swift 组件不想暴露自身的 API 给外部的 Objective-C 代码使用的话,可以将 Build Setting 中 Swift Compiler - General 里的 Install Objective-C Compatiblity Header 参数设置为 NO,其编译参数为 SWIFT_INSTALL_OBJC_HEADER,此时不会生成 <ProductModuleName>-Swift.h 类型的文件,也就意味着外部组件无法以 Objective-C 的方式引用组件内 Swift 代码的 API。

IMAGE

而当你的组件里如果压根就没有 Objective-C 代码的时候,你可以将 Build Setting 中 Packaging 里 Defines Module 参数设置为 NO,其编译参数为 DEFINES_MODULE, 此时不会生成 <ProductModuleName>.modulemap 类型的文件。

IMAGE

Swift 和 Objective-C 混编的三个“套路”

基于刚才的例子,我们应该理解了 swift 在编译时是如何找到其他 API 的,以及它又是如何暴露自身 API 的,而这些知识就是解决混编过程中的基础知识,为了加深影响,我们可以将其绘制成 3 个流程图

当 Swift 和 Objective-C 文件同时在一个 App 或者 Unit Test 类型的 target 中,不同类型文件的 API 寻找机制如下

画板.png

当 Swift 和 Objective-C 文件在不同 target 中,例如不同 Framework 中,不同类型文件的 API 寻找机制如下

画板.png

当 Swift 和 Objective-C 文件同时在一个target 中,例如同一 Framework 中,不同类型文件的 API 寻找机制如下

画板.png

对于第三个流程图,需要做以下补充说明

  • 由于 swiftc,也就是 swift 的编译器,包含了大部分的 clang 功能,其中就包含了 clang module,借由组件内已有的 modulemap 文件,swift 编译器就可以轻松找到相应的 Objective-C 代码。
  • 相比于第二个流程而言,第三个流程中的 modulemap 是组件内部的,而第二个流程中,如果想引用其他组件里的 Objective-C 代码,需要引入其他组件里的 modulemap 文件才可以
  • 所以基于这个考虑,并未在流程 3 中标注 modulemap。

构建 Swift 产物的新思路

在前面的章节里,我们提到了 Swift 找寻 Objective-C 的方式,其中提到了,除了 App 或者 Unit Test 类型的 target 外,其余的情况下都是通过 framework 的 module map 来寻找 Objective-C 的 API,那么如果我们不想使用 framework 的形式呢?

目前来看,这个在 Xcode 中是无法直接实现的,原因很简单,Build Setting 中 Search Path 选项里并没有 modulemap 的 search path 配置参数。

IMAGE

为什么一定需要 modulemap 的 search path 呢?

基于前面了解到的内容,swiftc 包含了 clang 的大部分逻辑,在预编译方面,swiftc 只包含了 clang module 的模式,而没有其他模式,所以 Objective-C 想要暴露自己的 API 就必须通过 modulemap 来完成。

而对于 Framework 这种标准的文件夹结构,modulemap 文件的相对路径是固定的,它就在 Modules 目录中,所以 Xcode 基于这种标准结构,直接内置了相关的逻辑,而不需要将这些配置再暴露出来。

从组件的开发者角度来看,他只需要关心 modulemap 的内容是否符合预期,以及路径是否符合规范。

从组件的使用者角度来看,他只需要正确的引入相应的 Framework 就可以使用到相应的 API。

这种只需要配置 Framework 的方式,避免了配置 header search path,也避免了配置 static library path,可以说是一种很友好的方式,如果再将 modulemap 的配置开放出来,反而显得多此一举。

那如果我们抛开 Xcode,抛开 Framework 的限制,还有别的办法构建 Swift 产物么?

答案是肯定有的,这就需要借助前面所说的 VFS 技术!

假设我们的文件结构如下所示:

1
2
3
4
5
6
7
8
9
├── LaunchPoint.swift
├── README.md
├── build
├── repo
│ └── MyObjcPod
│ └── UsefulClass.h
└── tmp
├── module.modulemap
└── vfs-overlay.yaml

其中 LaunchPoint.swift 引用了 UsefulClass.h 中的一个公开 API,并产生了依赖关系。

另外,vfs-overlay.yaml 文件重新映射了现有的文件目录结构,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
'version': 0,
'roots': [
{ 'name': '/MyObjcPod', 'type': 'directory',
'contents': [
{ 'name': 'module.modulemap', 'type': 'file',
'external-contents': 'tmp/module.modulemap'
},
{ 'name': 'UsefulClass.h', 'type': 'file',
'external-contents': 'repo/MyObjcPod/UsefulClass.h'
}
]
}
]
}

至此,我们通过如下的命令,便可以获得 LaunchPoint 的 swiftmodule,swiftinterface 等文件,具体的示例可以查看我在 github 上的链接 - manually-expose-objective-c-API-to-swift-example

1
swiftc -c LaunchPoint.swift -emit-module -emit-module-path build/LaunchPoint.swiftmodule -module-name index -whole-module-optimization -parse-as-library -o build/LaunchPoint.o -Xcc -ivfsoverlay -Xcc tmp/vfs-overlay.yaml -I /MyObjcPod

那这意味着什么呢?

这就意味着,只提供相应的 .h 文件和 .modulemap 文件就可以完成 Swift 二进制产物的构建,而不再依赖 Framework 的实体。同时,对于 CI 系统来说,在构建产物时,可以避免下载无用的二进制产物(.a 文件),这从某种程度上会提升编译效率。

如果你没太理解上面的意思,我们可以展开说说。

例如,对于 PodA 组件而言,它自身依赖 PodB 组件,在使用原先的构建方式时,我们需要拉取 PodB 组件的完整 Framework 产物,这会包含 Headers 目录,Modules 目录里的必要内容,当然还会包含一个二进制文件(PodB),但在实际编译 PodA 组件的过程中,我们并不需要 B 组件里的二进制文件,而这让拉取完整的 Framework 文件显得多余了。

IMAGE

而借助 VFS 技术,我们就能避免拉取多余的二进制文件,进一步提升 CI 系统的编译效率。

总结

感谢你的耐心阅读,至此,整篇文章终于结束了,通过这篇文章,我想你应该:

  • 理解 Objective-C 的三种预编译的工作机制,其中 clang module 做到了真正意义上的语义引入,提升了编译的健壮性和扩展性。
  • 在 Xcode 的 search path 的各种技术细节使用到了 hmap 技术,通过加载映射表的方式避免了大量重复的 IO 操作,可以提升编译效率。
  • 在处理 Framework 的头文件索引时,总是会先搜索 Headers 目录,再搜索 PrivateHeader 目录
  • 理解 Xcode Phases 构建系统中,Public 代表公开头文件,Private 代表不需要使用者感知,但物理存在的文件, 而 Project 代表不应让使用者感知,且物理不存在的文件。
  • 不使用 Framework 的情况下且以 #import <A/A.h> 这种标准方式引入头文件时,在 CocoaPods 上使用 hmap 并不会提升编译速度。
  • 通过 cocoapods-hmap-built 插件,可以将大型项目的全链路时长节省 45% 以上,Xcode 打包环节的时长节省 50% 以上。
  • clang module 的构建机制确保了其不受上下文影响(独立编译空间),复用效率高(依赖决议),唯一性(参数哈希化)
  • 系统组件通过已有的 Framework 文件结构实现了构建 module 的基本条件 ,而非系统组件通过 VFS 虚拟出相似的 Framework 文件 结构,进而具备了编译的条件。
  • 可以粗浅的将 Clang Module 里的 .h/m.moduelmap.pch 的概念对应为 Swift Module 里的 .swift.swiftinterface.swiftmodule 的概念
  • 理解三种具有普适性的 Swift 与 Objective-C 混编方法
    • 同一 target 内(App 或者 Unit 类型),基于 <PorductModuleName>-Swift.h<PorductModuleName>-Bridging-Swift.h
    • 同一 target 内,基于 <PorductModuleName>-Swift.h 和 clang 自身的能力
    • 不同 target 内,基于 <PorductModuleName>-Swift.hmodule.modulemap
  • 利用 VFS 机制构建,可以在构建 Swift 产物的过程中避免下载无用的二进制产物,进一步提升编译效率

最后,在编写这篇文章的过程中,我的同事 @叶樉 和 @宋旭陶 给与了我许多指导与帮助,也正是在大家的共同努力下,才有了这篇文章,希望它能对亲爱的读者您,有所帮助!

参考文档

作者简介

思琦,笔名 SketchK,美团点评 iOS 工程师,目前负责移动端 CI/CD 方面的工作及平台内 Swift 技术相关的事宜。

cocoapods-hmap-prebuilt - 一款可以让大型 iOS 工程编译速度提升 50% 的工具

作者 SketchK
2022年8月30日 00:32

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

cocoapods-hmap-prebuilt 是什么?

cocoapods-hmap-prebuilt 是美团平台迭代组自研的一款 cocoapods 插件,以 Header Map 技术 为基础,进一步提升代码的编译速度,完善头文件的搜索机制。

虽然以二进制组件的方式构建 App 是 HPX (公司移动端统一持续集成/交付平台)的主流解决方案,但在某些场景下(Profile、Address/Thread/UB/Coverage Sanitizer、App 级别静态检查、ObjC 方法调用兼容性检查等等等等),我们的构建工作还是需要以全源码编译的方式进行;再结合实际开发过程中,大多是以源码的方式开发,所以我们将实验对象设置为基于全源码编译的流程。

废话不多说,我们来看看它的实际使用效果!

总的来说,以美团和大众点评的全源码编译流程为实验对象的前提下,cocoapods-hmap-prebuilt 插件能将总链路提升 45% 以上的速度,在 Xcode 打包环节上能提升 50% 以上的速度,是不是有点动心了?

为了更好的理解这个插件的价值和功能,我们不妨先理解一下当前的工程中存在的问题!

为什么现有的项目不够好?

目前公司内的 App 都是基于 CocoaPods 做包管理方面的工作,所以在实际的开发过程中,CocoaPods 会在 Pods/Header/ 目录下添加组件名目录和头文件软链,类似于下面的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/Users/sketchk/Desktop/MyApp/Pods
└── Headers
├── Private
│ └── AFNetworking
│ ├── AFHTTPRequestOperation.h -> ./XXX/AFHTTPRequestOperation.h
│ ├── AFHTTPRequestOperationManager.h -> ./XXX/AFHTTPRequestOperationManager.h
│ ├── ...
│ └── UIRefreshControl+AFNetworking.h -> ./XXX/UIRefreshControl+AFNetworking.h
└── Public
└── AFNetworking
   ├── AFHTTPRequestOperation.h -> ./XXX/AFHTTPRequestOperation.h
   ├── AFHTTPRequestOperationManager.h -> ./XXX/AFHTTPRequestOperationManager.h
   ├── ...
   └── UIRefreshControl+AFNetworking.h -> ./XXX/UIRefreshControl+AFNetworking.h

也正是通过这样的目录结构和软链,CocoaPods 得以在 Header Search Path 中添加如下的参数,使得预编译环节顺利进行。

1
2
3
4
5
$(inherited)
${PODS_ROOT}/Headers/Private
${PODS_ROOT}/Headers/Private/AFNetworking
${PODS_ROOT}/Headers/Public
${PODS_ROOT}/Headers/Public/AFNetworking

虽然这种构建 search path 的方式解决了预编译的问题,但在某些项目中,例如多达 400+ 组件的巨型项目中,会造成以下几点问题:

  1. 大量的 header search path 路径,会造成编译参数中的 -I 选项极速膨胀,在达到一定长度后,甚至会造成无法编译的情况
  2. 目前美团的工程中,已经有近 5W 个头文件,这意味着不论是头文件的搜索过程,还是软链的创建过程,都会引起大量的文件 IO 操作,进而会产生一些耗时操作。
  3. 编译时间会随着组件数量急剧增长,以美团和大众点评有 400+ 个组件的体量为参考,全源码打包耗时均为 1 小时以上。
  4. 基于路径顺序查找头文件的方式有潜在的风险,例如重名头文件的情况,排在后面的头文件永远无法参与编译
  5. 由于 ${PODS_ROOT}/Headers/Private 路径的存在,让引用其他组件的私有头文件变为了可能。

上面的问题,好一点的不过是浪费了 1 个小时而已,而不好的情况则是让有风险的代码上线了,你说开发者头疼不头疼?

Header Map 是个啥?

还好 cocoapods-hmap-prebuilt 的出现,让这些问题变成了历史,不过要想理解它为什么能解决这些问题,我们得先理解一下什么是 Header Map!

Header Map 其实是一组头文件信息映射表!

为了更直观的理解 Header Map,我们可以在 build setting 中开启 Use Header Map 选项,真实的体验一下它。

IMAGE

然后在 build log 里获取相应组件里对应文件的编译命令,并在最后加上 -v 参数,来查看其运行的秘密:

1
clang <list of arguments> -c some-file.m -o some-file.o -v

在 console 的输出内容中,我们会发现一段有意思的内容:

IMAGE

通过上面的图,我们可以看到编译器将寻找头文件的顺序和对应路径展示出来了,而在这些路径中,我们看到了一些陌生的东西,即后缀名为 .hmap 的文件,后面还有个括号写着 headermap。

没错!它就是 Header Map 的实体。

此时 clang 已经在刚才提到的 hmap 文件里塞入了一份头文件名和头文件路径的映射表,不过它是一种二进制格式的文件,为了验证这个的说法,我们可以通过 milend 编写的hmap 工具来查其内容。

在执行相关命令(即 hmap print)后,我们可以发现这些 hmap 里保存的信息结构大致如下, 类似于一个 key-value 的形式,key 值是头文件的名称,value 是头文件的实际物理路径:

IMAGE

需要注意,映射表的键值内容会随着使用场景产生不同的变化,例如头文件引用是在 "..." 的形式下,还是 <...> 的形式下,又或是在 Build Phase 里 Header 的配置情况。例如,你将头文件设置为 public 的时候,在某些 hmap 中,它的 key 值就为 PodA/ClassA,而将其设置为 project 的时候,它的 key 值可能就是 ClassA,而配置这些信息的地方,如下图所示:

IMAGE

至此我想你应该了解到 Header Map 到底是个什么东西了。

当然这种技术也不是一个什么新鲜事儿,在 Facebook 的 buck 工具中也提供了类似的东西,只不过文件类型变成了 HeaderMap.java 的样子。

此时,我估计你可能并不会对 buck 产生太多的兴趣,而是开始思考上一张图中 Headers 的 public,private,project 到底代表着什么意思,好像我从来没怎么关注过,以及为什么它会影响 hmap 里的内容?

Public,Private,Project 是个啥?

在 Apple 官方的 Xcode Help - What are build phases? 文档中,我们可以看到如下的一段解释:

Associates public, private, or project header files with the target. Public and private headers define API intended for use by other clients, and are copied into a product for installation. For example, public and private headers in a framework target are copied into Headers and PrivateHeaders subfolders within a product. Project headers define API used and built by a target, but not copied into a product. This phase can be used once per target.

总的来说,我们可以知道一点,就是 Build Phases - Headers 中提到 Public 和 Private 是指可以供外界使用的头文件,而 Project 中的头文件是不对外使用的,也不会放在最终的产物中。

如果你继续翻阅一些资料,例如 StackOverflow - Xcode: Copy Headers: Public vs. Private vs. Project?StackOverflow - Understanding Xcode’s Copy Headers phase,你会发现在早期 Xcode Help 的 Project Editor 章节里,有一段名为 Setting the Role of a Header File 的段落,里面详细记载了三个类型的区别。

Public: The interface is finalized and meant to be used by your product’s clients. A public header is included in the product as readable source code without restriction.
Private: The interface isn’t intended for your clients or it’s in early stages of development. A private header is included in the product, but it’s marked “private”. Thus the symbols are visible to all clients, but clients should understand that they’re not supposed to use them.
Project: The interface is for use only by implementation files in the current project. A project header is not included in the target, except in object code. The symbols are not visible to clients at all, only to you.

至此我们应该彻底了解了 Public,Private,Project 的区别,简而言之,Public 还是通常意义上的 Public,Private 则代表 In Progress 的含义,至于 Project 才是通常意义上的 Private 含义。

此时,你会不会联想到 CocoaPods 中 Podspec 的 Syntax 里还有 public_header_filesprivate_header_files 两个字段,它们的真实含义是否和 Xcode 里的概念冲突呢?

这里我们仔细阅读一下官方文档的解释,尤其是 private_header_files 字段。

画板.png

我们可以看到,private_header_files 在这里的含义是说,它本身是相对于 public 而言的,这些头文件本义是不希望暴露给用户使用的,而且也不会产生相关文档,但是在构建的时候,会出现在最终产物中,只有既没有被 public 和 private 标注的头文件,才会被认为是真正的私有头文件,且不出现在最终的产物里。

其实看起来,CocoaPods 对于 public 和 private 的官方解释是和 Xcode 中的描述一致的,两处的 Private 并非我们通常理解的 Private,它的本意更应该是开发者准备对外开放,但又没完全 Ready 的头文件,更像一个 In Progress 的含义。

这一块是不是有点让人大跌眼镜,那么,在现实世界中,我们是否正确的使用了它们呢?

为什么用原生的 hmap 不能改善编译速度?

前面我们介绍了 hmap 是什么,以及怎么开启它(启用 Build Setting 中的 Use Header Map 选项),也介绍了一些影响生成 hmap 的因素(Public,Private,Project)

那是不是我只要开启 Xcode 提供的 Use Header Map 就可以提升编译速度了呢?

很可惜,答案是不行的!

至于原因,我们就从下面的例子开始说起,假设我们有一个基于 CocoaPods 构建的全源码工程项目,它的整体结构如下:

  • 首先,Host 和 Pod 是我们的两个 Project,Pods 下的 target 的产物类型为 static library。
  • 其次,Host 底下会有一个同名的 Target,而 Pods 目录下会有 n+1 个 target,其中 n 取决于你依赖的组件数量,而 1 是一个名为 Pods-XXX 的 target,最后,Pods-XXX 这个 target 的产物会被 Host 里的 target 所依赖。

整个结构看起来如下所示。

IMAGE

当构建的产物类型为 Static Library 的时候,CocoaPods 在创建头文件产物过程中,它的逻辑大致如下:

  • 不论 podspec 里如何设置 public_header_filesprivate_header_files,相应的头文件都会被设置为 Project 类型
  • Pods/Headers/Public 中会保存所有被声明为 public_header_files 的头文件
  • Pods/Headers/Private 中会保存所有头文件,不论是 public_header_files 或者 private_header_files 描述到,还是那些未被描述的,这个目录下是当前组件的所有头文件全集
  • 如果 podspec 里未标注 public 和 private 的时候,Pods/Headers/PublicPods/Headers/Private 的内容一样且会包含所有头文件。

正是由于这种机制,会导致一些有意思的问题发生。

  • 首先,由于所有头文件都被当做最终产物保留下来,在结合 header search path 里 Pods/Headers/Private 路径的存在,我们完全可以引用到其他组件里的私有头文件,例如我只要使用 #import <SomePod/Private_Header.h> 的方式,就会命中私有文件的匹配路径。
  • 其次,就是在 Static Library 的状况下,一旦我们开启了 Use Header Map,结合组件里所有头文件的类型为 Project 的情况,这个 hmap 里只会包含 #import "ClassA.h" 的键值引用,也就是说只有 #import "ClassA.h" 的方式才会命中 hmap 的策略,否则都将通过 header search path 寻找其相关路径,例如下图中的 PodB,在其 build 的过程中,Xcode 会为 PodB 生成 5 个 hmap 文件,也就是说这 5 个文件只会在编译 PodB 中使用,其中 PodB 会依赖 PodA 的一些头文件,但由于 PodA 中的头文件都是 Project 类型的,所以其在 hmap 里的 key 全部为 ClassA.h ,也就是说我们只能以 #import "ClassA.h" 的方式引入。

IMAGE

而我们也知道,在引用其他组件的时候,通常都会采用 #import <A/A.h> 的方式引入。至于为什么会用这种方式,一方面是这种写法会明确头文件的由来,避免问题,另一方面也是这种方式可以让我们在是否开启 clang module 中随意切换,当然还有一点就是,Apple 在 WWDC 里曾经不止一次的建议开发者使用这种方式引入头文件。

接着上面的话题来说,所以说在 Static Library 的情况下且以 #import <A/A.h> 这种标准方式引入头文件时,开启 Use Header Map 选项并不会帮我们提升编译速度。

但真的就没有办法使用 Header Map 了么?

cocoapods-hmap-prebuilt 诞生了

当然,总是有办法解决的,我们完全可以自己动手做一个基于 CocoaPods 规则下的 hmap 文件,正是基于这个想法,美团自研的 cocoapods-hmap-prebuilt 插件诞生了!

它的核心功能并不多,大概有以下几点:

  • 借助 CocodPods 处理 Header Search Path 和创建头文件 soft link 的时机,构建了头文件索引表并以此生成 n+1 个 hmap 文件(n 是每个组件自己的 private header 信息,1 是所有组件公共的 public header 信息)
  • 重写 xcconfig 文件里的 header search path 到对应的 hmap 文件上,一条指向组件自己的 private hmap,一条指向所有组件共用的 public hmap。
  • 针对 public hmap 里的重名头文件进行了特殊处理,只允许保存组件名/头文件名方式的 key-value,排查重名头文件带来的异常行为。
  • 将组件自身的 Ues Header Map 功能关闭,减少不必要的文件创建和读取

听起来可能有点绕,内容也有点多,不过这些你都不用关心,你只需要通过以下 2 个步骤就能将其使用起来:

  1. 在 Gemfile 里声明插件
  2. 在 Podfile 里使用插件
1
2
3
4
5
6
7
8
9
10
11
12
13
// this is part of Gemfile
source 'http://sakgems.sankuai.com/' do
gem 'cocoapods-hmap-prebuilt'
gem 'XXX'
...
end

// this is part of Podfile
target 'XXX' do
plugin 'cocoapods-hmap-prebuilt'
pod 'XXX'
...
end

除此之外,为了拓展其实用性,我们还提供了头文件补丁(解决重名头文件的定向选取)和环境变量注入(无侵入的在其他系统中使用)的能力,便于其在不同场景下的使用。

总结

至此,关于 cocoapods-hmap-prebuilt 的介绍就要结束了。

回看整个故事的开始,Header Map 是我在研究 Swift 和 Objective-C 混编过程中发现的一个很小的知识点,而且 Xcode 自身就实现了一套基于 Header Map 的功能,在实际的使用过程中,它的表现并不理想。

但幸运的是,在后续的探索的过程中,我们发现了为什么 Xcode 的 Header Map 没有生效,以及为什么它与 CocoaPods 出现了不兼容的情况,虽然它的原理并不复杂,核心点就是将文件查找和读取等 IO 操作编变成了内存读取操作,但结合实际的业务场景,我们发现它的收益是十分可观的。

或许这是在提醒我们,要永远对技术保持一颗好奇的心!

最后,非常感谢 @宋旭陶 同学在工作之余,和我一起完成了 cocoapods-hmap-prebuilt 插件的开发工作,也非常感谢 @叶樉 同学,在我困惑的时候给出很多富有建设性的指导和意见。

其实利用 clang module 技术也可以解决本文一开始提到的几个问题,但它并不在这篇文章的讨论范围中,如果你对 clang module 或者对 Swift 与 Objective-C 混编感兴趣,欢迎阅读参考文档中的 《从预编译的角度理解 Swift 与 Objective-C 及混编机制》一文,以了解更多的详细信息。

参考文档

作者

思琦,笔名 SketchK,美团点评 iOS 工程师,目前负责移动端 CI/CD 方面的工作及平台内 Swift 技术相关的事宜。

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

作者 SketchK
2022年8月29日 23:49

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

背景

目前,开源社区和业界内已经存在一些 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

关于 .d 文件一些思考与理解

作者 SketchK
2021年7月8日 06:42

Reactive Cocoa 里的 .d 文件到底有什么用呢?

问题的由来

最近在开发过程中,遇到了一个自己还无法回答的问题,就是 Reactive Cocoa 里的两个 .d 文件到底有啥用,以及怎么用?

01.jpg

DTrace

DTrace 是一个动态追踪技术,说的可能更接地气一点,就是可以使用 DTrace 附加在一个已经运行的程序上,且不会打断当前程序的运行,也不需要重新编译或者启动此程序。

乍一听,感觉很不错,好像我们可以搞点事情了,但放到 macOS 和 iOS 的场景下就有了一些限制。

DTrace 只能在 macOS 上运行,Apple 也在 iOS 上使用 DTrace,用以支持像 Instruments 这样的工具,但对于第三方开发者,DTrace 只能运行于 macOS 或 iOS 模拟器。

基本概念

这篇文章本身的目的是为了解决文章开篇提到的问题,所以不会科普太多 DTrace 技术本身的基本概念。这里只是把下面用到的概念说明一下,方便读者理解。

在 DTrace 里有两个比较重要的概念,它们分别是probe(探针)和 dtrace file(DTrace 脚本)。

探针是指我们利用在代码里埋的点,插的桩,它有一套标准的定义,本文在这里不展开了,感兴趣可以阅读这个资料:DTrace Book

DTrace 脚本,是用 D 语言编写的脚本,既可以用 DTrace 脚本声明 probe,也可以触发 probe。

声明 probe 的例子:

1
2
3
4
// 声明 probe               
provider syncengine_sync {
probe strategy_go_to_state(int);
}

或者调用 probe 的例子:

1
2
3
4
5
// 调用 probe  
syncengine_sync*:::strategy_go_to_state
{
printf("Transitioning to state %d\n", arg0);
}

那么怎么启用 DTrace 呢?整体来说,有两种途径:

  • 使用 dtrace 脚本来触发,也就是 .d 文件
  • 使用 dtrace 命令来触发,也就是命令行里的 dtrace 命令

注意,如果想使用 dtrace 还需要关闭 System Integrity Protection,也就是常说的 Rootless,具体操作步骤是:

  • 重新启动你的macOS机器
  • 当屏幕变成空白时,按住 Command + R,直到出现苹果的启动标志。这将使你的电脑进入 Recovery Mode
  • 现在,从顶部找到 Utilities 菜单,然后选择 Terminal
  • 在终端窗口打开后,输入 csrutil disable && reboot
  • 只要 csrutil disable 命令成功,你的电脑就会在禁用 Rootless 后重新启动

DTrace 的使用场景

那么从使用者的角度来说,DTrace 只适用于两种场景:

  • 追踪系统内核代码:使用者只需要直接调用系统预埋的 probe 即可。
  • 追踪 App 侧的自定义代码:使用者一方面需要在 App 侧埋 probe,另一方面也需要去调用自己埋的 probe。

对于追踪系统内核的代码,其实有很多文章在说明,这里就不展开来说了,感兴趣可以看看网上的文章, 大多都是在将这种场景的使用方式.

今天的目标也是为了解释第二个场景,进而说明 Reactive Cocoa 里的 .d 文件的用途。

在自己的代码里使用 DTrace 技术

这里我们设计一个 CLI 工具,这个 CLI 工具不会停止,也不会做任何事儿,它的逻辑大概如下(其实就是一个无限循环):

1
2
3
4
5
6
7
8
#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
@autoreleasepool {
while (1) { }
}
return 0;
}

声明探针

此时我们在工程里创建一个 provider.d 文件声明一个自定义的 probe

1
2
3
provider zsq {
probe go();
};

此时的文件目录是如下

1
2
3
4
▶ tree
.
├── main.m
└── zsq.d

预埋探针

在 Xcode 里面的 build rule 会有这么一个自动化操作,如果判断出目标文件是 .d 文件,也就是 dtrace 文件,会生成对应的 .h 文件。

结合上面的例子,此时 Xcode 的 build system 就会生成一个 zsq.h 文件,在 build log 里我们可以查看到它。

02.jpg

此时我们可以看一下 zsq.h 里的内容,对我们比较有用的是两个基于探针行为定义的宏 ZSQ_GO_ENABLEDZSQ_GO,感兴趣可以展开下面的代码来查看。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/*
* Generated by dtrace(1M).
*/

#ifndef_ZSQ_H
#define_ZSQ_H

#if !defined(DTRACE_PROBES_DISABLED) || !DTRACE_PROBES_DISABLED
#include <unistd.h>

#endif /* !defined(DTRACE_PROBES_DISABLED) || !DTRACE_PROBES_DISABLED */

#ifdef__cplusplus
extern "C" {
#endif

#define ZSQ_STABILITY "___dtrace_stability$zsq$v1$1_1_0_1_1_0_1_1_0_1_1_0_1_1_0"

#define ZSQ_TYPEDEFS "___dtrace_typedefs$zsq$v2"

#if !defined(DTRACE_PROBES_DISABLED) || !DTRACE_PROBES_DISABLED

#defineZSQ_GO() \
do { \
__asm__ volatile(".reference " ZSQ_TYPEDEFS); \
__dtrace_probe$zsq$go$v1(); \
__asm__ volatile(".reference " ZSQ_STABILITY); \
} while (0)
#defineZSQ_GO_ENABLED() \
({ int _r = __dtrace_isenabled$zsq$go$v1(); \
__asm__ volatile(""); \
_r; })


extern void __dtrace_probe$zsq$go$v1(void);
extern int __dtrace_isenabled$zsq$go$v1(void);

#else

#defineZSQ_GO() \
do { \
} while (0)
#defineZSQ_GO_ENABLED() (0)

#endif /* !defined(DTRACE_PROBES_DISABLED) || !DTRACE_PROBES_DISABLED */


#ifdef__cplusplus
}
#endif

#endif/* _ZSQ_H */

通过这个自动生成的头文件,我们就可以在自己的代码中预埋自定义的 probe,大体的逻辑使用方式就是先判断 probe 是否 enable,如果 enable,再真的执行它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#import <Foundation/Foundation.h>
#import "zsq.h"

int main(int argc, const char * argv[]) {
@autoreleasepool {
while (1) {
if(ZSQ_GO_ENABLED()) {
NSLog(@"Hello");
ZSQ_GO();
}
}
}
return 0;
}

触发探针

首先正常启用 CLI 命令后

1
2
// 启用 cli 命令行工具
..../SQTool

在没有触发 DTrace 之前,终端不会有任何输出

03.jpg

此时我们在另一个 terminal 里去启用 dtrace 来追踪预埋的探针

1
2
3
4
5
// -s 参数的 zsq.d 指的是定义的 probe 文件, 
// -P 参数的 zsq30629 指的是 probe name + PID, probe name 在 .d 里查看, PID 命令可以通过 ps -A 查看
sudo dtrace -s zsq.d -P zsq30629
// 或者
sudo dtrace -P zsq30629 // 如果你的 .d 文件已经被 dtrace 加载了,就无须重复使用 -s 参数重复加载

此时,dtrace 服务被激活,CLI 里预埋的 probe 生效,我们就看到执行 CLI 里的 terminal 不断的在输出 Hello,也就是被 ZSQ_GO_ENABLED() 包裹的逻辑之一。

04.jpg

总结

至此,我们完成了自定义探针的定义,预埋和调用。

那基于前面的 demo,我们来理解下 Reactive Cocoa 里的 .d 文件到底干了什么?以及怎么用?

以 Reactive Cocoa 里的 RACCompoundDisposableProvider 为例,它只是定义了一个 provider 为 RACCompoundDisposable,probe 为 added 和 removed 的探针。

05.jpg

而在 RACCompoundDisposable.m 中,会在 addDisposable 中预埋 RACCOMPOUNDDISPOSABLE_ADDED 的探针,感兴趣可以展开下面的代码来查看。

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
35
36
37
38
- (void)addDisposable:(RACDisposable *)disposable {
NSCParameterAssert(disposable != self);
if (disposable == nil || disposable.disposed) return;

BOOL shouldDispose = NO;

OSSpinLockLock(&_spinLock);
{
if (_disposed) {
shouldDispose = YES;
} else {
#if RACCompoundDisposableInlineCount
for (unsigned i = 0; i < RACCompoundDisposableInlineCount; i++) {
if (_inlineDisposables[i] == nil) {
_inlineDisposables[i] = disposable;
goto foundSlot;
}
}
#endif

if (_disposables == NULL) _disposables = RACCreateDisposablesArray();
CFArrayAppendValue(_disposables, (__bridge void *)disposable);

if (RACCOMPOUNDDISPOSABLE_ADDED_ENABLED()) {
RACCOMPOUNDDISPOSABLE_ADDED(self.description.UTF8String, disposable.description.UTF8String, CFArrayGetCount(_disposables) + RACCompoundDisposableInlineCount);
}

#if RACCompoundDisposableInlineCount
foundSlot:;
#endif
}
}
OSSpinLockUnlock(&_spinLock);

// Performed outside of the lock in case the compound disposable is used
// recursively.
if (shouldDispose) [disposable dispose];
}

那么基于这个探针,我们就可以使用 dtrace 去追踪 add 的行为。

那这种功能有什么用呢?

如果你在开发前端的时候使用 redux,估计你八成会使用到这样的一个 debug tool - reaction,它能将 saga 里的每个动作,展示在 debug tool 里面。

06.jpg

那么同理到我们的 Reactive Cocoa 中,我们就可以搞一个 debug tool 实时监控代码里某些行为,方便我们调试。

那 .d 文件对于我们意味着什么?

从组件化角度看,分为两种业务场景,一种是源码形式,一种是二进制形式

  • 在源码形式下,需要保留 .d 文件:由于组件的内的 .m 文件还会依赖 由 .d 自动生成的 .h 文件,如果想让编译通过,就需要保留 .d 文件。例如 RACCompoundDisposable.m 会依赖 RACCompoundDisposableProvider.d 生成的 RACCompoundDisposableProvider.h 文件,
  • 在二进制形式下,不需要保留 .d 文件:由于组件内的 .m 文件已经被编译成二进制,不再需要编译行为,.d 文件已经没有存在价值,也就不依赖 RACCompoundDisposableProvider.d 生成的 RACCompoundDisposableProvider.h 文件,

那结合上面的两个视角,我们在 CI 上又应该干点什么呢?

  • 基于现在的状况(即没有人把 .d 生成的 .h 放到公开头文件里),我们就可以把把 .d 文件当做 .m 文件一样看待,即在二进制产物中删掉 .d 文件即可。

那么可能就会有人,为啥不能把 .d 生成的 .h 放到公开头文件里呢,这个行为合理么?

  • 我认为是没有必要的,原因大致如下:
    • 首先 .d 文件生成的 .h 只是与 probe 相关的逻辑,为自己的组件提供了 dtrace 能力,方便自己的调试或者行为追踪。
    • 这种埋点自查的能力应当只在自己的组件内(例如组件 A)使用,即使提供给外界(组件 B)使用,那么组件 B 也无法追踪组件 A 的行为,组件 B 只能追踪自己的行为,如果想追踪自己的行为,那又为什么要用 A 里的 probe 呢?对吧。自己追踪自己就好,不要用别人的探针,避免歧义。
    • 所以这个 .d 文件从原理上是可以放到公开的 .h 文件中,但这并不是那么合理,所以从实际使用的角度上来说,是不应该将 .d 自动生成的 .h 文件放到公开的 .h 文件中。

好了,说到这里,我想你也大概明白了 .d 文件的作用和在组件化的时候要怎么对待它了,希望这篇文章能对你有所帮助!

参考资料

Swift 2021 生态调研报告

作者 SketchK
2021年4月22日 03:38

让我们一起看看 Swift 生态在 2021 年的现状吧!

回顾 2020

在去年的《一次关于 Swift 在 iOS 生态圈里的现状调研》一文中, 我们分析了整个大环境的现状,在文章发表后得到了大家的广泛关注,时隔一年,我们再来看看有什么变化吧?

Swift 语言

版本变化

首先从去年的 5.2 版本,到现在即将发布 5.4 版本,Swift 经历了 2 个小的版本变化,分别是 5.3 和 5.4

其中 5.3 版本给出了以下几个语言特性:

  • SE-0276:catch 语句在捕获 error 的时候,可以更加灵活自由,例如一次捕获多个 error 或者对 error 的值进行绑定。
  • SE-0279:支持多个尾随闭包,这个特性主要是为 SwiftUI 准备的。
  • SE-0266:enum 支持 comparable 协议,并根据顺序自行决定大小
  • SE-0269:在某些场景下可以避免 self 关键字的声明
  • SE-0281:通过 @main 关键字定位程序入口
  • SE-0267:在函数的泛型和扩展中就可以使用包含 where 关键字的语句
  • SE-0280:protocol witness 匹配模型在枚举值中的加强
  • SE-0277:新增 Float16 的数据类型
  • SE-0268:didSet 方法优化和语义更新
  • 首先在 SE-0271 中,Swift Package Manager 在资源文件的支持上有了进一步的提升,同时,在 SE-0278 中,SPM 对本地化资源的支持也有了改进,而且在 SE-0272 中,SPM 终于支持了二进制形式的组件。在 SE-0273,SPM 允许我们对特定的 target 进行特殊的依赖配置。

其中在未来的 5.4 版本又新增了以下几个语言特性:

  • SE-0287:提升了隐式成员表达式的类型推断能力。
  • SE-0284:在函数中可以定义多个可变参数。
  • SE-0289:在 5.1 就公布的 Function Builder 功能正式命名为 Result Builder,并在原先的基础上进行了完善。
  • SR-10069:嵌套函数支持重载
  • SE-0294:新增 executable 类型的 target,使得 SE-0281 新增的特性更易于使用。
  • property wrapper 除了可以作为属性外,还可以在函数里作为本地变量。

On the road to Swift 6

在 2021 年的 1 月,Swift 社区的 Ted Kremenek,他的另一个身份是 Manager of the Languages and Runtime Team @Apple,在 swift.org forum 公布了一则名为《On the road to Swift 6》的文章

在这篇文章里,提到了一些对 Swift 6 的规划,从大方向来说,Ted 提了三点:

  1. 加速整个 Swift 软件生态的发展:包含兼容更多的开发平台,简化软件的安装部署和大力发展各类工具库。
  2. 打造极致的开发体验:包括更快的构建速度,更好用的调试工具,更灵敏的代码补全和更丰富的诊断信息。
  3. 结合开发者的反馈进一步发展语言特性:包括完善 API 的设计,拓展其在底层系统,服务器和机器学习方面的应用场景,同时对某些主流的语言特性提供支持,例如并发特性和内存相关的特性。

同时 Swift 的核心团队也发生了一些变化,Saleem AbdulrasoolTom Doron 作为新成员加入到核心团队,而 Dave Abrahams 则推出了核心团队。

这里稍微提一下的,Tom 是 SwiftNIO 的核心开发,同时在 SSWG(Swift Server Work Group) 项目中也是主要的发起者,而 Saleem 是 Swift to Windows 的核心发起者,这两个变动结合着最开始的三个大方向,可以看出整个核心团队是言行一致的。

另外关于 Swift 6 的公布时间,Ted 的原话是这样的:

Instead of announcing a specific timeline for “Swift 6”, the plan is for the community to be a part of seeing these efforts progress, with focused efforts and goals, and we release Swift 6 when those efforts culminate.

所以这样看来,Swift 6 还是有一段时间才能与我们见面,毕竟人家说了 when those efforts culminate!

那反过来看,Swift 5 还将会是近期使用的主要版本。(PS:希望今年的 WWDC 21 不要被打脸)

技术社区

语言排行榜

同样我们来看一看编程语言排行榜 TIOBEPYPL 的情况,在 TIOBE 的排行榜中,Swift 在今年的排名是第 15 名,而 Objective-C 已经彻底排在了 20 名之外了。

1

而在 PYPL 的排行榜中,Swift 和 Objective-C 的热度还是较为接近的。

2

从社区的语言排行榜来看,虽然乍一看,感觉还是 Swift 和 Objective-C 共存的大环境,但其实背后也反映出,Swift 已经被大部分开发者所接受了。

社区活跃度

同样通过 GitHut 2.0 这个工具对 GitHub 进行分析。

下面四张图的 Y 轴分别代表了 Pull Requests ,Pushes,Stars,Issues 的数量,蓝色的线代表 Objective-C ,浅橙色的线代表 Swift。

可以发现,在 Pull Request 方面,Swift 占比约 0.595%,而 Objective-C 占比约 0.335%

3

同时 Push 方面,Swift 占比约 0.476%,而 Objective-C 占比约 0.310%

4

在 Stars 方面,Swift 占比约 2.107%,而 Objective-C 占比约 1.067%

5

在 Issue 方面,Swift 占比约 0.767%,而 Objective-C 占比约 0.607%

6

总的来看,在 GitHub 的大环境中,社区中的开发者还是持续看好 Swift,也相对更加活跃,尤其在 Star 这项指标上可以明显看出,它高出了 Objective-C 近一倍!

商用 SDK 的技术选型

在今年我们还发现了一些有意思的现象,不少商用 SDK 也开始了 Swift 的迁移。

例如国外的 Nordic Semiconductor 公司,它是北欧的一个半导体公司,主营蓝牙芯片,在业界属于领先地位,不少使用它家芯片的团队会涉及到固件升级问题,无线的升级方案需要进行固件传输、校验、升级管理等动作,而这些动作都得使用它们家提供的 SDK 来完成。

7

在 Nordic Semiconductor 的 GitHub 页面上,我们可以看到目前提供的商用 SDK 中,iOS 端只有 Swift 版本,而 Android 端只有 Java 版本

同时像 Google 的 Firebase 在其 RoadMap 里也明确指出了将更加关注 Swift 的使用体验并开始了部分改造。

相信不久的将来,会有越来越多的厂商加入到 Swift 的社区中,除了 Swift 是未来 这样人人都懂的道理以外,这两年新增的特性,例如 ABI 稳定,Module 稳定,以及 SPM 对 binary 组件的支持,都会导致厂商的态度改变,尤其是那些需要使用非源码形式发布组件的厂商,毕竟这些特性从根本上解决他们面临的工程问题。

Apple 生态

SDK 能力

同样,我们继续分析了 Apple Developer Documentation 下的 239 个 主题,发现今年的 Swift 独占和 Objective-C 独占的 SDK 情况如下

维度 个数 SDK 名称
Swift 独占 13 Swift(Swift Standard Library),Combine,SwiftUI,RealityKit,CareKit,Create ML(Create ML, Create MLUI),Playground Support,PlaygroundBluetooth,Apple CryptoKit,Swift Packages(Swift Package Manager),Developer Tools Support,System,WidgetKit
Objective-C 独占 12 DarwinNotify,DriverKit(macOS 专属),EndpointSecurity(macOS 专属),HIDDriverKit(macOS 专属),Kernel(macOS 专属),NetworkingDriverKit(硬件驱动相关),PCIDriverKit(硬件驱动相关),SerialDriverKit(硬件驱动相关),USBDriverKit(硬件驱动相关),USBSerialDriverKit(硬件驱动相关),xcselect (macOS 专属),SCSIControllerDriverKit

在 Swift 独占方面,新增了 3 个 SDK,分别是 Developer Tools Support,System,WidgetKit,其中 System 是个用于进行底层文件操作(low-level file operation)的库,似乎这也是 Apple 的首个用 Swift 编写的系统底层库(PS:如果说的不对,还请各位读者指正);另外一个想说的重点就是 WidgetKit,这也是首次 Apple 在推广系统新特性的时候强制要求开发者必须使用 Swift 技术,这个策略我认为还是十分高明的,它为 Swift 技术的推广和应用找到了新的出路。

同时 Objective-C 独占方面,新增了一个 SCSIControllerDriverKit,但相比于去年,Professional Video ApplicationsIOUSBHost 两个 Objective-C 独有的 SDK 被改造成了 Swift 和 Objective-C 都可以使用的情况,而 QTKit 被彻底废弃了。

至此,我们发现了,Swift 独占库的数量首次大于了 Objective-C 的独占库,是不是很有意思!

原生 App 分析

国外的开发者 Timac 在其文章《Apple’s use of Swift and SwiftUI in iOS 14》里对 iOS14 中的 Swift 和 SwiftUI 的使用情况进行了分析。

iOS 14.0 包含了 291 个使用 Swift 技术的二进制文件(PS: 还有一个统计口径是 351 个,不过这里面有很多程序对 Swift 的使用很初级,所以 Timac 就将其排除了),这个数量比 iOS 13 多了一倍以上,另外 Swift UI 也在 iOS 14 上被广泛使用,目前已经有 43 个了,其中去年新增的翻译应用是完全使用 Swift 和 SwiftUI 编写的 App。

8

iOS 中不同编程语言的发展

同样是 Timac 在其文章《Evolution of the programming languages from iPhone OS 1.0 to iOS 14》给出了很多有意思的结论。

首先,在 iOS 14 中,总共有 4173 个二进制文件,具体的列表可以参考 iOS 14.0 (18A373) 统计,其中:

  • 88% 使用 Objective-C
  • 17% 使用 C++
  • 8% 使用 Swift
  • 8% 全部使用 C
  • 1% 使用 SwiftUI

下面的图是 iPhone OS 1.0 到 iOS 14.0 中,各个二进制文件的情况,注意这里的二进制文件可以包含多个语言,所以下表的总数可能会大于二进制的总数,例如 iOS 14.0 里 44 + 351 + 337 + 708 + 3667 > 4173

9

从这个视角来看,也可以得出几个有意思的结论:

  • 首先,iOS 的每个版本都变得更加复杂
  • Swift 的使用在不断增多,而且至少目前来看,Swift 的使用已经超过了 C
  • Objective-C 的增长还是比较稳定的
  • C++ 的增长比较缓慢,或者说相当缓慢
  • C 的增加几乎没有变化

如果上面的图看起来不明显,我们可以通过这个图来看趋势。

10

当然上面的分析是基于数量来进行的,那么如果我们从体积上进行分析,也就是二进制大小的角度来看,又会得出怎样的结论呢?

Timac 在其文章《Comparing iPhone OS 1.0 with iOS 14 using tree maps》里,也给出了一些自己的解读。

下面是 Timac 根据相关的数据和脚本绘制出来的 iOS 14 的 tree map(矩形式树状结构绘图法)

11

  • 其中 Preinstalled Assets 和 Linguistic Data 是与机器学习相关的预置资源
  • Health 相关的内容在 iOS 14 的占比不算小,可以看出其重视程度
  • 在 iOS 3.1 之后,提供了 dyld shared cache 技术,红色区域就是支持这个特性的 framework。

当然,Timac 对这个结果又进行了更细致的划分,它的结果如下

12

这里我们从二进制的大小,或者代码量的多少来考察某个系统功能的重要性,我们可以明显的看到,Apple 的人工智能推动了设备上的机器学习,如图像和视频中的物体检测、语言分析、声音分类和文本识别等技术。

所以如果未来想继续在 iOS 上开发的话,机器学习可能会是一个必备的基础知识了(PS:如果你开发过 IntentConfiguration 类型的小组件,我想你大概就明白我在说什么了)。

国内外客户端的使用现状

数据样本

去年我们分析了国内外 App 使用 Swift 的情况,今年我们继续走起。

扫描的原理借鉴了《如何检测 iOS 应用程序是否使用 Swift?》,相应的工具可以参考 Swift App Analyzer,这是我和好基友 OneeMe 一起编写的。

App 排行榜的数据来源是 七麦数据 提供的,日期为 2021 年 3 月 21 日,国内免费应用 Top 100 榜单国外免费应用 Top 100 榜单

下面是扫描的结果:

序号 国内 App 版本 是否使用 Swift 国外 App 名称 是否使用 Swift
01 搜狗输入法 NO Twitter YES
02 百度地图 YES Uber YES
03 招商银行 YES Fontise YES
04 优酷 YES Prime Video YES
05 QQ浏览器 NO Nike YES
06 QQ音乐 YES Dasher YES
07 肯德基 YES Capital One YES
08 抖音极速版 NO PayPal YES
09 中国建设银行 YES Twitch YES
10 饿了么 NO Telegram YES
11 携程旅行 YES Translate YES
12 闲鱼 NO TV Remote YES
13 汽车之家 NO Life360 YES
14 WiFi 万能钥匙 YES Google Photos YES
15 微视 YES Walgreens YES
16 菜鸟 YES Pinterest YES
17 高德地图 NO Vrbo YES
18 知乎 YES Chase YES
19 手机营业厅 YES Starbucks YES
20 国家反诈中心 NO Pandora YES
21 58 同城 YES Google Docs NO
22 淘宝特价版 NO Waze YES
23 UC 浏览器 NO Credit Karma YES
24 小红书 YES MM Live YES
25 微博 NO Facebook YES
26 芒果TV NO Amazon Alexa YES
27 天眼查 NO Snapchat YES
28 驾考宝典 NO Coinbase YES
29 探探 YES Xbox YES
30 个人所得税 NO ClassDojo YES
31 腾讯地图 NO Walmart YES
32 SOUL YES Google Maps NO
33 美柚 YES PicsArt YES
34 轻颜相机 YES Chrome NO
35 BOSS 直聘 NO Hulu YES
36 快手极速版 YES Outlook YES
37 作业帮 YES Disney+ YES
38 美团秀秀 YES CapCut YES
39 Chrome NO Booking.com YES
40 迅雷 YES Instagram YES
41 贝壳找房 YES Zelle YES
42 WPS Office YES Messenger NO
43 百度网盘 YES SHEIN YES
44 美团外卖 NO Google Duo YES
45 番茄小说 NO Zoom NO
46 中国工商银行 YES Roku YES
47 快手 YES Target YES
48 美颜相机 YES WhatsApp YES
49 七猫小说 YES Grubhub YES
50 滴滴出行 YES Postmates YES
51 微信 YES PS App YES
52 韩剧 TV YES Tinder YES
53 酷狗音乐 NO Hopper YES
54 唯品会 YES Shazam YES
55 爱奇艺 YES Itsme YES
56 哔哩哔哩 YES Bird YES
57 阿里巴巴 NO Uber Eats YES
58 京东金融 NO Netflix YES
59 醒图 YES Domino’s YES
60 网易云音乐 YES Arch-US YES
61 支付宝 YES DoorDash YES
62 转转 YES Fetch Rewards YES
63 叮咚买菜 YES CBS Sports YES
64 今日头条 YES Shop YES
65 邮储银行 NO Spotify YES
66 懂车帝 YES TikTok YES
67 夸克 YES Lyft YES
68 美团 NO SoundCloud YES
69 喜马拉雅 YES WOMBO YES
70 得物(毒) YES Zillow YES
71 中国农业银行 NO TextNow YES
72 QQ 邮箱 YES HBO Max YES
73 钉钉 NO Discord YES
74 百度 YES Amazon Music YES
75 Top Widgets YES Google YES
76 Keep YES Google Drive YES
77 全民 K 歌 NO Airbnb YES
78 哈罗出行 NO Tubi YES
79 中国银行 YES Etsy YES
80 Days Matter YES IRL YES
81 新氧医美 NO Yelp YES
82 安居客 YES Peacock YES
83 企业微信 NO YouTube Music YES
84 中国移动 YES Venmo YES
85 手机淘宝 YES ESPN YES
86 云闪付 NO IRS2Go NO
87 QQ NO Ring YES
88 交管 12123 NO Wish YES
89 拼多多 YES ESPN YES
90 京东 YES Gmail YES
91 好看视频 YES Amazon NO
92 铁路 12306 NO Robinhood YES
93 大麦 YES YouTube NO
94 大众点评 NO Reddit YES
95 酷狗铃声 NO OfferUp YES
96 抖音 NO Musi NO
97 剪映 YES Widgetsmith YES
98 货拉拉 NO eBay YES
99 腾讯会议 NO Chick-fil-A YES
100 腾讯视频 NO Cash App YES

在 GitHub 上,其实还有一份统计数据 Snake List,是 Flexih 统计的,除了 Swift 技术外,还统计了 Weex,React Native,Flutter 等技术的情况,大家可以作为参考。

2021 年

在国外 Top 100 的免费应用中,Swift 混编占比 91%。

13

在国内 Top 100 的免费应用中,Swift 混编占比 59%。

14

在之前的文章中,《一次关于 Swift 在 iOS 生态圈里的现状调研》,我们也整理过一些数据。

在 2019 年,国内的 Swift 混编应用占比为 22%,国外的 Swift 混编应用占比 78%,
在 2020 年,国内的 Swift 混编应用占比为 30.4%,国外的 Swift 混编应用占比 82.3%。

如果将近几年的数据连着看,Swift 在国内外的变化趋势如下图所示:

15

数据解读

纯看数据的话:

  • 在国内,Swift 混编占比达 59%,较去年的 30%,又增长了 29%,整体占比也已经过半了!
  • 在国外,Swift 混编占比 91%,较去年的82.3%,又增长 9%,纯 Objective-C 的应用也只有个位数占比了。

那么我们再分析一些数据背后的内容:

  • 从表面看,除了去年提到的 BAT 之外,今日头条,快手,滴滴,支付宝,京东,拼多多等一众应用也都在今年完成了 Swift 的初体验,比较有意思的是美团系的应用(美团,大众点评,美团外卖)目前似乎还没有任何动静。
  • 虽然国内的 Swift 混编占比变高,但我个人认为,这并代表国内大部分公司要开始转型 Swift 技术了,这样的变化,主要是因为去年 Apple 新增的 Widget 技术导致的,因为想开发 Widget 必须使用 Swift 相关的技术,而上面提到的各个应用,大多都提供了相应的小组件。
  • 如果历史有可以借鉴的地方,那么 2021 年的国内 Swift 占比(59%)与 2019 年的国外占比(78%)还算比较接近,至少不像去年(30% 和 82.3%)的差距那么大,那么按照这个趋势发展的话,我们是否可以预言在未来的 3-5 年内,国内的 Swift 混编应用占比也将达到 90% 左右?

PS:在写这篇文档的时候,发现微博也支持小组件了,所以估计上面的这个数据又得增加 1% 了。

总结与展望

在做完了今年的调研后,我们能得出什么结论呢?

  • 虽然看起来现阶段的 Swift 还是在一个积累的过程,但随着 WidgetKit 这个标志性的 SDK 诞生,我相信这个发展阶段会从积累阶段慢慢转向发展期,毕竟现在 ABI 稳定了,Module 稳定了,对二进制组件的支持也有了,还有 Swift 语言本身的版本变化也逐渐稳定了,这些都给与了 Swift 很好的支持。
  • Swift 的发展方向绝不只是为了 Apple 生态体系内的那点事儿,这个从社区的规划也好,从 Timac 的那几篇分析文章也好,我们都可以看出它在多元发展上的决心,Swift 真的很想破圈。
  • 国内的 Swift 发展被去年的 iOS 14 新特性给盘活了,WidgetKit 功不可没,虽然还不能给出大部分公司都将转型的结论,但至少绝大部分互联网的头部公司已经兼容了 Swift 的开发,这是一个好的开始,相信在可见的未来,Apple 的转型决心必然会让国内的公司会更加重视这方面工作的重要性。

在最后,我来说说这一年的一些其他见闻:

虽然不久前 Google 归档了 Swift for TensorFlow 项目,让很多人看衰 Swift 在机器学习或者人工智能方面的发展,但其实我觉得是有点没必要,Swift 在这方面的发展其实并不依赖 Google,Apple 自己在这方面就很有建树,如果感兴趣应该看看 Machine Learning Research at Apple 这个网站,这才代表 Apple 和 Swift 在机器学习方面的真实水平。

另外,虽然还是能在某些技术群里看到 “Swift 无用”,”Swift 火不了”,”我们不需要用 Swift 开发” 的字眼,但这样的数量相比于前几年而言,真的越来越少了。

另外据我所知,字节跳动和快手团队正在大力发展 Swift 方面的建设,虽然这只是国内诸多公司的个例,但我相信随着这些头部大厂的加入,Swift 成为原生开发的主流趋势会在国内越来越明显,当然不得不承认,跨端技术在国内也有着极大的市场份额,所以估计未来作为 iOS 端上的程序员,可能要具备 Objective-C,Swift,JavaScript/TypeScript 和 Flutter 的语言技术栈。

好了,今年的调研报告就到此结束了,我们明年见!

参考文档

SketchK - 一次关于 Swift 在 iOS 生态圈里的现状调研
Paul Hudson - Hacking with Swift - What’s new in Swift 5.3?
Paul Hudson - Hacking with Swift - What’s new in Swift 5.4?
Swift.org forum - On the road to Swift 6
TIOBE
PYPL
GitHut 2.0
Apple - Apple Developer Documentation
Timac - Comparing iPhone OS 1.0 with iOS 14 using tree maps
Timac - Evolution of the programming languages from iPhone OS 1.0 to iOS 14
Timac - Apple’s use of Swift and SwiftUI in iOS 14
Timac - 如何检测 iOS 应用程序是否使用 Swift?
OneeMe - Swift App Analyzer
Flexih - Snake List
Apple - Machine Learning Research at Apple

使用 Swift 编写 CLI 工具的入门教程

作者 SketchK
2020年12月26日 23:28

为何要使用 Swift 编写脚本工具?
如何从头搭建一个 Swift CLI 项目?
如果你想知道答案,就来读读这篇文章吧!

概述

最近在工作中过程中基于 Swift 开发了两款命令行工具:

  • Nezuko ,一款面向美团壳工程的,类 create-react-app 的脚手架工具。
  • ImportSanitizer,一款能够修复不规范头文件引用方式的自动化工具。

在整个开发过程中,我们体会到了 Swift 带来的一些变化,当然这里既有好的,也有坏的,不过总的来说,使用 Swift 进行脚本开发还是一件让人愉悦的事情,所以我们迫不及待的邀请你,也就是这篇文章的读者,和我们一起加入 Swift 开发的大军中!

这篇文章通过 Step-By-Step 的方式,指导你完成一个基于 Swift 的命令行工具,不过在开始之前,我们先聊聊为什么要写脚本,以及为什么选用 Swift。

Why Scripting

对于软件工程师来说,我们经常会遇到这样的工作,例如重命名设计师提供的图片素材,在海量的数据中提取一些特定信息。这种工作有一些共性,就是它的逻辑很简单,就像代码里的 if-else 一样,只要你够严谨,就一定能得到想要的答案,

但这种机械性的工作很容易因为人为的因素导致错误,例如手抖,眼瞎,以及间歇性失忆,哈哈,而这时候,机器会显得比人靠谱多了!

同时,重复同样的工作是一件低效的事情,我们完全可以通过编写相应的代码将任务自动化,这将极大的提升我们的工作效率。

当然可能有人会说,我还是自己弄吧,但你不觉得这种重复的,机械性的工作很无聊么,我们可是要改变世界的工程师啊!

Why Swift

至于为什么使用 Swift 写脚本,我想有人可能会给出这样的答案:

swift 真的太棒了,我喜欢 Swift,它是最好的语言!我要用它写后端,要用它写前端,用它写 iOS,写 Android,用它写 CLI,总之,我要用它解决一切的编程问题!

但说实话,这是一个很主观的判断,并不应该成为我们使用 Swift 写 CLI 的客观因素,在实际使用了一段时间后,我认为下面几个因素才是我们使用 Swift 编写脚本的主要原因:

  • 降低了 App 开发者编写脚本的门槛,减少了上下文切换的负担
  • Swift 提供了一些真的很不错的内置库,例如 combine,core graphics,urlsession 等
  • 可以将 App 里的代码引入脚本,避免重复工作

关于前两点,我想大家应该会比较好理解,毕竟每次在 Swift 代码和 Bash,Ruby,Python,JavaScript 中切换,会让我产生一种深深的抗拒感,另外在 iOS 上已经得到证明的 core graphics 有理由让我们相信它的品质,同时天然内置的 Combine 让我们在异步编程上有了很好的体验,这让我想起被 JavaScript 里 yield 支配的恐惧,问我为什么要用 yield,不妨来试试 React + Redux + Redux-Saga 的大礼包呀!

关于最后一点,我想展开说说,一方面是因为它基于我真实的开发体验,另一方面是它确实让我真正意识到 Swift 写脚本的优势所在。

在年初的时候,我曾经一度痴迷 SpriteKit,并尝试开发一款属于自己的横版过关游戏,这其中涉及到大量的图片处理,例如使用 Animation 的方式将多张静态图片整合成动态效果,大体的效果就像下面的 gif 一样

1

这背后的代码大概如下所示,通过读取相应顺序和数量的图片构建 gif 动画

1
2
3
4
5
6
Animation(
name: "Units/swordsman/male/attack/right",
frameCount: 8,
duration: 1.12,
tintColor: .blue
)

虽然看起来代码不多,手写一下就 ok 了,但你得知道,就这样一个杂兵角色,就会有攻击,跳跃,行走,跑动等等等动作,再加上各个方向,以及特殊效果,如果再考虑到,我们的游戏里面大概有 20 多个兵种和 4 个英雄,我想你大概已经体会到这个工作的痛苦了!

在一开始,我用的是 Ruby 来解决重复代码的生成工作,但这里为了减少上下文切换,我将采用 Swift 类型的伪代码来做展示,方便大家快速理解

1
2
3
4
5
6
7
8
let unitKinds = ["swordsman", "archer", "knight", "catapult"]

for kind in unitKinds {
guard let config = try? File(path: "\(kind.identifer)/Config") else {
continue
}
try codeGenerator.generateCode(from: config)
}

虽然这么写已经帮我节省了不少时间,但我还是得每次手动维护 unitKinds 数组,并保持它与游戏中的模型数据同步,这其实也挺烦人的,不是么?

1
2
3
4
5
6
7
8
class Unit {
enum Kind: Int {
case swordsman
case archer
case knight
case catapult
}
}

某日,看着上面的游戏模型数据,我突然顿悟,如果我用 Swift 编写脚本的话,我完全可以复用游戏里的数据模型啊!于是乎,便有了下面的代码:

1
2
3
4
5
6
7
8
import GameModels

for kind in EnumSequence<Unit.Kind>() {
guard let config = try? File(path: "\(kind.identifer)/Config") else {
continue
}
try codeGenerator.generateCode(from: config)
}

至此,我终于做到了不用再手动维护脚本里的任何代码,就可以直接与 App 里的源码保持一致,这样是不是很 cool!同样的道理,我们还可以应用到很多方面,例如直接利用网络层的 modle 文件生成 mock 数据,而不用在 JSON 编辑器上小心翼翼的粘贴复制了!

所以还在等什么呢,让我们开始写一个 Swift CLI 吧!

使用 SPM 搭建开发框架

为了开发 command line tool(CLI),我们需要创建一个新的文件夹,并使用 swift package manager(SPM)来初始化项目

1
2
3
$ mkdir CommandLineTool
$ cd CommandLineTool
$ swift package init --type executable

最后一行的 type executable 参数将告诉 SPM,我们想创建一个 CLI,而不是一个 Framework。

项目里的文件

在 SPM 初始化项目后,我们会得到如下的一个文件夹结构

1
2
3
4
5
6
7
8
9
10
11
.
├── Package.swift
├── README.md
├── Sources
│ └── CommandLineTool
│ └── main.swift
└── Tests
├── CommandLineToolTests
│ ├── CommandLineToolTests.swift
│ └── XCTestManifests.swift
└── LinuxMain.swift

其中有几个需要关心的文件

  • Package.swift 文件: 用于描述当前 Package 的信息及其依赖,需要记住的是,在 SPM 的世界里,不再有 Pod 的概念,与之对应概念是 Package,而 CLI 本身也是一个 Package
  • main.swift 文件:这个文件在 Sources 目录下,它代表整个命令行工具的入口,另外记住不要更换这个文件的名字!
  • Tests 文件夹:这个文件夹是用于放置测试代码的。
  • .gitignore 文件:通过这个文件,git 会自动忽略 SPM 生成的 build 文件夹(.build 目录)以及 Xcode Project

将代码划分为 framework 和 executable

我的一个个人建议是,在一开始就最好将源代码分成两个模块,一个是 framework 模块,一个是 executable 模块。

这样做的原因有 2 点:

  • 会让测试变得更加容易
  • 让你的命令行工具也可以作为其他工具依赖的 Package

具体怎么做呢?

首先,我们要保证 Sources 目录下有两个文件夹,一个用于存放 executable 相关的逻辑,一个用于存放 framework 相关的逻辑,就像下面一样:

1
2
$ cd Sources
$ mkdir CommandLineToolCore

SPM 的一个非常好的方面是,它使用文件系统作为它的处理依据,也就是说,只要采用上述操作提供的文件结构,就等于定义了两个模块。

紧接着,我们在 Package.swift 里定义了两个target, 一个是 CommandLineTool 模块,一个是 CommandLineToolCore

1
2
3
4
5
6
7
8
9
10
11
12
import PackageDescription

let package = Package(
name: "CommandLineTool",
targets: [
.target(
name: "CommandLineTool",
dependencies: ["CommandLineToolCore"]
),
.target(name: "CommandLineToolCore")
]
)

通过上面这种方式,我们让 executable 模块依赖了 framework 模块。

构建 Xcode 项目

为了能方便的运行和调试代码,我们还需要使用配套的开发工具!

好消息是 SPM 可以根据文件信息自动创建 Xcode 工程,这意味着我们可以使用 Xcode 来开发 CLI 了。

而且在 .gitignore 中会自动忽略这个工程项目,这同时意味着,我们不需要更新 Xcode Project 文件,也不需要担心这类文件的冲突问题,只需要通过下面的命令即可完成工程文件的生成。

1
$ swift package generate-xcodeproj

记得需要在根目录下执行上面的命令,另外在执行过程中会得到一个 warning,让我们暂且忽略它,在后面我们会将其修复!

开始动手

定义程序入口

为了能够在命令行和测试用例中方便的运行我们的代码,我们最好不要在 main.swift 中添加过多的逻辑,而是通过程序调用的方式唤起 framework 中的主逻辑。

为了实现这样的目的,我们需要创建一个名为 CommandLineTool.swift 的文件,将其放在 framework 模块中(Sources/CommandLineToolCore),它里面的内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
import Foundation

public final class CommandLineTool {
private let arguments: [String]

public init(arguments: [String] = CommandLine.arguments) {
self.arguments = arguments
}

public func run() throws {
print("Hello world")
}
}

同时在 main.swift 中添加 run() 方法

1
2
3
4
5
6
7
8
9
import CommandLineToolCore

let tool = CommandLineTool()

do {
try tool.run()
} catch {
print("Whoops! An error occurred: \(error)")
}

Hello,World

让我们用命令行看看执行效果吧!不在在真正的运行前,我们还需要完成编译工作,让我们在根目录下执行 swift build 吧,然后再执行 swift run 命令。

1
2
3
$ swift build
$ swift run
> Hello world

我们其实可以通过直接调用 swift run 命令来达到运行程序的目的,因为如果需要的话,它会自动编译我们的项目,但学习一下底层命令的工作原理总是有益的。

增加依赖

除非你正在构建一些十分“特殊”的东西,否则你会发现自己需要为你的命令行工具添加一些依赖关系,毕竟有好用的轮子为啥不用呢?

任何 Swift Package 都可以被添加为依赖项,只需在 Package.swift 中指定它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import PackageDescription

let package = Package(
name: "CommandLineTool",
dependencies: [
.package(
name: "Files",
url: "https://github.com/johnsundell/files.git",
from: "4.2.0"
)
],
targets: [
.target(
name: "CommandLineTool",
dependencies: ["CommandLineToolCore"]
),
.target(
name: "CommandLineToolCore",
dependencies: ["Files"])
]
)

上面我添加了对 Files 组件的依赖,它可以让我们在 Swift 中轻松处理文件和文件夹的相关操作。在后面的教程中,我们将使用它在当前文件夹中创建一个文件。

安装/更新依赖

一旦我们声明了新的依赖关系,只需要求 SPM 解析新的依赖关系并安装它们,然后重新生成 Xcode 项目即可。

1
2
$ swift package update
$ swift package generate-xcodeproj

参数解析

让我们修改一下 CommandLineTool.swift 里的内容。

将其从打印 Hello, World 的逻辑变为根据命令行参数创建文件的逻辑

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
import Foundation
import Files

public final class CommandLineTool {
private let arguments: [String]

public init(arguments: [String] = CommandLine.arguments) {
self.arguments = arguments
}

public func run() throws {
guard arguments.count > 1 else {
throw Error.missingFileName
}

// The first argument is the execution path
let fileName = arguments[1]

do {
try Folder.current.createFile(at: fileName)
} catch {
throw Error.failedToCreateFile
}
}
}

public extension CommandLineTool {
enum Error: Swift.Error {
case missingFileName
case failedToCreateFile
}
}

如上所述,我们把对 Folder.current.createFile() 的调用包装在自己的 do、try、catch 中,以便为用户提供统一的,自定义的错误 API。

Argument Parser

除了刚才提到的参数解析方式,Apple 官方还提过了一个更优化的解决方案 - Swift Argument Parser

这里我们做一下简单的介绍,以官方代码为参考示例:

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
import ArgumentParser

struct Repeat: ParsableCommand {
@Flag(help: "Include a counter with each repetition.")
var includeCounter = false

@Option(name: .shortAndLong, help: "The number of times to repeat 'phrase'.")
var count: Int?

@Argument(help: "The phrase to repeat.")
var phrase: String

mutating func run() throws {
let repeatCount = count ?? .max

for i in 1...repeatCount {
if includeCounter {
print("\(i): \(phrase)")
} else {
print(phrase)
}
}
}
}

Repeat.main()

我们可以看到,它的使用方式并不麻烦。

  • 首先遵守 ParsableCommand 协议
  • 其次声明一个参数类型(Flag,Option,Argument),定义你需要从命令行中收集的信息,并用 ArgumentParser 的属性包装器来装饰每个存储属性
  • 最后在 run() 方法中实现核心逻辑。

在实际的运行过程中,ArgumentParser 会解析命令行参数,实例化你的命令类型。同时 ArgumentParser 会使用属性名,类型信息,以及你在属性包装器里提供的细节,来提供有用的错误信息和帮助信息,具体效果如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ repeat hello --count 3
hello
hello
hello
$ repeat --count 3
Error: Missing expected argument 'phrase'.
Usage: repeat [--count <count>] [--include-counter] <phrase>
See 'repeat --help' for more information.
$ repeat --help
USAGE: repeat [--count <count>] [--include-counter] <phrase>

ARGUMENTS:
<phrase> The phrase to repeat.

OPTIONS:
--include-counter Include a counter with each repetition.
-c, --count <count> The number of times to repeat 'phrase'.
-h, --help Show help for this command.

由于本文的例子较为简单,我们这里就不增加 ArgumentParser 的依赖来增加项目的复杂度了。

即使如此,我相信通过上面的介绍,你也大致了解到了 ArgumentParser 的使用方式了,记得将其用在你自己的项目中吧!

编写单测

我们几乎已经准备好发布这个命令行工具了,但在这样做之前,我们还是需要通过编写一些测试来确保它真正的按照预期工作。

由于我们之前将整个项目划分成了 framework 和 executable 的结果,所以测试将变得十分容易。我们所要做的就是以程序调用的方式运行它,并断言它创建了一个具有指定名称的文件。

首先在 Package.swift 文件中添加一个测试模块,在你的 target 数组中添加以下内容。

1
2
3
4
.testTarget(
name: "CommandLineToolTests",
dependencies: ["CommandLineToolCore", "Files"]
)

最后,重新生成 Xcode 项目。

1
$ swift package generate-xcodeproj

再次打开 Xcode 项目,跳到 CommandLineToolTests.swift 中,添加以下内容。

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
import Foundation
import XCTest
import Files
import CommandLineToolCore

class CommandLineToolTests: XCTestCase {
func testCreatingFile() throws {
// Setup a temp test folder that can be used as a sandbox
let tempFolder = Folder.temporary
let testFolder = try tempFolder.createSubfolderIfNeeded(
withName: "CommandLineToolTests"
)

// Empty the test folder to ensure a clean state
try testFolder.empty()

// Make the temp folder the current working folder
let fileManager = FileManager.default
fileManager.changeCurrentDirectoryPath(testFolder.path)

// Create an instance of the command line tool
let arguments = [testFolder.path, "Hello.swift"]
let tool = CommandLineTool(arguments: arguments)

// Run the tool and assert that the file was created
try tool.run()
XCTAssertNotNil(try? testFolder.file(named: "Hello.swift"))
}
}

此外,还可以添加另一个测试,以验证在没有给定文件名或文件创建失败时是否抛出了正确的错误。

要运行测试,只需在命令行上运行 swift test 即可。

安装工具

现在我们已经构建并测试了我们的命令行工具!下面开始,我们会尝试安装它,并使它能够在任何地方运行。

要做到这一点,需要在 swift build 后面增加 release 的配置,也就是 -c relase 参数,然后将编译后的二进制文件移到 /usr/local/bin

1
2
3
$ swift build -c release
$ cd .build/release
$ cp -f CommandLineTool /usr/local/bin/commandlinetool

调试技巧

命令行大多是需要输入参数的,所以在实际的开发过程中,我们如何在 Xcode 里添加入参呢?

首先,在 Xcode 的 Toolbar 中,我们点击 choose scheme 面板中的 Edit Scheme... 按钮

2

在弹出的界面中点击左侧 Run 面板,并继续点击右侧的 Argument 的 Tab 按钮,我们会看到如下的界面,此时我们可以在 Arguments Passed On Launch 中添加命令行所需的参数,例如这里我们添加了一个 Hello.swift 的参数。

3

此时,我们再次通过 CMD+R 的方式运行程序,就会在构建产物的目录中,看到生成的 Hello.swift 文件

4

让开源社区加速你的开发

除了 Foundation 自带的 Combine,CoreGraphes,URLSession 等,在官方的技术社区还有很多不错的组件能够加速你的开发,例如

  • Swift Tool Support Core:SPM 和 llbuild 里通用的基础架构代码,可以将其看做是 Foundation 在 CLI 方向的加强库。
  • Swift NIO:如果 URLSession 不能满足你的需求或者你需要进行跨平台开发,SwiftNIO 这个网络库应该是能满足你的诉求,它是一个跨平台异步事件驱动的网络应用框架,用于快速开发可维护的高性能协议服务器和客户端。
  • Swift Log:一个用于做日志记录工作的组件。
  • Swift Metrics:当我们需要为某个系统,某个服务做监控,做统计的时候,Swift Metrics 就是你的不二之选!
  • Swift Crypto:一个跨平台的加密库,基于 Apple 自身的 CryptoKit 改造而来。
  • Swift numerics:这个库为 Swift 提供了许多与数值计算相关的功能模块。
  • Swift Protobuf:如果你在网络通信的过程中传输的是 Protobuf 类型的文件,可以通过这个库进行解析
  • Swift Atomics:为各种 Swift 类型提供原子操作,包括整数和指针值。
  • Swift Backtrace:这个 Package 为项目提供了自动打印程序崩溃信息的能力。

总之,这个社区在不断的发展中,很多新的官方库也在如火如荼的建设中,如果你发现这里的内容还不够用,可以关注一下 VaporPerfectlySoft IncSwiftWasmCrossroad Labs 的 Group,也能找到很多不错的 package 资源。

当然,也有很多个人开发者提供了不错的 Package 资源,例如:

  • Guitar:这绝对会是你在开发中需要到的东西,一个正则匹配加强库!
  • Rainbow:可以对命令行里的输出内容增加文本颜色,背景样式等。
  • SwiftShell:可以在 Swift 里调用 Shell 命令的 Package
  • Swift-SH:同样是一个 Swift 里调用 Shell 的 Package,介绍它的原因是因为它的作者是 Homebrew 的开发者 mxcl
  • Files:与文件操作相关的 Package,在教程里已经提及过。
  • Path:Files 在处理一些路径上还是有短板,mxcl 开发的这个组件很好的补充了 Files 的功能。
  • Release:可以通过 Swift 脚本或命令行工具轻松解析 Git 仓库中的发布版本,支持远程仓库和本地仓库。
  • XGen:通过 Swift 脚本或命令行工具中轻松地生成 Xcode Project 和 Playground。

总结

通过这篇文章,你已经掌握了如何从零编写 Swift CLI 项目的所有基础知识,也了解了社区里的一些优秀资源,十分期待你开始使用 Swift 编写属于自己的命令行工具!

WWDC20 10654 - Create Swift Playground content for iPad and Mac

作者 SketchK
2020年8月12日 20:02

这是一个麻雀虽小,但五脏俱全的 Session,在短短的 8 分钟里,不仅包含了功能介绍,也有相应的代码演示。

不过整个 session 都是围绕在如何利用 Swift Playground 为 iPad 和 Mac 创建优质内容而展开的,所以都是很细节和琐碎的点。

引子

这是一个麻雀虽小,但五脏俱全的 Session,在短短的 8 分钟里,不仅包含了功能介绍,也有相应的代码演示。

不过整个 session 都是围绕在如何利用 Swift Playground 为 iPad 和 Mac 创建优质内容而展开的,所以都是很细节和琐碎的点。

下面的脑图展示了这个 session 的所有知识点,方便你复习和加深理解:

01.png

Swift Playground 在界面上的改变

如果使用过 Swift Playground ,那么你应该能回忆起,在 iPad 上是可以看到代码补全功能提供的 token。

IMAGE.png

为了适配 Mac 平台,Apple 团队为 Swift Playground 的代码补全功能做了进一步的优化。

现在,在 Mac 上不仅可以看到这些 token,还可以看到相应的帮助文档

IMAGE.png

除此之外,帮助文档没有对语言类型做限制,我们完全可以将文档的语言类型变为使用者的本地语言,通过这样的方式来降低他们的理解成本

如果想创建 API 的帮助文档,可以使用 3 个 / 来声明,除了描述方法外,也可以为参数添加说明,下图就是一个例子

IMAGE.png

在 iPad 上的话,可以通过 Quick Help 的弹窗或者代码补全的提示条来展示相应的代码说明,它的效果如下所示

IMAGE.png

针对不同平台展示定制化内容的能力

在 Playground book 格式的文件中,提供了两个新的 API 用来区分不同平台的定制化能力,它们分别是 supportedDevicesrequiredCapbilities

这里要记住一点,Playground 和 Playground Book 是两个不一样的东西,在 Xcode 或者 Swift Playground 里是无法直接触发 Playground Book 的 target 或者 Project 的,需要到官方下载 Swift Playgrounds Author Template, 如果你想更进一步了解这两种格式的区别,建议阅读 官方的说明文档Using Swift Playgrounds & Playground Books

supportedDevices 这个 key 是用来区分不同平台的,例如是 iPad 还是 Mac,而 requiredCapbilities 则是用来明确平台能力的,例如需要 AR 能力(Mac 拥有,而 iPad 没有),需要 WIFI 能力(两个平台都具备)。

它们的设定是在 manifest 文件和 feed json 文件中进行的,需要设定相应的 key 值,具体的示例可以参考下面的两张图

IMAGE.png

IMAGE.png

关于 requiredCapbilities 支持的键值,可以查看 UIRequiredDeviceCapabilities 的 API 说明,这里放一个传送门

已经知道了这些能力,那么在代码里如何使用呢,下面就是一段示例代码,我们可以根据不同的平台来编译不同的代码

IMAGE.png

为了让 Playground Book 的体验更好,我们肯定希望开发者去适配更多的能力,例如 AR,重力感应,GPS等,但如果用户一开始是在 Mac 上使用的话,可能会无法看到这些功能,例如你的逻辑是只有检测到具备这个功能才显示某个按钮,如果不具备则隐藏。

那么我们要怎么做才能暗示用户呢,Apple 的建议是在语言的描述上做好引导,例如在不同的平台上,对操作的描述是这样的

IMAGE.png

如果是一个通用的功能,说辞可以选择 tap 或者 select

IMAGE.png

适配系统的设置规则

在开发实际内容的过程中,我们要考虑到系统的设置,例如主色调,副色调和暗色模式等!

IMAGE.png

这张图单纯就是觉得主讲的妹子蛮可爱的,想骗你们看 session…..

Apple 提供的原生组件会自动响应这些变化,而开发者自定义的组件需要适配才能实现同样的功能,这一点需要开发者做好,涉及的资源建议放到 Asset catalog 中

除了上面的问题外,在开发跨平台的内容时,还需要关注 live view 的 Safe Area。

例如下面的视图中,左侧是在 ipad 上运行的 Live View, 右侧则是在 Mac 上运行的效果,仔细观察的话,会发现右侧的 Live view 在右上角多出有一些按钮,这就会导致两个 Live View 的 Safe Area 的区域不一致

IMAGE.png

之前开发者会使用 liveViewSafeAreaGuide 这个特殊的 API 获取相关值,现在终于可以直接使用 safeAreaLayoutGuide 这个更通用,更好理解的 API 了

IMAGE.png

总结

这篇 Session 的内容就到此结束了,我们复习一下整个文章的内容,大体有 3 部分

  1. Swift Playground 针对不同平台做了 UI 上的调整,使其更符合每个平台的使用习惯
  2. 提供了更加强大的定制化能力,不仅包括了平台定制,还包括了能力定制
  3. 开发者需要做好适配系统设置的工作,Apple 也为开发者提供了一些便捷能力

另外,如果各位有时间的话,强烈推荐观看一下 Explore Swan’s Quest 里的四个 Session,加起来的全部观影时间也就 35 分钟,但看完以后,你会发现原来 Swift Playground 竟然还有这么多有意思的用法,感觉像打开了一个新的世界一样。

相信我,这绝对不是骗你。

IMAGE.png

WWDC20 10680 - Refine Objective-C frameworks for Swift

作者 SketchK
2020年7月3日 04:25

每一年的 WWDC 里都会有一些类型 Apple 工程师教你如何写代码的 Session,这些 Seesion 的内容都偏向最佳实践,告诉你如何写出 Apple 风格的代码,解答你对代码里的各种疑惑,甚至给出你如何继续深入研究的方向,这对开发者来说,是一个非常好的学习机会。

在这个 Session 中,Apple 的工程师将告诉我们如何改造现有的 Objective-C 框架,使其能够更符合 Swift 的使用体验,所以你不仅能学习很多实际的技巧,也会进一步了解他们背后的思考。

话不多说,来看正文吧!

引子

每一年的 WWDC 里都会有一些类型 Apple 工程师教你如何写代码的 Session,这些 Seesion 的内容都偏向最佳实践,告诉你如何写出 Apple 风格的代码,解答你对代码里的各种疑惑,甚至给出你如何继续深入研究的方向,这对开发者来说,是一个非常好的学习机会。

在这个 Session 中,Apple 的工程师将告诉我们如何改造现有的 Objective-C 框架,使其能够更符合 Swift 的使用体验,所以你不仅能学习很多实际的技巧,也会进一步了解他们背后的思考。

话不多说,来看正文吧!

背景介绍

相比于六年前推出的 Swift 语言,Objective-C 在 Apple 生态圈的历史更为悠久,导致了历史包袱比较重的或者现有的工程中还会持续存在许多 Objective-C 的框架,这些框架不是孤立的,会与 Swift 的框架产生依赖关系和调用关系。而这种微妙的关系产生了许多棘手的问题。

IMAGE

不光社区里的开发者会遇到这样的问题,Apple 公司的工程师也无法例外,在 Session 里,Brent 用这样一段话来形容这个问题,我感觉很贴切:

We understand that, because Apple is in the same boat. We probably have more Objective-C frameworks than anyone in the world.

所以如何让 Objective-C 框架更好的为 Swift 服务也是他们要解决的问题之一,虽然 Swift 编译器在转换 Objective-C 接口时做了很多不错的优化工作,但很难满足所有开发者的期望,不过这不代表你没有办法去优化它,因为今天的 Session 就是做这个事儿的。

从改变途径来看的话,主要是通过以下几种方式:

  • 遵循编译器的某些规则
  • 在头文件里进行特殊标注
  • 用 Swift 做中间层,重新封装原有代码
  • 根据自己的喜好进一步优化

知识目录

IMAGE

Demo 工程

这个 Session 是围绕一个用于描述 NASA 载⼈航天计划的 SDK 展开的,这个 SDK 的名字叫做 SpaceKit,它是 Objective-C 编写的。

现在这个 SDK 会被一些 Swift 代码调用,所以我们要通过一些改造,使其更符合 Swift 的使用习惯。

如何查看编译器生成的 Swift 接口

考察一个 Objective-C SDK 是否符合 Swift 的使用习惯,最重要的一点就是看它生成的 Swift API 质量。那么,我们如何查看 Swift Compiler 自动生成的接口呢?

在 Objective-C 的头文件里,点击左上角的 Related Items 按钮,选择 Generated Interface 后,就会出现满足不同 Swift 版本的接口文件。

IMAGE

点开后,它的样子大体如下

IMAGE

这个功能对于我们理解如何生成更符合 Swift 使用习惯的 API 来说是非常重要的,所以希望你能掌握这个技巧!

自动生成的接口利与弊

下面是根据 Objective-C 源码自动生成的 API 接口,我们可以看到 Swift 编译器已经做了不少的优化,例如:

  • 将 NSString,NSDate 类型转换成了 String,Date;
  • 将 Objective-C 里的初始化方法转换成了 Swift 里的构造器方法;
  • 将原有的 - (NSSet *)previousMissionsFlownByAstronaut:(SKAStronaut *)astronaut 的方法名优化成了 previousMissionFlown(by astronaut:)
  • 将原有的 -(BOOL)saveToURL:(NSURL *)url error:(NSError **)error 的错误处理 API 改成了 Swift 风格的 API

IMAGE

但这样的 API 接口还存在多问题,让我们列举一下:

  • SKMission 的问题:

    • 过多的隐式解析可选类型
    • crew 属性里的 Any 定义过于模糊
    • save(to url:) 可能会在不该抛异常的时机点抛异常
    • previousMissionFlown(by astronaut:) 的方法名还不够优雅
  • SKAstronaut 的问题:

    • 构造器之间关系不够清晰
  • SKErrorDomain 和 SKErrorCode 的问题:

    • NSError 风格的 API 在 Swift 里的使用体验非常不好,尤其在 try catch 中
  • SKCapsule 和 SKRocket 类型的常量

    • 用于枚举的字符串常量在 Swift 里更适合使用 enum 类型来描述
    • SKCapsuleApolloCSM 的 API 消失了
    • SKRocketStageCount(_ rocket: String!) -> Unit 的 API 还有不少潜在的风险

如果你还看不出上面存在的所有问题,也无法提供所有问题的解决方案,那么这篇文章将十分适合你阅读。

所以让我一起来看看 Apple 工程师给出的解决方案吧!

改进的方法

如果想解决上面提到的各种问题,可以从下面四个方向入手:

  • 提供更丰富的类型信息
  • 遵守 Objective-C 的约定
  • 解决缺少 API 的问题
  • 改善框架在 Swift 里的使用体验

提供更丰富的类型信息

增加 nullability 的描述信息

Objective-C 指针既可以是一个有效值,也可以是空值,例如 null 或者 nil,这与 Swift 里的可选值行为十分相似。

如果我们再仔细想一下,就会发现在 Objective-C 里面,每个指针类型实际上都是可选类型,每个非指针类型都是非可选类型。可是大部分时间,一个属性或者方法不会处理输入值是 nil 的情况,或者永远不会返回 nil。

所以,默认情况下 Swift 会把 Objective-C 里的指针当做隐式解析可选类型,因为它认为这个值大部分情况下不会是 nil,但它也不完全确定。

虽说这种转换规则没什么毛病,但大量的隐式解析可选类型让代码变得意图模糊,好在我们有两个关键字注解可以去描述这个意图,他们分别是 nonnull 和 nullable

这两个注解在 Objective-C 里面只是用于记录开发者的意图,不是强制的。但 Swift 会用到这些信息来决定是否转换为可选类型。

另外需要注意的是,在标注完 nullability 后,原有的 Objective-C 代码可能会出现一些新的警告,这里请认真检查并按照提示进行修改,这会让你的代码更健壮。

IMAGE

除了 nonnull 和 nullable 意外,还有一对配合使用的宏 NS_ASSUME_NONNULL_BEGINNS_ASSUME_NONNULL_END 可以让我们的代码更清爽。

在这两个宏包裹的代码片段中,属性,⽅法参数和返回值的默认注解都是 nonnull 类型的,这样一来,我们就可以删掉许多冗余的代码。

IMAGE

但是这些方法并不适用所有的场合,例如你将 nonnull 直接放在常量前会触发编译器错误。还好这种错误是有解决办法的!

nonull 和 nullable 只能在方法和属性上使用,如果想拓展其使用场景,就需要直接调用这两关键字底层的内容,也就是 _Nonnull_Nullable

这两种注解除了可以用在全局常量,全局函数的场景外,也适用于任何 Objective-C 任何地方的指针类型,甚至那种指向指针类型的指针。

IMAGE

现在我们看到 SKRocketSaturnV 终于如期所愿的摆脱了隐式解析可选类型!

IMAGE

然后我们看看下面的 API 可能存在的问题,在这里我们从 API 层面假设 capsule 是一个非空值,但可能这是不合理的,并不是每次的飞行计划都需要载人,不是么?

IMAGE

那么,如果 Objective-C 返回了⼀个 nil 值,⽽在 API 层⾯,Swift 认为这是⼀个⾮空值,又会发⽣什么呢?

如果是 NSString 或者 NSArray 的话,Swift 会得到⼀个空的字符串或者数组,这可能会引起一些问题,但对于其他类型,可能会拿到⼀个⽆效的对象,总之,可能与你的预期不⼀样。

如果是 Objective-C 对象,你可能很难注意到这⼀点,因为 Objective-C 会忽略 nil,但在某些 case 下,你可以会因为 null 指针崩溃或者触发异常⾏为。

编译器不会对这种⾏为作出任何的承诺,所以改变 release mode 或者 xcode 版本可能有不同的表现!

不论怎样,需要记住的是,当你头⽂件⾥某个东西不会是 nil 的时候,Swift 不会对其强制解包,所以你不会在返回 nil 的地⽅看到崩溃。

IMAGE

那么对于这种 case,就没有解决办法了么?

好消息是 Objective-C 编译器和 Clang 的静态检查能够很好的解决这个问题,所以在写好 nullability 的注解后,最好关注⼀下编译器警告和静态分析结果!

就如下图所示一样

IMAGE

当然我们知道,开发者可能还会遇到一些特殊的 case,在这些 case 里,他们没法确定代码到底是有值,还是没有值,所以 Apple 还提过了 _Null_unspecified 的注解词。

IMAGE

_Null_unspecified 标注的内容会被转换成隐式解析可选类型,这种类型在 Swift 里的使用场景大体如下,例如某个属性在其⽣命周期早期为 nil,之后再不会是 nil 的情况。

当然,在你⽆法确定的 case 里也可以这样使用,因为

  • 如果一切按照预期,你可以⽆需解包,继续使⽤
  • 如果返回的是 nil,你会稳定的复现这个 bug,⽽不是⼀些奇怪的⾏为

利用泛型约束接口

原有的接口中,没有对 crew 这个数组里的元素进行约束,这会使得其转换到 Swift 的 API 时,将其中的元素描述为 Any。

虽然也不是什么大的毛病,但用起来确实会显得有点别扭!

IMAGE

我们都知道 Objective-C 也提供了一些泛型的能力,所以我们完全可以将其优化到一个更好的层次上,通过在 Objective-C 里添加相应的语法内容,就可以将其在 Swift 的使用体验改善不少。

当然除了 NSArray,NSDictionary 等基本类型也适用这个技巧!

IMAGE

对于数字处理统一使用 Int

我们先看看这样一段代码,下⾯的函数返回⼀个计数值,很显然,你在喊倒计时的时候,数值不会为负,所以在 Objective-C ⾥⾯以 NSUInteger 的形式返回。

这样的声明,意味着在 Swift 里会返回⼀个 UInt,而这意味打破了 Swift 的使用习惯。

IMAGE

当我们想对比特位进行运算的时候,我们通常会使用 unsigned 类型的数据,因为 signed 的数据在处理起来会有些麻烦,而且在这种场景下,我们还十分关注数据的位数,但是由于 NSUInteger 的大小会因架构不同而产生一些变化,导致使用它的人并不多。

IMAGE

与此初衷不同的是,大多数人使用 NSUInteger 是为了表明这个数值是⾮负的,虽然这种用法是可行的,但它还是会存在一些严重的安全漏洞,所以这种设计思路并没有被 Swift 采用。

Swift 采取的策略是在进⾏有符号运算时,要求开发者必须将⽆符号类型转换为有符号类型,如果 Swift 在处理⽆符号运算时,产⽣了负值,就会直接停⽌运算。

也正是这样的策略,会让 Swift 中的 Int 和 UInt 在混合起来使用的时候变得很麻烦,当然,这在 Objective-C ⾥⾯的也是一个棘手的问题。

所以混合使用 Int 和 UInt 并不是 Swift 里的最佳实践,在 Swift 里面,我们建议将所有进行数值计算的类型声明为 Int,即使它永远不可能为负数。

IMAGE

对于 Apple 自己的框架,他们设置了一个白名单用于将 NSUInteger 转换为 Int。

对于开发者而言,决定权在我们自己手里,我们可以⾃⾏选择是否使⽤ NSInteger,但 Apple 的工程师强烈推荐你这么做。

或许在 Objective-C ⾥⾯差距不是很⼤,但在 Swift ⾥⾯很重要!

将字符串类型的常量变得更有条理

下面我们来看看这样一段代码,从某种角度上来说,SKRocketStageCount 这个 API 很容易被滥用,因为只要传一个字符串就行了,但其实我们希望传入的是以 SKRocket 为前缀的常量字符串。

可惜 Swift 无法感知这一切,它能看到的只是函数需要的是字符串⽽已,如果传了其他值的字符串,就会出现不符合预期的情况。

IMAGE

在 Swift ⾥通常会把这些常量变成⼀个具有字符串原始值的枚举或者结构体,然后改变函数的入参类型,使其接受相应的枚举或者结构体类型。

那么我们怎么去改造这个接口呢?我们先说个最简单的方法:

使⽤ typedef 将常量分组,并将涉及此常量的地⽅改为新的类型。而在 Swift 中,typedef 会被转换成 typealias

IMAGE

这已经使得代码发生了一些变化,不过这还不是最终效果!

此时,你只需要在 typedef 后⾯加上 NS_STRING_ENUM 即可, 此时,原有的字符串常量将以结构体的⽅式导⼊到 Swift 中, 而且,你注意到没有,SKRocketStageCount 的⼊参类型彻底的变了!

IMAGE

怎么样,一共就 2 步,就能得到原汁原味的 Swift API,是不是还不错!

这样的使用方式,可以在 Apple 的框架里看到不少实实在在的例子,例如 NSAttributedStringKey,NSCalendarIdentifier,NSNotificationName,NSNotificationUserInfoKey 等。

所以放心使用它吧!

遵守 Objective-C 的约定

关于构造器的相关约定

接下来,我们来看看构造器方面的问题。

下面的代码中,SKAstronaut 有两个初始化构造器,⼀个入参类型为 PersonNameComponents, ⼀个入参类型为字符串,

这就意味着,如果要声明⼀个 SKAstronaut 的⼦类,就需要重写两个⽅法,但 NSPersonNameComponents 本质也代表一个字符串,所以这样的工作显得有点多于。

IMAGE

同时,你还会在使用的时候发现,莫名其妙的多出来一个构造器,它没有任何的入参,我们在源⽂件⾥找不到任何与此相关的定义,但其实它来⾃ superclass。 因为 SKAstronaut 继承⾃ NSObject。

虽然有这么一个方法,你也能调⽤,但很可能它⽆法正常⼯作!

IMAGE

如果你深入分析上面的两个问题会发现,它们的内核是一样的。

在 Objective-C 中,有⼀个关于初始化器的约定,它确保开发者知道如何写⼀个总是能被正确初始化的⼦类。

这个约定的大体内容是这样的,将初始化器分为两类,designated 和 convenience。 你需要覆盖所有 designated 初始化器,以便安全地继承 convenience 的初始化器。

IMAGE

这个约定和 Swift 里面的构造器约定十分相似,但它们有个本质的区别!

Objective-C 的这种构造器约定不是语⾔级别的强制规则,更多的是⼀个开发者之间的约定,例如 convenience 必须选择⼀个 designated 的接口,但实际上很多 Objective-C 的类并没这么做,这也意味着如果有⼦类的话,如何正确构造它会成为⼀个头⼤的问题!

这是⼀个⾮常⾮常不好的事情,尤其对框架使⽤者⽽⾔,如果想写出⾼质量的代码,就必须阅读源码,或者逆向来观察它的行为,甚至通过猜测的方式, ⽽这都会导致⼦类出现异常的概率变⼤。

IMAGE

如果你忘了重写⼀些必要的构造器,作为框架的维护者是不会收到警告的,⽽使⽤者恰巧使⽤了这个 API,那就意味着这个类的初始化可能出现了问题,使⽤者会感觉很痛苦,为什么写个构造器这么难?

所以作为框架的维护者,我们需要去直面这个问题!

通常 designated 构造器会调⽤ [super init] 这个方法,而 convenience 构造器会调⽤⾃⾝的某个 designated 构造器

IMAGE

所以,我们需要在 designated 构造器后面添加 NS_DESIGNATED_INITIALIZER, 对于 convenience 类型的构造器,你不需要做任何事情

IMAGE

在添加完 NS_DESIGNATED_INITIALIZER 以后,可能会遇到一些错误提示,它会要求你重写⽗类的 designated 构造器,因为这是一个潜在的 bug,如果有⼈使⽤了⽗类的 designated 构造器,而你没有对此进行处理,对象的构造就可能会出现问题。

所以

如果你想支持这些父类构造器,就去实现它!

如果你不想支持这些父类构造器,就需要完成如下的工作

  • .m 文件里重写父类构造器,并调用 doesNotRecognizeSelector 方法

IMAGE

  • .h 文件里用 NS_UNAVAILABLE 声明对应的 API

IMAGE

除此之外,还需要提醒的有两点

  • 除了关注父类的 designated 构造器,开发者也需要关注 convenience 类型的构造器。
  • NS_UNAVAILABLE 的 API 不会被继承

通过这些改造,你的 Swift 接口将会变得清晰明了!

关于错误处理的相关约定

让我们在看看下面的代码

在整个文章的开始部分,我们提到这段代码可能会在不该抛出异常的时候抛出异常,至于原因,其实在这个 API 的注释里就已经说明了。

IMAGE

可能有人会问,看起来没什么问题啊?

这其实就是问题本身,可能我们会认为如果⼀个⽅法要发出失败的信号,就意味着必须要返回 false 且为 error 设置⼀个 non-nil 值;如果只返回⼀个 false 并不是真的失败。

但事实上,通常的约定是:返回 false 就是失败!即使 error 是 nil。

IMAGE

Apple 的工程师并不建议把 error 设置为 nil,因为这样的话,使用者就无法感知发⽣了什么,但如果你坚持这样做,也就是返回 false 的同时,返回了一个 error 为 nil 的值,从惯用的约定来看,它仍然代表失败!

所以在 swift ⾥调⽤这种 Objective-C 的⽅法时,它会⾃动导⼊ throws,而且 swift 会认为你遵循刚才提到的约定,所以只要⽅法返回 false 它就会抛出异常!

但是 Swift 又不允许你抛出异常的时候,提供的信息是 nil。如果没有 error,swift 会抛出⼀个基础库里⾮公开的 error 类型,由于这是⼀个⾮公开的类型,你是⽆法 catch 这些信息的,但是你可以在 logs ,debugger 或者 error message ⾥看到相关的信息。

这意味着⼀些 Obective-C 代码即使没有失败也返回了 false,或者失败了但没有告诉你原因。

IMAGE

所以回到一开始的那段代码上,⽂档注释⾥说明了在某些情况下,例如有东西需要保存的时候,会直接返回 false,虽然其实这不能算是失败,但是 Swift 会因为 false 抛出异常。由于⽅法没有设置正确的 Error 信息,就会提到我们前⾯说的情况,抛出⼀个基础库里⾮公开的 error 信息。

所以如何解决它呢?

最简单的方法就是去掉特殊情况,让 false 总是意味着失败,⽽且让该⽅法遵守前⾯提到的约定。

IMAGE

不过这也引入了新的问题,如果你的用户需要判断那种特殊情况的时候,这种⽅法就⾏不通了!

另外一种方式是,在 API 后面标注 NS_SWIFT_NOTHROW 来告诉 Swift 你不想遵守那个约定,这样的话,Swift 会让你⼿动处理错误相关的代码

虽然这解决了问题,但并不是最好的方式,建议将这个 API 废弃,此时你只需要在后面添加 DEPRECATED_ATTRIBUTE 即可

IMAGE

在 Objective-C 里最好的解决方案是重新写一个 API 并返回额外的信息来表示刚才提到的特殊场景。

例如可以添加⼀个布尔输出参数来说明⽂件是否真的被保存了,然后返回值就可以遵守之前的约定了。

IMAGE

目前来看,这是在 Objective-C 里的最佳解决方案了!

如果你还想进一步优化,我们还是有办法的,只是我们需要用 Swift 将其包裹一下并对外保留 Swift 接口,而非原先的 Objective-C 接口。

在 Swift 里,我们可以提供这样的一段代码来优化使用体验

在 Objective-C 里面,我们要小心返回值,因为他的惯用约定会认为返回 false 即是失败,而在 Swift 里,我们则可以忽略这一点,返回值与抛出异常或者失败是完全没有关系的!

所以代码会变成下面的样子!

IMAGE

为了让这个文章的内容更连贯,我将原 Session 里这部分的内容做了精简,此处还提到了以下几个知识点:

  • 在框架的头文件里,也就是 umbrella header 里,不要导入 generated header,也就是系统自动生成的 <framework>-Swift.h,这个头文件会声明 swift ⽂件⾥所有被 @objc 标记的内容
    • 不这么做的原因是会产生循环依赖,因为不导⼊ umbrella header ⾥的内容,Swift ⽆法⽣成 <framework>-Swift.h,但如果 umbrella header 导⼊了 <framework>-Swift.h,那么 swift 就会试图读取这个还没⽣成的⽂件,而这就会造成问题!
  • 在某些平台上,Swift 的 bool 和 Objective-C 的 bool 略有不同,主要是在内存表示方面的问题。
    • 通常,swift 会做⼀层转换,但是现在的代码⾥你操作的是指针,也就是将 Swift 的 bool 指针直接扔给 Objective-C 使⽤,而对于这种 case 来说,Swift 还没覆盖,所以我们需要做⼿动转换,声明⼀个 ObjcBool 类型即可

现在我们的 Swift 使用者终于可以使用到非常舒服的 API 了,不过还有一个问题就是,他在这种场景下,依然能接触到 Objective—C 里面提供的新 API,-(BOOL)saveToURL:(nonnull NSURL *)url wasDirty:(nullable BOOL *)wasDirty error:(NSError **)error

他一定会面临这样的处境,我该用哪个呢?

所以作为框架的维护者,你现在的情况是 :

  • 希望 Objective—C 的使⽤者用到原先的 API,
  • 希望 Swift 的使用者用到 Swift 里提供的 API

针对这种情况,你也有相应的解决办法,此时你需要

  • 在原有的头⽂件⾥对相应的 Objective—C 的⽅法标记 NS_REFINED_FOR_SWIFT

IMAGE

  • 修改 Swift 里的代码实现

IMAGE

可能有人会好奇,NS_REFINED_FOR_SWIFT 到底干了什么?

其实这个标记做的事情很简单,它把对应的 Swift 版本 API 进行了改造,改造的内容就是在其开头增加了两个下划线在开头,

当 Xcode 看到这样的 api 时,会让编辑器将其隐藏起来,例如代码补全的时候,但它不代表你不可以调用,所以在刚才的 Swift 文件里,我们看到了 self.__save(to:url, wasDirty: &wasDirty) 的代码。

此时,我们仍然使⽤了 Objective—C 的实现,⽽且使⽤者也⽆法直接调取那些我们不想让他获取的 API 了!真是一个让人开心的结果!

解决缺少 API 的问题

通常 Swift 编译器会导⼊ Objective—C 头⽂件⾥的⼀切,但如果它⽆法识别如何导⼊的时候,就会忽略这些内容,进而导致某些 API 没有在 Swift 里展示。

这些场景会是如下的这些情况:

IMAGE

回到代码上,我们可以看到 SKCapsuleApolloCSM 这个 API 消失了!

IMAGE

在这里,我们用宏定义了一些字符串,在这里宏本质上只是⼀个⽂本⽚段,你可以在 Objective—C 源码的任何地⽅使⽤它。

同⼀个宏在不同的地⽅可能有不同含义,而 Swift 本⾝是没法搞清楚这些的,所以 Swift 只能识别符合某些特定模式的宏,这种模式主要是⽤来声明常量的。

它允许你为另外⼀个宏命名,或者给宏设置某个值,但是两者同时使用就会出现识别问题,

所以来看第四个宏,它替换了另外⼀个宏,并在原本的内容上增加了 “.csm”,这对 Swift 来说有点超出其能力范围了!

有许多⽅法解决这个问题,最简单的就是⽤完整的字符串来表达这个宏,而不是用相对复杂的宏拼接。

IMAGE

但如果你是⽤这些字符串做枚举,建议你把它转成真正的字符串常量,这样就可以像前面的 SKRocket 常量⼀样进⾏字符 串枚举了!那才是好的最佳实践!

改善框架在 Swift 里的使用体验

如何改善 Swift 的 API 名称

Swift 和 Objective—C 的命名风格是有所不同,例如 Swift 的 API 是由基名(previousMissionsFlown)和参数标签(by)组成的,⽽ Objective—C 基本上只有参数标签(previousMissionsFlownByAstronaut),没有单独的基名,所以基名的信息会包含在第⼀个参数标签⾥,这也导致了 Objective—C 的方法名会显得略长一些。

为了解决 API 风格上的问题,Swift 会根据一些规则重命名,通常这个结果还不错,但这毕竟是计算机的审美结果,很难满足开发者的诉求。

IMAGE

例如某些开发者会认为 flown 应该是参数标签⾥的⼀部分,⽽不是 base name,因为这个⽅ 法获取的是以前的任务列表,它们是某个宇航员所执⾏的任务!

当然这不是绝对的,这⾥只是个假设。

所以为了解决这个问题,我们使⽤ NS_SWIFT_NAME 重新命名这个⽅法

IMAGE

好了!这个 API 终于满足你的诉求了!

或许你会说我知道 NS_SWIFT_NAME 能重命名方法名,但 NS_SWIFT_NAME 的能力还有很多施展空间!

例如下面的枚举!

IMAGE

或许,乍一看,这个枚举其实已经写得挺好的了,但其实也有不少改进的空间。

可能你会想到⽤ NS_SWIFT_NAME 删除其前缀 SK,因为在 Swift 里面没有这种做法,但不推荐这么做, ⼤部分 Objective—C 的类都会将框架前缀与⼀个像 query 或者 record 的词组合起来,例如 SKFuleKind ⾥的 SK 和 FuleKind。

所以我们需要用别的方法来优化使用体验,针对目前框架,我们有一个 SKFule 类,此时我们可以让这个枚举和 SKFule 联合使⽤,所以我们将其改为 SKFuel.Kind

IMAGE

除了重命名枚举外,NS_SWIFT_NAME 的另外一个常见使⽤场景是处理那些与 Swift 风格差异过大的 API,这在许多 C 语言的库里十分常见,例如全局函数,全局变量等。

像上面的例子中,SKFuelKindToString 就是一个全局函数,我们不仅可以⽤ NS_SWIFT_NAME 对其重命名,还可以去掉额外的信息,添加⼀个参数标签。

IMAGE

不过刚才处理全局函数的例子只是展示了 NS_SWIFT_NAME 能力的冰山一角!下面我们会再展开几个例子:

⾸先你可以将 global function 转换成 static method,做法是在 NS_SWIFT_NAME 里指明 Objective—C 的类型并在 类型后面使用点语法声明⽅法名

IMAGE

然后,你还可以将其变为实例⽅法!

IMAGE

最后,你也可以将某个⽅法变为⼀个属性,只需要在前⾯增加⼀个 getter,同理 setter

IMAGE

将这些技术应⽤在充满 C 函数的框架⾥,可以很好的重塑 API,如果你用过 Core Graphics 的话,你会深有体会!

下⾯要说说 NS_SWIFT_NAME 的能⼒边界,例如刚才的那个 getter 例子,即使你将⽅法名改为 description,你也⽆法让这个类型遵守 string convertible protocol。

但是我们可以通过在 Swift 文件里添加扩展来使其满足 protocol conformance!

如下图所示,给 SKFuel.Kind 写了⼀个扩展,并使其符合⾃定义字符串转换协议,由于 Objective-C 头文件已经⽤ NS_SWIFT_NAME 提供了相应的属性,所以写成这样,我们的 SKFuel.Kind 已经遵守了相应的协议并满足其使用要求。

IMAGE

为了让这个文章的内容更连贯,我将原 Session 里这部分的内容做了精简,此处还提到了以下几个知识点:

  • 我们应当注意到刚才的 Swift 文件,在那里我们可以写出任何你想提供的 Swift 版本专⽤的 API。例如整合了 UIView 的 SwiftUI 组件,或者将原有 API 的 completion handler 换成 Combine 的 API.
  • 如果对 SwiftUI 和 Combine 感兴趣,可以查看去年的两个相关 Session,Integrating SwiftUICombine in Practice
    IMAGE

如何提升 Error Code 在 Swift 里的使用体验

error code 枚举在许多框架里都能见到,它通常会和 NSError ⼀起使⽤。

通常这类代码会分为两个部分

  • 声明⼀个带有特定错误代码的 NS_ENUM
  • 为了防⽌错误码与其他框架的错误码发⽣冲突,还会声明了⼀个用于表明作用域的字符串常量。

下⾯的代码在接口层⾯是没有问题的,但在使⽤时,就会出现⽐较明显的问题!

IMAGE

假设我们有这样⼀个场景:我们需要考虑在执⾏某次飞⾏任务的过程中,如果发射终⽌了,我们要确保救援队能去营救宇航员!

所以代码会是如下的样⼦:

IMAGE

先调⽤发射任务的代码,如果是发射终⽌错误,我们需要对齐进行 catch,然后就是如何 catch 到特定的 case 了, 这里需要 error 中的作用域(domain)和错误码(code)信息

  • ⾸先我们要把它作为⼀个 NSError 来捕获。
  • 接下来,我们需要确保错误是 SKError 的作用域,⽽不是其他可能使⽤相同错误代码的作用域。
  • 然后我们需要将错误代码从⼀个 Int 转换成⼀个 SKErrorCode
  • 最后我们才可以检查它是否是我们想要的情况

看看上面这一坨代码,这只是为了匹配一个错误而已,真的有点复杂了!

毕竟这在 Swift 里可以通过一行模式匹配就搞定!所以我们有办法解决这个问题么?

答案很简单,将 NS_ENUM 替换成 NS_ERROR_ENUM, ⽤错误的作用域替(SKErrorDomain)换原始类型(NSInteger)

此时,我们再看 Swift 接口文件的话,我们会发现它已经不再是静态常量,⽽是结构体了!

IMAGE

它里面的具体内容会如下所示,是不是变得很 Swift 了?

IMAGE

除了上⾯的优化,Swift 编译器还做了如下的调整

  • SKError 自动遵循了 Error 协议,使得其使用习惯更贴近 Swift 的⽤法。
  • 提供一个 tilde equal 操作符,这就是 case 和 catch 语句匹配时使⽤的匹配操作符,

SKError 的这部分内容在⽣成界⾯是不可见的,但编译器真的会合成他们,并供你使⽤!⽽你要做的就是改⼀⾏代码!

IMAGE

至此,我们终于打造出来一个比较不错的 API 接口了!

总结

现在回到文章最开始的地方,思考⼀个问题,在⽣成的 Swift 接口中,并不是所有的 API 都有明显的缺陷,例如最后一个 Error Code 的案例,可能只有当我们看到 SKErrorCode 被使⽤的时候,才意识到这里有改进的空间。

虽然查看编译器生成的 Swift 头文件是一个好的方法。但⽣ 成的接口并不是全部,真正重要的是使用者在实际使⽤过程中写出的调⽤代码。

所以当我们在思考如何打造一个更适合 Swift 使用的接口是,不光要看看⽣成的接口。也应该考虑实际的使用场景。

IMAGE

总之,在 Session 中,Apple 的工程师提供给了我们很多在 Swift 里改善 Objective-C 框架体验的方法,也着重提醒了我们,相比于关注头文件,我们要更关注实际的使用体验和使用场景。

相信大家一定都有不少的收货,可能有人会问,这就是所有的手段了么?当然,Session 里面并没有展示全部的细节,如果你想进一步了解相关的内容,可以阅读以下内容:

  • Swift Document 里的 “Language Interoperability” 章节,传送门在此

如果你对 Swift API 本身的风格还不够了解,建议先阅读下面的文档:

当然如果你想了解这种混编过程的细节,建议您观看 WWDC 18 Behind the Scenes of Xcode Build Process

编译系统(Compilation System)和编译流程(Compilation pipeline)到底是什么

作者 SketchK
2020年6月7日 21:45

编译到底是什么一文中,我们了解了编译在计算机编程中扮演的角色和作用,这次我们将探讨编译系统是由哪些角色组成的!

回顾

从某种程度上来说:

编译,其实就是把源代码变成目标代码的过程。如果源代码编译后要在操作系统上运行,那目标代码就是汇编代码,我们再通过汇编和链接的过程形成可执行文件,然后通过加载器加载到操作系统里执行。如果编译后是在解释器里执行,那目标代码就可以不是汇编代码,而是一种解释器可以理解的中间形式的代码即可。

组成编译系统的基本元素

通常来说,编译系统是由 4 个部分组成

  • 预处理(preprocessor):负责引用 header file,libraries 的文件并组成完整的代码,以 C 语言为例,就是根据 # 开头的指令,例如 #include <stdio.h> 插入 stdio.h 的内容

  • 编译器(compiler):把重新组合好的代码再转交给 compiler,并转化成汇编语言(assembly language),使用汇编语言最重要的原因是不同的高级语言都可以变成汇编语言,汇编码也比机器码好 debug,PS:汇编语言会因硬件的架构而有所不同。

  • 汇编器(assembler):将汇编语言转换为机器码(machine code),并打包成重新定位的目标文件(object file)

  • 链接器(linker):负责合并所有的 object file 并产生可执行的文件,它可以被加载到内存中执行。

我们再把流程绘制成如下的图示:

IMAGE

编译器的组成

知道了编译系统后,我们再探究下编译系统里面编译器(compiler)环节。

按照龙书里的说法,我们可以将编译器里做的事情分为两个阶段:

  1. 分析(Analysis): 又称为 Compiler 的前端处理(front-end),分析与解构原始代码,并将其整理成中间代码(intermediate representation)与符号表(symbol table)并传给下一个阶段,当中如果发现任何问题就会提示报错
  2. 生成(Synthesis):又称为 compiler 的后端处理(back-end),根据符号表与中间代码产出目标代码

为了理解编译器的作用,我举一个很简单的例子。这里有一段 C 语言的程序,我们一起来看看它的编译过程。

1
2
3
4
int foo(int a){
int b = a + 3;
return b;
}

这段源代码,如果把它编译成汇编代码,大致是下面这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
    .section    __TEXT,__text,regular,pure_instructions
.globl _foo ## -- Begin function foo
_foo: ## @foo
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
addl $3, %eax
movl %eax, -8(%rbp)
movl -8(%rbp), %eax
popq %rbp
retq

你可以看出,源代码和目标代码之间的差异还是很大的。那么,我们怎么实现这个翻译呢?

其实,编译和把英语翻译成汉语的大逻辑是一样的。前提是你要懂这两门语言,这样你看到一篇英语文章,在脑子里理解以后,就可以把它翻译成汉语。编译器也是一样,你首先需要让编译器理解源代码的意思,然后再把它翻译成另一种语言。

表面上看,好像从英语到汉语,一下子就能翻译过去。但实际上,大脑一瞬间做了很多个步骤的处理,包括识别一个个单词,理解语法结构,然后弄明白它的意思。同样,编译器翻译源代码,也需要经过多个处理步骤,

所以,我们将编译器的两个工作环节进行更细致的划分

  • 分析 (analysis):
    • 词法分析器 (lexical analyzer),也称 scanner,建立 symbol table
    • 语法分析器 (syntax analyzer),也称 parser
    • 语义分析器 (semantic analyzer)
    • 生成中间码 (intermediate code generator)
    • 中间码最佳化 (code optimizer) (optional & machine-independent)
  • 生成 (synthesis):
    • 目标代码生成器 (code generator)
    • 目标代码最佳化 (machine-independent code optimizer) (optional & machine-independent)

流程图如下:

因为 Symbol Table 会在各个步骤都会使用到,因此将其独立画出来

IMAGE

当然国外也有人将其这样划分

IMAGE

现代语言,在语法解析过程,是可以不依赖符号表的,所以符号表一般都推迟到语义分析阶段才去建立,例如 Java 语言。
当然也有个别语言,在语法解析阶段需要查一下符号表(也就是获取上下文信息),才能知道某个标识符应该位于哪个语法中。所以,这个时候符号表会提前建立,在词法分析的时候就开始建立。

词法分析

首先,编译器要读入源代码。

在编译之前,源代码只是一长串字符而已,这显然不利于编译器理解程序的含义。所以,编译的第一步,就是要像读文章一样,先把里面的单词和标点符号识别出来。程序里面的单词叫做 Token,它可以分成关键字、标识符、字面量、操作符号等多个种类。把字符串转换为 Token 的这个过程,就叫做词法分析。

IMAGE

把字符串转换为 Token(注意:其中的空白字符,代表空格、tab、回车和换行符,EOF 是文件结束符)

语法分析

识别出 Token 以后,离编译器明白源代码的含义仍然有很长一段距离。下一步,我们需要让编译器像理解自然语言一样,理解它的语法结构。这就是第二步,语法分析。

上语文课的时候,老师都会让你给一个句子划分语法结构。比如说:“我喜欢又聪明又勇敢的你”,它的语法结构可以表示成下面这样的树状结构。

IMAGE

那么在编译器里,语法分析阶段也会把 Token 串,转换成一个体现语法规则的、树状的数据结构,这个数据结构叫做抽象语法树(AST,Abstract Syntax Tree)。我们前面的示例程序转换为 AST 以后,大概是下面这个样子:

IMAGE

这样的一棵 AST 反映了示例程序的语法结构。比如说,我们知道一个函数的定义包括了返回值类型、函数名称、0 到多个参数和函数体等。这棵抽象语法树的顶部就是一个函数节点,它包含了四个子节点,刚好反映了函数的语法。

再进一步,函数体里面还可以包含多个语句,如变量声明语句、返回语句,它们构成了函数体的子节点。然后,每个语句又可以进一步分解,直到叶子节点,就不可再分解了。而叶子节点,就是词法分析阶段生成的 Token(图中带边框的节点)。对这棵 AST 做深度优先的遍历,你就能依次得到原来的 Token。

语义分析

生成 AST 以后,程序的语法结构就很清晰了,编译工作往前迈进了一大步。但这棵树到底代表了什么意思,我们目前仍然不能完全确定。

比如说,表达式“a+3”在计算机程序里的完整含义是:“获取变量 a 的值,把它跟字面量 3 的值相加,得到最终结果。”但我们目前只得到了这么一棵树,完全没有上面这么丰富的含义。

IMAGE

这就好比西方的儿童,很小的时候就能够给大人读报纸。因为他们懂得发音规则,能念出单词来(词法分析),也基本理解语法结构(他们不见得懂主谓宾这样的术语,但是凭经验已经知道句子有不同的组成部分),可以读得抑扬顿挫(语法分析),但是他们不懂报纸里说的是什么,也就是不懂语义。这就是编译器解读源代码的下一步工作,语义分析。

那么,怎样理解源代码的语义呢

实际上,语言的设计者在定义类似“a+3”中加号这个操作符的时候,是给它规定了一些语义的,就是要把加号两边的数字相加。你在阅读某门语言的标准时,也会看到其中有很多篇幅是在做语义规定。在 ECMAScript(也就是 JavaScript)标准 2020 版中,Semantic 这个词出现了 657 次。下图是其中加法操作的语义规则,它对于如何计算左节点、右节点的值,如何进行类型转换等,都有规定。

IMAGE

ECMAScript 标准中加法操作的语义规则

所以,我们可以在每个 AST 节点上附加一些语义规则,让它能反映语言设计者的本意。

  • add 节点:把两个子节点的值相加,作为自己的值;
  • 变量节点(在等号右边的话):取出变量的值;
  • 数字字面量节点:返回这个字面量代表的值。

这样的话,如果你深度遍历 AST,并执行每个节点附带的语义规则,就可以得到 a+3 的值。这意味着,我们正确地理解了这个表达式的含义。运用相同的方法,我们也就能够理解一个句子的含义、一个函数的含义,乃至整段源代码的含义。

这也就是说,AST 加上这些语义规则,就能完整地反映源代码的含义。这个时候,你就可以做很多事情了。比如,你可以深度优先地遍历 AST,并且一边遍历,一边执行语法规则。那么这个遍历过程,就是解释执行代码的过程。你相当于写了一个基于 AST 的解释器。

不过在此之前,编译器还要做点语义分析工作。那么这里的语义分析是要解决什么问题呢?

给你举个例子,如果我把示例程序稍微变换一下,加一个全局变量的声明,这个全局变量也叫 a。那你觉得“a+3”中的变量 a 指的是哪个变量?

1
2
3
4
5
int a = 10;       //全局变量
int foo(int a){ //参数里有另一个变量a
int b = a + 3; //这里的a指的是哪一个?
return b;
}

我们知道,编译程序要根据 C 语言在作用域方面的语义规则,识别出“a+3”中的 a,所以这里指的其实是函数参数中的 a,而不是全局变量的 a。这样的话,我们在计算“a+3”的时候才能取到正确的值。

而把“a+3”中的 a,跟正确的变量定义关联的过程,就叫做引用消解(Resolve)。这个时候,变量 a 的语义才算是清晰了。

变量有点像自然语言里的代词,比如说,“我喜欢又聪明又勇敢的你”中的“我”和“你”,指的是谁呢?如果这句话前面有两句话,“我是春娇,你是志明”,那这句话的意思就比较清楚了,是“春娇喜欢又聪明又勇敢的志明”。

引用消解需要在上下文中查找某个标识符的定义与引用的关系,所以我们现在可以回答前面的问题了,语义分析的重要特点,就是做上下文相关的分析。

在语义分析阶段,编译器还会识别出数据的类型。比如,在计算“a+3”的时候,我们必须知道 a 和 3 的类型是什么。因为即使同样是加法运算,对于整型和浮点型数据,其计算方法也是不一样的。

语义分析获得的一些信息(引用消解信息、类型信息等),会附加到 AST 上。这样的 AST 叫做带有标注信息的 AST(Annotated AST/Decorated AST),用于更全面地反映源代码的含义。

IMAGE

好了,前面我所说的,都是如何让编译器更好地理解程序的语义。不过在语义分析阶段,编译器还要做很多语义方面的检查工作。

在自然语言里,我们可以很容易写出一个句子,它在语法上是正确的,但语义上是错误的。比如,“小猫喝水”这句话,它在语法和语义上都是对的;而“水喝小猫”这句话,语法是对的,语义上则是不对的。

计算机程序也会存在很多类似的语义错误的情况。比如说,对于“int b = a+3”的这个语句,语义规则要求,等号右边的表达式必须返回一个整型的数据(或者能够自动转换成整型的数据),否则就跟变量 b 的类型不兼容。如果右边的表达式“a+3”的计算结果是浮点型的,就违背了语义规则,就要报错。

总结起来,在语义分析阶段,编译器会做语义理解和语义检查这两方面的工作。词法分析、语法分析和语义分析,统称编译器的前端,它完成的是对源代码的理解工作。

做完语义分析以后,接下来编译器要做什么呢

本质上,编译器这时可以直接生成目标代码,因为编译器已经完全理解了程序的含义,并把它表示成了带有语义信息的 AST、符号表等数据结构。

生成目标代码的工作,叫做后端工作。做这项工作有一个前提,就是编译器需要懂得目标语言,也就是懂得目标语言的词法、语法和语义,这样才能保证翻译的准确性。这是显而易见的,只懂英语,不懂汉语,是不可能做英译汉的。通常来说,目标代码指的是汇编代码,它是汇编器(Assembler)所能理解的语言,跟机器码有直接的对应关系。汇编器能够将汇编代码转换成机器码。

熟练掌握汇编代码对于初学者来说会有一定的难度。但更麻烦的是,对于不同架构的 CPU,还需要生成不同的汇编代码,这使得我们的工作量更大。所以,我们通常要在这个时候增加一个环节:先翻译成中间代码(Intermediate Representation,IR)。

中间代码

中间代码(IR),是处于源代码和目标代码之间的一种表示形式。

我们倾向于使用 IR 有两个原因。

第一个原因,是很多解释型的语言,可以直接执行 IR,比如 Python 和 Java。这样的话,编译器生成 IR 以后就完成任务了,没有必要生成最终的汇编代码。

第二个原因更加重要。我们生成代码的时候,需要做大量的优化工作。而很多优化工作没有必要基于汇编代码来做,而是可以基于 IR,用统一的算法来完成。

优化

那为什么需要做优化工作呢?这里又有两大类的原因。

第一个原因,是源语言和目标语言有差异。源语言的设计目的是方便人类表达和理解,而目标语言是为了让机器理解。在源语言里很复杂的一件事情,到了目标语言里,有可能很简单地就表达出来了。

比如“I want to hold your hand and with you I will grow old.” 这句话挺长的吧?用了 13 个单词,但它实际上是诗经里的“执子之手,与子偕老”对应的英文。这样看来,还是中国文言文承载信息的效率更高。

同样的情况在编程语言里也有。以 Java 为例,我们经常为某个类定义属性,然后再定义获取或修改这些属性的方法:

1
2
3
4
5
6
7
8
9
Class Person{
private String name;
public String getName(){
return name;
}
public void setName(String newName){
this.name = newName
}
}

如果你在程序里用“person.getName()”来获取 Person 的 name 字段,会是一个开销很大的操作,因为它涉及函数调用。在汇编代码里,实现一次函数调用会做下面这一大堆事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#调用者的代码
保存寄存器1 #保存现有寄存器的值到内存
保存寄存器2
...
保存寄存器n

把返回地址入栈
把person对象的地址写入寄存器,作为参数
跳转到getName函数的入口

#_getName 程序
在person对象的地址基础上,添加一个偏移量,得到name字段的地址
从该地址获取值,放到一个用于保存返回值的寄存器
跳转到返回地

你看了这段伪代码,就会发现,简单的一个 getName() 方法,开销真的很大。保存和恢复寄存器的值、保存和读取返回地址,等等,这些操作会涉及好几次读写内存的操作,要花费大量的时钟周期。但这个逻辑其实是可以简化的。

怎样简化呢?就是跳过方法的调用。我们直接根据对象的地址计算出 name 属性的地址,然后直接从内存取值就行。这样优化之后,性能会提高好多倍。

这种优化方法就叫做内联(inlining),也就是把原来程序中的函数调用去掉,把函数内的逻辑直接嵌入函数调用者的代码中。在 Java 语言里,这种属性读写的代码非常多。所以,Java 的 JIT 编译器(把字节码编译成本地代码)很重要的工作就是实现内联优化,这会让整体系统的性能提高很大的一个百分比!

总结起来,我们在把源代码翻译成目标代码的过程中,没有必要“直译”,而是可以“意译”。这样我们完成相同的工作,对资源的消耗会更少。

第二个需要优化工作的原因,是程序员写的代码不是最优的,而编译器会帮你做纠正。比如下面这段代码中的 bar() 函数,里面就有多个地方可以优化。甚至,整个对 bar() 函数的调用,也可以省略,因为 bar() 的值一定是 101。这些优化工作都可以在编译期间完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
int bar(){
int a = 10*10; //这里在编译时可以直接计算出100这个值,这叫做“常数折叠”
int b = 20; //这个变量没有用到,可以在代码中删除,这叫做“死代码删除”


if (a>0){ //因为a一定大于0,所以判断条件和else语句都可以去掉
return a+1; //这里可以在编译器就计算出是101
}
else{
return a-1;
}
}
int a = bar(); //这里可以直接换成 a=101

综上所述,在生成目标代码之前,需要做的优化工作可以有很多,这通常也是编译器在运行时,花费时间最长的一个部分。

IMAGE

而采用中间代码来编写优化算法的好处,是可以把大部分的优化算法,写成与具体 CPU 架构无关的形式,从而大大降低编译器适配不同 CPU 的工作量。并且,如果采用像 LLVM 这样的工具,我们还可以让多种语言的前端生成相同的中间代码,这样就可以复用中端和后端的程序了。

生成目标代码

编译器最后一个阶段的工作,是生成高效率的目标代码,也就是汇编代码。这个阶段,编译器也有几个重要的工作。

第一,是要选择合适的指令,生成性能最高的代码。

第二,是要优化寄存器的分配,让频繁访问的变量(比如循环变量)放到寄存器里,因为访问寄存器要比访问内存快 100 倍左右。

第三,是在不改变运行结果的情况下,对指令做重新排序,从而充分运用 CPU 内部的多个功能部件的并行计算能力。

目标代码生成以后,整个编译过程就完成了。

编译器流程总结

IMAGE

Swift 编译器的不同之处

我们可以在 Swift 官网看到一篇名为 Swift Compiler 的文章,它从比较高的层面讲述了 Swift 编译器的主要流程,这里我们直接通过翻译的方式来介绍这些步骤:

  • 解析(Parsing):解析器是一个简易的递归下降解析器(在 lib/Parse 中实现),并带有完整手动编码的词法分析器。这个分析器会生成 AST,但不包含任何语义信息或者类型信息,并且会忽略源码上的语法错误。

  • 语义分析(Semantic Analysis):语义分析阶段(在 lib/Sema 中实现)负责获取已解析的 AST(抽象语法树)并将其转换为格式正确且类型检查完备的 AST,以及在源代码中提示出现语义问题的警告或错误。语义分析包含类型推断,如果可以成功推导出类型,则表明此时从已经经过类型检查的最终 AST 生成代码是安全的。

  • Clang 导入器(Clang Importer):Clang 导入器(在 lib/ClangImporter 中实现)负责导入 Clang 模块,并将导出的 C 或 Objective-C API 映射到相应的 Swift API 中。最终导入的 AST 可以被语义分析引用。

  • SIL 生成(SIL Generation):Swift 中间语言(Swift Intermediate Language,SIL)是一门高级且专用于 Swift 的中间语言,适用于对 Swift 代码的进一步分析和优化。SIL 生成阶段(在 lib/SILGen 中实现)将经过类型检查的 AST 弱化为所谓的「原始」SIL。SIL 的设计在 docs/SIL.rst 有所描述。

  • SIL 保证转换(SIL Guaranteed Transformations):SIL 保证转换阶段(在 lib/SILOptimizer/Mandatory 中实现)负责执行额外且影响程序正确性的数据流诊断(比如使用未初始化的变量)。这些转换的最终结果是「规范」SIL。

  • SIL 优化(SIL Optimizations):SIL 优化阶段(在 lib/Analysis、lib/ARC、lib/LoopTransforms 以及 lib/Transforms 中实现)负责对程序执行额外的高级且专用于 Swift 的优化,包括(例如)自动引用计数优化、去虚拟化、以及通用的专业化。

  • LLVM IR 生成(LLVM IR Generation):IR 生成阶段(在 lib/IRGen 中实现)将 SIL 弱化为 LLVM LR,此时 LLVM 可以继续优化并生成机器码。

从原文的内容里,我们可以看到 Swift 编译器主要是在前端部分增加了一些环节,主要是在语义分析和中间代码生成的过程中增加了几个步骤:

IMAGE

这一部分后续会继续完善,目前的水平和实践还只能到解释这么多

今天的内容就先到这了,希望通过这篇文章你能对编译系统,编译流程和编译环节做的事情有了一个初步的概念!

参考文献

编译到底是什么?

作者 SketchK
2020年5月25日 01:05

因为工作原因,最近要做包管理工具方面的开发,需要对 Compiler 有一些最基本的理解,写这篇文章的目的有两个:

  • 为了记录和整理自己的近期的学习内容,方便日后查阅
  • 抛开大段代码和抽象概念,通过通俗易懂的写作方式来加深自己对这些概念的理解

废话不多说,我们一起看看内容吧!

需要了解的概念

在看了不少关于编译相关的文章之后,我发现下面的词汇是大量出现的。

知道这些词汇代表的意思,以及对应的层次,能够更好地看懂别人所要表达的意思。

高级语言代码 High-Level Code

高级语言代码,自然是指由高级编程语言编写代码,对计算机的细节有更高层次的抽象。

相对于低级编程语言(low-level programming language)更接近自然语言(人类的语言),集成一系列的自动工具(垃圾回收,内存管理等),会让程序员更快乐的编写出更简洁,更易读的程序代码。

低级语言代码 Low-Level Code

低级语言代码,指由低级编程语言编写的代码,相对高级语言,少了更多的抽象概念,更加接近于汇编或者机器指令。但是这也意味着代码的可移植性很差。

在我看来,高与低,只是一组相对词而已。越高级的语言,性能、自由度越不及低级语言。但是在抽象、可读可写性、可移植性越比低级语言优秀。
在以前的年代,C/C++语言相对汇编语言,机器指令来说,肯定是高级语言。

而到了今天,我们更多人对C语言偏向认知为「低级语言」。
或许未来世界的开发者,看我们现在所熟悉的Java、PHP、Python、ECMAScript等等,都是「low」到爆的语言。

汇编语言 Assembly Language

汇编语言作为一门低级语言,对应于计算机或者其他可编程的硬件。它和计算机的体系结构以及机器指令是强关联的。换句话说,就是不同的汇编语言代码对应特定的硬件,所以不用谈可移植性了。

相对于需要编译和解释的高级语言代码来说,汇编代码只需要翻译成机器码就可以执行了。所以汇编语言也往往被称作象征性机器码(symbolic machine code)

字节码 Byte Code

字节码严格来说不算是编程语言,而是高级编程语言为了种种需求(可移植性、可传输性、预编译等)而产生的中间码(Intermediate Code)。它是由一堆指令集组成的代码,例如在 javac 编译过后的 java 源码产生的就是字节码。

源码在编译的过程中,是需要进行「词法分析 → 语法分析 → 生成目标代码」等过程的,在预编译的过程中,就完成这部分工作,生成字节码。
然后在后面交由解释器(这里通常指编程语言的虚拟机)解释执行,省去前面预编译的开销。

机器码 Machine Code

机器码是一组可以直接被 CPU 执行的指令集,每一条指令都代表一个特定的任务,或者是加载,或者是跳转,亦或是计算操作等等。所有可以直接被 CPU 执行的程序,都是由这么一系列的指令组成的。

机器码可是看作是编译过程中,最低级的代码,因外再往下就是交由硬件来执行了。
当然机器码也是可以被编辑的,但是以人类难以看懂的姿势存在,可读性非常差。

建立模糊的印象

如果要用一种现实生活中的职业来形容编译器的作用,我想翻译官是一个不错的选择。不论是同声传译,还是各个节目或者动漫的专业字幕组,反正只要能够把 A 语言流畅的翻译成 B 语言的都算。

但翻译的工作并不是那么简单,需要理解某种语言的文字,语法才能进行,当然更专业的人还能使用精简的句子传达意境。总之,这里的 ”翻译“ 其实不仅仅是翻译,还要再经过编辑,这也就就是 “compile“ ,编译的意思。

编译器 Compiler

在有了一个模糊的印象后,我们在聚焦到 compiler 上,compiler 就是计算机编程语言里的翻译官,不同的 compiler 会编译成不同的语言,有可能是转换成机器语言(machine code), byte code, 甚至是另外一种语言,如图:

IMAGE

最终产出的 target program 是能够被直接执行的,所以程序的编译到执行应该是这样的:

IMAGE

这种方式也叫做提前编译,Ahead-Of-Time Compilation(AOT),wiki 传送门:点我

直译器 Interpreter

还有另外一种语言处理的工具:直译器(Interpreter),相较于上图,compiler 是编译 source code 后产出可执行的代码,由使用者输入 input 后,再得到 output。而直译器是 source code 与 input 一起给出,直接编译并执行,产出 output,而使用直译器的语言有耳熟能详的 Python,它的架构如下:

IMAGE

另外 compiler 与 interpreter 在速度上也有一定的差异,compiler 产生的 target program 执行的比 interpreter 快。但 interpreter 的纠错能力又比较好,因为它是一行行的检查与执行程序中的代码。

关于 Interpreter,有的翻译叫做直译器,有的叫做解释器,wiki 传送门:点我)

编译器与直译器的异同

表现 Behavior

  • 编译器把源代码转换成其他的更低级的代码(例如二进制码、机器码),但是不会执行它。
  • 直译器会读取源代码,并且直接生成指令让计算机硬件执行,不会输出另外一种代码。

性能 Performance

  • 编译器会事先用比较多的时间把整个程序的源代码编译成另外一种代码,后者往往较前者更加接近机器码,所以执行的效率会更加高。时间是消耗在预编译的过程中。
  • 直译器会一行一行的读取源代码,解释,然后立即执行。这中间往往使用相对简单的词法分析、语法分析,压缩解释的时间,最后生成机器码,交由硬件执行。直译器适合比较低级的语言。但是相对于预编译好的代码,效率往往会更低。如何减少解释的次数和复杂性,是提高直译器效率的难题。

Compilation + Interpretation

再来,就势必要提及赫赫有名的 Java,为什么呢?Java 是一个结合 Compilation 和 Interpretation 的程序语言,这是什么意思呢?

就是 Java 会先编译成 byte code,接着再直译成机器码,这样的好吃是,Java 经历过一次编译,就可以通过虚拟机(Virtual machine)在不同的机器上直接执行。

沿用文章开始的翻译人员例子,byte code 就像是目前的通用国际语言 - 英语。只要将 A 语言翻译成英文,且 B 国人人能直接把英语翻译成自己的语言(当然前提是大家都会英文),此时,大家的交流就没有任何障碍了,整体的架构如下:

IMAGE

这种方式也叫做即时编译,Just-In-Time Compilation(JIT),wiki 传送门:点我

结合到实际

IMAGE

从左往右看,

  • 以 Java 为例,我们在文本编译器写好了 Java 代码,交由编译器编译成 Java Bytecode。然后 Bytecode 交由 JVM 来执行,这时候 JVM 充当了直译器的角色,在解释 Bytecode 成 Machine Code 的同时执行它,返回结果。

  • 以 BASIC 语言(早期的可以由计算机直译的语言) 为例,通过文本编译器编写好,不用经历编译的过程,就可以直接交由操作系统内部来进行解释然后执行。

  • 以 C 语言为例,我们在文本编译器编写好源代码,然后运行 gcc hello.c 编译出 hello.out 文件,该文件由一系列的机器指令组成的机器码,可以直接交由硬件来执行。

从抽象里看本质

无论是编译 (Compiler),还是直译 (Interpreter),甚至是即时编译。
本质还是人与计算机的交流形式,人的语言最终转换成机器语言。

一句 Hello World,经过一些列的编译和直译,最终转换成一系列包含机器指令的那些 0 和 1,机器傻傻执行完之后,告诉你结果。

就这么一个过程,我们就需要很多的翻译官。
有些翻译官可以做到同声传译(直译),有些翻译官却只能把我们的意图记下来再全部翻译(编译)给计算机。

而往往一个翻译官能力有限,也只能把你的语言,翻译成另外一种低级点的语言,再由另外懂这个语言的翻译官来翻译更接近计算机能读得懂的语言。

总结

这篇文章从一些与编译相关的常见概念说起,通俗的描述了编译原理范畴内的编译器与直译器:

  • 编译 Compile:把整个程序源代码翻译成另外一种代码,然后等待被执行,发生在运行之前,产物是另一份代码。
  • 直译 Interpret:把程序源代码一行一行的读懂然后执行,发生在运行时,产物是运行结果。

同时我们还用一些常见的计算机编程语言作为例子,浅显的解释了它们的编译过程。

希望通过这篇文章,你能对编译在计算机领域里扮演的角色和功能形成一个清晰的认知。

Xcode Concept 学习笔记

作者 SketchK
2020年5月15日 04:04

只怪自己当年学东西不够扎实,这次让我好好理解一下 Xcode 里的相关基础概念吧!

如果使用 Xcode 进行开发,我们常常会与这么几个概念打交道:Workspace,Project,Target,Scheme 和 Build Setting。

官方对这些概念的解释可以参考这篇文档 - Xcode Concepts

虽然文档本身已经被列为 Archived 的状态,但大毛病没有,还是可以拿来再学习一遍,所以后面的内容将围绕它展开。

另外在学习和调研的过程中,我发现了一个蛮有价值的博客,地址是 pewpewthespells.com,里面有不少关于 Xcode 的文章,估计是个做 CI/CD 工作的妹子,下面的几篇文章可以当做索引手册收藏起来:

概述

  • Target -一个 target 代表一个产品
  • Project - 对应 .xcodeproj 类型的文件,project 包含了构建产品所需的源文件,一个 project 可以有多个 target
  • Workspace - 对应 .xcworkspace 文件,用来组织管理 project 和其他文档的,workspace 可以包含多个 project,project 可以属于多个 workspace
  • Scheme - scheme 决定了哪个 target 去运行,它可以针对编译,运行,测试,打包等进行配置
  • Build Setting - build settings 就是构建产品时的一些设置,target 可以覆盖 project 一些相同的设置

Xcode Target

一个 Target 确定一个产物(product)的构建,包括一些指令(instructions),例如怎么从一个 Project 或者 Workspace 的一堆文件导出一个产物。简单来说,一个 Target 就定义了一个产物,一个 Target 对应一个 Product,它管理着一个产物的 Build System 的“输入”(一堆源文件和一些处理这些源文件的 Instruction)。 Projects 可以包含一个或者多个 Target,它们代表不同的产物,例如:如果你的产物需要做 Lite 和 Pro 版本,那么你可以考虑采取两个 Target 来处理。

构建一个产物的 Instructions(指令)的表现形式是构建设置(Build Settings),构建规则(Buidling Rules)和构建参数(Build Phases),这些都可以在 Xcode 的 Project Editor 中调整。一个 Target 的 Build Settings 继承 Project 的 Build Settings,但是可以重写覆盖 Project Settings。同时间内只能有一个 Active Target,Xcode Scheme 能够指定 Active Target。

一个 Target 可以跟其他 Target 相关联。如果一个 Target 在构建的时候需要另外一个 Target 的输出,我们说前者依赖于后者。

如果两个 Target 在相同的 Workspace 里,Xcode 能够发现它们的依赖关系,它能够以需要的顺序构建产品。这样的关系可以被称为隐形从属依赖(Implicit Dependency)。当然你也可以在 Build Setting 里为两个 Targets 指明显示依赖关系(Explicit Dependency)。

例如:在同一个 workspace 中,可以构建一个 library 和一个链接这个 library 的 application。Xcode 可以发现这种依赖关系,并首先自动构建 library。但是,如果想链接某个版本的 library,就需要在 build settings 明确依赖关系,该依赖项会覆盖隐式依赖项。

Xcode Project

Xcode Project 是个构建一个或者多个产物所需要的文件,资源,信息等的存储库(repository)。Project 包含用于构建产物的所有元素,并且管理这些元素间的关系。它包含一个或多个 Target,指定怎样去构建产品。Project 在工程里面默认的为所有的 Target 指定 Build Settings,当然每个 Target 可以覆盖 Project 的 Build Settings,去指定自己特有的 Build Settings。

一个 Xcode project 包含下面的信息:

  • 源文件的引用:
    • 源码,包括头文件和实现文件
    • Libraries and Frameworks
    • 资源文件(plist等)
    • 图片文件
    • nib 文件(xib, stroyboard等)
  • 用于在结构导航器( structure navigator)中组织源文件(source files)的组(groups),这里又分物理文件和引用文件
  • Project 级别的 Build Configurations. 你可以为 Project 指定多个 Build Configuration,例如,Xcode 就默认为我们指定了 Debug 和 Release 的 Build settings,当然你也可以自定义。
  • Targets,每个 Target 会指定:
    • 通过 Project 构建的一个产物的引用
    • 构建该产物所需的资源文件的引用
    • 用于构建该产物的构建配置(Build configurations),包括对其他 Targets 和 Settings 的依赖;如果 Targets 的 build configurations 没有配置时,使用 Project 级别的 Build Configurations
  • 用来 Debug 和 Test 程序的可执行环境(Executable Environment),包括:
    • 从 Xcode run 或 debug 时启动的可执行文件
    • 要传递给可执行文件的命令行参数
    • 程序 run 时要设置的环境变量

A project 可以单独存在,也可以被包含在 workspace 里面(cocoapods 就是被包含在 workspace 里面)。

Xcode Workspace

一个 Workspace 是一个 Xcode 文档,组合不同的 Project、文档,所以你可以同时管理多个 Project。一个 Workspace 可以包含任意数量的 Xcode projects 和其他文件。除了组织每个 Xcode Projects 中的所有文件外,Workspace 还维护 projects 与他们各自 Targets 之间的隐式/显示关联。

Workspace 扩展 workflows 的范围

一个工程文件(Project File)包含指向 project 中所有文件的指针,Build Configurations 和 Project 的其他信息。在 Xcode 3 之前,Projects 之前关联是很复杂的事情,大多数工作流仅限于单个 Project。从 Xcode 4 之后,你可以创建一个 Workspace 去包含多个 Projects 和其他文件。

除了提供被包含在 Xcode Project 中的所有文件的访问外,Workspace 还拓展许多重要的 Xcode Workflows 的范围。例如,由于 indexing(文件索引)遍布整个 Workspace,所以,在 workspace 中, code completion、Jump to Definition 和所有其他的内容感知特性,可以在所有 Projects 中无缝衔接运作。因为 refactoring operations(重构操作)横跨整个 Workspace 的所有内容,所以,你可以在一个 framework project 中重构 API,并且在其他 application projects 中使用这个 framework。构建时,一个 project 可以利用 workspace 中其他 projects 的 products。

workspace 文档包含被囊括的 projects 和其他文件,不再有其他数据。一个 project 可以被多个 workspace 持有。下图展示一个 workspace 包含两个 Xcode projects 以及一个文档 project。

Workspaces 中的 Projects 共享 Build Directory

默认情况下,Workspace 下面的 projects 都是在同一个目录下构建的,也就是 Workspace 的编译目录(workspace build directory)。由于是在同一个目录下面,Project 的资源文件都彼此都是可见的,可互相引用的。所以,如果你有多个 Projects 使用相同库的时候,不需要将它分别拷贝到各个 Project 中。

Xcode 会在编译目录下检查文件发现它们的隐形从属依赖。例如,如果 Workspace 中的一个 Project 编译的时候需要链接到相同 Workspace 的其他 Project 某个库,Xcode 会自动帮你先编译那个库,即使构建配置没有显式的指定从属依赖关系。如果需要的话,你可以指定显式从属依赖,但是你必须创建 Project 引用。

Workspace 中的每个 Project 仍然有属于它们自己的独立的标识。你能通过 project 的打开方式控制 project 受不受其他 projects 的影响,例如单独打开 Project 而不是通过 Workspace。因为,一个 Project 可以属于多个 Workspace,你可以任意组合 Projects,而无需重新配置 projects 或者 workspaces。

你可以使用 workspace 默认的 build directory,也可以自己指定一个。注意:如果一个 project 指定一个 build directory,这个 build directory 会覆盖全部所在的 workspace 里的默认 build directory。

Xcode Scheme

一个 Xcode Scheme(方案)定义三样东西:一个要生成的目标(targets to build)的集合、building 时使用的配置(configuration)、以及要执行的测试集合。

你可以拥有任意数量的 scheme,但一次只能有一个是活跃状态(active),你可以指定 scheme 是否储存在 project 中(这种方案下,scheme 在每一个包含这个 project 的 workspace 中都可用),或者储存在 workspace 中(仅在当前 workspace 中可用)。选择要激活的 scheme 时,可以选择运行目标(设备)。

Build Setting

一个 Build Setting 是一个变量,包含着如何构建产物的信息。例如,可以指定 Xcode 传递给编译器的选项

Build Settings 有 Project 和 Target 两个级别,Project 级别中的 Build Setting 适用项目中所有的 targets,只要该项 setting 没有被 Target 级别的重写覆盖。

每个 Target 管理着创建一个产物的源文件,一个 build configuration 指定一组 build settings,用于以特定的方式构建一个 product。例如,通常有 debug 和 release 俩种分开的 build configurations。

一个 Build Setting 包含两个部分:Setting Title(标题) 和 Definition(定义),类似于 key-value 结构。前者标示该 Build Setting 的名称,后者是一个常量或一个表达式,用于确定 Build Setting 的值。

另外,当你通过 Project 模板新建一个 Project 时,Xcode 会生成一个默认的 Build Settings,你也可以为 Project 或者某个 Target 创建自定义的 Build settings。你还可以设定 Conditional Build Settings,一个 Conditional Build setting 的值取决于是否满足一个或多个先决条件。这个机制也可以被用在指定用于基于目标架构构建产品的SDK。

参考资料

  1. Xcode: What is a target and scheme in plain language?
  2. Xcode Project vs. Xcode Workspace - Differences
  3. Xcode 相关概念
  4. iOS项目Project和Target配置详解

一次关于 Swift 在 iOS 生态圈里的现状调研

作者 SketchK
2020年5月1日 00:53

这是一次调研性质的文章,通过它让我们来看看 Swift 在国内的现状到底是什么样子的?

Swift 的发展历程

概述

通过官网的 Document Revision HistoryXcode Release Notes 梳理了一下 Swift 的发展历程和重大事件。

Swift 版本 Xcode 版本 发布时间 重大事件
Swift 1.0 ~ 1.2 6.x 2014 语⾔发布
Swift 2.0 ~ 2.2.1 7.x 2015 对协议,泛型能力进一步扩展,开始支持 Linux,随后出现了以 Swift 语言为核心的后端框架 Perfect,Vapor,Kitura
Swift 3.0 ~ 3.3.1 8.x 2016 发布了 Swift Package Manager,同时以 GCD,Core Graphics 为代表的基础库 API 风格发生了大幅度转变,摆脱了 Objective-C 时代的烙印
Swift 4.0 ~ 4.1.3 9.x 2017 在整体的语法,使用和理念上基本定型,提出了 Codable 协议,同时 Xcode 的 Swift Syntax Mirgration 的最低版本固定为 4
Swift 4.2 ~ 4.2.4 10.x 2018 Swift社区从邮件列表转向论坛,语言小幅升级,主要是功能完善,性能提升,同年 Swift for TensorFlow 发布并开源
Swift 5.0 ~ 5.0.3 10.2.x 2019 ABI 稳定,iOS 12 开始内置 Swift 运行时
Swift 5.1 ~ 5.2 11.x 2020 新增 Property Wrapper ,Opaque Type 等新的语法功能,同年 WWDC 上,Apple 发布了 SwiftUI,Combine,Catalyst 等 Swift 语言的专属 SDK
Swift 5.3 质量和性能增强,增加对 Windows 和其他 Linux 发行版的支持。

结合着自己的 Swift 学习经历,不难发现:

在 Swift 4 之前,由于语言整体还没定型,确实存在着发一个新版本,学一门新语言的情况,但在 Swift 4 之后,Swift 变化变得收敛了许多,不过也出现了入门容易,精通难的情况,毕竟光 Swift 的语法糖数量就快赶上了 C++ 了。

语言排行榜

Swift 语言从诞生之日开始,就一直存在各种各样的争议:一方面的焦点在于 Swift 的应用领域还是集中在 Apple 生态下,让人觉得不够大气,毕竟新时代的语言就是要全能,另一方面的焦点就是 Swift 语言的变化太快,每个版本都是是全新的感觉,这让开发者意识到,东西虽好,但代码还是要一个个的自己改的。

不过随着时间的推移,Swift 在后端,人工智能,物联网上的解决方案和应用场景不断出现,它已经远远不在是一个只能在 Apple 生态下运行的语言,关于这个非常推荐看看 Onevcat 在 GMTC 2019 上的分享:在分歧中发展——2019,我们能用 Swift 做什么

ABI 在 Swift 5.0 的时候也终于稳定了,虽然 ABI 稳定是使用 binary 发布框架的必要非充分条件,但都 ABI了,module stability 也不会太远了,这些信号都让开发者的信心不断增强。

为了验证这一点,我们也可以从编程语言排行榜 TIOBEPYPL 里看出一些端倪。

下图为 TIOBE 2020 年 四月排行榜,Swift 的排名在 11 名,Objective-C 的排名在 17 名,份额差距在 0.6% 左右:

TIOBE 2020 年 四月排行榜

下图为 PYPL 2020 年 四月排行榜,Swift 的排名在 9 名,Objective-C 的排名在 8 名,份额差距在 0.17% 左右:

PYPL 2020 年 四月排行榜

不论哪种排名,我们应该都可以得出这样一个结论:相比于 Objective-C 的下降趋势,Swift 的未来会更让人期待。

社区活跃度

观察一个编程语言活跃度的最好地方就是 Github,通过 Pull Requests ,Issues,Pushes,Stars 的情况,我们就可以大致了解到它的情况。

恰好 Githut 提供了这样的能力,通过观察 2020 年第一季度的数据,我们可以清楚的观察到 Swift ,Objective-C 等语言在社区的活跃度。

整体趋势图

下面四张图的 Y 轴分别代表了 Pull Requests ,Issues,Pushes,Stars 的数量,蓝色的线代表 Objective-C ,浅橙色的线代表 Swift,深橙色的线代表 Kotlin。

Pull Requests

Issues

Pushes

Stars

我们可以发现,从 2016 年开始,Swift 的数据已经超过了 Objective-C,现在有一种取而代之的快速发展趋势,这说明社区中更多的开发者习惯把精力放在 Swift 上,而不是放在 Objective-C 上。

这就给相应的开发者一个提醒,如果你坚持使用 Objective-C,那么你可能会面临一个风险,你所依赖的第三方开发库,他们的原作者很有可能已经不愿意去维护他们了。

同时,我们也可以发现 Swift 和 Kotlin 作为端上的新语言,它们的发展趋势是稳步上升的,这代表它们在社区是受欢迎的,那这也意味着掌握这些新技术会更容易与其他程序员进行交流和沟通,至于未来能不能完全取代现有语言的份额,还需要时间来证明。

个体情况对比

如果关注过一些 iOS 相关的 Newsletters,我们应该都可以感受到,相比于 Swift 的第三方开源库,同类型的 Objective-C 开源库明显处于劣势,不论是新增数量,还是活跃度;甚至有些 Objective-C 的库已经被开发者废弃,转而使用 Swift 重构。

下面是两种语言的两个常见方向的第三方开源库的数据对比,我们明显可以看出 Objective-C 项目的活力在下降,甚至出现了 2 年没有更新的状态。

名称 Massonry SnapKit Pop Hero
语言 Objective-C Swift Objective-C Swift
Stars 17.7K 16.3K 19.8K 18.3K
Opening issue 108 64 44 167
Opening Pull Request 19 16 14 14
Latest Commit 2017.09.28 2019.11.27 2018.10.12 2020.04.26
Latest release 2017.09 2019.08 2018.10.12 2019.10.29

对于那些还在持续更新的 Objective-C 库,它的更新频率又是什么样子的呢?我们拿 AFNetworking 和 Alamofire 做一个比对:

AFNetworking 版本 时间点 间隔时间 Alamofire 版本 时间点 间隔时间
4.0.1 2020.04.21 1 个月 5.1.0 2020.04.05 0.5 个月
4.0.0 2020.03.31 12 个月 5.0.5 2020.03.24 0.2 个月
2.7.0 2019.02.13 7 个月 5.0.4 2020.03.16 0.1 个月
3.2.1 2018.05.05 5 个月 5.0.3 2020.03.15 0.5 个月
3.2.0 2017.12.16 25 个月 5.0.2 2020.02.24 0.1 个月
2.6.3 2015.11.11 5.0.1 2020.02.23

虽然 AFNetworking 还在更新,但其更新的周期实在是让人摸不着头脑,相比于 Alamofire 稳定的更新频率,你到底愿意用哪个呢?

举个实际的例子,比如大家都是做 HTTP 请求的,3.0 协议已经发出,从社区的反馈来看,未来的开发者更愿意把精力放在 Swift 上面,现在 SSL Certificate Verify 的验证,是可以把证书链从上至下全部验证一遍,这在 Alamofire 里已经支持的非常好,而 AFNetworking 在此领域目前是缺失的。

这就有可能在未来的某个时间点,工程所依赖的,第三方的,不可替换的,核心的开发框架有严重的问题或者功能缺失时,项目的进度会受影响,作为开发者会陷入进退两难的状况。

Apple 的 SDK

在 Apple 的 Apple Developer Documentation列表 中,我们发现共有 196 个条目,其中 Swift 独占的 SDK 共计 10 个,Objective-C 独占的 SDK 共计 14 个。

维度 个数 SDK名称
Swift 独占 10 Swift(Swift Standard Library),Combine,SwiftUI,RealityKit,CareKit,Create ML(Create ML, Create MLUI),Playground Support,PlaygroundBluetooth,Apple CryptoKit,Swift Packages(Swift Package Manager)
Objective-C 独占 14 QTKit (macOS 专属),Professional Video Applications(FxPlug,macOS 专属),xcselect (macOS 专属),DarwinNotify,DriverKit(macOS 专属),EndpointSecurity(macOS 专属),HIDDriverKit(macOS 专属),IOUSBHost(macOS 专属),Kernel(macOS 专属),NetworkingDriverKit(硬件驱动相关),PCIDriverKit(硬件驱动相关),SerialDriverKit(硬件驱动相关),USBDriverKit(硬件驱动相关),USBSerialDriverKit(硬件驱动相关)

虽然乍一看 Objective-C 独占的 SDK 较多,但这些 SDK 主要是面向 macOS 和硬件驱动方向的,对 iOS 开发本身没有任何影响。

而 Swift 独占的 SDK 就完全不一样了, Apple 这些年主推的技术方向,例如 AR,AI,Health,SwiftUI,Combine 等一系列 SDK 都只有 Swift 的版本了,这无疑在暗示开发者:来吧,用就用 Swift 吧!

Swift 在国内外 iOS 客户端的使用现状

说了这么多,Swift 在 iOS 生态里到底用的怎么样?最直接的办法就是看看它在 Apple Store 里的占有率,这里我们会考察 2019 和 2020 年的情况。

2019 年

很庆幸,这部分工作,淘宝团队已经做过了!

相关的内容在 从探索到落地,手淘引入 Swift “历险记” 2019 一文中已经披露,这里借用它们原话:

我们通过爬虫分析国内外 APP Store 排行榜 Top 1000 的APP,通过文件扫描分析得到结论。

  • 国内使用 Swift 的 APP 约占比 22%,美区使用 Swift 的 APP 约占比 78%,其中美区剩余没有使用 Swift 的 APP 大部分来自中国地区本地化的产品,如抖音,快手等,可以得出一个结论,国内还是小众的 Swift,在国外已经是现状。
  • Github/Stack Over Flow 社区等 Objective-C 开源库和问题提问已经基本停滞,未来我们在落地新技术,Objective-C 可能已经是最坏的打算,加之 WWDC 17年以来,苹果不再提供 Objective-C 的示例,组内同学也多次遇见 Objective-C Bug 去社区提问,毫无热度的情况。
  • 苹果在 WWDC19 年发布了 4 个 Pure Swift 框架,无法简单的被 Objective-C 混编。

未来我们极有可能因为苹果的强制推进风格和社区文化的落后产生技术踏空,无法迅速响应业务,甚至无法招聘到会使用 Objective-C 的工程师。

IMAGE

通过了解,淘宝的数据来源是七麦数据提供的,日期为 2019 年 02 月 19 日,国内排行榜传送门2019版国外排行榜传送门2019 版

2020 年

现在回到 2020 年,Swift 在国内外的应用情况又发生了什么变化呢?

为了得到这个问题的答案,我也打算对国内外免费榜里的 APP 进行扫描,先不说 1000 个 ipa 的下载工作量有多大,256G 的硬盘估计也装不下这么多 APP,索性就弄前 102 名吧!

本来是想做前 100 的,只是因为当时下载国内 ipa 的时候多下载了 2 个,索性本着样本越多越接近真实情况的道理就把国外的数据量也放到了 102 个,并没有什么特别原因。

扫描的原理借鉴了 如何检测 iOS 应用程序是否使用 Swift? 里的思路,相应的工具已经放在 Github 供大家使用,点击传送门即可,这里要感谢一下好基友 @ForelaxX 制作了这个脚本工具。

App 排行榜的数据来源是七麦数据提供的,日期为 2020 年 4 月 27 日,国内排行榜传送门2020版国外排行榜传送门2020版

通过扫描这 102 个 App,最终发现国内的 Swift 占比为 30.4%(31/102),国外的 Swift 占比 82.3%(84/102),相比于 2019 年的数据,国内的 Swift 应用增长了 10% 左右,国外的 Swift 应用增长了 5% 左右。

IMAGE

下图是结合 2019 年和 2020 年的百分比趋势变化图

IMAGE

下面是扫描的详细结果:

国内 App 版本 是否使用 Swift 国外 App 名称 是否使用 Swift
拼多多 NO Zoom NO
腾讯会议 NO TikTok NO
钉钉 NO Houseparty YES
个人所得税 NO Youtube NO
剪映 YES Instagram YES
交管 12123 NO Facebook NO
抖音 NO Messenger NO
微信 YES Amazon NO
微视 YES Cash App YES
支付宝 NO DoorDash YES
QQ NO American Idol YES
快手极速版 NO Netflix YES
手机淘宝 YES Snapchat YES
淘宝特价版 NO PREQUEL YES
企业微信 NO Gmail YES
快手 NO Wish YES
和平营地 NO Hulu YES
QQ 音乐 NO Disney+ YES
百度 YES Shop YES
闲鱼 NO Pinterest NO
网易云音乐 NO Amazon Prime Video YES
腾讯视频 NO PayPal YES
高德地图 NO Spotify YES
酷狗音乐 NO Google Duo YES
小红书 YES Discord YES
美团 NO Venmo YES
京东 NO Wayfair YES
夸克 NO Walmart YES
哔哩哔哩 NO Twitter YES
瑞幸咖啡 YES WhatsApp YES
爱奇艺 NO Twitch YES
BOSS 直聘 NO SHEIN YES
云闪付 NO Google Chrome NO
百度网盘 YES Roku YES
中国建设银行 NO intoLive YES
阿里巴巴 NO Nike YES
WPS Office NO PicsArt YES
58 同城 NO Google YES
Keep YES Calm YES
微博 NO eBay YES
闽政通 YES Norton Secure YES
QQ 浏览器 NO Airtime YES
中国工商银行 YES YEE YES
美图秀秀 YES Omegle YES
安居客 NO Google Driver YES
七猫小说 YES Tinder YES
贝壳找房 NO Target YES
农行掌上银行 NO Google Map NO
京东金融 NO OfferUp YES
轻颜相机 YES Grubhub YES
番茄小说 NO Google Photos YES
FaceApp YES Yubo YES
得物 YES Google Classroom YES
优酷视频 NO YOLO YES
秘乐短视频 YES Pandora YES
网上国网 NO Fonts YES
哈罗出行 NO Uber Eats YES
中国银行手机银行 YES Mercari YES
好省 YES Reddit YES
喜马拉雅 NO SoundCloud YES
WIFI 万能钥匙 NO Hangouts Meet YES
扫描全能王 NO Google Docs NO
UC 浏览器 NO Instacart YES
驾考宝典 NO Fitness Coach YES
天眼查 NO PictureThis NO
人人视频 NO Xbox YES
QQ 邮箱 NO Tubi YES
淘宝直播 NO VideoToLive YES
西瓜视频 NO Amazon Photos YES
最珠海 NO Zillow YES
办事通 NO Robinhood YES
知乎 YES Hangouts YES
搜狗输入法 NO News Break YES
菜鸟裹裹 NO Enlight Video NO
招商银行 NO Youtube Music YES
苏宁易购 NO letgo YES
美团外卖 NO InShot NO
学习强国 NO Fetch Rewards YES
饿了么 NO Splice YES
全民 K 歌 NO Postmates YES
今日头条 NO ESPN YES
识货 YES Duolingo YES
懂车帝 YES Quibi YES
全球骑士特购 NO Amazon Music YES
Facetune2 YES Audible YES
中国联通 YES Microsoft Outlook YES
平安好车主 NO Esty YES
腾讯新闻 NO SONIC Drive-in YES
醒图 YES Funimate Video YES
酷狗铃声 NO Telegram YES
百度地图 NO Instacart Shopper YES
探探 YES AliExpress Shopping NO
多闪 NO YouTube Studio NO
中国移动 NO Miscorsoft Teams YES
作业帮 NO PlayStation NO
邮政手机银行 NO Bumble YES
Zoom NO Messenger Kis NO
滴滴 YES Facetune2 YES
智联招聘 YES Hoop YES
虎牙直播 NO TextNow YES
芒果 TV YES Vinkle YES
百度贴吧 YES McDonald’s YES

通过这组数据,我们可以分析出很多有意思的东西

首先,BAT 三巨头的门户 APP 都已经具备了 Swift 混编的能力,例如百度系的百度主 App,百度网盘,百度贴吧,阿里系的手淘,芒果视频,腾讯系的微信,微视

其次,国内的独角兽巨头们,似乎也做好了迎接 Swift 的准备,例如字节跳动(剪映,飞书),滴滴出行,自如,网易,小红书,得到,瑞幸咖啡,猿题库,英语流利说等。

最后,一个有意思的现象是,我发现国外 Top 102 的 ipa 总大小为 10G,而国内 Top 102 的 ipa 总大小将近 24G,不知道这是不是用 Swift 编写代码为包大小带来的正向收益,还是我们 ”CMD+C 和 CMD+V“ 的代码复用机制造成的。

总结与展望

做完这个调研,能得出什么结论呢?

  1. 综合 Swift 的发展历史,语言排名,社区活跃度等因素来看,Swift 的发展是处于上升趋势的,可能相比于一些明星语言和明星技术热点,它的表现不是那么突出,但总趋势上升是不可否认的,这同样适用于 Objective-C 的总趋势下降。
  2. Apple 应该会继续增加 Swift 语言在其生态圈的重要性并进行相应的战略部署,即使 Swift 语言还没完全达到 Module Stability,但从推出的 Swift 独占 SDK 已经看出了他们的想法。
  3. 国内外各个互联网厂商在转向 Swift 的道路上已经走起来了,即使国内的占比还落后于国外,但总的趋势和涨幅势头已经十分明显了,以 BAT 为首的大部分互联网公司已经完成了 Swift 的混编工作,尤其是它们旗下的明星 App 已经接入了 Swift。

拿着这些结论来看看大家平日里对 Swift 的印象,我们又发现了哪些不一样的地方呢?

虽然国内 iOS 圈里常有人说”Swift 无用“,”Swift 火不了“,”我们不需要用 Swift 开发“,但冷静的分析下来,国内的各大厂商真的抛弃 Swift 了么?

通过这份真实的数据,我想答案很明显,它们都没有放弃 Swift,而且都在积极的做准备,这就很像嘴上说着不要不要,但身体却出卖了自己。

国外的 Swift 氛围不用多说,大家都已经可以看出谁会是 iOS 端上未来的主角。这里我们就说说国内的情况:

我们都知道,淘系的主 App —- 手机淘宝在今年完成了 Swift 混编能力的建设,这意味着什么?对于淘系App 来说,这么庞大,复杂的工程都已经具备了混编的能力,那么阿里旗下的其余 App 转型将不再有什么逾越不了的鸿沟。

号称 App 工厂的字节跳动,在很早就尝试了 Swift 的混编开发,早期还限定在一些小型的项目,现在它们已经具备了中大型项目的 Swift 混编能力,这一点可以在它们的海外明星应用 Helo 上的得到验证,目前这款 App 在字节跳动内部的排名已经跻身到前 5 名了,另外从一些渠道得知,它们的明星应用 - 今日头条也将在近期开展 Swfit 混编能力的建设。

而国内那些还在高速发展的公司,例如美团,京东,拼多多,快手等公司,在 Swift 上的探索还显得比较落后,可能还停留在一些小型内部应用或者 B 端的应用上,甚至也有可能完全没有开展过相关的工作。

这么看来,国内厂商的 Swift 格局已经十分清晰,大体会呈现三个梯队:

第一梯队:以 BAT 为代表的顶端团队已经解决了复杂的,巨型的,历史包袱重的工程项目如何使用 Swift 的问题,对他们而言,未来的问题就是怎么将 Swift 用到真正的业务代码上了,玩的好,玩的溜的将是它们要关注的问题了。

第二梯队:以字节跳动,网易为代表的一些公司,已经解决了部分 Swift 混编的问题,对于其内部工程复杂度最高,历史包袱最重的 App 还没有实现混编的能力,例如字节跳动的 Helo 和抖音,网易的网易公开课和网易云音乐,这些公司在近期面临的问题可能会是如何解决混编。

第三梯队:以美团,京东为代表的一些公司,在这方面还没有开展相应的工作或者开展的还比较少,他们目前可能更关注的还是在解决自身业务发展的问题,例如动态化,中台建设,容器化等方面的技术积累与战略部署。

不管怎么看,Swift 在国内的发展既不是完全停滞,也不是无人问津,只是真正玩的人比较“低调”而已,但该来的一定会来。

不知道看完这篇文章,你认为 Swift 在国内的发展会是什么样子呢?

Swift Tips 32 - Assigning to self in struct initializers

作者 SketchK
2020年3月4日 18:38

每天了解一点不一样的 Swift 小知识

代码截图

01.png

代码出处: Swift Tips 032 by John Sundell

小笔记

这段代码在说什么

这段代码为 Bool 类型进行了扩展,并利用扩展为现有的 Bool 类型添加了一个新的构造器:init(input: String)。当输入参数为 y, yes, 👍 的时候,构造出来的实例值为 true,其余情况则为 false。

值类型和类类型的构造过程

我们都知道,构造器可以通过调用其它构造器来完成实例的部分构造过程,这一过程被称为构造器代理(initializer delegation),这种模式能避免多个构造器间的代码重复。

但是构造器代理的实现规则和组织形式在值类型(value type)和类类型(class type)中有所不同。

值类型是不支持继承的,例如枚举和结构体,所以它们的构造器代理过程相对简单,因为它们只能代理给自己的其它构造器。

类则不同,它可以继承自其它类,这意味着类要确保所有继承下来的存储型属性在构造时能被正确的初始化,这也是为什么类类型在构造过程中有两段式的构造过程,要遵守构造器代理的三个原则,并进行四种构造安全检查

说了这么多,回到今天的代码上!

通过前面的讲解,我们应该回想起了值类型与类类型在构造过程中的差异,想必这时候的大家,对于代码截图中能直接使用 self,而不需要调用 super.init() 等操作也都能理解了。

如果还是有很多疑惑,不妨重温一下官方手册里关于构造过程的章节

让 Struct 构造器变得更好用一点

在一些特定的场景下,Swift 能够为 Struct 类型生成一个默认构造器,也可以叫它为逐一成员构造器(memberwise initializer),例如下面的代码,Swift 就为 myStruct 结构体创建了一个构造器:init(myString: String?, myInt: Int?, myDouble: Double?)

1
2
3
4
5
struct myStruct {
let myString: String?
let myInt: Int?
let myDouble: Double?
}

IMAGE

虽然这个自动生成的默认构造器看起来中规中矩,也帮我们省去了不少打字的功夫,但在每次使用的时候,我们不得不完整的写出三个参数,即使这个参数是没有值的,这确实有点麻烦!

1
let aStruct = myStruct(myString: "1", myInt: nil, myDouble: nil)

那么有什么好的技巧来解决这个问题呢?

答案就是重新声明整个构造函数并在构造函数的每个参数后面添加默认值为 nil 的逻辑!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct myStructWithInit {
let myString: String?
let myInt: Int?
let myDouble: Double?

init(myString: String? = nil, //👈
myInt: Int? = nil,
myDouble: Double? = nil) {

self.myString = myString
self.myInt = myInt
self.myDouble = myDouble
}
}

此时,我们再次创建 myStructWithInit 类型的实例,会发现代码提示里的构造函数变成了两个!一个是我们之前见过的样式,另一个是没有任何参数的样式。

IMAGE

不过,这并不意味着你只有 2 个构造方法可以用哦,现在的你也可以用下面的方式来构造实例了!

1
2
3
4
5
myStructWithInit(myString: "Something")
//or
myStructWithInit(myString: "Something", myInt: 2)
//or
myStructWithInit(myString: "Something", myInt: 2, myDouble: 3.0)

惊不惊喜,意不意外!现在我们终于可以自由的使用构造函数啦!

之所以能这样做,是因为当我们为函数的某个参数设置了默认值后,再调用该函数时,就可以忽略它。这个知识点可以从官方手册里的默认函数值一节中找到。

所以,今天你学会如何通过默认参数值这个技巧来创建更灵活,更自由,更方便的结构体构造函数了么?

一次让刷新控件好玩起来的尝试

作者 SketchK
2020年1月22日 19:01

一次有意思的尝试?

写在前面的话

虽然我所处的团队与业务开发息息相关,但近一年,我个人已经很少写一些业务代码了,做的事情可能更偏向基础技术的建设,技术栈也从 Objective-C 转向了 JavaScript 的世界。

然而,我内心还是想写点 Swift 相关的东西,写点与 iOS 相关的东西,或许可能我内心是真的很热爱它们吧。

01

所以年底的时候,我又重新捡起了 Swift 这门语言 ,并在 瓜瓜 的公众号上持续输出一些与此技术相关的文章,大多是一些使用技巧和语法细节。而我因为工作原因,经常会在 Objective-C,JavaScript,TypeScript,Ruby,Python 之间来回切换,不得不说 Swift 的综合体验还是可以的,除了 Xcode。

没错! 它就是 Swift Tips 系列,如果你感兴趣,记得关注我们的公众号 - 让技术一瓜共食

不过,光研究语言本身,是枯燥的,也不立体,我还是想把 Swift 用到一些具体的场景上,毕竟玩起来才有意思!

于是我想起了自己早年间立的一个 flag:尝试一次游戏开发

所以我迅速下单买了 Ray Wenderlich 家的《2D Apple Games by Tutorials》一书,虽然是 Swift 4 和 iOS 11 的版本,但说实话 Swift 4 到 5 的变化算比较温和,另外教材里的 iOS 系统也只落后现实世界里两个版本,SpriteKit 整体也没有什么翻天覆地的变化,落后的内容补上两个季度的 WWDC 就好了,所以,学就完事了!

看完这本书后,我不得不说老外的教材真的很好,只是可惜这书以后不再更新了。

02

大概花了一个月的时间,陆陆续续的把《2D Apple Games by Tutorials》里面的代码和作业也都敲了一遍,自我感觉还不错,问题就来了,怎么把学的东西用起来呢?

一开始我是想做个策略回合制的游戏,与战场女武神,皇家骑士团的游戏类型相似。说实话,在开动之前,我对自己还挺有信心的,因为有做游戏开发的想法,所以很早就有为自己培养一些与游戏相关的技能点,例如原画绘制,音乐制作等,下面是我自己的一些小作品。

03

但真的进入开发状态后,我发现事情并不像自己想的那么简单……

这里我就不展开说了,只说说核心观点吧,我理解游戏的本质还是在讲故事,至于音乐,原画,动效,编程都是为这个故事而服务的,如果没有一个好的故事,游戏也就失去了灵魂。

IDEA 的由来

在意识到自己并没有一个好故事可以说的状态下,我决定把新 get 到的开发技能用到自己的工作场景中,可问题就是怎么把一个轻量级的游戏与日常的 App 结合起来呢?

我脑海里突然想起来了 Chrome 浏览器里的小恐龙!

04

说实话,我并不清楚做这个 Feature 的初衷是什么,但作为一名用户,在无网的时候玩上一会这样的游戏,既可以转移我的注意力,又可以缓解我无法上网的焦虑感。

所以从用户体验的角度上来说,这是一个很不错的 IDEA!而它很好的启发了我!

带着改善用户体验的想法,我反复观察了自己和周围好朋友使用 App 的习惯。我发现使用户焦虑感增强的几个操作无外乎都是围绕页面刷新的,例如:

  • 下拉刷新时转动的指示器
  • Web 页面的加载条
  • 下载资源时的进度条
  • 刷新失败或者无网状态下的空白页

不过从以上操作的频率来看,下拉刷新应该是最频繁的,也是最常见的。

所以决定了,就是你了!让我们做一个更好玩的下拉刷新控件吧!

  • 从体验角度的考量

在关于下拉操作,国内的应用主要有两种交互形式,一种是以淘宝,京东为代表的“二楼形态”,另一种是以美团,拼多多为代表的“经典形态”,

“经典形态”就不用多说了,“二楼形态”的本质是通过下拉跳转到另一个页面,当然在这个下拉的过程中可以触发刷新,不过我认为这并不是一个必要的过程,在我的理解范围里,微信的下拉操作其实也是一个“二楼形态”,只不过它没有刷新,只是通过下拉跳转到了小程序选择界面。

05

所以,如果仅仅从这两种传统的思路下手来改进用户体验,提升的空间是十分有限的。

这时候我发现手机版本的 Chrome 在下拉交互上是有点与众不同的!

06

我发现它的下拉操作是可以与使用者进行更细粒度的交互,这一点很重要!这让我意识到它会赋予下拉刷新很多新的可能性!

试想一下,如果我们在下拉刷新的同时,能玩到小时候 FC 机上的公路战士(Road Fighter),或者曾经风靡一时的手游 —- 神庙逃亡(Temple Run)会是何种体验!

07

当然,在下拉操作里玩塞尔达,无双系列也不是不可能,但体验未必好,因为那种游戏的交互实在太复杂了,不是一个手指左滑右划就能完成的,但像公路战士和神庙逃亡这一类游戏的交互就很简单,逻辑也足够轻量,放在下拉刷新的交互中也显得比较合适。

从体验角度上来说,作为用户,一旦触发下拉刷新就可以玩一把轻松有趣儿的小游戏,让自己从等待网络数据的焦虑中解脱出来,听起来还不错吧?

  • 从技术角度的考量

作为一名程序员,我们总要需要考虑代码上的改动会带来哪些好的变化,哪些不好的变化。

如果真的在下拉刷新里做个小游戏,从某种程度上来说,它一定会增加工程的复杂性,说不定还会影响到项目的包大小,首页的加载时长,FPS 等性能指标或者业务指标。

那么它能带来哪些好的变化呢?

在回答这个问题之前,我们先看看国内这些年客户端技术的大趋势,在国内,大部分电商都在做动态化相关的事儿,从 RN,Weex,Flutter,再到一些自研的技术,例如天猫的 Tangram,手淘的 Dinamic,美团的 Flexbox 等。

简单来说,它们的出发点可能都是希望自己的 App 做到不依赖客户端发版,就能实现内容上或者逻辑上的更新。但从原理上来说,它们的思路大体都是下发一个布局描述文件,然后绑定实际的业务数据,最终通过渲染引擎变成我们所看到的界面。

不过这也就带来了一些变化,原先的后台只需要下发视图所展示的数据,例如 UILabel 中的内容,而动态化方案不仅要下发 UILabel 展示的内容,还需要下发 UILable 自身的描述信息。这种变化会使整体传输的数据量迅速变大,甚至会使得网络请求的次数变多(一次请求业务数据,一次请求布局文件)。

在性能监控或者业务监控的指标中,页面的加载时长是一种十分常见的指标。但动态化方案因为多了一次转换的过程,所以在某种程度上,它会使这个指标发生恶化的。

虽然我们会做很多事情来优化加载时长,并改善这一指标,但所有的优化都是有尽头的,而且根据二八原则,这样优化在后期的投入产出比是十分有限的,开发者绞尽脑汁的做了一堆优化,但用户可能并不会感知到这 1-2 ms 的优化,反而让代码的维护成本加大了不少。

08

那么我们能不能从另一个角度出发,例如通过一些引导来转移用户的注意力,从而让他们忽略掉这些加载时长?

刚好,前面提到的可交互的游戏式的下拉刷新,它就满足了转移用户注意力的诉求。如果需要一些指标衡量它,我们可以通过下面的方式来观察和推导:

建立两个时间指标,一个是刷新页面所需的时长,从发起网络请求,到页面刷新完毕的总时间 ,T-Net,另一个是用户下拉刷新后的游玩时长,从触发游戏开始,到用户结束游戏的总时间,T-Game。

对于 T-Game - T-Net > 0 的用户, 我们可以假设他们对加载时长不敏感,针对这种情况,我们可以通过在游戏里植入红包或者其他方式增加用户的留存,并改善它们的用户体验。

对于 T-Game - T-Net < 0 的用户,我们可以假设他们对加载时长十分敏感,那么我们就可以对这些用户做有针对性的优化。例如从智能预加载或者推荐策略上入手。

所以从这个角度来看,可交互的游戏式的下拉刷新在技术层面也有了实际的价值和意义!

  • 从产品角度的考量

在大公司待久了,你总要面对这样一个问题,这个需求到底有什么意义?它能带来哪些实际的利益,能为我们这个部门赚钱么?

这个 feature 同样会面临这样的问题,于是我仔细的思考了自己所处的业务线和业务形态。(PS: 下面的这些思考是我个人的理解,不用过分解读)

我所在的部门负责了国内某电商平台的首页,很像京东,手淘的首页业务,它自身并不包含一个完整的交易链,承担的首要任务是流量分发,为公司内其他业务线带来用户和持续增加的可能性。

说白了,在我的理解范围内,首页就像一个展览厅,为了让展览厅里面的商品卖得更好,我们通常会有两种思路:

第一种:在最合适的位置向顾客展示最合适的商品。
第二种:尽可能的增加展位,展示更多的商品

如果将这个思路放到电商平台的首页模块中,我们也可以看到它们的踪影,例如手淘技术文章里经常提到的千人千面,它本质上就是一种优化推荐策略的方案,它可以看做是第一种方式的变体,而近年来,很多 App 从原先的单卡片流变成双列卡片流,它就是第二种策略的变体,在同样的空间内增加了展示位置,提升了展示密度。

之前我们也说过下拉刷新的两种形态,“二楼形态”就是将下拉刷新作为了一个入口,为 App 内的其他内容进行导流,它本质上是属于第二种策略的。

因为在“经典形态”下,下拉刷新是不能作为一个入口的,而“二楼形态”的出现,使得下拉刷新的操作可以进入到其他页面,这就在有限的空间里增加了更多的展位。

在这个前提下,我们再来看下可交互的游戏式下拉刷新,它与二楼形态很接近,当游戏结束后,它完全能够作为一个入口,例如玩完了某个品牌的红包雨活动后跳转到某个指定频道,另外在游戏过程中,又可以结合一些页面上的元素进行品牌宣传,增加曝光量,例如红包雨里面的背景图,红包的品牌等。

总体来说,相比于传统的“二楼形态”,这种玩法让下拉刷新变得更有“价值”了!

冷静的分析

在通过用户角度,技术角度,产品角度三个方面分析后,我认为,这个事儿是有搞头的!但我相信我肯定不是第一个想到这个点子的人,所以我迅速在 Google 上搜索了一下,果然不出所料,Github 上已经有人做过类似的 Demo,这里刚好它们做一些介绍,也说说我的个人观点。

  • BreakOutToRefresh

09

这个 Demo 里的游戏实现使用的 SpriteKit 框架完成的,支持 Swift 和 Objective-C。它的 Github 链接是 https://github.com/dasdom/BreakOutToRefresh。 它基本符合了我需要的两个核心功能点,一个是可交互的,一个是游戏化的。

但它实际的游戏体验并没有达到我的预期,主要有以下几个问题:

  1. 游戏的交互是上下移动的,这和触发刷新的下拉操作有一定相似性,这就导致误操作的几率增大。
  2. 游戏中的交互元素体积较小,物体的碰撞感和操作性都比较差,使得游戏的体验并不理想。
  3. 即使是这种打方块的游戏,它的逻辑在下拉刷新里也显得略微复杂,尤其在小球弹到边界时,会出现一些奇怪的不符合逻辑的现象,例如小球弹到右边界时会返回而不是游戏结束。

当然 BreakOutToRefresh 的作者 dasdom 提到了它的这个 Demo 又是源于 boztalay 的 BOZPongRefreshControl,不过可惜的是 BOZPongRefreshControl 并不是可交互的,我们看到的效果可以简单的理解为是已经预置好的动画,所以这里也就不展开说明了。

至于其他实现,我在市面上还真是再也没找到了,如果你有什么发现,请记得给我留言!

思考,思考,再思考

在有了上面的思考后,我开始着手设计自己理解的那种可交互的,游戏化的下拉刷新组件。

  • 关于交互方式

在 iOS 系统内,常见的手势有以下几种:

10

结合到下拉刷新的场景中,我们会发现 Swipe,Pinch,Rotation,Screen Edge Pan 等操作并不合适,相对合适的可能就只剩 Tap,Pan 和 Long Press 了。为了不那么反人类,我们应该避免游戏里的交互操作与刷新的下拉操作发生冲突。

在结合上面几点后,我们发现游戏里操作精灵的方式就只能在左右滑动和点按中选择了。

当然,你可以发明一些新的手势,但这里就不展开讨论了!

  • 关于游戏内容

在确定了可交互的方式后,我们需要进一步讨论游戏的呈现形式,也就是游戏的内容。在之前的调研中,我们得出了游戏的交互逻辑不能过于复杂,且要避免精灵的体积过小,不知道你脑海里想到了哪些游戏?

在我的脑海里,马上就想起了小时候玩的红白机,例如魂斗罗,超级马里奥,热血物语等,但说实话,这些8 Bit 的游戏还是有太复杂了,放到下拉刷新里显得太重太重了,不光是游戏逻辑,就连交互也有很大的区别,毕竟咱们应该都还都记得 “上上 下下 左左 右右 AB AB” 的秘籍!

所以,我们应该把时间再往前推一推,回到雅达利时代的游戏机上!

11

怎么样,看起来够复古了吧!

雅达利游戏机本身的操作方式十分简单,一个方向键,一个按钮,与我们现在仅有的左右滑动和点按操作相似,另一点是那个时代的游戏逻辑轻量简单,这里我们举几个例子:乒乓,太空侵略者,吃豆人等,所以,我们可以完全借鉴这些当年的雅达利游戏机上的经典作品来实现自身的诉求。

当然,为了让游戏能更接地气,也就是更好的服务到自身的业务中,我这里整合几个实际的游戏场景好了!

例如临近双十一,淘宝要派发红包了!

12

例如正在使用滴滴打车时,可以让等待中的用户模拟一把正在赶路的司机!

13

又例如在下单点外卖的时候,我们可以来一把太空大战!

14

Show Me The Code

在有了上面的设想后,我以抢红包的点子为例开发一个真实的 Demo,这里我并没有像 BreakOutToRefresh 那样把代码抽象成一个 SDK,因为我的初衷是希望大家将这个思路应用到自己的 App 中,而不是那些代码。

这里我也不会对细节做过多的讲解,主要是做一些笼统的介绍和分析,具体的示例代码请移步到我的 Repo 中查看。如果你觉得这个项目很有意思,记得给我一个 Star 哦!

Github 地址:https://github.com/SketchK/a-playable-refresh-demo

  • SpriteKit

因为之前自学的就是 2D 游戏开发,所欲这里我毫不犹豫的选择了苹果自己的开发框架 SpriteKit,至于语言方面,我选择了 Swift,完全是个人喜好。

示例代码中的核心文件包含以下几个部分:

15

这几个文件承载了游戏的核心逻辑,而其中最最最重要的两个文件就属 GameScene 和 GameView,这里我们先说 GameScene。

这个场景下主要有两个类型的精灵,一种是红包精灵,它在游戏开始后,会不断的从界面上方出现,并逐渐掉落到界面的下方,这里使用了 SKAction 来完成相应的工作,而另一种是英雄精灵,它在游戏里只有一个并在界面初始化的时候被创建,根据手势的左右偏移量进行移动,这里我使用了较为传统的手动计算方式。

这个类型的游戏核心点就是碰撞问题,一方面是英雄与游戏界面的碰撞,这里我们要做到英雄精灵的活动范围不能超出游戏界面,它的逻辑放在了 update 这个生命周期方法中,另一方面就是英雄与红包的碰撞,我们需要通过这个检测它们之间的碰撞来让游戏继续进行和结束,这一部分的逻辑放在了 didEvaluateActions 中。

  • 刷新机制

至于 GameView ,它承担了两个重要的工作,一个是刷新控件的逻辑,一个是游戏界面的切换逻辑,这两个工作有一定的内在联系,你可以拆分成两个模板,也可以整合到一个模块中,就像代码里的做法,我的方案并不是一个最佳解决方案。

另外刷新机制这块,其实这个 Demo 做的并不完美,如果想深入了解,我建议大家看看社区里一些优秀的下拉刷新组件库,这里你只需要了解游戏界面的切换逻辑是与刷新控件相关联的就好。

尾声

看到这里,这篇文章就要结束了,可能干货也不是那么多,主要说了说我在编写这个 Demo 时都想了什么,做了什么,干了什么。

很遗憾的是,我无法将这个想法用在自家的 App 中,但我十分希望它能被用起来,并可以观察到它的实际上线效果。如果有人这么做了,欢迎与我联系讨论!

项目的代码地址为:https://github.com/SketchK/a-playable-refresh-demo,如果你觉得它还不错,请记得给我一个 Star 哦!

最后祝大家新年快乐!

Swift Tips 031 - Recursively calling closures as inline functions

作者 SketchK
2019年12月30日 23:57

每天了解一点不一样的 Swift 小知识

代码截图

01.png

代码出处: Swift Tips 031 by John Sundell

小笔记

这段代码在说什么

这段代码在 records 函数中内定义了一个名为 iterate 的嵌套函数,当 nextRecord 满足 matches 方法的条件时,它返回 nextRecord 并继续遍历 recordIterator 里的元素,当 nextRecord 不满足 matches 方法的条件时,它通过 iterate 的递归,继续遍历 recordIterator 的元素。

通过这样的方式我们可以记录下 Database 中所有符合条件的 Record 实例。

嵌套函数

在 Swift 中,我们也可以把函数定义在某个函数体中,这样的函数被称作嵌套函数。

默认情况下,嵌套函数是对外界不可见的,但是可以被它们的外围函数调用。一个外围函数也可以返回它的某一个嵌套函数,使得这个函数可以在其他域中被使用。

就像示例代码中的 iterate 就是一个嵌套函数,而 records(matching:) 是一个外围函数。

更多关于嵌套函数的话题可以阅读官方手册的嵌套函数章节。除此之外,我还推荐阅读一下 Matt Neuburg 在 《iOS 10 Programming Fundamentals with Swift》里展示的内联函数在真实工程里的使用场景,很有启发性!

不过今天的内容远不止这些!

似懂非懂的 Sequence 和 Iterator

初看这段代码,不知道你的感受如何?我是觉得好像大体都能看的懂,但很多细节点又不是很明白,例如 AnySequence { AnyIterator(iterate) } 是个什么东西?makeIterator() 返回的对象为什么会有 next() 方法等等。

所以想要完全消化这段代码的全部含义,就得搞清楚 Sequence 和 Iterator 的概念和基本用法。

Sequence 和 Iterator 是什么

在 Swift 的世界中,Sequence 代表的是一系列具有相同类型值的集合,并且提供对这些值的迭代能力。

迭代一个 Sequence 最常见的方式就是 for-in 循环,如下:

1
2
3
for element in someSequence {
doSomething(with: element)
}

Sequece 本身并不是什么基类,只是一个协议,这个协议只有一个必须实现的方法 makeIterator(),它需要返回一个 Iterator 且遵守 IteratorProtocol 类型。它的定义如下:

1
2
3
4
protocol Sequence {
associatedtype Iterator: IteratorProtocol
func makeIterator() -> Iterator
}

这也就是说,只要提供一个 Iterator 就能实现一个 Sequence,那么 Iterator 又是什么呢?

Iterator 是一个遵守了 IteratorProtocol 协议的实体,它用来为 Sequence 提供迭代能力。这个协议要求声明了一个 next() 方法,用来返回 Sequence 中的下一个元素,或者当没有下一个元素时返回 nil。associatedtype 声明了元素的类型。 它的定义如下:

1
2
3
4
public protocol IteratorProtocol {
associatedtype Element
public mutating func next() -> Self.Element?
}

对于 Sequence 而言,我们可以用 for-in 来迭代其中的元素,但其实这个功能的背后就是 IteratorProtocol 在起作用。这里我们举一个例子:

1
2
3
4
5
let animals = ["Antelope", "Butterfly", "Camel", "Dolphin"]
for animal in animals {
print(animal)
}
// Antelope Butterfly Camel Dolphin

实际上编译器会把以上代码转换成下面的代码:

1
2
3
4
var animalIterator = animals.makeIterator()
while let animal = animalIterator.next() {
print(animal)
}

实现一个 Sequence 和 Iterator

为了加深理解,我们不妨亲自写一个 Sequence,但就像刚才分析的一样,我们需要先实现一个 iterator

假设我们的 Iterator 要实现这样的功能:它接收一个字符串数组,并可以迭代这个数组中所有字符串的首字母。当数组中的最后一个字符串迭代完毕后,退出迭代。

代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct FirstLetterIterator: IteratorProtocol {
let strings: [String]
var offset: Int

init(strings: [String]) {
self.strings = strings
offset = 0
}

mutating func next() -> String? {
guard offset < strings.endIndex else {
return nil
}
let string = strings[offset]
offset += 1
return String(string.first!)
}
}

上面这段代码做了两个事情:

  1. 这个 Iterator 的需要输入一个字符串数组。
  2. next() 中,判断边界,并返回数组中索引为 offset 的字符串的首字母,并把 offset 加 1。

这里省去了 Element 类型的声明,编译器可以根据 next() 的返回值类型推断出 Element 的类型。

有了已经实现好的 Iterator,就可以很简单的用它实现 Sequence,在 makeIterator() 中返回这个 Iterator 即可。

1
2
3
4
5
6
7
struct FirstLetterSequence: Sequence {
let strings: [String]

func makeIterator() -> FirstLetterIterator {
return FirstLetterIterator(strings: strings)
}
}

现在 Sequence 已经实现好了,可以测试一下效果。
我们可以创建一个 FirstLetterSequence,并用 for-in 循环对其迭代:

1
2
3
4
for letter in FirstLetterSequence(strings: ["apple", "banana", "orange"]) {
print(letter)
}
// a b o

值类型的 Iterator 和引用类型的 Iterator

一般 Iterator 都是值类型的,值类型的 Iterator 的意思是:当把 Iterator 赋值给一个新变量时,是把原 Iterator 的所有状态拷贝了一份赋值给新的 Iterator,原 Iterator 在继续迭代时不会影响新的 Iterator。

例如用 stride 函数创建一个简单的 Sequence,它从 0 开始,到 9 截止,每次递增 1,即为 [0, 1, 2, …, 8, 9]。然后获取到它的 Iterator,调用 next() 进行迭代。之后我们再做一个赋值操作,创建一个新的 i2,并把 i1 的值赋给 i2,并进行一些操作:

1
2
3
4
5
6
7
8
9
10
let seq = stride(from: 0, to: 10, by: 1)
var i1 = seq.makeIterator()
i1.next() // Optional(0)
i1.next() // Optional(1)

var i2 = i1
i1.next() // Optional(2)
i1.next() // Optional(3)
i2.next() // Optional(2)
i2.next() // Optional(3)

从打印的结果会发现:i1 和 i2 是两个独立的 Iterator,它们互不影响,赋值时对 i1 做了一份完整的拷贝。所以这里的 Iterator 是一个值类型 Iterator。

当然,我们也可以把任意值类型的 Iterator 变成引用类型的 iterator,而且实施起来也很简单。把任何一个值类型 Iterator 用 AnyIterator 这个包一下就形成了一个引用类型的 Iterator。

结合上面的代码,我们再进行一些操作:

1
2
3
4
5
6
var i3 = AnyIterator(i1)
var i4 = i3
i3.next() // Optional(4)
i4.next() // Optional(5)
i3.next() // Optional(6)
i3.next() // Optional(7)

引用类型的 Iterator,再赋值给一个新的变量后,新的 Iterator 和原 Iterator 在进行迭代时会互相对对方产生影响。

基于函数的 Sequence 和 Iterator

AnyIterator 有一个初始化器,可以传入一个闭包,AnyIterator 会把这个闭包的内容作为调用 next() 时执行的内容。这样创建一个 Iterator 时可以不用创建一个新的 class 或 struct。

例如我们可以这样创建一个斐波那契数列的 Iterator:

1
2
3
4
5
6
7
8
func fibsIterator() -> AnyIterator<Int> {
var state = (0, 1)
return AnyIterator {
let upcomingNumber = state.0
state = (state.1, state.0 + state.1)
return upcomingNumber
}
}

然后可以用 AnySequence 来创建 Sequence,AnySequence 也有一个支持传入闭包的初始化器,于是可以把上面的函数名作为参数传入。

1
2
3
let fibsSequence = AnySequence(fibsIterator)
Array(fibsSequence.prefix(10))
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

另外,还有一种更简单的方法来创建 Sequence,用 Swift 标准库中的 sequence 函数。这个函数有两个变体:

第一个是 sequence(first:next:)
第一个参数是 Sequence 中的第一个值,第二个参数传入一个闭包作为 next() 的内容。

例如创建一个从大到小的随机数 Sequence。

1
2
3
4
5
6
7
8
9
10
let randomNumbers = sequence(first: 100) { (previous: UInt32) in
let newValue = arc4random_uniform(previous)
guard newValue > 0 else {
return nil
}
return newValue
}

Array(randomNumbers)
// [100, 90, 60, 35, 34, 21, 3]

第二个变体是 sequence(state:next:),这个要更为强大,它可以在迭代过程中修改状态。

1
2
3
4
5
6
7
8
let fibsSequence2 = sequence(state: (0, 1)) { (state: inout (Int, Int)) -> Int? in
let upcomingNumber = state.0
state = (state.1, state.0 + state.1)
return upcomingNumber
}

Array(fibsSequence2.prefix(10))
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

sequence(frist:next:)sequence(state:next:) 的返回值类型是一个 UnfoldSequence。

可能有人会好奇 unfold 是一个什么概念?其实它出自函数式编程的范畴里,在函数式编程中有 fold 和 unfold 的概念。fold 是把一系列的值变为一个值,例如 reduce 就是一个 fold 操作。unfold 是 fold 的反操作,把一个值展开成一系列的值。

再回首

结合着嵌套函数,Sequence 和 Iterator 这些知识点,让我们再重新阅读一下最开始的代码片段,不知道这一次你是否有了什么新的感受?

如何有任何疑问或者建议,欢迎交流!

❌
❌