https://mp.weixin.qq.com/s?__biz=MzI2NTAxMzg2MA==&mid=2247486526&idx=1&sn=30dd6beb3e03d730251ea69943da3038&scene=21#wechat_redirect

image.png

1、名词解释

Mach-O

Mach-O 是 iOS 系统不同运行时期可执行的文件的文件类型统称。主要分以下三类:

  • Executable - 可执行文件,是 App 中的主要二进制文件
  • Dylib - 动态库,在其他平台也叫 DSO 或者 DLL
  • Bundle - 苹果平台特有的类型,是无法被连接的 Dylib。只能在运行时通过 dlopen() 加载

Mach-O 的基本结构如下图所示,分为三个部分:
image.png

  • Header 包含了 Mach-O 文件的基本信息,如 CPU 架构,文件类型,加载指令数量等
  • Load Commands 是跟在 Header 后面的加载命令区,包含文件的组织架构和在虚拟内存中的布局方式,在调用的时候知道如何设置和加载二进制数据
  • Data 包含 Load Commands 中需要的各个 Segment 的数据。

绝大多数 Mach-O 文件包括以下三种 Segment

  • __TEXT - 代码段,包括头文件、代码和常量。只读不可修改。
  • __DATA - 数据段,包括全局变量, 静态变量等。可读可写。
  • __LINKEDIT - 如何加载程序, 包含了方法和变量的元数据(位置,偏移量),以及代码签名等信息。只读不可修改。

Image

指的是 Executable,Dylib 或者 Bundle 的一种。

Framework

有很多东西都叫做 Framework,但在本文中,Framework 指的是一个 dylib,它周围有一个特殊的目录结构来保存该 dylib 所需的文件。

虚拟内存(Virtual Memory)

虚拟内存是建立在物理内存和进程之间的中间层。是一个连续的逻辑地址空间,而且逻辑地址可以没有对应的实际物理内存地址,也可以让多个逻辑地址对应到一个物理内存地址上。

Page Fault

当进程访问一个没有对应物理地址的逻辑地址时,会发生 Page Fault

Lazy Reading

某个想要读取的页没有在内存中就会触发 Page Fault,系统通过调用 mmap() 函数读取指定页,这个过程叫做 Lazy Reading

COW(Copy-On-Write)

当进程需要对某一页内容进行修改时,内核会把需要修改的部分先复制一份,然后再修改,并把逻辑地址重新映射到新的物理内存去。这个过程叫做 Copy-On-Write

Dirty Page & Clean Page

Image 加载后,被修改过内容的 Page 叫做 Dirty Page,会包含着进程特定的信息。与之相对的叫 Clean Page,可以从磁盘重新生成。

共享内存(Share RAM)

当多个 Mach-O 都依赖同一个 Dylib(eg. UIKit)时,系统会让这几个 Mach-O 的调用 Dylib 的逻辑地址都指向同一块物理内存区域,从而实现内存共享。Dirty Page 为进程独有,不能被共享。

地址空间布局随机化(ASLR)

当 Image 加载到逻辑地址空间的时候,系统会利用 ASLR 技术,使得 Image 的起始地址总是随机的,以避免黑客通过起始地址+偏移量找到函数的地址
当系统利用 ASLR 分配了随机地址后,从 0 到该地址的整个区间会被标记为不可访问,意味着不可读,不可写,不可被执行。这个区域就是 __PAGEZERO 段,它的大小在 32 位系统是 4KB+,而在 64 位系统是 4GB+

代码签名(Code Sign)

代码签名可以让 iOS 系统确保要被加载的 Image 的安全性,用 Code Sign 设置签名时,每页内容都会生成一个单独的加密散列值,并存储到 __LINKEDIT 中去,系统在加载时会校验每页内容确保没有被篡改。

dyld(dynamic loader)

dyld 是 iOS 上的二进制加载器,用于加载 Image。有不少人认为 dyld 只负责加载应用依赖的所有动态链接库,这个理解是错误的。dyld 工作的具体流程如下:
image.png
参考:dyld启动流程[3]

Load dylibs

dyld 在加载 Mach-O 之前会先解析 Header 和 Load Commands, 然后就知道了这个 Mach-O 所依赖的 dylibs,以此类推,通过递归的方式把全部需要的 dylib 都加载进来。
一般来说,一个 App 所依赖的 dylib 在 100 - 400 左右,其中大多数都是系统的 dylib,因为有缓存和共享的缘故,读取速度比较高。

Fix-ups

因为 ASLR 和 Code Sign 的原因,刚被加载进来的 dylib 都处于相对独立的状态,为了把它们绑定起来,需要经过一个 Fix-ups 过程。Fix-ups 主要有两种类型:Rebase 和 Bind。

PIC(Position Independent Code)

因为代码签名的原因,dyld 无法直接修改指令,但是为了实现在运行时可以 Fix-ups,在 code gen 时,通过动态 PIC(Position Independent Code)技术,使本来因为代码签名限制不能再修改的代码,可以被加载到间接地址上。当要调用一个方法时,会先在 __DATA 段中建立一个指针指向这个方法,再通过这个指针实现间接调用。

Rebase

Rebase 就是针对“因为 ASLR 导致 Mach-O 在加载到内存中是一个随机的首地址”这一个问题做一个数据修正的过程。会将内部指针地址都加上一个偏移量,偏移量的计算方法如下:

  1. Slide = actual_address - preferred_address

所有需要 Rebase 的指针信息已经被编码到 LINKEDIT 里。然后就是不断重复地对 DATA 中需要 Rebase 的指针加上这个偏移量。这个过程中可能会不断发生 Page Fault 和 COW,从而导致 I/0 的性能损耗问题,不过因为 Rebase 处理的是连续地址,所以内核会预先读取数据,减少 I/O 的消耗。

Binding

Binding 就是对调用的外部符号进行绑定的过程。比如我们要使用到 UITableView,即符号 OBJC_CLASS$_UITableView, 但这个符号又不在 Mach-O 中,需要从 UIKit.framework 中获取,因此需要通过 Binding 把这个对应关系绑定到一起。
在运行时,dyld 需要找到符号名对应的实现。而这需要很多计算,包括去符号表里找。找到后就会将对应的值记录到 __DATA 的那个指针里。Binding 的计算量虽然比 Rebasing 更多,但实际需要的 I/O 操作很少,因为之前 Rebasing 已经做过了。

dyld2 & dyld3

image.png
在 iOS 13 之前,所有的第三方 App 都是通过 dyld 2 来启动 App 的,主要过程如下:

  • 解析 Mach-O 的 Header 和 Load Commands,找到其依赖的库,并递归找到所有依赖的库
  • 加载 Mach-O 文件
  • 进行符号查找
  • 绑定和变基
  • 运行初始化程序

上面的所有过程都发生在 App 启动时,包含了大量的计算和I/O,所以苹果开发团队为了加快启动速度,在 WWDC2017 - 413 - App Startup Time: Past, Present, and Future[4] 上正式提出了 dyld3。
dyld3 被分为了三个组件:

  • 一个进程外的 MachO 解析器
    • 预先处理了所有可能影响启动速度的 search path、@rpaths 和环境变量
    • 然后分析 Mach-O 的 Header 和依赖,并完成了所有符号查找的工作
    • 最后将这些结果创建成了一个启动闭包
    • 这是一个普通的 daemon 进程,可以使用通常的测试架构
  • 一个进程内的引擎,用来运行启动闭包
    • 这部分在进程中处理
    • 验证启动闭包的安全性,然后映射到 dylib 之中,再跳转到 main 函数
    • 不需要解析 Mach-O 的 Header 和依赖,也不需要符号查找。
  • 一个启动闭包缓存服务
    • 系统 App 的启动闭包被构建在一个 Shared Cache 中, 我们甚至不需要打开一个单独的文件
    • 对于第三方的 App,我们会在 App 安装或者升级的时候构建这个启动闭包。
    • 在 iOS、tvOS、watchOS中,这这一切都是 App 启动之前完成的。在 macOS 上,由于有 Side Load App,进程内引擎会在首次启动的时候启动一个 daemon 进程,之后就可以使用启动闭包启动了。

dyld 3 把很多耗时的查找、计算和 I/O 的事前都预先处理好了,这使得启动速度有了很大的提升。



2、App 启动

App 启动为什么这么重要?

  • App 启动是和用户的第一个交互过程,所以要尽量缩短这个过程的时间,给用户一个良好的第一印象
  • 启动代表了你的代码的整体性能,如果启动的性能不好,其他部分的性能可能也不会太好
  • 启动会占用 CPU 和内存,从而影响系统性能和电池

所以我们要好好优化启动时间。

启动类型

App 的启动类型分为三类

  • Cold Launch 也就是冷启动,冷启动需要满足以下几个条件:
    • 重启之后
    • App 不在内存中
    • 没有相关的进程存在
  • Warm Launch 也就是热启动,热启动需要满足以下几个条件:
    • App 刚被终止
    • App 还没完全从内存中移除
    • 没有相关的进程存在
  • Resume Launch 指的是被挂起的 App 继续的过程,需要满足以下几个条件:

    • App 被挂起
    • App 还全部都在内存中
    • 还存在相关的进程

      App 启动阶段

      App 启动分为三个阶段
  • 初始化 App 的准备工作

  • 绘制第一帧 App 的准备工作及绘制(这里的第一帧并不是获取到数据之后的第一帧,可以是一张占位视图),这时候用户与App已经可以交互了,比如 tabbar 切换
  • 获取到页面的所有数据之后的完整的绘制第一帧页面

在这个地方,苹果再次强调了一下,建议「用户从点击 App 图标到可以再次交互,也就是第二阶段结束」的时间最好在 400ms 以内。目前来看,大部分 App 都没有达到这个目标。
下面,我们把上面三个阶段分成下面这 6 个部分,讲一下这几个阶段做了什么以及有什么可以优化的地方。
image.png

System Interface

初始化 App 的准备工作,系统主要做了两个事情:Load dylibs 和 libSystem init
在 2017 年苹果介绍过 dyld3 给系统 App 带来了多少优化,今年 dyld3 正式开发给开发者使用,这意味着 iOS 系统会将你热启动的运行时依赖给缓存起来。以达到减少启动时间的目的。这也就是提升 200% 的原因之一。
视频中只说优化了热启动时间,理论上对于 iOS 系统来说 dyld3 应该还可以优化冷启动时间,所以不知道是因为给 iPad 增加了多任务功能的原因,还是没有把所有功能开放的原因,作者只提了热启动这个原因暂时还不太清楚。
除此之外,在 Load dylibs 阶段,开发者还可以做以下优化:

  • 避免链接无用的 frameworks,在 Xcode 中检查一下项目中的「Linked Frameworks and Librares」部分是否有无用的链接。
  • 避免在启动时加载动态库,将项目的 Pods 以静态编译的方式打包,尤其是 Swift 项目,这地方的时间损耗是很大的。
  • 硬链接你的依赖项,这里做了缓存优化。

也许有人会困惑是不是使用了 dyld3 了,我们就不需要做 Static Link 了,其实还是需要的,感兴趣的可以看一下 Static linking vs dyld3[5] 这篇文章,里面有一个详细的数据对比。
libSystem init 部分,主要是加载一些优先级比较低的系统组件,这部分时间是一个固定的成本,所以我们开发人员不需要关心。

Static Runtime Initializaiton

这个阶段主要是 Objective-C 和 Swift Runtime 的初始化,会调用所有的 +load 方法,将类的信息注册到 runtime 中。
在这个阶段,原则上不建议开发者做任何事情,所以为了避免一些启动时间的损耗,你可以做以下几个事情:

  • 在 framework 开发时,公开专有的初始化 API
  • 减少在 +load 中做的事情
  • 使用 initialize 进行懒加载初始化工作

    UIKit Initializaiton

    这个阶段主要做了两个事情:

  • 实例化 UIApplication 和 UIApplicationDelegate

  • 开始事件处理和系统集成

所以这个阶段的优化也比较简单,你需要做两个事情:

  • 最大限度的减少 UIApplication 子类初始化时候的工作,更甚至与不子类化 UIApplication
  • 减少 UIApplicationDelegate 的初始化工作

    Application Initializaiton

    这个阶段主要是生命周期方法的回调,也正是开发者最熟悉的部分。
    调用 UIApplicationDelegate 的 App 生命周期方法:
    1. application:willFinishLaunchingWithOptions:
    2. application:didFinishLaunchingWithOptions:
    和 UIApplicationDelegate 的 UI 生命周期方法:
    1. applicationDidBecomeActive:
    同时,iOS 13 针对 UISceneDelegate 增加了新的回调:
    1. scene:willConnectToSession:options:
    2. sceneWillEnterForeground:
    3. sceneDidBecomeActive:
    也会在这个阶段调用。感兴趣的可以关注一下 Getting the Most out of Multitasking 这个 Session,暂时没有视频资源,怀疑是现场演示翻车了,所以没有把视频资源放出来。

在这个阶段,开发者可以做的优化:

  • 推迟和启动时无关的工作
  • Senens 之间共享资源

    Fisrt Frame Render

    这个阶段主要做了创建、布局和绘制视图的工作,并把准备好的第一帧提交给渲染层渲染。会频繁调用以下几个函数:

    1. loadView
    2. viewDidLoad
    3. layoutSubviews

    在这个阶段,开发者可以做的优化:

  • 减少视图层级,懒加载一些不需要的视图

  • 优化布局,减少约束

更多细节可以从 WWDC2018 - 220 - High Performance Auto Layout[6] 中了解

Extend

大部分 App 都会通过异步的方式获取数据,并最终呈现给用户。我们把这一部分称为 Extend。
因为这一部分每个 App 的表现都不一样,所以苹果建议开发者使用 os_signpost 进行测量然后慢慢分析慢慢优化。

。。。。。