启动优化,在不影响业务的前提条件下,怎么提高启动的速度,这是我们要考虑的事情。
在这,根据系统打印提示信息这条主线,看下启动过程中,每个阶段都做的什么,在这些阶段我们能做哪些优化的事情。
添加Xcode的打印环境DYLD_PRINT_STATISTICS
Total pre-main time: 390.79 milliseconds (100.0%)
dylib loading time: 228.22 milliseconds (58.4%)
rebase/binding time: 5.04 milliseconds (1.2%)
ObjC setup time: 21.47 milliseconds (5.4%)
initializer time: 135.91 milliseconds (34.7%)
slowest intializers :
libSystem.B.dylib : 3.75 milliseconds (0.9%)
libMainThreadChecker.dylib : 41.48 milliseconds (10.6%)
xxxx : 155.96 milliseconds (39.9%)
这是main函数之前系统启动过程中,也就是pre-main,经过的一些阶段,咱们针对每个阶段来详细说。
dylib loading
这是库加载的时间,包括共享缓存库、我们自己的动态库,如果进行了越狱开发,还包含了我们插入的动态库。
系统的共享缓存库(UIKit、Foundation)这些,系统是做了优化的,加载时间一般都是固定的,浪费时间的点一般在我们自己的动态库,我们自己的动态库怎么优化呢,超过6个最好合并一下。使用静态库等。
rebase/binding
rebase :这个是因为我们可执行文件加载到虚拟内存的时候,都会使用ASLR的技术,也就是在内存的最前边加一个随机的偏移值,来保证我们应用的安全。所以,我们使用的系统的函数、我们自定义的类、变量这些指针,都需要加上这个偏移值,才是真正在内存中的位置。
换句话说,类越少、方法越少,需要修正的就越少。
那么就可以使用一些工具检测你APP中的无用类,无用的方法等。
binding:这个是指针对外绑定,上边提到了共享缓存,也就是系统的库,是不加载到我们虚拟内存中的,他是在公共内存中,供大家都使用的,那么我们写了系统的函数,怎么最终找到这个真是的函数地址,就需要binding这个操作,当然,这个分为普通绑定和lazy绑定,这一部分其实没什么需要优化的空间。
ObjC setup
这一部分是加载类信心,初始化类,加载类信息,是从Mach-O的_DATA段中加载一系列类相关的信息,然后初始化,这部分先加载所有的类,插入到一个大表中,加载方法、protocol、property等,然后就是初始化类,设置类的superClass指针(这一步是递归创建的),设置类的ro/ rw相关的字段,然后合并Category相关的信息,将Category的方法、协议、property按照规则,插入到生成的类中,方法协议这些都是二维数组的形式存放,后编译的Category,插入的位置越靠前。这个想了解细节的可以看下源码。
那这部分的优化,还是在类的数量,方法的数量,协议的数量这些。
initializer time
这部分主要做的事情,加载load方法,C++静态初始化函数(attribute((constructor))),attribute((constructor)) 的调用是在load的后边,这个可以了解一下。
这部分的优化:减少load方法,尽量将一些操作进行懒加载,也就是放到+(void)initialize 函数中,这个函数是在类在第一次被调用的时候进行调用的,注意的是,这个方法可能会被多次调用,最好加dispatch_once做一下防护。
attribute((constructor))这种方法最好就是不用。
这是系统打印的阶段流程,pre-main我们能做的事情和系统能做的事情就是这些。
在这里拓展一下系统详细的启动流程,可以比对上述阶段做下了解。
应用的启动流程
- 内核加载我们的Mach-O的可执行文件
- 从Mach-O文件中,找到dyld的路径并加载
从最开始的dyld start开始uintptr_t start() - slide 首先生成一个ASLR的随机值 (这就是那个偏移值)
- rebaseDyld 开始符号的绑定过程 (将我们的指针开始rebase操作)
- // allow dyld to use mach messaging
mach_init(); - dyld main (这是dyld的main函数)
- 配置环境变量 (Xcode相关的一些环境变量的读取)
- load shared cache(加载共享缓存,系统动态库UIKit等)
- 这里边有几个判断
- 缓存库是不是只用在这个程序
- 是否存在reuseExistingCache,加载过就什么都不做
- 不存在缓存中,加载mapCacheSystemWide
- 这里边有几个判断
- 加载主程序Mach-O(实例化主程序)
- 这是第一个被加载的image
- load inserted libraries 插入动态库(越狱开发修改这个字段,插入自己的动态库),等于问你插入哪些动态库,并且加载
- link 链接程序,这是一个递归的过程,链接我们依赖系统的动态库,这里边也有rebase操作(指针修复,前边加一个偏移)
- binding 符号的绑定
- link 我们inset的dylib
- binding 过程,weak binding等
- initializeMainExecutable()初始化主程序开始了
- 最终dyld调用到了notifySingle()//后续就到了runtime去初始化了
- 初始化以后,会将一个函数指针load_images,传给dyld,让dyld调用,load_images这个函数里边调用了load方法。do while循环调用所有load
-
attribute((constructor)) void func(){
}
也就是执行C++的构造函数 - 调用真正的main入口
runtime中objc所作的预处理,因为在runtime中放了两个回调函数供dyld去调用
- objc - setup
- 从Mach-O中读取类信息,注册到一个全局的表中
- 读取Mach-O方法信息(sel),注册到全局的一个表中。
- 创建类,信息的组装realizeClassWithoutSwift
- ISA指针
- superclass指针
- cache
- bits相关
- 生成rw
- 添加方法、协议、property等
- 处理category,方法、协议拷贝进bits中。(注意这个二维数组的形式)
- 生成rw
以上过程包含两部分,dyld的程序初始化过程,有一部分需要objc runtime的支持。
整个pre-main就这些,也可以自己进行了解后,看些是否还有更多需要注意的地方。
有了pre-mian,就有 main之后相关。
我这里认定,从main到第一个页面的viewDidAppear是main之后。
这里边都是具体业务相关,真正可以节省时间的点,都在这里边;那么启动前的代码应该怎么写呢?
个人理解,原则上到viewDidAppear所有的函数,都应该只是为了服务第一个页面的创建及渲染,等第一个页面出来后,在初始化其他相关的内容;当然这也只是美好的愿望,有些库的初始化和一些其他操作,可能不仅是为了第一个页面服务,并且还必须要放到这里去做。
这其实可以叫做分阶段启动。
必须要做的:
- 埋点功能、
- Crash 采集
- 网络配置
等
可以延后的:各种SDK的初始化、配置文件的请求等。其实地理位置的请求也可以延后,之前看过美团还是哪个公司,他们会先用上次缓存的地理位置请求,等到首页加载完以后,如果地理位置不一样,再重新刷新。
第二种思路
可以考虑多线程启动,目前手机都是多核,可以开2-3个线程,是可以加快初始化速度的。
使用这个方案有几个注意点:
- 如果主线程忙完,子线程还没忙完,这时候会黑屏,所以启动的时候,就要加一个假的图片,等每个线程结束,都去问一下其他线程状态怎么样了,都结束了,假的图片消失,显示首页。
- 不是所有的SDK初始化都适合放到子线程,需要测试,有些依赖主线程的执行,有些内部使用了UIKit相关的。
其实目前大部分APP都有广告图,我们可以在显示广告的时候,去初始化其他的东西。
main之后也就这些。
去年的时候,抖音发布了一个二进制重排的技术进行优化,这里也研究了一下。
我们APP加载进内存,其实是一个虚拟内存,不是真正的物理内存,代码的执行是要在真正的物理内存进行的,这时候,又有一个中间的映射表出现,我们虚拟内存的数据,通过映射表,映射到真正的物理内存,然后进行代码的执行,为什么要有中间的映射表呢?因为他可以使用页的形式分块加载进物理内存,不必将整个APP全部加载进内存,每一页的大小是16KB,也就是代码执行到哪,就通过映射表将这16KB的数据映射到物理内存去执行,这样可以充分利用物理内存,保证整个物理内存基本都是高效率运转,并且安全性也有保证。
原理就是这样,如果每次启动时候的代码,分散在虚拟内存中,可能会引发多次的映射过程(缺页异常),如果能把启动是的代码,放到一个16KB页中,是不是映射一次就可以了,当然这是理想的情况,整个启动的过程大约4000次左右的映射过程大约0.2ms左右,如果进行优化,能减少百分之10左右。
我们可以使用Instruments 中的system trace 进行查看page fault的次数。
具体怎么将启动的函数聚合到一起,两种方案:
- 抖音使用的是HOOK objc_msgSend,找到调用的所有函数,有几个缺点,静态方法hook不到,swift的结构体和枚举都是值类型、我们自己写的C函数、block这些都hook不到。(具体hook方法可以使用fishkook,一个fb开源的工具,但是还要用到汇编相关,不过肯定很多开源的)
- 可以考虑使用Clang编译器带的函数,一般叫做Clang插桩,Clang编译器会在每个函数执行过程中插入代码,打印当前函数,这样找到的方法比较全面。
__sanitizer_cov_trace_pc_guard
__sanitizer_cov_trace_pc_guard_init配置这两个函数,然后将启动方法输出到一个文件中,配置到在Xcode中order-file。这样,系统在编译的时候,会按照我们制定的顺序加载。默认的编译顺序是按照Build Phases中的Compile source里边加载的。
搞完,我们可以通过输出link map查看symbols的编译顺序。
这里涉及到一点,冷启动和热启动;我们通过page fault的次数可以看出,如果启动过的APP,被杀掉以后,下一次启动page fault的次数减少了很多,说明启动过,内存中这些数据就已经存在了(内存中的数据销毁,其实就是被覆盖,如果这个时候,我们启动很多其他的应用,再次启动这个应用,还是会减缓启动时间)。
冷启动就是内存中,完全不存在这个APP,热启动就是被杀掉,然后接着启动,这时候,内存中很多数据已经有了,不需要操作了。
工具拓展:
怎么监控启动时间呢?
前滴滴出行技术专家戴铭,他实现了hook objc_msgSend,然后在这个里边添加开始调用函数和函数调用结束的标识,从而了解每一个函数的执行时间。
粗颗粒,可以使用一些定时工具,计算从启动,到启动完,或者某个阶段的时间(BLStopwatch可以看下)
Instruments 也是很好的工具 Timer 、system trace都可以显示每个函数的执行时间,他的实现原理好像是固定时间抓取函数的调用栈,粗略的算下执行时间,就和微信开源的性能检测工具Matrix,定时抓取调用栈信息,比对栈的头部,看这个调用栈出现了几次,次数多了,就认为是卡顿,优点类似。
当然,整个流程还有一些细节点,如果要考虑特别完善,要考虑网络、首页渲染、数据读取等一些细节,看有没有优化的空间。