本⽂基于2021年GMTC全球⼤前端技术⼤会"移动端性能优化"专题下[贝壳找房 iOS 冷启动优化实践]主题分享整理而来。
前言
随着贝壳找房在产业互联网领域不断深耕,各项业务都在持续高速发展,业务功能和复杂度不断增长的同时,带来的问题是 App 启动变慢,用户体验变差,如果没有收口处理,开发团队每过一段时间都要花时间进行优化。
冷启动优化是性能优化的重要一环,其重要性毋庸置疑,启动的快慢是一个 App 给人的第一印象,对于 C 端 App 甚至会影响用户的留存。每个客户端研发对冷启动优化基本都耳熟能详,但往往知识点是零散的,不够系统和全面,以至于优化只是根据自己熟悉的部分做了几点,效果不是特别明显。我们首先要明确目标,在高目标的驱动下,传统思维方式的转变、许多近乎苛刻的优化方法和全面的防劣化手段就是题中应有之义。
本文将从优化思想到技术实现的细节讲解,让你体系化地了解启动优化过程的设计和实现。值得强调的是,本文并不会面面俱到,对一些耳熟能详的优化方法,比如+load的治理、二进制重排等,网上已经有很多资料,读者可以自行查阅。这里着重介绍对较大收益的方案系统化落地、大家容易忽视的一些优化方法以及对优化成果进行防劣化治理。主要分为以下几个部分:
图一 主要内容
1 启动优化常见误区
1.1 冷启动测试标准
图二 冷启动测试标准
大家对于冷启动的线下测试需要注意几点:
(1)重启⼿机,并静置 2-3 分钟或杀进程后静置 10 分钟以上。
(2)打开⻜⾏模式或者 mock ⽹络环境,减少网络环境的影响。
(3)关闭 iCloud,减少系统同步数据的影响。
(4)线下为减少误差,可以测50次求平均值。
业界对于冷启动完成的定义有不同的标准,有的以didFinishLaunching结束为准,有的以首帧开始渲染为准,我们这里以首页渲染完成也就是首页VC的viewDidAppear为准,这样更符合用户的真实感受,把首页的加载优化纳入到了冷启动优化的过程里。
对于测试工具,使用Xcode的instrument以及App Launch等都可以查看耗时,但是不够直观,这里选用了开源的AppleTrace工具,可以以火焰图的形式直观地展示出启动过程中的各方法耗时。而且在代码发生变化时,通过可视化的方式可以非常直观的看出来哪块对启动时间造成了影响。
1.2 冷启动优化的本质
图三 冷启动优化本质
首先, 优化的首要目标是去除那些无用的操作,只做必要的事,不做重复计算,这点在任何优化中都是共通的。
第二,只做现在必须做的事,不提前优化,不过度优化。
第三,资源换时间,包含我们常见的缓存就是空间换时间,还有后面讲到的编译期读取I/O到内存,拿编译时间的增加换启动时间的减少,就是时间换时间。
第四,算法、策略等内部优化,这个是针对每个优化项内部进行优化,举个例子,iOS里常见的KV存储NSUserDefault, 因为需要整体的序列化和反序列化,启动期间用到NSUserDefault的地方积少成多也会耗时。而微信开源的MMKV基于mmap内存映射,提供可供随时写入的内存块, 不必担心 crash 导致数据丢失, 而且增量更新,速度较NSUserDefault有约百倍提升。
第五,充分利用CPU的多核性能,平衡好任务的串行和并行,用最短时间把任务完成。
1.3 冷启动优化的常见误区
图四 冷启动优化的常见误区
这里列出来一些常见的误区,给大家分享一下,避免少踩坑:
首先,耗时操作简单放到子线程就可以了。举个例子,定位请求各种传感器的请求间隔通常会设置为几十毫秒,即使放在子线程也会把CPU打满,子线程的资源竞争也会影响到主线程的耗时。所以,一方面要看线程切换的成本,另一方面还要看放到子线程,后续依赖能否满足。
第二,任务延迟固定时间。这种延迟1s,2s执行某项任务的操作,对于某些低端机型来讲,只是把时间转嫁到后半段,整体的启动时间并没有变化。另外延迟固定时间是特别不推荐的写法,没有明确的先后关系,会带来潜在的质量问题。
第三,延迟到首页渲染完成之后。有的同学说这样是不是就可以了,没错,冷启动的时间减少了,但是我们要保证首页渲染完成之后不卡,否则一堆任务堆积在那,展示的时间变短了,但是用户可交互时间没有变化,那就是自欺欺人。
第四,CPU 占用过高会有问题。这点可能和大家常识想悖,平时我们总是说CPU、内存、时间越少越好,但这不是绝对的。举个例子,一个是CPU占用2%,启动时间2s, 另一个是CPU占用100%,启动时间1.5s, 你会选哪个呢,当然是选第2个。CPU占用过高只是那1,2s的时间,启动完成后通过任务调度把CPU迅速降下来就没问题,而且设备的性能就是需要尽可能压榨的,以几秒钟的高CPU占用,换来启动时间的减少,收益远大于成本。
2 优化思路分析
2.1 iOS冷启动阶段划分
对iOS冷启动进行优化,首先我们需要知道冷启动包括哪些阶段:
图五 iOS冷启动阶段划分
iOS 13 推出了 MetricKit 框架,用于收集电量和性能数据,将启动过程分成 System Interface、Runtime Init、UIKit Init、Application Init 和 First Frame Render、Extended 等六个阶段:
System Interface:App 初始化的准备工作,包含进程的创建、加载主二进制 、启动 dyld、加载动态库等,然后进行 libSystem 初始化,即一些系统底层组件的初始化。
Runtime Init:初始化Objective-C的Runtime,执行+load和static initializer初始化函数等。
UIKit Init:实例化UIApplication和UIApplicationDelegate,开始事件处理,与系统交互。
Application Init:处理UIApplicationDelegate的各种生命周期回调。
Initial Frame Render:首帧渲染,主要是创建、布局和绘制视图等工作,并把准备好的第一帧提交给Render Server进程去渲染。
Extended:首帧之后的工作,如异步获取数据、视图更新、用户交互等。
2.2 优化可行性分析
图六 优化可行性分析
上图针对冷启动的每个阶段,进行了优化可行性分析,这里不再赘述。
2.3 贝壳 iOS 冷启动阶段划分
图七 贝壳 iOS 冷启动阶段划分
我们将启动过程拆分成 T1、T2、T3 和 T4 四个阶段:
T1 阶段:从创建进程到 main 函数执行,对应 MetricKit 的 System Interface 和 Runtime Init,通过系统函数sysctl可以获取进程创建的时间戳。
T2 阶段:main 函数开始到 AppDelegate类中 didFinishLaunchingWithOptions:执行结束,对应 MetricKit 的 UIKit Init 和 Application Init。
T3 阶段:didFinishLaunchingWithOptions执行结束到首页viewDidAppear,更接近用户的视觉感受。
T4 阶段:viewDidAppear到启动15s, 主要是为了保证启动后的流畅度,这一阶段主要关注CPU、内存、线程等指标。
2.4 优化成果
图八 优化成果
3 主要优化方案
3.1 整体思路
图九 整体思路
3.2 现状
图十 现状
有了优化的整体思路,我们还要立足现状,这里之所以引出贝壳 B 端 App 架构,因为 B 端平台支撑了多个 App,需要平台统一管控启动项,同时优化方案需要抽象,不局限于特定 App。
3.3 主要方案
图十一 主要方案
3.4 框架优化-最小集
图十二 最小集
我们梳理最小集的时候,标准可以严苛一点,比如crash监控最开始启动,这个应该没有异义,否则就是自欺欺人,rootViewController要attach window, 首页的展示和底部的 tabbar 的创建,其他的基本都可以延迟或懒加载。许多同学可能说abtest也重要啊,热修复也重要啊,可能有非常多的理由,如果标准不严苛,都觉得自己重要,那优化的空间就太小了。
业务关心的其实只是自己的功能正常不正常,我们可以这样想,如果最小集足够小和稳定,就像 hello world 一样,那所谓重要的代码放在 hello world 前和放在 hello world 后又有多大的区别呢?而且标准不是一成不变的,如果真的有那种优先级高的,有足够的理由或者不可调和的矛盾,我们可以再挪回来,但目前看还没有。我们梳理了 App 里的启动项,以正常流程为例,之前有将近30项,梳理完最小集,只保留了5项左右。
3.4.1 框架优化-生命周期延迟
图十三 生命周期延迟
传统的方式大多是针对启动期间的任务项做并发、闲时等处理,但是任务项本身耗时、并发对不齐、加上线程切换、任务调度都会耗时。什么都不干肯定不会耗时,这是一个特别简单的道理,但是很多人做不到,仍然按照传统的思维一点一点往后挪。举个例子,就像一个毛线团绕在一起,你要一根一根拽出来,每根线都会受到其他的限制,但是如果我们换个思路,只留下几根线,剩下的一把拽出去,是不是速度会快很多呢。
图十四 生命周期类图
核心类介绍:
LJAppLauncher: 启动期间必须项的管理器,分为头部任务、尾部任务和并行任务,这里梳理完最小集后,启动项已经很少,只有crash监控、http参数配置、window创建、tabbar创建、首页创建,如果有依赖关系,使用addInOrder,如果无依赖关系可以并行使用addNoOrder,如果优先级较低可以使用addToTail, 这里以crash为例。
CrashInitialization: 遵循InitializationProtocol协议,头部和尾部串行任务实现方法setupWithOptions,异步任务实现asyncWithOption方法。
LJAppLaunchManager:首页渲染完成后的生命周期管理,接收到首页渲染完成的通知后,再进行生命周期的分发,任务项分为高优先级、低优先级、闲时任务、并行任务。
LJAppLaunchTaskService:遵循LJAppLaunchLifecycleProtocol, 可以通过launchPriority指定优先级,默认低优先级,根据是否需要在主线程串行执行、异步执行、闲时执行等特性,把相关代码填入相应方法。
CrashInitialization启动期间最小集的一个示例,LJAppLaunchTaskService是冷启动后任务项的一个示例,拆分成细粒度后的任务项都可以类似LJAppLaunchTaskService。
3.4.2 框架优化-启动器
图十五 启动器
我们的启动器也分成了两个阶段:
第一个阶段是对最小集统一管理。
第二个阶段是对延迟的任务分散生命周期,动态注册。
由于最小集的原因,冷启动加载的任务极少,速度必然会加快。另一方面,冷启动的数据优化了,体验也要跟着优化,不能把延迟过来的任务项都堆到一起引起卡顿,那样就没有意义了。这就需要梳理清楚任务项的依赖、能否在子线程执行、在子线程哪些串行哪些并行、能否闲时处理,充分利用设备性能,集中力量用最短的时间把这些事做完。
3.4.3 框架优化-任务编排
图十六 任务编排
这里采用了面向阶段调度的方法,首先根据优先级和功能属性划分阶段,先集中力量把阶段一完成,再完成阶段二,最终把串行的任务变为有向无环图,原本要靠锁来保证状态同步,现在启动框架严格按照有向无环图的顺序执⾏各项 SDK 的初始化,实现了无锁化。接着根据框架的动态注册能力,把任务项打散,分阶段填充。
3.4.4 框架优化-线程管理
图十七 线程管理
我们根据优先级和分类创建了 3 个线程队列:
主线程队列,管控所有UI操作及必须在主线程执行的高优和低优任务
后台线程队列,管控可以并行执行的任务项
闲时线程队列,管控可以闲时执行的任务
自己创建的queue也是基于GCD封装的,比单独调用系统的GCD好处在于:
如果只是调用系统的GCD,会出现各自为政,难以统一管控,而从线程管理器中获取队列,可以进一步进行优化和统一管控。
系统的GCD优先级和顺序难以统一控制,不利于整体调优。
自己创建的queue可以利用runloop的闲时,对queue里的每个任务不管主线程还是异步都在压缩CPU的时间片,让方法分布更紧凑。
3.5 首页渲染优化
图十八 首页渲染优化
首先技术选型也很重要,首页不使用Flutter,就可以避免flutter引擎的初始化,节省了这部分时间。
像首页、底部tab等都会用到图片,低端机上imageNamed:读取图片耗时明显,可以在main函数之后,提前异步加载,或者更极端一点,可以将图片用base64存进代码,并预先读入内存。
手动读取vc.view触发viewDidLoad。
大家习惯于整体刷新,就像tableView,直接reload很简单,但许多时候我们可以把功能拆细粒度,微小变化时局部刷新,这样肯定比全部刷新要节省时间。
也是大家容易忽略的一点,有些视图只在if条件里才会展示,大家经常在if外面进行创建,这也是没有做到按需。
3.6 动态库懒加载
图十九 动态库简介
动态库的使用很常见,大家可以用 otool 命令查看一下 App里用到了哪些动态库,由于动态库是在 App 启动的时候通过 dyld 根据依赖关系递归的加载到内存中,如果动态库数量多了,会大大的拖慢应用的启动速度。
图二十 动态库使用
动态库运行时手动加载有两种方式:一种是 [NSBundle loadAndReturnError], 另一种是dlopen, 这里推荐第一种苹果封装的 API,优点是可以访问 bundle 里的资源,而且也是基于dlopen实现的,增加了验签过程。
图二十一 动态库注意事项
为了避免编译期编译符号找不到,需要添加-undefined dynamic_lookup这个指令。主工程的配置也需要跟着修改:Build Settings-Strip Style 修改为Non-Global Symbols,将外部引用的符号保留。
动态库依赖的静态库符号缺失会导致动态库load失败或者运行时crash,这就需要我们能提前发现问题并解决,比如可以通过脚本进行扫描。nm –um 命令获取动态库依赖的外部符号,nm –gm 获取 APP 依赖的所有符号,前者不在后者中,则说明缺失。
3.7 编译期消除I/O
图二十二 编译期消除I/O
具体的做法:提前在工程根目录下放置空的.h和.m, .h的接口可以提前写进去,编译时python提前把Plist文件中数据拿出来转成OC的数据后,转成了OC的字面量都是字符串的形式,这时就可以写入.m生成出来实现方法。等到业务方调用某个接口时,之前调用OC的系统方法牵扯到I/O耗时,现在变成了 - (NSDictionary *) readPlist { return @{key:value} }; 这种就是纯方法调用的耗时了。
好处:读取本地配置文件 I/O 耗时较多,一个文件要耗费几ms, 随着业务增加当启动期间的 I/O 文件多时,这部分时间就很可观了,可能达到几十ms甚至更多, 而我们在编译期写入内存,业务只是方法调用,不到1ms, 而且不管业务怎么变化,都是不到1ms, 这部分价值就大了。几十个研发同学每次编译增加了不到1s,而几十万经纪人每次启动都能减少几十ms, 这个时间换时间就非常值得,日活千万、上亿的 App 收益会更大。
3.8 static initializer治理
图二十三 static initializer治理
3.8.1 背景
C++代码需要静态链接,必须保证在main函数之前,全局常量初始化完毕, 会增加main函数之前的执行时间,随着全局常量的增多,这部分耗时也很有必要优化。
3.8.2 哪些可以产生initializer
(1) 执行C函数
int test() {
return 1;
}
int x = test();
(2) 结构体的⾮编译时常量
CGRect rect = CGRectZero;
(3) \__attribute((constructor))
static __attribute__((constructor))
void ljshl_MessageCellIdentifiers(void) {
kLJSHLMessageCellIdentifiers = @[
@"LJSHLMessageBotCell",
@"LJSHLMessageSystemCell",
@"LJSHLMessageUserCell",
@"LJSHLMessageSelfCell",
@"LJSHLMessageVIPCell",
@"LJSHLMessageSystemWelcomeCell"
];
}
(4) C++全局类对象初始化
class testGlobalVar {
public:
testGlobalVar() {
std::cout<<"testGlobalVar"<<std::endl;
}
};
testGlobalVar var;
(5) C++类的构造函数
class testGlobalVar {
public:
static testGlobalVar *globalVar;
testGlobalVar() {
std::cout<<"testGlobalVar"<<std::endl;
}
};
testGlobalVar *(testGlobalVar::globalVar) = new testGlobalVar;
3.8.3 如何扫描
原理:mod\_init\_func的运⾏时机晚于+load,在运⾏时hook mod。
3.8.4 如何分发
通过遍历Symbols找到符号所属的⽂件号,根据对应⽂件号在Object files找到所属组件和⽂件名,进⽽找到对应负责⼈。
3.8.5 如何治理
\__attribute((constructor))和全局常量删除或者懒加载。
3.9 无用代码删除
图二十四 无用代码删除
曾经我也认为删无用代码对冷启动的影响太小,删1万行代码也优化不了2ms, 哪个业务线你让他精简1万行代码很容易做到呢,万一删错出线上问题呢?直到后来我们做包瘦身,发现有的业务线大片功能已经用 flutter 实现了,但是 native 代码还存在,这种一删就是10万行,收益还是很大的。
静态分析,是比较常用的手段,但是由于 OC 是动态运行时语言,有些代码通过静态分析是无用的,但是运行时有调用,删除了就会有问题。
0 PV扫描,针对静态分析的弊端,我们拿线上所有用户30天的数据,如果30天所有人都没有访问的页面,可以认为是代码中的无用页面,扫描出来后,各业务线对照扫描结果直接删除即可,既没有什么工作量,又规避了风险。
基于类的动态覆盖率检查,第二种方案是针对页面维度的,如果想进一步删除无用类,可以利用 runtime 特性,记录某个类有没有被调用,实现进一步的删除。
3.10 其他优化项
图二十五 其他优化项
3.11 优化难点
图二十六 优化难点
4 规范与监控
4.1 规范
图二十七 规范
4.1.1 启动项管控
新加的启动任务必须经过我们评估和审核,但基本到不了这一步。首先,我们既然已经定下了启动项那5项基本的最小集,没有足够的理由是不会把最小集扩大的。其次,我们提供了动态注册的方式供各方接入新的启动项,实现了解耦,不需要老来打扰我们,更重要的是我们生命周期整体进行了延迟,从根本上规避了对启动的入侵。
4.1.2 PreMain管控
\+ load方法、静态常量、动态库的加载都会影响Premain的时间,也是需要避免添加的。
4.1.3 新增耗时
根据经验,排除测试误差的影响,线上版本每个任务项相对上个迭代有5ms以上的增长时,大概率是增加了耗时,需要进行优化。
4.2 监控
图二十八 监控
由于部分业务是需要+load时机,如性能监控等,这部分是合理的,需要有白名单机制,我们需要把这部分排除出去,其他的尽量都治理完成,避免新增。通过CI 工具,扫描出所有的符号,如果有白名单之外的启动项、对系统生命周期的监听、+load和initializer这些,就禁⽌该组件的集成。
5 总结与展望
5.1 总结
图二十九 总结
首先总结一下我们上面讲述的方案,大概可以分为低成本高收益方案和深入优化方案两类。
大家之前的冷启动优化可能会侧重+load治理、二进制重排、删无用代码、启动任务管理等等。
今天听完这次分享,我希望能记住下面几个方案:
(1)main 函数之后的优化,启动任务的最小集和按生命周期进行延迟,优化效果非常大。
(2)首页渲染优化,imageNamed 提前在面函数之后立刻异步预加载,或者把图片 base64 提前写入内存、只创建首页VC,其他VC在首页渲染后加载、首页微小变化时局部刷新,不整体刷新。
(3)main 函数之前的优化,动态库懒加载、C++全局静态常量的治理、编译期 I/O 提前写入内存等。
这些都能带来切切实实的收益,大家也可以对照下自己的 App,每实践一项都能带来一项的收益。
5.2 展望
5.2.1 自动归因
图三十 自动归因
每个版本排查耗时的增长非常麻烦,我们希望以后能够做到自动归因。首先对启动阶段和任务进行细化,把每个版本的启动数据聚合、归一化。然后新旧版本做diff,发现增加的部分及逐层调用关系,最后对增量数据进行智能分析、归因和分发。
5.2.2 智能预测
图三十一 智能决策
关于智能决策,这里首先提出两个问题:
通常的启动控制是一种通用化的最优策略,但是针对局部不同的设备优化程度不一样,设备性能差异、启动场景不同,带来的效果也不同。
启动时间的快慢对用户留存率等会有影响,但具体怎么量化并不清楚。
针对这两个问题,我们该怎么去解决呢?
首先,我们可以方便的根据用户特征、机型特征、行为轨迹,做动态的启动任务编排,对启动任务进行差异化处理,真正做到千人千面,总体最优。
例如,针对push等场景,可以将对应落地页所需要的启动任务提前,以提升该场景的启动速度。
更准确的衡量冷启动优化带来的留存率的增长。我们可以通过ABTest对照,更细粒度去印证启动优化带来的业务价值。
而决策引擎也可以分成两个阶段:
使用固定规则。
利用端智能做预测,比如进入首页后不同人群的操作规律是不相同的,大概率先进入哪些场景,从而把启动任务提前。
最后要感谢公司其他移动端团队的支持和共建,也感谢业界其他公司和个人的优秀的探索,让我们站在巨人的肩膀上。
招聘:我们是贝壳找房人店平台中心的移动端作业组,服务于几十万经纪人,致力于提升经纪人的作业效率和使用体验,通过科技赋能产业互联网,打造新居住品质服务平台。我们的业务包含flutter、平台架构等众多核心业务,长期招聘Android/iOS/Flutter开发工程师,感兴趣的同学可投递简历到mobile_hr@ke.com。