app启动速度通常关乎用户对app的总体评价,在这方面也有很多优秀关于优化方面的文章,不过这类文章更多地着墨于具体的优化方案,对原理的介绍往往并不详实,所以对于想了解个中原理进而深入学习系统机制的研发会有些美中不足的感觉。
本文根据wwdc 2012 iOS App Performance: Responsiveness,wwdc 2016 Optimizing App Startup Time及wwdc 2017 app start time:Past,Present and Future深入探讨启动原理与优化策略
应用启动概论
伴随app启动的过程会出现app应用界面放大出现的效果,iPhone及iPad上这个放大动画分别为400ms与500ms,如果动画结束时app已经启动完成,那么用户看起来就像app在点击了图标之后马上启动了一样,这样的启动速度自然是最佳的。
watchdog
watchdog机制会在app发生超时的场景下强行中止其运行,由下表可知它对启动场景的最大容忍时间为20s(xcode在debug期间会禁用watchdog)
场景 | watchdog 超时 |
---|---|
启动 | 20秒 |
恢复运行 | 10秒 |
暂停(退后台) | 10秒 |
退出 | 6秒 |
后台执行 | 10分钟 |
启动时间的衡量
watchdog判定启动结束的时间点是第一个CATransaction的结束,这个点意味着UI在CPU中第一次布局和绘制的结束,其标志性的api调用为[UIApplication _reportAppLaunchFinished](iOS6及之前版本,是此内部api调用为启动结束标志,但iOS8以后已经无法断点到这个api,现在做启动优化判定启动结束的一般的做法是rootViewController的viewDidAppear调用时间点)
而对于特定功能的app比如相机应用来说,用户所感知到的启动终结应当是快门达到可点按状态所需要的时间,也就是说如果在watchdog超时间内完成界面启动而功能却并未完成初始化,仍然算做启动未完成,只是已经没有在启动过程被系统强行终止的危险而已。
启动过程
阶段 | 主要工作 |
---|---|
链接和加载 | 1.库映射到app进程空间 2.绑定符号(比如app引用了framework中的某常量符号)3.运行静态初始化 |
UIKit初始化 | 创建Fonts, status bar, 读取user defaults, 反序列化main nib |
Application回调 | 启动行将结束时将控制权交回给app |
首次CoreAnimation transaction | new首个CATransaction,用于在didFinishLaunching后批处理layout和绘制views,发生在CA::Transaction::commit, iOS6以前这次提交最迟会在[UIApplication _reportAppLaunchFinished]中发生,尽管这个内部api已经难寻踪迹,但这次commit在后续的iOS系统也一直发生在didFinishLaunching之后 |
链接与加载阶段优化策略
1.精简依赖的framework(每个OC库在加载阶段都会有些额外的工作要做,比如类的hash表需要在加载阶段将各类添加进去)
2.不要将require的framework标记为optional,因为optional需要更多的检查消耗
3.避免静态的初始化过程:
全局C++对象的创建:
static std::map GlobalMap = {{1,2},{3,4}};
在main之前的load阶段执行的代码:
+ (void)load{ //do any stuff }
__attribute__((constructor)) void doInitializationStuff() {}
将尽可能多的工作放到运行期去做,比如+(void)initialize{}
UIKit 初始化优化策略
涉及的api
UIApplicationInitialize(iOS7以后应该已经没有了)
UIApplicationInstantiateSingleton
-[UIApplication _createStatusBarWithRequestedStyle: ...]
-[UIApplication _loadMainNibFileName:bundle:]
1 精简main nib的大小,更好的优化自然是用代码创建UI
2 不要在userdefault中存太多数据,因为userdefaults是作为property文件存储的,整个property list会一次性反序列化,所以不要存大块的数据,比如
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSData* data = UIImagePNGRepresentation(image);
[ud setObject: data forKey: @"image"];
Application 回调 优化策略
此阶段过程如下:
回调application: willFinishLaunchingWithOptions:
恢复application 状态
回调application: didFinishLaunchingWithOptions:
首次Core Animation Transaction
提交的重要阶段:
- 准备阶段:解码图片
- layout: 计算所有layer的大小(-layoutSubviews)
- 绘制: -drawRect:
提交阶段的优化策略很明确,即尽可能精简root viewController首次出现时的view层次复杂度及图片素材的总量,同时不要做太复杂的drawRect操作
app启动原理及优化实践
这部分根据wwdc2016 session406, 主要内容为启动原理及优化实践
Mach-O 和虚存 掠影
Mach-O文件类型:
Executable - 应用主二进制文件
Dylib - 动态链接库(类似于其它平台的DSO和DLL)
Bundle - 无法链接的Dylib,只能通过dlopen(), 比如插件
Image - Executable, Dylib 或者 Bundle
Framework - 包含所属资源与头文件目录的Dylib
Mach-O文件又划分为段segment, 比如__TEXT, __DATA, __LINKEDIT, 每个段大小均为pagesize的整数倍,在arm64 pagesize为16KB,其它架构为4KB,段又划分为sections,section之间不重叠
常见段名 | 内容 |
---|---|
__TEXT | Mach-O头,代码和只读常量 |
__DATA | 所有可读可写的内容,比如全局变量,静态变量等 |
__LINKEDIT | 不包含函数与变量但包含函数与变量的信息,比如名字和地址,即加载程序的“元数据” |
Mach-O Universal Files
构建同时支持32位与64位架构时会将两个架构的Mach-O文件合并为UniversalFile
支持的架构会列举在fat header中,这个header也是一个page size
虚存
虚存是一种间接管理内存的方式,是为了方便多进程使用物理内存,常见特性比如:当访问的虚存对应的页不在内存中时发生的页错误引发加载,同一内存页映射到多进程中的内存共享模式,文件映射页mmap()与lazy reading特性(读到特定地址时才引发页错误引发加载),copy on write(COW,多进程共享数据页,直到对其进行修改时才引发内存将页复制到新内存页并将进程映射的页指向新内存页)
虚存的这些特性应用在__TEXT段极为合适,COW对于__DATA段是很好的优化,这也引出了另一个概念,即脏数据页和干净数据页:脏数据页是包含特定进程信息的页,而干净页是内核可以重新从disk中读取的数据页,所以脏数据页比干净数据页会带来更多消耗。
页权限属性:rwx(代码段设置为只读 r,数据段 rw)
加载dylib的时候,会将其文件映射到内存中,由于大部分全局变量都初始化为0,所以静态优化把它们都放在后面,以不占用空间,而且通过VM特性在首次读取时将其填充为0,dyld的第一件事情是读取Mach-O header,即第一页,会引发页错误进而进行页加载,它会发现此页进行了文件映射并加载文件首页到物理内存。然后dyld开始读mach header,接着Mach header说在__LINKEDIT段中有些信息需要读下,于是dyld开始读最下面那段,同样引发页错误及加载,然后LINKEDIT告诉dyld需要对DATA段做一些修正才能让dylib真正可运行。dyld于是开始入数据段写一些数据,这时候COW发生,数据页变脏。而如果此时另一个进程需要这个dylib,那么TEXT段和LINKEDIT段只需要复用已经在物理内存页中的这两个段即可,操作系统只需要将对应的内存页映射到新进程的虚存中即可。而数据段如果仍在内存中,也可以类似地映射,否则需要再次读取disk,这算是动态库加载的一项优化。而LINKEDIT段只有在dyld修正DATA段的时候才有用,此后其内存页即可回收做它用。
有两件想提及的事情是安全是如何影响dyld的,而正是这两件事关安全的事情影响了dyld。
一个是ASLR(address space layout randomization),用于随机化加载地址的常用技术。
另一个是code sign,构建时每页Mach-O文件都会生成各自的加密hash,均存储在LINKEDIT中,于是每页均可以验证其是否被更动过手脚。
EXEC
exec是一个系统调用,会使用指定的新程序替换当前进程,内核会将整个进程空间抹掉并将你指定的可执行文件映射进来,由于ASLR的存在,映射到的是一个随机的地址,并且从这个地址到0的整个区域都会标记为不可读不可写不可执行。在32位进程中,这个区域大小为4KB,在64位进程最少为4GB。它会用来捕获空指针引用及指针截断错误,因为块虚存未映射任何实际物理地址,所以访问会引发异常。
多年以前EXEC很轻松,因为只需要将程序映射到进程,再设置PC就可以开始执行了,但随着动态链接库的发明,出现了helper程序来帮忙加载动态链接库,apple平台上这个程序叫做dyld。内核在映射可执行文件到进程之后,会映射dyld到进程另一个随机地址,然后将PC设置进dyld中,并让其完成进程的启动。
dyld执行阶段 | 职责 |
---|---|
加载所有dylib | 读取主可执行文件的头以获取依赖库的列表,然后开始找每个dylib,一旦找到即开始打开并读取dylib的头部,因为需要确保它是Mach-O文件,验证这个文件并找到其code signature,将code signature注册到内核,对dylib的每个段调用mmap,最终递归加载完所有依赖的dylib(由于大多都是OS dylib,OS本身在构建时预先做了很多dyld在加载这些dylib时需要做的计算等工作) |
fix-up(包含下列2个阶段) | 将这些独立的dylib绑定在一起,鉴于code signing不允许我们修改指令,而为了在不修改指令的情况下让dylib调用另一个dylib,需要借助现代编译代码生成的动态PIC(Position Independent Code)机制,意味着代码可以动态加载进虚存,也就是说调用是间接寻址的。如果被调用方会以指针的形式存在DATA数据段中,这个指针才是最后真正被调用的 |
rebase | 调整指向image内部的指针,早期可以为dylib指定偏好的加载地址,如果进程可以满足这些要求,则dyld不需要做任何fix-up,但现在ASLR的存在,dylib会加载到随机的地址,这种情况下需要计算一个slide=actual_address-preferred_address,并为每个内部指针加上这个slide,而这些内部指针本身的地址是存在LINKEDIT段中。由于我们只是将数据映射到进程,修改地址的时候一般会发生COW,所以rebase通常因为这些IO会很耗时。所幸修改是顺序进行的,所以在内核看来写数据是顺序地进行的,内核会为我们预加载,提升性能 |
binding | 调整指向image外的指针,通常是用字符串形式的符号名来表示的,所以"malloc"代表这个指针指向malloc,而dyld就需要找到其实现并将地址写入指针,也意味着要查找符号表并做很多计算。虽然需要很多的计算,但由于rebasing已经做了大部分的IO,所以binding需要的IO比较少 |
objc | 到了objc阶段,大部分类数据结构已经就绪,指向其方法,父类的指针皆已就绪,但还有一些objc运行时需要的数据未完成。第一个是OC动态性需要可以通过类名创建对象,所以OC运行时需要维护一个类名与类的映射表。所以加载类定义时,类名需要注册到一个全局表中。另一个问题是C++中的fragile base class问题,OC由于在加载阶段fix-up时会修正所有ivar的偏移而不存在这个问题。另一个问题是在另外的dylib中定义的category,需要在这个阶段将方法添加到类实现中。此外OC依赖于selector的唯一性,所以还需要将selector唯一化 |
initializer | 执行c++编译生成的等号右边的任意初始化表达式,执行oc中的+load |
查看image中需fix-up的指针信息的命令:
xcrun dyldinfo -rebase -bind -lazy_bind myapp.app/myapp
上述所有工作完成之后,dyld会调用可执行文件中的main()
提升启动速度实用策略
首先最好的启动时间是比启动动画更快,前述启动概论有提及,400ms最好
在scheme中添加环境变量 DYLD_PRINT_STATISTICS 1 后,在device log中会输出main之前所有执行过程的耗时统计
优化项 | 优化策略 | 备注 |
---|---|---|
dylib加载 | 少用动态链接库,使用静态库,懒加载(dlopen) | 合并动态链接库(基本不太具有可操作性,因为要先拿到所有的代码),dlopen实际会引发很多细微的性能和正确性问题,而且会在后续引发相较下更多的消耗,虽然加载被延迟的,这个方案可行,但是需要谨慎使用。 |
fix-up | 使用前述工具查看fix-up指针所在的segment及section,减少fixup的指针数 减少C++虚函数的使用,因为它会创建虚函数表,会和OC的metaclass一样在DATA段中添加需要fix-up的数据 可以多使用swift struct,因为它们生成的需要fix-up的指针更少,而且swift更加内联化的特性可以更多地减少fix-up指针 |
如果在objc域中见到oc类的符号,则可以考虑精减OC类数量及实例变量数量,尤其是对于一些鼓励实现很多简单的类的编程模式,这些简单的类通常只有一到两个方法,这种编程模式会导致启动速度越来越慢 |
initializer | 将+load更多地用+initialize代替,C/C++中的____attribute____((constructor))显式初始化函数更多地由call site initializer即dispatch_once或者 pthread_once, std::once代替 隐式初始化C++全局变量使用call site initializer,或者使用非全局数据去代替,又或者不使用重量的初始化,比如C++中的POD,plain old data 当然也可以用swift来改写,因为swift全局变量首先肯定会在被使用之前初始化,但并不是在initializer中初始化,而是使用dispatch_once |
对于POD类型数据,静态链接器会为DATA段预计算所有数据,所以这些数据不需要运行,也不需要fix-up,这种隐式初始化可以通过Apple LLVM 8.1 - Custom Compiler Flags=>Other Warning Flags 添加编译器警告Flag -Wglobal-constructors 来提示此类initializer dyld运行时可以不使用锁因为它是单线程运行的,但如果使用了dlopen,initializers运行会相应发生改变,同时需要打开锁以应对多线程运行的情况,这也意味着处理不好可能会有死锁的危险;另外也不要在initializer中启动线程 |
抖音代码段重排启动优化方案