查看原文
其他

iOS 15 如何让你的应用启动更快

iOS大全 2021-12-15

推荐关注↓

这是一篇来自 Noah Martin 的文章,作者发现了 WWDC2021 中没有被提及的一处改动,却能够帮助你的 App 在 iOS15 上运行的更快。

WWDC21 上最吸引人的功能被深埋在 Xcode 13 的发布说明中。

所有以 macOS 12 或 iOS 15 及更高部署目标构建的程序和 dylibs 现在都使用新的链式 fixups 格式。这种新的格式使用了不同的加载命令和 LINKEDIT 数据,并且不会在旧的操作系统版本上运行或加载。

没有任何文档或会议来了解这一变化,但我们可以通过逆向工程来了解苹果在新操作系统上的不同做法,以及它是否会优化你的应用程序。

首先,介绍一下控制应用程序启动的程序,即 dyld 的背景。

认识dyld

动态链接器(dyld)是每个应用程序的入口点。它负责让你的代码准备好运行,所以对 dyld 的任何改进都会改善应用程序的启动时间。在调用 main 、运行静态初始化方法或设置 Objective-C 运行时之前,dyld 会进行 fixups 工作。这包括 rebase 和 bind 操作,这些操作修改了应用程序二进制文件中的指针,以保证在运行时所有地址的有效性。要查看这些操作,你可以使用 dyldinfo 命令行工具。

% xcrun dyldinfo -rebase -bind Snapchat.app/Snapchat
rebase 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 中,需要移位一个常量值(被称为滑动 slide )。而地址 0x107595A70 位于 __DATA/__const ,应该指向 libswiftCore.dylib 中的一个 Hashable[1] 的协议描述符

dyld 使用 LC_DYLD_INFO 加载命令和 dyld_info_command 结构来确定二进制文件中 rebase 、bind 和导出符号[2]的位置和大小。Emerge(免责声明:是一个可以查看 App 包二进制大小和分布的软件,本文的作者就是作者😬)对这些数据进行分析,让你直观地了解它们对二进制文件大小的贡献,并能提供建议,使用链接器标志使其更小。

一个新的格式

当我第一次将一个为 iOS 15 构建的应用程序导入到 Emerge 时,软件没有展示出可视化的 dyld fixups 。这是因为 LC_DYLD_INFO_ONLY 加载命令不见了,它被 LC_DYLD_CHAINED_FIXUPSLC_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 应用程序来解析 iOS 15 的二进制文件并打印每个符号。

let bytes = (tryData(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) -> [(StringUInt)] {
    guard load(asUInt8.self) == 0 else {
      // This is a terminal node
      print("symbol name \(name)")
      return []
    }
    let numberOfBranches = UInt(advanced(by: 1).load(asUInt8.self))
    var mutablePointer = self.advanced(by: 2)
    var result = [(StringUInt)]()
    for _ in 0..<numberOfBranches {
      result.append(
        (mutablePointer.readNullTerminatedString(),
         mutablePointer.readULEB()))
    }
    return result
  }
}

链式

真正的变化是在 LC_DYLD_CHAINED_FIXUPS 。在 iOS 15 之前,rebase、bind 和lazy bind 各自存储在一个单独的表中。现在它们被合并成链,链的起点指针包含在这个新的加载命令中。

应用程序的二进制文件被分成几个部分,每个部分都包含一连串的 fixups ,这些 fixups 可以是bind 或 rebase(不再有 lazy binds )。二进制文件中的每个64位 rebase[3] 位置现在都编码了它所指向的偏移量以及下一个 ccc 的偏移量,如这个结构所见。

struct dyld_chained_ptr_64_rebase
{

uint64_t    target    : 36,
            high8     :  8,
            reserved  :  7,    // 0s
            next      : 12,
            bind      :  1;    // Always 0 for a rebase
};

36位用于指针 target ,足以满足 2³⁶ = 64GB 的二进制,12位用于提供下一个 fixup 的偏移量(stride = 4)。因此,它可以指向 2¹² * 4 = 16kb 内的任何地方--正好是 iOS 上的 page 大小。

这种非常紧凑的编码意味着遍历链的整个过程就可以覆盖整个二进制文件。在我的测试中,超过 50% 的 dyld 都能够被新的格式系统优化并最终减少二进制包的大小,只有少量的元数据被保留下来以引导每个 page 的第一次 fixup 。最终的结果是,大型 Swift 应用程序的大小减少了 1mb 以上。

这个过程的源代码在 MachOLoaded.cpp 中,二进制布局在 /usr/include/macho-o/fixup-chains.h 中。

顺序问题

为了理解新 fixup 格式背后的动机,我们必须了解应用程序启动期间最昂贵的操作之一:page fault 。当应用程序启动过程中访问文件系统中的代码时,需要通过一个 page fault 将其从磁盘文件中带入内存。应用程序二进制文件中的每个 16kb 范围都被映射到内存中的一个页面。一旦页面被修改,只要应用程序不停止,它就需要留在 RAM 中(称为 dirty page)。iOS 通过压缩最近没有使用的页面来优化 dirty page。

应用程序启动时的 fixup 需要改变应用程序二进制中的地址,因此整个 page 都不可避免被标记为 dirty。让我们看看在应用启动时有多少页面被 fixups。

% 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 > binds
450

当使用表结构存储 fixup 数据时,首先需要解决 rebase ,然后是 bind 。这意味着 rebase 需要很多 page fault ,并且最终大部分是 IO 绑定[4]。另一方面,bind 所访问的 page 是 rebase 所使用的 page 的 30%。

而现在,在 iOS 15 中,链式 fixups 将每个内存 page 的所有变化合并在一起。Dyld 现在可以更快地处理它们,只需调整一次内存,就可以同时完成 rebase 和 bind 。这使得像内存压缩器这样的操作系统功能可以利用链式 fixups 中的信息,不需要在 bind 过程中回去解压旧 page 。由于这些变化,dyld 中的 rebase 功能变成了一个无用的功能。

总的来说,这一变化主要影响到的是 iOS 应用程序的逆向工程和探索动态链接器的细节,但它很好地提醒了影响你的应用程序性能的低层次内存管理。虽然这一变化只在你以 iOS 15 为目标时生效,但请记住,你仍然可以做很多事情来优化应用程序的启动时间。

  • 减少动态框架的数量
  • 减少应用程序的大小,以减少内存页的使用(这就是作者制作 Emerge 的原因!)。
  • 将代码从 +load 和静态初始化器中移出
  • 使用更少的类
  • 将工作推迟到绘制第一帧之后

[1] dyldinfo 的符号被篡改了,你可以用 xcrun swift-demangle '_$sSHMp' 获得人类可读的名称。

[2] 导出是 bind 的第二部分。一个二进制文件会与从其依赖关系中导出的符号绑定。

[3] bind 也是如此,一个指针实际上是 rebase 和 bind( dyld_chained_ptr_64_bind )的联合体,用一个位来区分这两者。bind 也需要导入符号名,这里不作讨论。

[4] asciiwwdc.com/2016/sessions/406

原文:How iOS 15 makes your app launch faster

转自:掘金 ZacJi

https://juejin.cn/post/6978750428632580110

- EOF -

推荐阅读  点击标题可跳转

1、Tagged Pointer 对象安全气垫为何会失效

2、Swift 5.5 带来了 async/await 和 actor 支持

3、APP 终极性能生存指南


看完本文有收获?请分享给更多人

关注「 iOS大全 」加星标,关注 iOS 动态

点赞和在看就是最大的支持❤️

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存