1、名词解释
Mach-O
Mach-O 是 iOS 系统不同运行时期可执行的文件的文件类型统称。主要分以下三类:
- Executable - 可执行文件,是 App 中的主要二进制文件
- Dylib - 动态库,在其他平台也叫 DSO 或者 DLL
- Bundle - 苹果平台特有的类型,是无法被连接的 Dylib。只能在运行时通过 dlopen() 加载
Mach-O 的基本结构如下图所示,分为三个部分:
- 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 工作的具体流程如下:
参考: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 在加载到内存中是一个随机的首地址”这一个问题做一个数据修正的过程。会将内部指针地址都加上一个偏移量,偏移量的计算方法如下:
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
在 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已经可以交互了,比如 tabbar 切换
- 获取到页面的所有数据之后的完整的绘制第一帧页面
在这个地方,苹果再次强调了一下,建议「用户从点击 App 图标到可以再次交互,也就是第二阶段结束」的时间最好在 400ms 以内。目前来看,大部分 App 都没有达到这个目标。
下面,我们把上面三个阶段分成下面这 6 个部分,讲一下这几个阶段做了什么以及有什么可以优化的地方。
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 中做的事情
-
UIKit Initializaiton
这个阶段主要做了两个事情:
实例化 UIApplication 和 UIApplicationDelegate
- 开始事件处理和系统集成
所以这个阶段的优化也比较简单,你需要做两个事情:
- 最大限度的减少 UIApplication 子类初始化时候的工作,更甚至与不子类化 UIApplication
- 减少 UIApplicationDelegate 的初始化工作
Application Initializaiton
这个阶段主要是生命周期方法的回调,也正是开发者最熟悉的部分。
调用 UIApplicationDelegate 的 App 生命周期方法:
和 UIApplicationDelegate 的 UI 生命周期方法:application:willFinishLaunchingWithOptions:
application:didFinishLaunchingWithOptions:
同时,iOS 13 针对 UISceneDelegate 增加了新的回调:applicationDidBecomeActive:
也会在这个阶段调用。感兴趣的可以关注一下 Getting the Most out of Multitasking 这个 Session,暂时没有视频资源,怀疑是现场演示翻车了,所以没有把视频资源放出来。scene:willConnectToSession:options:
sceneWillEnterForeground:
sceneDidBecomeActive:
在这个阶段,开发者可以做的优化:
- 推迟和启动时无关的工作
-
Fisrt Frame Render
这个阶段主要做了创建、布局和绘制视图的工作,并把准备好的第一帧提交给渲染层渲染。会频繁调用以下几个函数:
loadView
viewDidLoad
layoutSubviews
在这个阶段,开发者可以做的优化:
减少视图层级,懒加载一些不需要的视图
- 优化布局,减少约束
更多细节可以从 WWDC2018 - 220 - High Performance Auto Layout[6] 中了解
Extend
大部分 App 都会通过异步的方式获取数据,并最终呈现给用户。我们把这一部分称为 Extend。
因为这一部分每个 App 的表现都不一样,所以苹果建议开发者使用 os_signpost 进行测量然后慢慢分析慢慢优化。
。。。。。