美人相机启动优化

3,139 阅读16分钟

前言

去年年底针对美人相机启动缓慢做了一次调研和优化,这周有空抽空整理一下,从自己针对这次美人相机启动的调研、实施经历做一次总结以及学习的记录。

文章主要从三个部分展开,针对 WWDC 疑惑的地方进行的一系列探讨(毕竟喜欢钻牛角尖),针对美人相机进行的项目分析,以及美人相机的优化和总结,文章内容可能相对比较杂。

关于 APP 启动的理论

关于 APP 启动时间,主要由 pre-main 和 main 之后的时间组成,即总的启动时间 = main 之前加载的时间 + main 之后加载的时间,所以接下来分两部分展开介绍。

启动时间越接近 400ms 越好,并且最好控制在 20s 以内,不然系统会以为 APP 进入一个死循环,应用进程将会被系统强制杀除。

pre-main 加载时间

pre-main 之前没有具体的工具可以进行测量,所以比较棘手,为此苹果针对 iOS 10 之后的机器提供了 DYLD_PRINT_STATISTICS 的环境变量的支持,只需要在 scheme 中添加 DYLD_PRINT_STATISTICS 并且打开即可在控制台输出 pre-main 加载时间:

添加 DYLD_PRINT_STATISTICS

项目开发中最好一直开启 DYLD_PRINT_STATISTICS,比如某部分需求完成之后发现 pre-main 提升了不少时间,可以重点对这部分进行一次 review

pre-main 加载时间打印

运行的时候就可以打印出详细的 pre-mian 的时间:

pre-main 时间

可以直观的看到 pre-main 之前的时间由 dylib loadingrebase / bindingObjC setupinitializer 四部分耗时构成。

热启动和冷启动

每次启动的 pre-main 的时间都是存在波动的,并且还有一个热启动和冷启动的概念,冷启动即 APP 的一些数据还没有在内核缓存中,一般是第一次启动,或者很久没有启动 APP 内核缓存已经被其他 APP 占用更新,热启动指的是 APP 的一些数据已经被内核缓存过,所以热启动 pre-main 时间比冷启动 pre-main 时间较短。

Mach-O

理解 APP 启动详细过程,了解 Mach-O 是不可或缺的一部分了,苹果 WWDC 介绍启动优化的 seesion 中也有做出介绍,在没有接触到启动优化的时候,其实对 Mach-O 有点陌生,在 Mac 上我们可以使用 file 命令查看文件的类型,不同文件虽然都属于 Mach-O 这种格式,但是他们具体的类型却不一样:

使用 file 命令查看文件类型

所以关于 Mach-O 到底是个什么玩意,查找了苹果 Mach-O ABI 文档了解了一波,以下是苹果文档原文,简单的理解就是 Mach-O 是 a.out 格式的一个更加灵活的替代品:

The Mach-O file format provides both intermediate (during the build process) and final (after linking the final product) storage of machine code and data. It was designed as a flexible replacement for the BSD a.out format, to be used by the compiler and the static linker and to contain statically linked executable code at runtime. Features for dynamic linking were added as the goals of OS X evolved, resulting in a single file format for both statically linked and dynamically linked code.

Mach-O 格式的文件,基本结构都由 Header、Load commands 和 Data 三部分构成:

Mach-O 基本结构

关于 Mach-O 的结构,可以用命令查看,也可以用工具,快速查看那就推荐特别好用的 MachOView,将工程编译找到沙盒文件然后用 MachOView 文件打开程序的二进制文件即可:

使用 MachOView 查看 Mach-O 结构

Header 是一个结构体可以在苹果开源的内核源码中找到,这样看起来会更直观,Header 可以指定目标架构,比如是在 arm64 还是 x86 上,用于内核验证,保证平台的正确性等等:

mach_header 结构体

Load Commands 用于指定布局和文件的链接特性,符号表的位置,动态链接器路径等等,Load Commands 指令个数和总的占用大小在 Mach Header 中已经被指定,在 mach_header 的 ncmds 和 sizeofcmds 字段可以查看,Load Commands 由多个 Load Command 构成,每一个 Load Command 也是一个结构体,并且不同的 Load Command 对应的结构体结构也不相同,但是所有的 Load Command 都必须存在的信息是指令类型 cmd 和指令大小 cmdsize:

load_command 结构体

在 MachOView 中点开 Load Commands 查看:

load_command 结构体

__PAGEZERO、__TEXT、__DATA、__LINKEDIT 加载指令都是 LC_SEGMENT,这些字节被映射到虚拟内存,所以段和虚拟内存页面是字节对齐的(其他的类型可以自己源码中查看):

LC_SEGMENT 解释

Load Commands 中关于映射的段的详细信息都可以在 MachOView 中查看到,这些是一一对应的:

MachOView 显示图

可以清楚的看到,__TEXT、__DATA、__LINKEDIT 段名都是大写的,并且每个段被划分成 0 或者多个 section,section 名都是小写的,例如 __text,__stubs,__const 等等,另外需要特别注意的是 Mach Header 和 Load Commands 的信息本身也存在于 __TEXT 段中,__TEXT 段中信息通常都是只读的,__DATA 是可读写的。

WWDC Mach-O 结构

点击 APP 启动的过程

首先 iOS 系统也是代码编写的,所以内部发生一系列的调用,感兴趣的可以研究一下苹果的内核源码,现在已经开源了。

内核创建程序进程 (fork),然后加载并执行程序(execve),接着发生一系列的调用,先是加载程序的 Mach-O(load_machfile)的执行,然后会有一个解析 Mach-O (parse_machfile)的调用,这一过程也伴随着 mach header 的校验,比如内核解析 mach file 的时候会去校验当前程序是否被当前机器所支持,解析当前 Mach-O 的文件类型是可执行文件还是 dynamic link editor 等:

check machine type

接着 Load Commands 被映射到内存中,遍历 Load Commands 中所有的 Load Command,然后根据对应的指令进行相应的操作,内核找到 LC_LOAD_DYLINKER 指令,然后返回对应的一个 ret:

case LC_LOAD_DYLINKER

LC_LOAD_DYLINKER 是只有可执行文件才有的指令,你可以用 MachOView 点开系统动态库查看,其实是没有 LC_LOAD_DYLINKER 指令

可执行文件 Mach-O LC_LOAD_DYLINKER 指令

如果条件满足,就会执行加载动态链接器(the dynamic link editor 简称 dyld)的操作:

case LC_LOAD_DYLINKER

load_dylinker 做了一系列,可以用 file /usr/lib/dyld 查看 dyld 也是 Mach-O 格式的一种,所以也有个 parse_machfile 的调用,其实和加载程序可执行文件差不多,但是两者类型并不同,用 MH_EXECUTE 和 MH_DYLINKER 区分,有兴趣的可以看下内核源码中 load_dylinker 的方法具体实现。

当 dyld 被加载到内存中,最终 dyld __main 被执行:

case LC_LOAD_DYLINKER

内核做了一系列的准备以及 dyld 的加载,dyld 启动后,接下来的事情将由 dyld 完成,dyld 在程序进程上运行和程序有着相同的权限,接着就构成 WWDC 中介绍的 dyld 的工作流水线:

Load dylibs -> Rebase -> Bind -> ObjC -> Initializers

Load dylibs

接下来的步骤就比较好理解了,dyld 启动后将会在可执行文件的 Mach Header 上找到类型为 LC_LOAD_DYLIB 的加载指令,查找需要的动态库,因为每个 dylib 也是 Mach-O 镜像文件,需要执行同可执行文件一样类似映射到内存上,验证 Mach header 等等,并且 dylib 会依赖其他的 dylib,所以 dylibs 加载是一个递归的过程。

如果需要查看项目所有的动态库加载详细的信息,可以打开 Dynamic Library Loads,就会在运行时在控制台显示详细的信息:

设置链接动态库信息

一般 APP 通常会加载 100 到 400 个动态库,基本上都是系统动态库,不过系统动态库加载是有被苹果优化过的,所以耗时不是特别的明显

Fix-ups

加载到内存的可执行文件以及动态库中的符号都是不可用的状态,因为 ASLR 的存在,可执行文件和动态链接库在虚拟内存中加载的地址每次都是随机的,所以需要 Fix-ups,Fix-ups 主要经过两个步骤,即 Rebase -> Binding。

Rebase

Rebase:说白了就是因为 Mach-O 镜像文件加载到内存中的地址和初始地址不同,所以 Dyld 需要 rebase 去进行指针修正,点击 MachOView 的 Dynamic Loader info 查看详细的 rebase 信息,rebase 主要是修复 __DATA 中的指针:

Rebase 信息
Binding

因为动态库不编译进程序最终的二进制文件中,而是在运行的时候动态的查找调用函数的地址,调用外部符号进行绑定的过程就称作 Binding,比如项目中用到 UIView ,因为 UIView 属于 UIKit 框架,所以需要进行绑定操作:

Binding 信息

除了 Binding 还有 Lazy Binding、Weak Binding 感兴趣的可以继续深入去研究,由于篇幅问题暂不继续介绍了,这里主要介绍 WWDC 中的部分。

关于 Mach-O 各个 section 代表的具体意思,可以从这篇文章上获取(找半天并没有在苹果官方上找到 Mach-O ABI,只找到了别人转存的)

Objc Setup

数据修正后将会注册 Objc 的类,如果有分类,还需要将分类定义的方法插入的方法列表中,并且保证每一个 selector 是唯一的

initializers

经过上述的 fix-ups 之后最后一步就是进行静态初始化工作,例如 Load 函数,如果有 C++ 的一些初始化构造函数也将会被执行。

main 之后

执行完上述的步骤之后,接着 dyld 就会调用 main(),接着就是我们熟悉的步骤了:

UIApplicationMain() -> willFinishLaunchingWithOptions -> didFinishLaunchingWithOptions

所以如果 willFinishLaunchingWithOptions 和 didFinishLaunchingWithOptions 中存在耗时操作将会影响 APP main 之后的启动时间。

美人相机优化实施

对流程每一步都很熟悉之后优化 APP 启动就会得心应手,发现美人相机启动瓶颈从而针对每一项做出改进。

动态库加载方面

因为主要的动态库就是系统的动态库,又由于系统本身进行的一些优化处理,动态库的加载对美人相机的影响其实不是很大,所以优化空间不是很大,但是由于如果链接进无用的系统动态库,并且动态库和动态库之间存在依赖,动态库过多也会造成一些 bind 的一些损耗。

因为美人相机项目迭代问题,工程还是之前较老的设置,为了竟可能的去避免,一些系统框架项目没有用到,但是还是导入了的问题,这里采取的一个优化措施是删除 Link Binary With Libraries 中的所有的系统动态库,改成自动 Link 系统动态库:

Link Automatically

代码部分优化

因为 rebase / binding 主要存在 I/O 瓶颈,所以减少 rebase / binding 就需要减少 __DATA 段中的指针数量,这个前面也介绍了,rebase / binding 主要针对的就是 __DATA 中的指针数量,指针数量越多,数据修复的时间就越长,使用 AppCode 分析美人相机代码:

Appcode 项目分析

TIPS:因为 AppCode 也会分析 Pods 内部的代码,试了设置了也没用,都会去检测,检测时间非常非常非常的久,所以有个比较取巧的办法就是,先移除 Pods 的代码,单纯的分析美人相机工程的代码。

检测完接下来就是单纯的体力活,但是需要注意的是,AppCode 检测的结果并非准确,而且一些大厂庞大的项目可能会忽略这一步,工作量比较大,而且因为 Runtime 的原因,可能存在一些运行时的映射,也会被误检到。

结合美人相机项目并且为了后续迭代版本考虑,长痛不如短痛,综合考虑,这一步还是进行了实施,所以总的就是 AppCode 分析结果 + 人工 Check,移除了项目中无用的类,分类和无用的方法。

代码部分优化其他方面

如果大量使用 C++ 代码的部分,需要减少 C++ 虚函数的使用,因为虚函数创建的虚表也会存在 __DATA 段中,目前美人相机上层部分暂时没有大量使用 C++ ,除了底层人脸识别的代码库,这部分将会和公司底层开发同学沟通关于这部分规范的制定。

还有就是苹果推荐的 Swift Struct 类型,因为需要 Fix-ups 的内容比较少。

关于 Objc setup 这部分的优化

Objc setup 这部分由于 Fix-ups 中 rebase / binding 部分优化过,所以这部分可以优化的基本上都已经被优化过了,因为基本上类数量和 selector 减少了,所以 setup time 也会相应的减少。

initializers 部分的优化

这部分在美人相机中,其实主要就是 +load 中代码可能会造成的影响

苹果已经不推荐使用 +load,建议使用 +initialize 懒加载的方式,而不是只要类被装载在项目中就会运行 +load,如果 +load 中存在大量耗时操作,对启动造成的影响非常大

关于 +load 可以采用 instrument 进行检测分析(之前好像没太注意),找到耗时的 load 操作,一般在 load 中做大量耗时操作本身就是一个不规范的操作了,使用 instrument 的方法就是,取 initializing 阶段样本进行分析 :

instrument initializing

根据分析结果找到耗时操作,进行相对应的优化,在 load 优化的时候,遇到一个问题,发现使用的 AF 的库造成的影响非常的大:

AF time

查看 AF +load 函数耗时的地方主要是生成 configuration 的时候:

但是对新建的一些项目还有 AF 的 demo 进行分析,发现其实 AF load 不是耗时那么多,后面查询资料并没有找到相关的介绍,这是我一个比较疑惑的地方,一直没能解决,不清楚的是这个属不属于正常现象,如果有踩过坑或者知道的大佬,希望可以解答一下我的疑惑。

update:后面再次检测发现这个问题又恢复正常了,真是一个让人百思不得其解的问题。。。

和 +load 类似的还有 atribute((constructor)) ,它将方法显示的标记为初始化器,这样只要程序运行都会去执行,如果存在耗时操作,将会对启动造成影响,所以建议少用,美人相机检测了一下,没有发现,所以主要的还是 load 的影响。

因为使用 Xcode 调试的话,会注入一些调试的库,所以也会算进去 pre-main 的时间:

注入调试相关库的加载耗时

如果不想受到这些动态库链接的影响,可以在 scheme 中关闭:

关闭 GPU 调试相关

关于 AppDlegate 中代码的优化

美人相机中主要是 didFinishLaunchingWithOptions ,排查 didFinishLaunchingWithOptions 中可能的耗时部分,使用 instrument 进行分析:

didFinishLaunchingWithOptions 耗时

其实对于 main 之后的影响,首页界面的渲染并没有出现在 didFinishLaunchingWithOptions 中,如果在 didFinishLaunchingWithOptions 使用到了 rootViewController.view 的话,那结果就不一样了,目前美人相机的逻辑主要耗时部分存在 appSetting 处,进一步排查分析,发现耗时的竟然是美人相机接入的神策的统计的一处的代码:

神策统计耗时

奇怪的地方是,一般统计 SDK 并不会造成特别明显的耗时操作,经过一系列的分析和查找神策文档,发现其实是美人相机 pod 神策 SDK 的方式有问题,发现问题然后在内部进行沟通讨论确定美人相机统计不需要上传函数堆栈之后,更改了神策的 pod 方式,关闭堆栈解析上传:

更改神策统计 POD 方式

更改后神策统计部分耗时由原来在 didFinishLaunchingWithOptions 占比 15% 占了较大部分减少到了 4.1% (可以和百度统计对比观察):

优化前:

优化前

优化后:

优化后

关于第三方 SDK 的问题,已经和 iOS 小组的组长提出建议,接入 SDK 的时候要仔细研究文档,或者询问第三方 SDK 的供应商,对第三方 SDK 的接入利弊需要做一个清晰的了解之后再接入

非启动时间影响的首页视图优化

因为目前美人相机的代码逻辑,经过检测,目前首先创建显示的时间不会影响启动时间,但是会影响用户对首页呈现是否流畅的感觉,所以针对首页也进行了一些优化,其中包括,非第一次显示的视图采用延时加载,减少 CPU 的占有率。

优化效果呈现

本次优化采用的机器是我一个比较旧的 iPhone5s iOS 11 系统,建议使用旧机器进行性能优化,统计的时间是关于 pre-main 之前热启动的时间(即已经启动过而非第一次安装或清除后台数据重新启动的数据),因为 pre-main 时间存在波动,所以进行了多次启动数据的统计,这里取了10个样本,有个大致直观的感受,并且 Debug 和 Release 不同,release 版本进行的 link 优化等操作,所以整体的时间还需要实际操作感受:

pre-main 优化效果

关于 main 之后的效果上面已经说到过了。

额外的点

目前系统使用的动态链接库程序为 dyld2 ,苹果也有介绍 dyld3,目前 iOS 11 所有的系统应用都是采用 dyld3 ,会有一部分的改变,并且启动会得到优化,同个程序,用 dyld3 启动的会比 dyld2 更快,未来所有的第三方应用也将会采用 dyld3 来进行加载,关于如何过渡到 dyld3 以及 dyld3 的详细介绍可以观看 WWDC 视频进行了解。

总结

这篇针对 WWDC 视频中的疑惑,自己去进行了一系列的探索和学习,并且主要针对内核启动 APP 以及 dyld 如何被启动的做了一次分析,以及 Mach-O 文件的结构,对 rebase 和 binding 有个直观的感受,可以比较清楚的对症下药,关于 dyld 源码部分,以及具体详细的过程,由于篇幅和时间问题,dyld 源码加载过程就没有详细展开,只针对 WWDC 介绍的几个步骤进行了展开,有机会再输出一篇关于 dyld 源码学习的记录,如果文章存在部分错误的地方,还请多多指正。

参考链接

Apple 内核源码
Apple dyld 源码
优化 App 的启动时间
Optimizing App Startup Time WWDC
iOS一次立竿见影的启动时间优化 - 简书