1 前言

启动是 App 给用户的第一印象,一款 App 的启动速度,不单单是用户体验的事情,往往还决定了它能否获取更多的用户。所以到了一定阶段 App 的启动优化是必须要做的事情。App 启动基本分为以下两种

1.1 冷启动

App 点击启动前,它的进程不在系统里,需要系统新创建一个进程分配给它启动的情况。这是一次完整的启动过程。

表现:App 第一次启动,重启,更新等

1.2 热启动

App 在冷启动后用户将 App 退后台,在 App 的进程还在系统里的情况下,用户重新启动进入 App 的过程,这个过程做的事情非常少。

所以我们主要说道说道冷启动的优化

2 启动流程

2.1 APP 启动都干了什么

要对启动速度进行优化,我们需要知道启动过程中的大致流程是什么,做了什么事情,是否能针对性优化。

下图是启动流程的详细分解

  1. 点击图标,创建进程
  2. mmap 主二进制,找到 dyld 的路径
  3. mmap dyld,把入口地址设为_dyld_start

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,放到帧缓冲区里,硬件根据时钟信号读取帧缓冲区内容,完成屏幕刷新

2.2 启动各阶段时长统计

上一小节对启动各个阶段过程的详细阐述,归纳起来大致分为 6 个阶段 (WWDC2019):

通过对各个阶段进行时长统计分析,进行优化然后对比。

可以在 Xcode 中设置环境变量 DYLD_PRINT_STATISTICS 和 DYLD_PRINT_STATISTICS_DETAILS 看下启动阶段和对应的耗时 (iOS15 后环境变量失效)

也可以通过 Xcode MetricKit 本身也可以看到启动耗时:打开 Xcode -> Window -> Origanizer -> Launch Time

如果公司有对应的成熟监控体系最好,这里我们主要通过手动无侵入埋点去统计启动时长,对启动流程 pre main-> after main 进行统计分析

2.1.1 进程创建时间打点

通过 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;
    }

2.1.2 main() 执行时间打点

// main之前调用
// pre-main()阶段结束时间点:__t2
void static __attribute__ ((constructor)) before_main()
{
  if (__t2 == 0)
  {
    __t2 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
  }
}

2.1.3 首屏渲染时间打点

启动的终点对应用户感知到的 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

3 启动优化

上节我们主要分析了 App 启动流程和时长统计,下面就是我们要优化的方向,尽可能对各个阶段进行优化,当然也不是过度优化,项目不同阶段、不同规模相应的问题会不一样,做针对性分析优化.

3.1 Pre Main 优化

3.1.1 调整动态库

查看了现有工程,基本都以动态库进行链接,总计 48 个,所以思路如下

3.1.2 rebase&binding&objc setup 阶段

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…

对项目进行检测,发现还是很多无用类的:

然后二次分析验证,进行优化

3.1.3 二进制重排

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,可以不用区别对待

3.2 After Main 优化

这部分是个大头的优化项,实际场景需要我们根据自己的具体项目来分析,但大体遵循一些相同的思路

3.2.1 功能/方法优化

3.2.2 首屏渲染优化

屏幕显示遵循一套图形渲染管线来完成最终的显示工作:

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 渲染阶段:

主要是一些图元的操作、几何处理、光栅化、像素处理等,不一一细说,这部分操作我们能做的工作毕竟是有限的

所以,我们大致可以做的优化点如下:

4 成果

经过一些列优化,还是有一些速度的提升,虽然工程还不是大型工程,不过及早持续优化可以防止业务迭代到一定程度难以下手的地步。

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%.

当然目前还处于未上线版本,后续上线后借助监控平台借助线上更多数据,更多机型来更好的的进行分析优化

5 总结

启动速度瓶颈非一日之寒,需要持续的进行优化,这当中也少不了监控体系的持续建设和优化,日常线上数据的分析,防止业务快速迭代中的启动速度劣化,对动态库的引入、新增 +load 和静态初始化、启动任务的新增都要加入 Code Review 机制,优化启动架构为启动这些基础性能保驾护航。

作者:京东物流 彭欣 

来源:京东云开发者社区 自猿其说 Tech


↙↙↙阅读原文可查看相关链接,并与作者交流