一款应用首先带给用户的就是启动体验,时间越短则体验越好,苹果更是建议应用第一个加载时间不宜超过 400 毫秒,可是据说 Swift 引用类型对应用的大小及启动速度有影响,这具体是怎么回事?
应用的启动体验是你带给用户的第一印象。在等待应用启动的过程中,每一毫秒对他们来说都很宝贵,他们完全可以将这些时间花在别处。如果你的应用很吸引用户,他们在一天内使用了很多次你的应用,那么他们肯定会一遍又一遍耐心地等待应用启动。苹果建议第一个画面的加载不应该超过 400 毫秒。这样可以确保在 Springboard 的应用启动动画结束前,你的应用就做好准备可以使用了。
由于只有 400 毫秒的时间,所以开发人员必须非常小心,应尽力避免意外增加应用的启动时间。然而,应用的启动过程非常复杂,有很多可变因素,因此我们很难准确地把握究竟哪些方面影响到了启动的速度。在构建自己的应用期间,我深入研究了应用大小与启动时间的关系。在本文中,我会揭开应用启动过程中较为神秘的一些方面,并向你展示 Swift 引用类型对应用的大小以及启动速度有何种影响。
应用启动的时候,Dyld 会加载 Macho-O 可执行文件。Dyld 是苹果负责加载应用的程序。它的运行过程与你编写的代码相同,会在启动的时候加载所有依赖框架,包括系统框架。
Dyld 的任务之一是重定位二进制元数据中的指针,这些元数据描述了源代码中的类型。动态运行时功能需要这些元数据,但这些元数据也会导致二进制文件膨胀。以下是某个已编译的应用二进制文件中包含的 Obj-C 类的布局:
struct ObjcClass {
let isa: UInt64
let superclass: UInt64
let cache: UInt64
let mask: UInt32
let occupied: UInt32
let taggedData: UInt64
}
每个 UInt64 都是一段元数据的地址。由于它包含在应用二进制文件中,因此任何人从商店下载到的数据都是完全相同的。然而,由于地址空间布局随机化(Address Space Layout Randomization,简称 ASLR),因此每次启动应用时,这些数据在内存中的位置都会不同(并非总是从 0 开始)。这是一项安全功能,目的是为了防止他人猜测某个特定功能在内存中的位置。
ASLR 的问题在于,它会导致应用的二进制文件中硬编码的地址出错,实际的起始地址有随机的偏移量。Dyld 的任务就是重定位所有指针,纠正起始位置。可执行文件中的每个指针,以及所有依赖框架(包括递归依赖),都要经过这样的处理。此外,Dyld 还需要设置其他可能会影响启动时间的元数据,比如绑定,但是在本文中,我们只讨论重定位。
所有这些指针的设置都会导致应用的启动时间增加,因此减少指针设置可以缩减应用二进制文件的大小,加快启动速度。下面,我们来看一看这些指针设置源自何方,以及可能产生的影响。
上述,我们看到重定位的时间是由应用的 Obj-C 元数据引起的,但为什么 Swift 应用中会包含这些元数据呢?Swift 具有 @objc 属性,它可以让 Objective-C 代码看到 Swift 中的声明,但是即使 Obj-C 代码看不到 Swift 类型,也会生成元数据。这是因为所有 Swift 类型都包含苹果平台的 Objective-C 元数据。我们来看一看下面这个声明:
final class TestClass { }
这是纯 Swift 代码,并没有继承 NSObject,也没有使用 @objc。但是,它仍然会在二进制文件中生成一个 Obj-C 类元数据,而且还会产生 9 个需要重定位的指针!为了证明这一点,下面我们使用 Hopper 工具检查二进制文件,并查看“纯 Swift”类的 objc_class 条目:
图:应用二进制文件中的Obj-C元数据
将环境变量
DYLD_PRINT_STATISTICS_DETAILS 设置成 1,就可以看到启动应用时需要重定位的指针数量。在应用启动完成后,控制台中就会输出重定位的总数。我们甚至可以准确地找出这 9 个指针的位置。
并非所有 Swift 类型都会添加相同数量的重定位。如果通过重载超类或遵循 Obj-C 协议的方式,将方法公开给 Obj-C,则添加的重定位更多。另外,Swift 类上的每个属性都将在 Objective-C 元数据中生成一个 ivar。
根据设备类型以及运行的应用,重定位对实际启动时间的影响也会有不同。我测量了一台旧 iPhone 5S 上的实际情况。
iOS 的启动大致可分为:热启动和冷启动。热启动指的是,系统已经启动过了应用,并缓存了一些 Dyld 设置信息。由于我测试的首次启动是冷启动,因此速度略微慢一些。
类数量 | 重定位 | 重定位时间(ms) |
0 | 17715 | 8.71 |
1000 | 26726 | 9.23 |
10000 | 107726 | 43.31 |
20000 | 197721 | 104.23 |
40000 | 377724 | 195.26 |
我们可以看到,每进行 2000 次重定位操作,启动时间就会增加大约 1 毫秒。但这些时间不会直接累加到启动时间,因为某些操作可以并行完成,但是这些操作的确有一个下限,当重定位超过 40 万个时,应用的启动时间就已经接近了苹果建议的 400 毫秒的一半。
我测量了几款流行的应用中重定位操作的发生次数,并借以了解这些操作在实践中的普遍程度。
% xcrun dyldinfo -rebase TikTok.app/TikTok | wc -l
2066598
抖音有 200 多万个重定位,这导致它的启动时间超过了一秒钟!抖音使用了 Objective-C,但是我也测试了一些大型的 Swift 应用,它们使用了单体二进制体系结构,其中的重定位次数大约在 68.5 万~180 万次之间。
尽管每个类都会增加重定位操作,但我并没有建议将每个 Swift 类都换成 struct。大型 struct 也会增加二进制文件的大小,而且在某些情况下,你需要的只是引用而已。与其他提升性能的手段一样,你应该避免过早优化,而且首先应该从测量开始。在发现问题之后,你可以寻找应用中需要改进的地方。以下是一些常见的情况:
假设有如下这样的一个数据层:
class Section: Decodable {
let name: String
let id: Int
}
final class TextRow: Section {
let title: String
let subtitle: String
private enum CodingKeys: CodingKey {
case title
case subtitle
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
title = try container.decode(String.self, forKey: .title)
subtitle = try container.decode(String.self, forKey: .subtitle)
try super.init(from: decoder)
}
}
final class ImageRow: Section {
let imageURL: URL
let accessibilityLabel: String
private enum CodingKeys: CodingKey {
case imageURL
case accessibilityLabel
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
imageURL = try container.decode(URL.self, forKey: .imageURL)
accessibilityLabel = try container.decode(String.self, forKey: .accessibilityLabel)
try super.init(from: decoder)
}
}
这段代码会产生大量元数据,但是同样的功能可以通过值类型实现(更适合在数据层中使用),并最终减少 22% 的重定位。你需要用组合替换掉对象继承,例如具有关联值的枚举,或泛型等。
struct Section<SectionType: Decodable>: Decodable {
let name: String
let id: Int
let type: SectionType
}
struct TextRow: Decodable {
let title: String
let subtitle: String
}
struct ImageRow: Decodable {
let imageURL: URL
let accessibilityLabel: String
}
即使 Swift 没有使用类别,而是使用了扩展,但你仍然可以通过声明使用了 Objective-C 函数的扩展来生成类别二进制元数据。声明方式如下:
extension TestClass {
@objc
func foo() { }
override func bar() { }
}
这两个函数都包含在二进制元数据中,但是由于它们是在扩展中声明的,因此可以通过 TestClass 的合成类别引用。将这些函数移到原始类声明中,可以避免二进制文件包含额外的类别元数据。
此外,你还可以使用基于闭包的回调(例如 iOS 14 引入的回调)完全避免 @objc。
Swift 类中的每个属性都会添加 3~6 个重定位,具体取决于该类是否为 final 类。如果有很多拥有 20 多个属性的大型类,那么这个数字就非常惊人了。例如:
final class TestClass {
var property1: Int = 0
var property2: Int = 0
...
var property20: Int = 0
}
将其转换为 struct,可以减少 60% 的 rebase!
final class TestClass {
struct Content {
var property1: Int = 0
var property2: Int = 0
...
var property20: Int = 0
}
var content: Content = .init()
}
代码生成
回报率最高的提升方法之一就是改进代码生成。代码生成的一种流行的用法是在多个代码库中建立共享的数据模型。如果你在多种类型上进行此操作,则需注意它们会增加多少 Obj-C 元数据。然而,即便是值类型,也会增加代码量以及重定位的开销。最佳解决方案是尽可能减少生成的类型数量,或者用生成的函数替换自定义类型。
上述这些示例只是由于二进制文件规模扩大,而导致启动时间增加的几种情况。还有其他导致启动时间增加的原因,比如从磁盘加载到内存的代码量越大,启动时间就会越长。
原文链接:
https://medium.com/codestory/why-swift-reference-types-are-bad-for-app-startup-time-90fbb25237fc
声明:本文为 CSDN 翻译,转载请注明来源。