[iOS] 学习笔记 - 启动优化
讲真十一之前呢非常想到假期不想学习,十一的时候又很想看剧不想学习,我发现我最近太不努力了...... 但是能不能不学习,所以学习一下大佬们的文章来记录一下。
基础知识可以康康我上一篇文章:iOS启动流程
读入程序
启动其实就是把我们的可执行程序ipa,读入到系统内存,并且取址执行的过程,和操作系统其实是非常相似的,毕竟一个手机就是一个小的操作系统。
所以这里不可以避免会遇到虚拟内存的概念,从iPhone 6s开始,物理内存的Page大小是16K,6和之前的设备都是4K,这是iPhone 6相比6s启动速度断崖式下降的原因之一。(可以参考:操作系统概述
)
可执行文件也是一个文件,我们都知道文件的读写非常耗时,读一个文件需要先加载到内核缓冲区,然后再拷贝给每个进程;写入的时候也是先写到进程自己的区域,再拷贝到share的内核区,最后固化到磁盘:
文件读写
这样的操作就会造成很多无用的copy,所以产生了mmap的概念,也就是不做实际的拷贝,而是在虚拟内存中分配一段逻辑内存给文件,当操作这段的时候,会直接映射到操作处理实际的多线程share的同一个物理内存,这样其实就不用做拷贝的动作了:
当读取物理地址的时候,如果发现还没有读入,就会引发缺页异常,触发File Backed Page In
,这个时候会把对应的文件内容读入物理内存;读入的时候会自动把变量区的没有赋值的变量给一个初始值0,这个过程也叫zero fill。
启动的路径上会触发很多次Page In,其实也比较容易理解,因为启动的会读写二进制中的很多内容。Page In会占去启动耗时的很大一部分
那么程序的Page In
做了些什么呢?
- MMU找到空闲的物理内存页面
- 触发磁盘IO,把数据读入物理内存
- 如果是TEXT段的页,要进行解密
- 对解密后的页,进行签名验证。
Page In的主要时间消耗是因为解密,I/O其实还好,那么为什么要给 Text 段的页加密呢?
因为上传到App Store后,会对TEXT段进行加密,防止ipa下载下来,就直接可以看到代码。这也就是为什么逆向里会有个概念叫做“砸壳”,砸的就是这一层TEXT段加密。iOS 13对这个过程进行了优化,Page In的时候不需要解密了。
【优化】二进制重排
首先之前抖音有做过一个优化可以参考:抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%
由于启动的时候会调用很多函数,他们随机的分布在各个地方,这样在启动的时候就会触发很多次page in,导致时长拉长,所以二进制重排的原理就是让启动相关的函数放到相近的位置,减少page in的次数,也就可以缩短启动时间啦。
链接器ld有个参数-order_file支持按照符号的方式排列二进制。获取启动时候用到的函数们可以参考抖音的实践。
【优化】插桩 - 找到启动用了哪些函数 / app中哪些函数没有使用
我们会希望删除app中木有用到的函数,这就可以通过插桩来实现啦,插桩就是在保证被测程序原有逻辑完整性的基础上在程序中插入一些探针,通过探针的执行并抛出程序运行的特征数据,通过对这些数据的分析,可以获得程序的控制流和数据流信息,进而得到逻辑覆盖等动态信息。
关于插桩的应用可以参考:启动优化之Clang插桩实现二进制重排,也可以看看LLVM插桩。
这里其实主要是focus在通过插桩上传被调用的函数,然后我们就可以在统计平台看到哪些函数没用过,就可以删除掉啦。
【优化】移动 string 存放位置
很多string是存在Text区的,读取的时候就需要解密,那么如果我们可以把它移动到其他的区域呢?
在 target -> Build Settings -> Other Link Flags 里添加如下指令,会把 TEXT 字段的部分内容转移到 RODATA 字段:
-Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring
-Wl,-rename_section,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab
-Wl,-rename_section,__TEXT,__const,__RODATA,__const
-Wl,-rename_section,__TEXT,__objc_methname,__RODATA,__objc_methname
-Wl,-rename_section,__TEXT,__objc_classname,__RODATA,__objc_classname
-Wl,-rename_section,__TEXT,__objc_methtype,__RODATA,__objc_methtype
这样当启动过程中需要string的时候就可以不用频繁访问Text段而引发解密啦。
这个还有另一个应用:当可执行文件的TEXT段超过了80M,为避免苹果对 TEXT 字段的审核限制。当然其实跟安装包瘦身好像没有什么关系,所以除非快不行了否则不建议操作。
dyld
dyld动态库链接器是启动最开始载入的,用于载入各种库。
用户点击图标之后,会发送一个系统调用execve
到内核,内核创建进程。接着会把主二进制mmap进来,读取load command中的LC_LOAD_DYLINKER
,找到dyld的的路径。然后mmap dyld到虚拟内存,找到dyld的入口函数_dyld_start
,把PC寄存器设置成_dyld_start
,接下来启动流程交给了dyld。
注意这个过程都是在内核态完成的,这里提到了PC寄存器,PC寄存器存储了下一条指令的地址,程序的执行就是不断修改和读取PC寄存器来完成的。
load对启动时间的影响
在Build Settings里可以配置write linkmap,这样在生成的linkmap文件里就可以找到有哪些文件里包含load或者static initializer:
__mod_init_func,static initializer
__objc_nlclslist,实现+load的类
__objc_nlcatlist,实现+load的Category
load是在启动的时候执行的,里面的操作很有可能会触发page in,导致启动时长增加,所以应该尽量减少load函数的实现哦。
static initializer
静态初始化也是启动流程中的一环,负责初始化编译时不能确定的静态变量,例如下面酱紫的:
//会产生静态初始化
static Logger logger;
//不会产生静态初始化
static const int var_3 = 4;
并不是所有的static变量都会产生静态初始化,编译器很智能,对于在编译期间就能确定的变量是会直接inline。所以我们要尽量减少静态变量哦~
UIKit Init
+load和static initializer执行完毕之后,dyld会把启动流程交给App,开始执行main函数,main函数里要做的最重要的事情就是初始化UIKit。UIKit主要会做两个大的初始化:
- 初始化UIApplication
- 启动主线程的Runloop(App的LifeCycle方法是基于Runloop的Source0的;首帧渲染是基于Runloop Block的)
由于主线程的dispatch_async是基于runloop的,所以在+load里如果调用了dispatch_async会在这个阶段执行。
UIKit初始化之后,就进入了我们熟悉的UIApplicationDelegate回调,也就是AppLifeCycle啦。
渲染
大家一般会用Root Controller的viewDidApper作为渲染的终点,但其实这时候首帧已经渲染完成一小段时间了,Apple在MetricsKit里对启动终点定义是第一个CA::Transaction::commit()。但是我们监控启动时长的时候其实还是从root viewController的viewDidAppear来看的。
iOS的渲染是在一个单独的进程RenderServer做的,App会把Render Tree编码打包给RenderServer,RenderServer再调用渲染框架(Metal/OpenGL ES)来生成bitmap,放到帧缓冲区里,硬件根据时钟信号读取帧缓冲区内容,完成屏幕刷新。CATransaction就是把一组UI上的修改,合并成一个事务,通过commit提交。
参考:iOS 图像渲染原理
绘制流程事实上,app 本身并不负责渲染,渲染则是由一个独立的进程负责,即 Render Server 进程。
App 通过 IPC 将渲染任务及相关数据提交给 Render Server。Render Server 处理完数据后,再传递至 GPU。最后由 GPU 调用 iOS 的图像设备进行显示。
Core Animation 流水线的详细过程如下:
- 首先,由 app 处理事件(Handle Events),如:用户的点击操作,在此过程中 app 可能需要更新 视图树,相应地,图层树 也会被更新。
- 其次,app 通过 CPU 完成对显示内容的计算,如:视图的创建、布局计算、图片解码、文本绘制等。在完成对显示内容的计算之后,app 对图层进行打包,并在下一次 RunLoop 时将其发送至 Render Server,即完成了一次 Commit Transaction 操作。
- Render Server 主要执行 Open GL、Core Graphics 相关程序,并调用 GPU
GPU 则在物理层上完成了对图像的渲染。 - 最终,GPU 通过 Frame Buffer、视频控制器等相关部件,将图像显示在屏幕上。
启动整体流程
这里直接借了同事的图了,因为实在是太优秀了!(讲真非常膜拜那个小哥哥,他的文章质量都超级高!)
app启动流程所以从我们点击app图标开始,首先系统会创建我们app的进程,然后去加载我们的mach-o文件到物理内存,之后找到dyld,将PC指针置为_dyld_start
,如果这个时候是重启手机/更新/下载App的第一次启动,那么dyld3会去做一些加载动态库以及rebase bind还有加载类信息的事情,然后就会加载我们所用到的动态库们,并对他们进行rebase以及bind,然后会加载OC运行时的东西,比如category。之后会加载load以及静态初始化,以及UIKit的init初始化Application以及主线程runloop之类的,之后才会开始life cycle,进行UI渲染。
dyld2和dyld3的主要区别就是没有启动闭包,就导致每次启动都要:
- 解析动态库的依赖关系
- 解析LINKEDIT,找到bind & rebase的指针地址,找到bind符号的地址
- 注册objc的Class/Method等元数据,对大型工程来说,这部分耗时会很长
(注意闭包缓存保存在:tmp/com.apple.dyld ,不要删除哦否则启动会慢)
检测启动时长
启动时长打点可以分阶段,比如从进程创建到load、到main开始、再到life cycle回调、最后到显示第一帧。
我们每次可以定时打包,然后跑自动化例如UI测试脚本,统计启动时长,如果有问题就可以二分法找到有问题的MR。
自动化测试需要关注一些问题,比如需要控制每次启动的其他变量(例如手机机型温度、环境温度、手机各种开关)是不变的,并且要多次测试取平均。
当新版本上线的时候,由于更新以后第一次会触发dyld3的启动闭包,所以会比平时慢一些。
【优化】instruments的各种小工具
- Time Profiler
Time Profiler是一种比较粗粒度的优化方式,因为它的原理是每1ms去看当前运行线程的调用栈,那么如果某个线程里面的函数刚好每次都没有被采集到的,就会miss掉。
- System Trace
System Trace 比 Time Profiler 更加精细,但是使用也更加复杂,Virtual Memory部分主要关注Page In这个事件,Thread State主要关注挂起和抢占两个状态,记住主线程不是一直在Run的。
- os_signpost
结合swizzle,os_signpost可以发挥出意想不到的效果,比如hook所有的load方法,来分析对应耗时,又比如hook UIImage对应方法,来统计启动路径上用到的图片加载耗时。
【优化】实践
删除多余的启动项,或者延迟,如果不能延迟一定要并发,不能并发的就简化逻辑之类的减少执行时间。
- Main之前的优化点:
-
减少动态库依赖,尽量变为静态库,不用的依赖就删掉,合并动态库实际上不太现实。
-
动态库懒加载。没有参与主二进制的直接or间接链接就不会自动在启动的时候链接动态库,在使用到这个库的时候可以再通过
-[NSBundle load]
来加载。 -
清理无用代码(app瘦身)
-
尽量减少load实现(例如很多DI绑定会在load里面实现,但是实际上可以通过clang attribute绑定在编译期,虽然clang的那些小技巧还木有看懂0.0 可以参考这个叭(Clang Attributes 黑魔法小记),或者懒加载绑定也可以,反正就是别load的时候绑定就行
-
静态初始化和+load方法一样,也会引起大量page in,可以把静态变量移动到方法内部,因为方法内部的静态变量会在方法第一次调用的时候初始化。
- Main之后的优化点:
- 延迟 or 下线一些三方SDK,可以让vendor去改
- 使用asset管理图片,加载会比bundle快。预先加载解码启动图片。
- 需要主线程等待的子线程任务都应该设置成高优的,高优的线程数量不应该多于CPU核心数量,并发的线程数过多会导致线程切换TCB耗时过多。
- 避免启动用gif,gif加载耗时很长;如果是lottie可以先显示一个静态图然后再加载lottie。
- 小技巧:
开启Background App Refresh,增加热启动。
后台启动有一些要注意的点,比如日活、AB组逻辑都会受影响,需要做不少适配。往往需要启动器来支撑,因为正常启动在didFinishLaunch执行的任务,在后台启动的时候需要延迟到第一次回前台的时候再执行。