本 Session 讲了为了让你的应用包体积更小,运行更快,启动速度更快,我们对 Swift 和 Objective-C 运行时做了怎样的优化。同时通过本 Session 你将发现如何通过高效的协议检查,更小的消息发送,以及优化后的 ARC 机制,来提高你的 App 性能。
# 前言
WWDC2022 上苹果更新了 Xcode14,里面提到了一些相关的优化。其中讲了通过对 Swift 和 Objective-C 运行时做了一些优化,达到了包体积变得更小、运行速度更快,启动速度更快的目的。如果你是用 Xcode14 来构建 App,那么会有其中三点优化
- 高效的协议检查(针对 Swift protocol check)
- 更快的消息发送机制(message send)
- release 和 retain 调用优化(release & retain)
- Autorelease elision 的优化(自动释放省略)
当你用 Swift 或 Objective-C 编写代码时,其实是会经历三个个步骤。
- 编码,通过 Xcode 编写代码
- 编译,使用了 Swift 和 Clang 编译器
- 运行,通过 Swift 和 Objective-C 运行时中完成
此次的这些关键优化其实就是在第三步骤运行时来完成的,运行时嵌入在我们所有平台的操作系统中。编译器在构建时不能做的事情,运行时可以做。而此次所有的修改其实对于开发者来说是无感透明的,所以任何代码都不用改动,只要你使用 Xcode14 来进行打包编译,便会享受的这些优化点。
# Swift 协议检查(Protocol checks)
先来看一个例子!
1 | // 定义一个协议 |
看上面代码,因为 log 函数的参数需要输出字符串,所以在输出前要先判断这个 value 是否遵循 CustomLoggable 协议,Swift 是静态语言,所以一般来说这样的检查都是发生在编译时期。但是编译器不一定能拿到足够的协议元数据信息来完成检查。比如这里并不知道每次传入的 Any 类型是哪个确定类型,也就无法确定是否遵循 CustomLoggable
协议。所以这种检查常常发生运行时,系统借助计算好的协议检查元数据 (protocol check metadata),运行库知道这个特殊对象是否符合协议。
这些元数据的构建虽然大部分在编译期间,但是还是有一部分是要在运行时完成,比如上面的例子,而且一个项目中肯定不止有一个协议,所以随着协议越多运行时的效率就越低,对于用户来说这个时间大部分是启动时间,所以用户感知为启动时间变长。而 Xcode14 新推的的 Swift Runtime 解决了这个问题,只要你是用 Xcode14 编译且运行在 iOS16 及以上版即可。
按照苹果的说法,他们会把 是否遵循协议
的这个判断前置到 build 时期,也就是把 协议元数据计算
的步骤前置到 build 中,具体就是他把这些操作放在 App 可执行文件和启动时任何动态库的 dyld 闭包的一部分
为什么这样做可以节省启动时间,需要先了解下 app 启动流程,需要一个知识背景 从iOS11开始dyld3被加入,iOS13第三方库也开始使用dyld3加载。
所以我们要看下 dyld3 的加载流程
dyld 3 主要包含了两个过程 进程外(启动前)和进程内(启动后),我们来看启动前做了那些事情
- 进程外 Mach-O 分析器和编译器 (out-of-process mach-o parser)
dyld 3 中将采用提前写入把结果数据缓存成文件的方式构成一个 lauch closure(可以理解为缓存文件) - 分析依赖库
- 执行符号查找
- Write closure 缓存服务 (launch closure cache )
系统程序的 closure 直接内置在 shared cache 中,而对于第三方 APP,将在 APP 安装或更新时生成,这样就能保证 closure 总是在 APP 打开之前准备好。说白了就是把上面做的结果全都缓存起来
综上看来以前需要在 in-process 中做的事情,现在在 out-of-process 就可以完成,启动时或者运行时直接读取缓存数据即可,加快了启动速度和运行时的性能。其实在笔者看来当我们下载或者更新 App 的时候 App 上的进度条其实是分两部分 正在下载
和 正在安装
,此次的优化可能略微提高安装的时长来降低启动速度,提高运行时性能。
on apps that rely heavily in Swift, this could add up to half the launch time
如果有条件的同学可以试下是否可以提高这么多的启动耗时。
# 消息发送优化(Message send)
直接抛结果,苹果这边给到的数据是使用 Xcode14 编译打包的数据可以让 ARM64 上发送消息消耗从 12 字节降低到 8 字节,二进制大小也有 2% 的降低,也就是苹果对包大小和性能都做了优化,默认是同时开启的,由苹果来平衡两者的关系,当然也可以使用 objc_stubs_small
来仅仅优化包大小。
下面我们看下是怎么优化的,同样使用官方代码举例
1 | // 声明一个日历对象 |
大家知道 OC 调用方法最终会走到 _objc_msgSend
,所以上面代码不算最终的 return,会走 7 个 _objc_msgSend
,其中每一个都需要一条指令来调用就是 bl 如下图
该函数定义为 Id objc_msgSend(id self, SEL _cmd, ...)
,参数定义为 self 是函数的调用方,SEL 为具体调用哪个函数,具体的方法查找流程就不在这里赘述。
我们拿其中具体的一个函数调用来分析
1 | NSDate *theDate = [cal dateFromComponents: dateComponents]; |
比如这个函数调用,转化为 mesagesend 的时候就变成这样
1 | objc_msgSend(cal, @selector(dateFromComponents)) |
为了告诉运行时调用哪个方法,我们必须传递一个 Selector 给这些 objc_msgSend 调用,就如上图的 @selector(dateFromComponents)
我们再来看 Id objc_msgSend(id self, SEL _cmd, ...)
执行后他是怎么执行汇编指令的。
1 | // 使用adrp找到该方法的地址 消耗4字节 |
从上面的代码看出每次执行方法调用都会 走以上三个步骤,每个步骤消耗 4 字节 一共消耗 12 字节,而前两步是准备 selector,任何一次方法调用都会执行他,目前的策略是每调一个方法都会生成上面三步,那么此时优化空间就来了。
因为这里存在相同的代码(前两步), 我们可以考虑共享它,并且只在每个 selector 中触发它一次,而不是每次发送消息时都生成这段指令代码
。所以我们可以把这部分相同代码提取出来,放到一个小助手函数中 (helper function), 并调用该函数。通过使用同一 selector 进行多次调用 (通过传递参数不同,内部指令是相同的,现在封装成一个存根函数,以前是散落在各个 _objc_msgSend 调用处),我们可以保存所有这些指令字节。所以可以理解为 把前两步封装一下
所以原来的调用就变成了
1 | bl _objc_msgSend$dateFromComponents 4字节 |
这也就是苹果说的从 12 字节优化到 8 字节,其中 _objc_msgSend$dateFromComponents
也被称为 selector stub 存根函数
同样 _objc_msgSend
本身也有一个存根函数写法
这样一来我们现在就有两个存根函数
- _objc_msgSend$dateFromComponents:
- _objc_msgSend:
这两个函数封装了一些通用的东西,共享了最多的代码,使代码尽可能的小,但是这样带来的不足是我需要连着两个 bl 跳转,这对操作系统来说开销较大。所以为了平衡包体积和性能,我们可以使用下面这种方法来提升这一点。我们可以把前面调用的两个存根函数封装成一个 (都封装成_objc_msgSend$dateFromComponents),这样,我们可以使代码更紧凑,不需要那么多调用。如下图这样
这就回到了之前的问题,你可以通过 _objc_stubs_small
标记了只降低包大小,或者采用默认的方式让系统自动平衡,两者的区别在汇编层面就体现在如下图
综上:这就是 Meesage send 占用从 12 bytes 降低到 8 bytes 和二进制大小下降 12% 的原因
# Retain and release
这个优化是苹果这边使 Retain and release 的开销更小,苹果的说法是 Retain and release 的调用开销从 8 字节降低到 4 字节,同时包体积也会有 2% 的优化
我们知道 ARC 相比于 MRC 是开发者不需要再写 retain、release 这些代码,其实并不是不需要,而是编译器帮我们自动在需要的位置插入了这些代码,所以换句话说他们还是存在的,只是你看不到也不用在关心他们。
还是拿之前的例子来说
1 | // Retain/release calls inserted by ARC |
在变量创建的时候我们使用 retain 来增加的他的引用计数不被销毁,在方法结束后我们使用 release 来销毁不需要的变量,这也是 iOS 的内存管理机制。在 ARC 下这些都是编译器我们插入的代码,我们无需关心。
retain 和 release 都是 C 语言的函数,他们携带一个参数就是被操作的对象,同时他遵循 C 语言的 ABI,所以当你调用这些方法的时候系统还会为你做一些额外的事情,比如下图中的 mov 操作,而这些操正是我们优化的用武之地,通过自定义调用重新约定 retain/release 接口,我们可以根据对象指针的位置,适当的使用正确的变量,这样就可以不用移动它。简单的说, 就是修改了底层 ABI
。
我们是怎么做的优化呢?看下之前的流程,我们用下面这行代码举例
1 | objc_release(dateComponents); |
流程为
- 先执行 mov 把副本地址(X20, 也就是对象的地址)存到寄存器 x0
- 然后 bl 跳转到
_objc_release
函数进行释放
根据之前讲的每个指令消耗 4 字节,所以这里消耗 8 字节
我们修改 ABI 之后其省掉调用 mov 指令 然后原本跳转到_objc_release 函数 改为跳转到 _objc_release_x20
函数,而 mov 的指令放到 C 语言更底层的 ABI 里面去做,你可以理解为 我们封装了一个新的retain、release函数,你只要传入一个寄存器地址我就去更底层的地方完成mov操作,所以效率更高了
。现在因为只用执行一条指令,所以内存消耗为 4 字节。现在的流程看起来为
这么看来我们代码里大量的 release 和 retain 都经过这样的样的优化所以整体的二进制包降低 2% 同时调用内存消耗游 8 字节变为 4 字节,同时 ABI 接口修改,去除冗余 mov 指令调用,下沉到 ABI。 由于 ABI 是内嵌系统
,这里新增 mov 指令占用可以忽略不计。
Apple 果然是坚持用户体验优先,为了更好体验不惜修改 c 的 ABI
# Autorelease elision(自动释放省略优化)
iOS 中除了使用 release 之外还有另一个 就是 autorelease 自动释放机制,同样在这个地方苹果也做了自动释放省略的优化让自动释放机制效率更高。我们来看下面这个例子
1 | // Return Value Autoreleases |
创建一个临时对象 (theDate),并将其返回给调用方 (event)。 getWWDCDate()
方法中返回临时的 theDate,然后调用完成 (返回 theDate 之后,getWWDCDate 就调用完成)。这时调用方(event)将其保存到自己的变量中(theWWDCDate 中)。
根据系统插入 retain 和 release 的机制来说应该是这样的,但是明显 retain 处不能进行 release,因为我需要吧 theDate 返回回去,如果这里释放了我就没办法呢返回了。
因此,为了解决上述问题,需要使用一个特殊的约定用来返回这个临时返回值。这就引入了 Autorelease,这样调用者能够 retain 它。autorelease 在这里保证在调用方可以正常返回该值,而不被提前释放,延长释放生命周期。你之前可能看到过 autorelease 和 autoreleasePools:其实这是一种将 release 操作推迟到稍后某个时间的方法。所以上面的代码改为 Autorelease
1 | // Return Value Autoreleases |
系统并不知道他在什么时候会被释放,反正只要不在 retain 的时候释放就行,所以我在 retain 的时候先打个标记,标记他之后可能会被释放。但是这样的操作目前会带来一些开销,其实就是 我虽然打了release标记,但是我明明一会还要retain,没必要多此一举
,所以基于此我们之前引入了 Autorelease elision
来减少这部分开销( 如果Autorelease后紧接一个retain我就都不做了
)。我们先从汇编层面看下 Autorelease elision 做了什么
提炼出以下代码
1 | // What the compiler emits |
其实就是以下步骤
- 当我们返回值调用 Autorelease 时候系统会调用
_objc_autoreleaseReturnValue
来返回一个autoreleased value
- 执行 Autorelease 后编译器会添加个标记
mov x29, x29
而这句指令在实际运行中这个指令会变为二进制的形式变为0xAA1D03FD
- 后续的操作就运行时会先判断是否有对应的标记
0xAA1D03FD
,如果有,这意味着编译器告诉 runtime, 我们将返回一个已经被标记,但是将立即被持有(retain) 的临时变量,后面就不需要再 retain 操作了
1 | static ALWAYS_INLINE bool |
说白了就是在返回值身上调用 objc_autoreleaseReturnValue
方法时,runtime 将这个返回值 object 标记(储存在 TLS 中),然后直接返回这个 object(不调用 autorelease);同时,在外部接收这个返回值的 objc_retainAutoreleasedReturnValue
里,发现有之前的标记(TLS 中正好存了这个对象),那么直接返回这个 object(清楚之前的标记且不再调用 retain)。
注意:TLS 相关的含义可以参考 [这里](EarlGrey 源码阅读(一) | SeanChense)
但是这里有一个问题,以二进制的形式来加载代码并不是很常见,而且我们不但要加载它还要比较他尤其在 CPU 上并不是最优策略,所以这里还是有开销的,因此我们看下如何优化。
同样执行流程,当执行完 _objc_autoreleaseReturnValue
函数时候我们会获得一个返回地址,这个地址是一个指针,指向了被标记为 Autorelease 的对象。然后代码继续执行到 _objc_retainAutoreleasedReturnValue
这里要进行 reatin,而被 reatain 的变量地址我们也可以拿到,所以只要比较这两个指针即可,这样一来我们也不再需要 mov 操作
优化点
- 把原来的比较二进制数据改为比较指针。速度更快效率更高
- 减少 mov 指令 减少 4 字节,二进制大小预计降低 2%
# 总结
这就是 Xcode14+iOS16 的编译期间优化,可以看出苹果也在帮我们完成 OKR 减少包体积,提高启动速度,增加代码执行效率,同时也能看出苹果在追求极致用户体验道路上所做的事情。本文部分翻译自 Improve app size and runtime performance,同时也添加了自己的思考。