启动是App给用户的第一印象,一款App的启动速度,不单单是用户体验的事情,往往还决定了它能否获取更多的用户。所以到了一定阶段App的启动优化是必须要做的事情。App启动基本分为以下两种
App 点击启动前,它的进程不在系统里,需要系统新创建一个进程分配给它启动的情况。这是一次完整的启动过程。
表现:App第一次启动,重启,更新等
App 在冷启动后用户将 App 退后台,在 App 的进程还在系统里的情况下,用户重新启动进入 App 的过程,这个过程做的事情非常少。
所以我们主要说道说道冷启动的优化
要对启动速度进行优化,我们需要知道启动过程中的大致流程是什么,做了什么事情,是否能针对性优化。
下图是启动流程的详细分解
dyld 是启动的辅助程序,是 in-process 的,即启动的时候会把 dyld 加载到进程的地址空间里,然后把后续的启动过程交给 dyld。dyld 主要有两个版本:dyld2 和 dyld3。
iOS 12之前主要是dyld2,iOS 13 开始 Apple 对三方 App 启用了 dyld3,dyld3 的最重要的特性就是启动闭包,闭包存储在沙盒的 tmp/com.apple.dyld 目录,清理缓存的时候切记不要清理这个目录。
闭包里主要有以下内容:
上图虚线之上的部分是out-of-process的,在App下载安装和版本更新的时候会去执行,直接从缓存中读取数据,加快加载速度
这些信息是每次启动都需要的,把信息存储到一个缓存文件就能避免每次都解析,尤其是 Objective-C 的运行时数据(Class/Method…)解析耗时, 所以对启动速度是一个优化提升
4.把没有加载的动态库 mmap 进来,动态库的数量会影响这个阶段
dyld从主执行文件的header获取到需要加载的所依赖动态库列表,然后它需要找到每个 dylib,而应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以所需要加载的是动态库列表一个递归依赖的集合
5.对动态库集合循环load, mmap 加载到虚拟内存里,对每个 Mach-O 做 fixup,包括 Rebase 和 Bind。
对每个二进制做 bind 和 rebase,主要耗时在 Page In,影响 Page In 数量的是 objc 的元数据
如下图,编译的时候,字符串 1234 在__cstring的 0x10 处,所以 DATA 段的指针指向 0x10。但是 mmap 之后有一个偏移量 slide=0x1000,这时候字符串在运行时的地址就是 0x1010,那么 DATA 段的指针指向就不对了。Rebase 的过程就是把指针从 0x10,加上 slide 变成 0x1010。运行时类对象的地址已经知道了,bind 就是把 isa 指向实际的内存地址。
6.初始化 objc 的 runtime,由于闭包已经初始化了大部分,这里只会注册 sel 和装载 category
7.+load 和静态初始化被调用,除了方法本身耗时,这里可能还会引起大量 Page In,如果调用了dispatch_async则会延迟启动后的runloop开启后执行,如果触发静态初始化,则会延迟到运行时执行
8.初始化 UIApplication,启动 Main Runloop,可以在之前章节利用runloop统计首屏耗时,也可以在启动结束做一些预热任务
9.执行 will/didFinishLaunch,这里主要是业务代码耗时。首页的业务代码都是要在这个阶段,也就是首屏渲染前执行的,主要包括了:首屏初始化所需配置文件的读写操作;首屏列表大数据的读取;首屏渲染的大量计算等;sdk的初始化;对于大型组件化工程,也包含了很多moudle的启动加载项
10.Layout,viewDidLoad 和Layoutsubviews 会在这里调用,Autolayout 太多会影响这部分时间
11.Display,drawRect 会调用
12.Prepare,图片解码发生在这一步
13.Commit,首帧渲染数据打包发给 RenderServer,走GPU渲染流水线流程,启动结束
(tips: 2.2.10-2.2.13这里主要是图形渲染流水线的部分流程,Application产生图元阶段(CPU阶段))。后续会交由单独的RenderServer进程,再调用渲染框架(Metal/OpenGL ES)来生成 bitmap,放到帧缓冲区里,硬件根据时钟信号读取帧缓冲区内容,完成屏幕刷新
上一小节对启动各个阶段过程的详细阐述,归纳起来大致分为6个阶段(WWDC2019):
通过对各个阶段进行时长统计分析,进行优化然后对比。
可以在Xcode中设置环境变量DYLD_PRINT_STATISTICS和DYLD_PRINT_STATISTICS_DETAILS看下启动阶段和对应的耗时(iOS15后环境变量失效)
也可以通过Xcode MetricKit 本身也可以看到启动耗时:打开 Xcode -> Window -> Origanizer -> Launch Time
如果公司有对应的成熟监控体系最好,这里我们主要通过手动无侵入埋点去统计启动时长,对启动流程pre main-> after main进行统计分析
通过 sysctl 系统调用拿到进程创建的时间戳
#import <sys/sysctl.h>
#import <mach/mach.h>
+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(*procInfo);
return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}
+ (NSTimeInterval)processStartTime
{
struct kinfo_proc kProcInfo;
if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
} else {
NSAssert(NO, @"无法取得进程的信息");
return 0;
}
// main之前调用
// pre-main()阶段结束时间点:__t2
void static __attribute__ ((constructor)) before_main()
{
if (__t2 == 0)
{
__t2 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
}
启动的终点对应用户感知到的 Launch Image 消失的第一帧
iOS 12 及以下:root viewController 的 viewDidAppear
iOS 13+:applicationDidBecomeActive
Apple 官方的统计方式是第一个 CA::Transaction::commit,但对应的实现在系统框架内部,不过我们可以找到最接近这个的时间点
通过 Runloop 源码分析和调试,我们发现 CFRunLoopPerformBlock,kCFRunLoopBeforeTimers 和 CA::Transaction::commit()为最近的时间点,所以在这里打点即可.
具体就是可以通过在 didFinishLaunch 中向 Runloop 注册 block 或者 BeforeTimer 的 Observer 来获取这两个时间点的回调,代码如下:
注册block:
//注册block
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopPerformBlock(mainRunloop,NSDefaultRunLoopMode,^(){
NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];
NSLog(@"runloop block launch end:%f",stamp);
});
监听BeforeTimer 的 Observer
//注册kCFRunLoopBeforeTimers回调
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopActivity activities = kCFRunLoopAllActivities;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
if (activity == kCFRunLoopBeforeTimers) {
NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];
NSLog(@"runloop beforetimers launch end:%f",stamp);
CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes);
}
});
CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes);
综上分析现有项目版本启动时间均值:
[函数名:+[LaunchTrace mark]_block_invoke][行号:54]—————App启动————-耗时:pre-main:4.147820
[函数名:+[LaunchTrace mark]_block_invoke][行号:55]—————App启动————-耗时:didfinish:0.654687
[函数名:+[LaunchTrace mark]_block_invoke][行号:56]—————App启动————-耗时:total:4.802507
上节我们主要分析了App启动流程和时长统计,下面就是我们要优化的方向,尽可能对各个阶段进行优化,当然也不是过度优化,项目不同阶段、不同规模相应的问题会不一样,做针对性分析优化.
查看了现有工程,基本都以动态库进行链接,总计48个,所以思路如下
typedef struct{
const char * cls;
const char * protocol;
}_di_pair;
#if DEBUG
#define DI_SERVICE(PROTOCOL_NAME,CLASS_NAME)\
__used static Class<PROTOCOL_NAME> _DI_VALID_METHOD(void){\
return [CLASS_NAME class];\
}\
__attribute((used, section(_DI_SEGMENT "," _DI_SECTION ))) static _di_pair _DI_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#else
__attribute((used, section(_DI_SEGMENT "," _DI_SECTION ))) static _di_pair _DI_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#endif
原理很简单:宏提供接口,编译期把类名和协议名写到二进制的指定段里,运行时把这个关系读出来就知道协议是绑定到哪个类了。
无用代码删除在所有的性能优化手段里基本上是ROI最低的。但是几乎所 有ROI较高的技术手段都是一次性优化方案,经过几个版本迭代后再做优化就会比较乏力。相比之下,针对代码的检测和删除在很长的一段时间内提供了很大的优化空间
检测手段:静态扫描Mach-O文件对classlist和classrefs做差集,形成初步的无用类集合,并根据业务代码特征做二次适配
当然还有其他常用的技术手段包括AppCode工具检测以及以例如Pecker这样的基于 IndexStoreDB 、线上统计等。
不过以上方案对Swift的检测方案不太适用(和OC存储差异),这里可以参考github.com/wuba/WBBlad…
对项目进行检测,发现还是很多无用类的:
然后二次分析验证,进行优化
iOS系统中虚拟内存到物理内存的映射都是以页为最小单位的。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,就会出现Page Fault缺页中断,(对应System Trace的File Backed Page In) 然后操作系统把数据加载到物理内存中,如果已经已经加载到物理内存了,则会触发Page Cache Hit,后者是比较快的,这也是热启动比冷启动快的原因之一。
虽然缺页中断异常这个处理速度是很快的,但是在一个App的启动过程中可能出现上千(甚至更多)次Page Fault,这个时间积累起来会比较明显了。
基于上面原理. 我们的目标就是在启动的时候增加Page Cache Hit,减少Page Fault,从而达到优化启动时间的目的
我们需要确定,在启动的时候,执行了哪些符号,尽可能让这些符号的内存集中在一起,减少占用的页数,就能减少Page Fault的命中次数
程序默认情况下是顺序执行的:
如果启动需要使用的方法分别在2页Page1和Page2中(method1和method3),为了执行相应的代码,系统就必须进行两个Page Fault。
如果我们对方法进行重新排列,让method1和method3在一个Page,那么就可以较少一次Page Fault。
通过Instruments中的System Trace工具来看下当前的page fault加载情况
这里有个注意点,为了确保App是真正的冷启动,需要把内存清干净,不然结果会不太准,下图是我直接杀掉App,重新打开得到的结果
可以看到,和第一次测试差的有点多,我们可以在杀掉App后,重新打开多个其他的App(尽可能多),或者卸载重装,这样在重新打开App的时候,就会冷启动
综上我们要做的就是将启动时调用的函数符号集中靠前排列,减少缺页中断数量
输出的文件在App沙盒,用模拟器运行更方便,得到文件app.order,这里面就是排好序的符号列表,根据App的执行顺序,如果项目比较大的话,会比较久.
把order文件放到工程目录,配置到Xcode里面Build Setting -> Order File -> $(PROJECT_DIR)/xxx.order
Link Map文件
Intermediates.noindex/xxxx.build/Debug-iphoneos/xxx.build/xxx-LinkMap-normal-arm64.txt
生成app文件路径
Products/Debug-iphoneos/xxx.app
这里我们只关注Link Map File的符号表Symbols,这里的顺序就是Mach-O文件对应的顺序,如果与xxx.order的顺序一致,就表明改成功了
再次通过System Trace工具测试修改前后对比
优化前后对比,缺页中断明显减少
获取函数调用符号,采用Clang插桩可以直接hook到Objective-C方法、Swift方法、C函数、Block,可以不用区别对待
这部分是个大头的优化项,实际场景需要我们根据自己的具体项目来分析,但大体遵循一些相同的思路
屏幕显示遵循一套图形渲染管线来完成最终的显示工作:
1.Application阶段(应用内):
Handle Events:
这个过程中会先处理点击事件,这个过程中有可能会需要改变页面的布局和界面层次。
Commit Transaction:
此时 App 会通过 CPU 处理显示内容的前置计算,比如布局计算、图片解码等任务,之后将计算好的图层进行打包发给 Render Server。(核心Core Animation负责)
Commit Transaction 这部分中主要进行的是:Layout、Display、Prepare、Commit 等四个具体的操作, 最后形成一条事务,通过 CA::Transaction::commit()提交渲染
构建视图相关,layoutSubviews、addSubview 方法添加子视图、AutoLayout根据 Layout Constraint 计算各个view的frame,文本计算(size)等。
layoutSubviews:在此阶段会调用,但是满足条件如frame,bounds,transform属性改变、添加或者删除view、显式调用setNeedsLayout等
绘制视图:交给 Core Graphics 进行视图的绘制,得到图元 primitives 数据,注意不是位图数据,位图是GPU阶段根据图元组合而得。但是如果重写了 drawRect: 方法,这个方法会直接调用 Core Graphics 绘制方法得到 bitmap 数据,同时系统会额外申请一块内存,用于暂存绘制好的 bitmap,导致绘制过程从 GPU 转移到了 CPU,这就导致了一定的效率损失。与此同时,这个过程会额外使用 CPU 和内存,因此需要高效绘制,否则容易造成 CPU 卡顿或者内存爆炸。
Core Animation 额外的工作,主要是图片解码和转换,尽量使用GPU支持的格式, Apple推荐JPG和PNG
譬如在UIImageView中展示图片,会经历如下过程: 加载、解码、渲染 简单说就是将普通的二进制数据 (存储在dataBuffer 数据) 转化成 RGB的数据(存储在ImageBuffer), 这个被称为图像的解码decode, 它有如下特点:
decode解码过程是一个耗时过程, 并且是在CPU中完成的. 也就是我们这部分的prepare中完成。
解码以后的RGB图占用的内存大小只与bitmap的像素格式(RGB32, RGB23, Gray8 …)和图片宽高有关, 常见bitmap大小: 每个像素点大小 width height, 而与原来的压缩格式PNG, JPG大小无关.
2.GPU渲染阶段:
主要是一些图元的操作、几何处理、光栅化、像素处理等,不一一细说,这部分操作我们能做的工作毕竟是有限的
所以,我们大致可以做的优化点如下:
经过一些列优化,还是有一些速度的提升,虽然工程还不是大型工程,不过及早持续优化可以防止业务迭代到一定程度难以下手的地步。
iPhone 7p多次均值
优化前
[函数名:+[LaunchTrace mark]_block_invoke][行号:54]—————App启动————-耗时:pre-main:4.147820
[函数名:+[LaunchTrace mark]_block_invoke][行号:55]—————App启动————-耗时:didfinish:0.654687
[函数名:+[LaunchTrace mark]_block_invoke][行号:56]—————App启动————-耗时:total:4.802507
优化后
[函数名:+[LaunchTrace mark]_block_invoke][行号:54]—————App启动————-耗时:pre-main:3.047820
[函数名:+[LaunchTrace mark]_block_invoke][行号:55]—————App启动————-耗时:didfinish:0.254687
[函数名:+[LaunchTrace mark]_block_invoke][行号:56]—————App启动————-耗时:total:3.302507
pre main阶段下降平均大概20%, after main阶段平均下降大概60%, 总体均值下降30%.
当然目前还处于未上线版本,后续上线后借助监控平台借助线上更多数据,更多机型来更好的的进行分析优化
启动速度瓶颈非一日之寒,需要持续的进行优化,这当中也少不了监控体系的持续建设和优化,日常线上数据的分析,防止业务快速迭代中的启动速度劣化,对动态库的引入、新增 +load 和静态初始化、启动任务的新增都要加入Code Review机制,优化启动架构为启动这些基础性能保驾护航。
作者:京东物流 彭欣
来源:京东云开发者社区 自猿其说Tech
启动是App给用户的第一印象,一款App的启动速度,不单单是用户体验的事情,往往还决定了它能否获取更多的用户。所以到了一定阶段App的启动优化是必须要做的事情。