iOS 15 如何让 App 启动更快?

逆锋起笔

共 5682字,需浏览 12分钟

 · 2021-08-05

译者 | 无阻我飞扬  责编 | 晋兆雨
出品 | CSDN(ID:CSDNnews)
WWDC21中最有趣的特性被深深地隐藏在 Xcode 13发布说明中:
部署在 macOS 12 或 iOS 15 及更高版本操作系统上的所有程序及 dylibs现在都使用链式修复格式。这种格式使用不同的加载命令和 LINKEDIT 数据,不能在低版本的操作系统上运行或加载。
目前还没有任何文献或会议可以了解更多有关于此更改的信息,但我们可以对其进行逆向工程,以了解 Apple 在新版本上有何不同,它是否优化了App的启动时间。首先,了解控制App启动的程序的一些背景知识。

认识dyld
dyld是苹果的动态链接器,是苹果操作系统一个重要组成部分,是每个App的入口点。它负责让APP的代码做好运行准备,因此对dyld的任何改进都会使得App启动时间缩短。在调用main、运行静态初始化程序或设置 Objective-C运行时间之前,dyld负责执行修正操作,包括变基和绑定操作,这些操作修改App二进制文件中的指针以包含在运行时有效的地址。想要了解它们如何运行,可以使用 dyldinfo 命令行工具。
% xcrun dyldinfo -rebase -bind Snapchat.app/Snapchatrebase information (from compressed dyld info):segment section address type__DATA __got 0x10748C0C8 pointer...bind information:segment section address type addend dylib symbol__DATA __const 0x107595A70 pointer 0 libswiftCore _$sSHMp
这意味着地址 0x10748C0C8 位于 __DATA/__got,需要按一个常量值进行移位。地址 0x107595A70 在 __DATA/__const, 应该指向 Hashable[1] 的协议描述符在libswiftCore.dylib
dyld 使用 LC_DYLD_INFO 载入命令和 dyld_info_command 结构确定二进制文件中变基、绑定和导出符号[2]的位置和大小 。Emerge (声明:我是创始人),解析这些数据,直观了解它们对二进制大小的贡献,建议链接器标志使它们变得更小:

 

一种新的格式

当第一次上传一个为iOS15构建的App时,通过 Emerge,并没有看到dyld修正的效果。因为缺少LC_DYLD_INFO_ONLY加载命令,它已被替换为LC_DYLD_CHAINED_FIXUPS 和 LC_DYLD_EXPORTS_TRIE
% otool -l iOS14Example.app/iOS14Example | grep LC_DYLD      cmd LC_DYLD_INFO_ONLY% otool -l iOS15Example.app/iOS15Example | grep LC_DYLD      cmd LC_DYLD_CHAINED_FIXUPS      cmd LC_DYLD_EXPORTS_TRIE
导出数据与之前完全相同,树的每个节点代表符号名称的一部分。 

iOS 15 中唯一的变化是数据现在由 linkedit_data_command 引用,该命令包含第一个节点的偏移量。为了验证这一点,我写了一个简短的 Swift App来解析 iOS 15 二进制文件并打印每个符号:
let bytes = (try! Data(contentsOf: url) as NSData).bytes      bytes.processLoadComands { load_command, pointer in        if load_command.cmd == LC_DYLD_EXPORTS_TRIE {          let dataCommand = pointer.load(as: linkedit_data_command.self)          bytes.advanced(by: Int(dataCommand.dataoff)).readExportTrie()        }      }    
extension UnsafeRawPointer { func readExportTrie() { var frontier = readNode(name: "") guard !frontier.isEmpty else { return }
repeat { let (prefix, offset) = frontier.removeFirst() let children = advanced(by: Int(offset)).readNode(name: prefix) for (suffix, offset) in children { frontier.append((prefix + suffix, offset)) } } while !frontier.isEmpty }
// Returns an array of child nodes and their offset func readNode(name: String) -> [(String, UInt)] { guard load(as: UInt8.self) == 0 else { // This is a terminal node print("symbol name \(name)") return [] } let numberOfBranches = UInt(advanced(by: 1).load(as: UInt8.self)) var mutablePointer = self.advanced(by: 2) var result = [(String, UInt)]() for _ in 0..<numberOfBranches { result.append( (mutablePointer.readNullTerminatedString(), mutablePointer.readULEB())) } return result } }



真正的变化在 LC_DYLD_CHAINED_FIXUPS。 在 iOS 15 之前的版本,变基、绑定和延迟绑定分别存储在单独的表中。现在它们已组合成链,在这个新的加载命令中,包含链起点的指针: 

App二进制文件被分解成多个段,每个段都包含一个可以绑定或变基的修复链(不再有延迟绑定)。二进制文件中的每个 64 位 rebase[3] 定位,对它指向的偏移量以及到下一个修正的偏移量进行编码,如以下结构所示:
struct dyld_chained_ptr_64_rebase{uint64_t target : 36,high8 : 8,reserved : 7, // 0snext : 12,bind : 1; // Always 0 for a rebase};
指针对象使用36位,足以容纳 2³ ⁶ = 64GB 的二进制文件,12 位用于提供下一个修正的偏移量(步幅 = 4)。因此,它可以指向 2 ¹² * 4 = 16kb范围内的任何位置——正是 iOS 上的页面大小。
这种非常紧凑的编码意味着遍历链的整个过程可以包含在二进制的现有大小内。 在我的测试中,超过 50% 的 dyld 数据对二进制大小的贡献被保存,因为只保留了少量元数据用来指示每个页面上的第一个修正。最终结果是Swift App的大小减少了 1mb 以上。
这个过程的源代码在 MachOLoaded.cpp 中 ,二进制设计在 /usr/include/macho-o/fixup-chains.h

排序问题

要理解这种改变背后的动机,我们必须注意App启动时开销最大的操作——缺页异常。在App启动期间访问文件系统上的代码时,需要通过缺页异常将其从文件写入到内存。App二进制文件中的每个 16kb区间都映射到内存中的一个页面。一旦页面被修改,它就需要在App运行期间一直保留在 RAM 中(称为脏页面)。iOS 通过压缩最近未使用的页面来优化这一点。

App启动时的修正需要更改App二进制文件中的地址,因此整个页面都被标记为脏页面。让我们看看在app启动期间修正程序使用了多少页面:
% xcrun dyldinfo -rebase Snapchat.app/Snapchat > rebases% ruby -e 'puts IO.read("rebases").split("\n").drop(2).map { |a| a.split(" ")[2].to_i(16) / 16384 }.uniq.count'1554% xcrun dyldinfo -bind Snapchat.app/Snapchat > binds450
对于表的格式,首先解析变基,然后是绑定。这意味着变基需要许多缺页异常,并且最终主要是 IO 绑定 [4]。另一方面,绑定访问了30% 的变基使用的页面,有效地进行了第二次内存传递。
现在在 iOS 15版本中,链式修正将每个内存页面的所有更改组合在一起。dyld 现在可以通过一次遍历内存来更快地处理它们,同时完成变基和绑定。 这使得诸如内存压缩器之类的操作系统功能能够利用众所周知的排序,而无需在绑定期间返回并解压缩旧页面。由于这些改变,dyld中的变基函数变成了一个空操作:

 https://opensource.apple.com/source/dyld/dyld-851.27/src/ImageLoaderMachOCompressed.cpp.auto.html
总的来说,这种改变主要影响对 iOS App进行逆向工程和探索动态链接器细节,这很好地提醒了大家,低级的内存管理会影响App性能。虽然这种改变仅在iOS 15版本上的App有效,但请记住,仍然可以做很多事情来优化App启动时间:
  • 减少动态框架的数量

  • 减少应用程序大小,从而减少内存页面的使用(这就是我制作 Emerge 的原因!)

  • 将代码移出 +加载以及静态初始化程序

  • 使用 更少的类

  • 将工作推迟到绘制第一个框架后

参考链接:

  • [1] The symbol from dyldinfo is mangled, you can get the human readable name with xcrun swift-demangle '_$sSHMp'.
  • [2] Exports are the second piece of a bind. One binary binds to symbols exported from its dependencies.
  • [3] The same goes for binds, a pointer is actually a union of rebase and bind (dyld_chained_ptr_64_bind) with a single bit used to differentiate the two. Binds also require the imported symbol name which isn’t discussed here.
  • [4] https://asciiwwdc.com/2016/sessions/406
原文链接:https://medium.com/geekculture/how-ios-15-makes-your-app-launch-faster-51cf0aa6c520
作者:Noah Martin
声明:本文由CSDN翻译,转载请注明来源


iOS 15 内置原生壁纸下载
优酷 iOS 插件化页面架构方法
这也行?iOS后台锁屏监听摇一摇
189.31G iOS 学习资料分享
iOS APP图标版本化

浏览 13
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报