iOS 应用启动慢的原因找到了!

一款应用首先带给用户的就是启动体验,时间越短则体验越好,苹果更是建议应用第一个加载时间不宜超过 400 毫秒,可是据说 Swift 引用类型对应用的大小及启动速度有影响,这具体是怎么回事?作者 | Noah Martin   译者 | 弯月出品 | CSDN(ID:CSDNnews)应用的启动体验是你带给用户的第一印象。在等待应用启动的过程中,每一毫秒对他们来说都很宝贵,他们完全可以将这些时间花在别处。如果你的应用很吸引用户,他们在一天内使用了很多次你的应用,那么他们肯定会一遍又一遍耐心地等待应用启动。苹果建议第一个画面的加载不应该超过 400 毫秒。这样可以确保在 Springboard 的应用启动动画结束前,你的应用就做好准备可以使用了。由于只有 400 毫秒的时间,所以开发人员必须非常小心,应尽力避免意外增加应用的启动时间。然而,应用的启动过程非常复杂,有很多可变因素,因此我们很难准确地把握究竟哪些方面影响到了启动的速度。在构建自己的应用期间,我深入研究了应用大小与启动时间的关系。在本文中,我会揭开应用启动过程中较为神秘的一些方面,并向你展示 Swift 引用类型对应用的大小以及启动速度有何种影响。

Dyld应用启动的时候,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 还需要设置其他可能会影响启动时间的元数据,比如绑定,但是在本文中,我们只讨论重定位。所有这些指针的设置都会导致应用的启动时间增加,因此减少指针设置可以缩减应用二进制文件的大小,加快启动速度。下面,我们来看一看这些指针设置源自何方,以及可能产生的影响。

Swift 和 Obj-C上述,我们看到重定位的时间是由应用的 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)0177158.711000267269.231000010772643.3120000197721104.2340000377724195.26我们可以看到,每进行 2000 次重定位操作,启动时间就会增加大约 1 毫秒。但这些时间不会直接累加到启动时间,因为某些操作可以并行完成,但是这些操作的确有一个下限,当重定位超过 40 万个时,应用的启动时间就已经接近了苹果建议的 400 毫秒的一半。

示例我测量了几款流行的应用中重定位操作的发生次数,并借以了解这些操作在实践中的普遍程度。% xcrun dyldinfo -rebase TikTok.app/TikTok | wc -l2066598抖音有 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: Stringprivate 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: Stringprivate 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 中的类别即使 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 翻译

(0)

相关推荐