文 / JackYi
编辑 / 乔
本文共7064字,预计阅读时间26分钟。
App 的启动速度直接影响到用户对产品的第一感官体验,这也是性能优化工作中最为重要的一环。通过对线上数据进行分析,我们了解到 App 启动越快,带来的用户留存率也越高。所以,做好启动不仅有利于用户体验指标的提升,也会促进核心业务的增长。
快手 App 启动定义
开始点
通过 sysctl 获得进程创建的时间。
结束点
对于上下滑的精选页面和直播页面,采用视频接受第一帧作为结束点;对于发现、关注、同城等双列页面,采用第一张图片可展示作为结束点;对于其他业务场景,则采用 viewDidAppear 执行完成作为结束点。
问题现状
在体系化地做启动优化之前,我们将目前存在的问题划分为以下两个方面:
体验问题
用户进线、App Store 评论有反馈启动很慢的情况;
iOS 开发群反馈启动慢,影响线下研发调试效率;
线上启动报警,无法精确定位问题。
技术问题
监控指标和用户体感不一致,缺少 premain 阶段数据,对于 premain 阶段的认知偏少;
没有启动框架管控启动任务,代码耦合严重,AppDelegate 代码超过2万行;
没有防劣化的机制,大团队多提交导致启动有逐渐劣化的趋势;
线上监控埋点不全,无法合理解释线上报警原因和用户反馈的问题。
为了解决以上问题,我们开启了启动优化治理,取得P50 50%、P90 60%下降的收益,并且通过防劣化技术体系的建设,线上再没有出现超过50ms的劣化问题。
App 启动全流程主要分为 premain 和 postmain 两个大阶段。premain 阶段的耗时主要和工程的代码量、架构设计、运行初始化相关,监控和治理难度比较大,这一阶段对于代码规模较大的 App 来说,是需要及时治理和管控的;postmain 阶段的耗时主要是初始化各个 SDK 和首页业务逻辑的耗时消耗,这部分需要重点关注,优化的 ROI 相对较高。
premain 阶段启动优化
1. premain 阶段执行流程
premian 阶段的优化主要是减少系统解析初始化 App 的消耗以及 App 业务逻辑在 premain 阶段的消耗。
通过减少无用代码、三方库,清理无用的动态库依赖,使用动态库懒加载技术等方式可以减轻系统解析 App 的消耗;
通过 +load、static initializer、二进制重排技术可以减少 App 业务逻辑在 premain 阶段的消耗。
2.动态库懒加载
清理无用代码和依赖的方案对于代码规模较大的 App 来说,依然是不够的,我们探索了一种业务动态库懒加载的方案,将启动过程中没有用到的业务和相关 SDK 不集成到主二进制中,而是打包成单独的动态库,在启动完成之后,再去根据业务场景进行懒加载操作。如下图所示:
启动时,只会加载主二进制 Main Executeable 和 Common 动态库,其他Lazy Load 中的动态库将在启动后延迟加载。
这样操作需要注意以下两点:
第一,懒加载中的动态库方法不能直接调用,而需要通过 router 层做一层转发,router 层在第一次接收到该事件的时候,调用 dlopen 把对应的动态库真正加载到内存中;
第二:要做防劣化的处理,防止动态库在启动完成之前加载,测试证明,通过 dlopen 方式加载动态库的效率是低于原本直接集成到主二进制中的,如果在启动阶段,直接就触发了动态库懒加载,将会造成启动的劣化。
3. +load 监控与治理
首先非常不推荐大家在 +load 中添加任何代码。具体原因有如下两点:
第一,这些代码的运行时机非常靠前,出现问题,不能被稳定性 SDK 捕获到,无法保证代码的稳定性;
第二,+load 方法都是在主线程中执行,耗时的操作必定会造成启动时间的增加,而且 +load 可以在任意 OC 类中添加,在大型项目中,业务方容易随意添加,积少成多,造成持续性的启动劣化问题。
如何监控 +load 方法
找到第一个执行 +load 方法:分析 dylib 的依赖循序,找到最底层依赖的动态库,让这个动态库依赖监控库,这样监控库中的 +load 方案,就是整个 App 中第一个执行的 +load 方法;
通过遍历主二进制和动态库中的 __objc_nlclslist 和 __objc_nlcatlist 这两个 section,找到所有实现 +load 的类和分类;
在第一步中找到的第一个执行的 +load 方法中,对第二步中找到的所有类和分类进行方法交换,添加监控耗时代码,从而统计出每一个 +load 方法的耗时。
如何治理管控 +load 方法
对于存量的 +load 方法,主要和业务方确认每一个 +load 的用途,采用删除、延迟、懒加载或者子线程调用,移到 initialize 中执行要注意分类方法覆盖的问题。这里并没有采用把函数指针保证到 DATA 段的这种方式,因为启动阶段读取 DATA 本身就有耗时,对于中低端机不是太友好;而且每一个 +load 还是需要业务方确认它的用途,用一种更加合理的方式进行处理;
对于新增的 +load 方法要进行管控,对于源码集成到主工程的新增 +load 方法,通过在 MR 流水线中新增 diff 检查,遇到 +load 关键字 block 住该 MR 的流水线,并且给出提示;对于二进制集成的新增的 +load 方法,采用启动实验室定时任务检查,如果相比上一个时间节点的 commit,有新增或者耗时明显增加的 +load 方法,找到相关业务方进行处理。
4. static initializer 监控和治理
在同一个二进制中,+load 方法执行之后,就会执行 static initializer 方法,很多开发者对于 static initializer 方法并不是特别熟悉,以下几种典型代码会导致 static initializer ,影响启动耗时。
__attribute__((constructor)) 修饰的方法
__attribute__((constructor)) static void test1() {
usleep(2000);//demo 耗时任务
NSLog(@"test1");
}
通过函数获得返回值的全局变量
bool test2() {
usleep(3000);//demo 耗时任务
NSLog(@"test2");
return true;
}
static bool global_c_1 = test2();
bool global_c_2 = test2();
C++ 全局对象初始化的构造函数
class Test3{
public:
Test3() {
usleep(4000);//demo 耗时任务
NSLog(@"test3");
}
};
static Test3 test3_1 = Test3();
Test3 test3_2 = Test3();
Test3 test3_3;
全局 C++ string 初始化
const std::string test4 = "1234";
全局变量初始化需要构造 Objective-C 类
static NSDictionary * dictObject5_1 = @{@"one":@"1"};
NSDictionary * dictObject5_2 = @{@"one":@"1", @"two":@"2"};
还有一些其他场景,这里就不再一一列举了;不能在编译期确定值的一些全局变量的初始化,可以认为都是在这个阶段执行。
如何监控 static initializer 方法
Instruments 中自带的 Static Initializer Calls 的功能;
代码监控:主要是监控 macho 文件中的 __mod_init_func 这个 section;同一个 macho 文件中 +load 的执行时机是早于 static initializer,可以在 +load 函数中,hook __mod_init_func 这个 section 中所有的函数指针到自定义函数进行统计耗时处理;注意针对于 DATA_CONST 中 __mod_init_func 这个 section 是没有写权限的,需要使用 vm_prtect 函数进行权限修复之后,才能 hook 成功。
管控治理 static initializer 方法
对于__attribute__((constructor)) 的方法,可以参考 +load 的治理手段;
其他场景就是要减少全局变量在启动阶段显示初始化,考虑使用局部变量代替全局变量;
管控手段和 +load 类似。
5.二进制重排技术
基本原理
虚拟内存和物理内存不是从初始阶段就有映射关系的,而且有一个懒加载的过程,如果 App 申请的某一个虚拟内存和物理内存没有映射,就会产生缺页中断,缺页中断会有分配物理内存,磁盘 IO 和验证签名的消耗,大量的缺页中断就会产生耗时。
启动过程中,因为代码分布的随机性比较大 (+load 和 static initializer),每一个执行代码很有可能会在不同的分页中,就会带来大量的缺页中断。
为了解决上面的问题,就需要让启动阶段执行到的代码,尽可能在二进制中分布的更加紧凑,如下图所示,原本会触发两次的 page in,由于让这些代码重新分布到同一个分页中,就只会触发一次 page in,从而减少启动过程中产生的 page in的消耗。
重排代码在二进制中的分布通过 Xcode 提供的 Order File 选项进行配置。
生成 orderfile
使用 Xcode 自带的一个 Clang 插桩工具,通过在 Build Setting 进行配置。
覆盖 C、C++、OC 方法,可以在 Other C Flags 中配置 -fsanitize-coverage=func, trace-pc-guard ;
覆盖 Swift 方法,可以在 Other Swift Flags 中配置 -sanitize-coverage=func; -sanitize=undefined ;
在 release 环境中,将 STRIP_STYLE 设置为 debugging,避免地址回溯不到函数符号;
在 __sanitizer_cov_trace_pc_guard 方法回调中,收集所有被采集到的符号并进行解析;这里可以参考开源项目 AppOrderFiles1 的实现。如果 App 代码规模比较庞大,使用这个项目的话,你会发现收集分析符号的操作耗时较高,当时测试发现,收集到 300万+的符号,需要耗时6个小时才能解析完成;分析耗时原因之后发现,主要是解析符号过程中 dladdr 调用的耗时较高;可以采用先对全部符号进行去重(剩下2万+符号),再去进行 dladdr 的符号解析的操作,这样耗时就会下降到分钟级别。
配置 ordefile
这是 Xcode 提供的能力,把上一步生成的 orderfile 文件配置到 Build Setting 中的 Order File 选项中,打开 linkmap 选项,重新编译,你会发现 linkmap 文件中开头部分的符号都是按照 ordefile 中的符号顺序进行排序,说明已经重排成功。如果你的项目存在多个动态库,除了主二进制外,每一个动态库都需要进行配置。
全流程自动化
启动阶段的执行代码每一个版本都会更新,每一个发版 App 对应的 orderfile 文件也需要不断地更新,这就需要自动化流程的接入,避免重复人力的消耗。自动化流程如下图所示:
6.其他
网上也看到一些其他 premain 阶段的优化,比如 Text 段重命名,还有减少无用类和代码;前者通过验证在工程中收益不是太大,后者从启动收益来看 ROI 较低;但是这两个手段对于包大小管控来说,更有意义一些,这篇文章就不过多详述了。
postmain 阶段启动优化
postmain 阶段的优化主要包括各个启动任务的初始化和首页业务逻辑渲染耗时,相对 premain 阶段,postmain 阶段的优化带来的启动优化收益也会较高;如果是短期内想提升 App 的启动速度,建议重点优化这个阶段。
postmain 阶段主要涉及首页业务较多,每一个 App 遇到的情况可能都不尽相同,这里主要分享下我们总结的一些最佳实践。分三步走:
第一步:
利用启动框架梳理启动任务,保证启动阶段的任务都是必要的,这也会简化后面第二步和第三步的分析过程。
第二步:
梳理业务逻辑,保证主线程和子线程合理调度;快手的启动涉及到三个网络请求,只有这三个网络请求全部返回,才能触发启动完成事件,所以异步网络提前请求也是一种优化思路,并且这个 ROI 很高。原先主线程任务和网络异步任务都是按照顺序执行的,如下图所示:
经过优化之后,启动业务执行流程如下图所示:
第三步:
细节分析,找出最后留存所有任务的耗时瓶颈,进行优化;这个阶段主要会用到两个工具,一个是通过 hook objc_msgSend 产出的线下火焰图,还有一个是 Instruments 自带的 App Launch 工具;结合使用这两个工具,可以从函数调用、线程状态等细节分析,精准定位可以优化的点。
以下是我们优化的一些案例:
单例初始化耗时,子线程优先持有单例进行初始化,这个时候主线程也要访问这个单例,造成主线程无用等待;通过简化单例初始化,具体功能懒加载解决;
全局配置中心属性太多,序列化耗时严重;通过全局统一存储改成按照 key 存储解决;
第一次调用 fishhook 带来大量的 page in 耗时;通过子线程调用相关功能解决;
加载多国语言耗时;通过针对当前 App 的运行环境,只加载一种语言资源解决;
原本懒加载的动态库在启动完成之前提前加载;通过 Assert 弹窗做做好防劣化工作,及时提醒相关业务方进行合理的代码迁移进行处理;
一些比较重量级的资源,比如图片,播放器、语言文件可以选择提前子线程预加载;
通过 xpc 通信获取系统信息,比如定位、运营商、通讯录等信息,不要在主线程执行;
简单的 lottie 动画、关键帧动画、gif 动画,使用贝塞尔曲线绘制;
首页 tab 框架支持懒加载,启动只加载落地页 VC,其他 tab 用户点击再加载;
落地页业务组件,可以选择提前加载关键组件,比如播放器组件,其他组件通过渐进式方式再去渲染。
1.启动框架
上文有提及到,最初快手的所有启动任务都是直接写在 AppDelegate 几个关键的生命周期中,造成 AppDelegate 文案代码超过 2 万行,造成这个文件维护极其困难。
启动任务归属不明确
代码耦合严重,同一个功能的初始化,代码有可能散在不同的代码行,很难确定哪一些代码属于同一个功能的初始化,每一个启动任务归属的 FT 和负责人;提交代码也很容易产生冲突。
启动任务无法合理调度
启动代码大部分都是主线程执行,无法对每一个任务进行合理调度;很多任务业务只想做一下预热操作,并不一定要在 willLaunch 或者 didLaunch 阶段去执行,在不适合的地方初始化,造成耗时积累。
无法精细化监控
如果启动耗时增加,无法统计每一个任务的耗时变化,难以线上问题归因。
为了解决第一个问题,我们首先将 AppDelegate 中所有的代码进行梳理和分类,最终将所有的代码按照独立功能分类,最终产出 100+ 启动前的任务和 70+ 启动后的任务,每一个任务找相关业务方确认所有归属的 FT 负责人、对于线程执行的要求以及任务依赖的要求,启动任务按照 FT 进行分类管理。
为了解决后面两个问题,我们需要一个比较合理的启动调度器,可以按照不同业务方的需求进行合理的调度,并且可以统计每一个任务的执行耗时,形成报表,为线下防劣化和线上归因做数据支持。刚开始为了充分利用 CPU 的多核性能,利用多线程充分调度启动任务,使用分阶段网状依赖的执行方式,但是这种方式上线后,发现中低端机型有很多主线程等待子线程任务执行完成的情况发生,造成主线程的无用等待,而且子线程的执行优先级较低,也容易触发低端机的 watchdog 问题。后来把主线程等待子线程的问题拆解出来之后,就形成了主线程和子线程顺序执行,避免以上问题的发生。
2.TTI 任务调度
上文提及了我们梳理了超过 70+ 个启动后执行的任务,如果这些任务不执行合理的调度,就会造成启动完成之后,播放视频的卡顿问题。为了监控和治理这个阶段的问题,提出了一个启动 TTI 的概念。
TTI 介绍
冷启动结束之后用户可流畅使用 App 的时长。
TTI 定义
从启动完成开始到连续 300 帧帧耗时全都小于 84ms(两个电影帧)的耗时。
TTI 优化思路
根据线下火焰图工具和 TimeProfiler 工具找出耗时的根本原因,进行优化;
所有任务通过 CADisplayLink 机制结合 runloop 调度进行打散,避免所有任务在同一个帧显示周期中执行造成用户明显可见的长卡顿现象;同时梳理任务的执行线程要求,主线程和子线程合理调度;
动态计算每次任务的执行耗时,保存下来;作为下一次任务调度的参考时间,只有当剩余帧耗时执行时间大于上一次任务的调度时间,进行任务调度,降低触发单个帧耗时周期出现大于 84ms 的几率。
t1:当前帧耗时周期中,已经执行任务的耗时; t2:当前 84ms 阈值周期中,剩余可以执行任务的耗时; 通过对比 t2 和每一个任务上一次启动过程中执行耗时,判断这一次 84ms 阈值周期中是否可以调度该任务。 |
iOS 系统会有一些机制,让 App 在后台启动做一些事情,如果这个时候用户打开 App,就会有原本的冷启动变成热启动,有一种秒开的感觉,用户体验极佳,下图是 bgfetch 技术接入到快手的效果:
BackgroundFetch 机制
iOS 7.0 新增加的可以在后台更新应用程序界面和内容的 API,系统会根据用户行为唤醒应用程序。在相关代码中设置时间间隔,在间隔时间内启动应用更新数据,有 30s 执行时间。
如何开启 BGFetch 特性?
第一步:
Targets->Capabilities->Background Modes,勾选 Background fetch。
第二步:
AppDelegate 中的 didFinishLaunchingWithOptions: 方法中调用 setMinimumBackgroundFetchInterval: 方法。
第三步:
在应用程序中实现代理方法 application:performFetchWithCompletionHandler:在该代理方法中请求相关数据。
如何调试?
冷启动直接调试
在 Edit Scheme ->Run ->Options ->勾选Launch due to a background fetch event。
热启动后调试
从 Xcode 运行应用,当应用运行时,在 Xcode 的 Debug 菜单选择 Simulate Background Fetch。
prewarm 机制
iOS 15 新出的一个特性,系统可能会根据设备情况,预热应用程序:启动非运行的应用程序进程,以减少用户在应用程序可用之前的等待时间。预热 App 启动直到调用 UIApplicationMain 函数调用2。
以上两种方式和正常的冷启动的执行流程如下图对比:
除了以上两种可以触发后台启动之外,还有一些手段,比如 healthkit、hotspot 等方式,大家也可以探索一下。
在做启动优化的同时,防劣化的工作也非常重要,主要包括线下防劣化,阻止新的劣化问题带到线上;还包括线上问题归因,如果线上出现启动报警,能够准确归因,为线下优化提供思路。
线下防劣化
线下防劣化贯穿整个研发周期,如下图所示:
1. pipeline 静态扫描
在合码阶段,通过对比 MR 的 diff 信息,找到是否有相关启动任务的改动;包括 +load 方法,static initializer 方法,启动框架注册添加相关代码,AppDelegate 关键生命周期方法;如果出现以上关键字,将会堵塞 MR 流程,直到审核通过才能进行后续流程。
2.启动实验室定时任务
每 6 个小时,触发一次定时任务,会把当前最新的 dev 分支和 6 个小时之前的 dev 分支,打包,然后运行 30 次自动化任务;分析这 30 次自动化任务的数据,通过对比 +load 方法、static initializer 方法、启动注册任务、启动核心分阶段数据、函数耗时火焰图 trace 分析等手段计算这 6 个小时之内是否有劣化产生。
3.周版本对比数据
每一个 dev 分支封板的时候,会触发一次该自动化任务;该任务会通过对比本次封板和上周的封板数据,产出是否有版本劣化的问题;测试也会根据这个数据,产出准出报告。
4.灰度数据
目前苹果对于灰度限制比较严格,灰度数据较少,可以作为上线之前的参考信息。
线上问题定位
线上问题定位,主要是通过线上各个上报埋点,在数据平台上形成可视化的报表进行分析;在不同的阶段,有不同的监控方式,如下图所示:
1. premain 阶段
主要监控每一个 +load 和 static initializer 方法的耗时,因为这两个监控有额外耗时,线上采样开启。
2. willLaunch 和 didLaunch 阶段
其中所有的问题都是通过启动任务注册和执行,每一个任务的耗时也会上报上来形成报表。
3. coverShow 阶段
主要包括 didlaunch 结束到首帧展示之前,通过拆分主业务流程为多个分阶段,每一个分阶段的耗时和任务细节通过埋点进行上报,从而监控这个阶段的线上数据情况。
4. afterLaunch 阶段
因为这个阶段变化较大,除了注册到 TTI 调度框架的任务会上报耗时之外,也做了一个短卡顿监控,对于调度框架之外的劣化也能够进行有效的监控。
本文从遇到的问题出发,介绍了 App 启动的不同阶段对应的优化手段,后台启动的技术点,以及性能防劣化的技术建设,通过这一攻一防保住优化成果。
目前拿到了阶段性成果,后续还有一些方向可以继续探索优化:
1、线下防劣化数据分析和线上报警归因可以做到问题自动归因,减少人力分析带来的成本;
2、后台启动和保活能力可以继续探索,增加 App 冷启变热启的比例;
3、沉淀劣化问题知识库,在开发阶段就可以给开发者进行提醒;
4、函数耗时火焰图支持 C、C++、Swift,探索在线上开启火焰图的能力。
欢迎加入
快手主站技术部客户端团队由业界资深的移动端技术专家组成,通过领先的移动技术深耕工程架构、研发工具、动态化、数据治理等多个垂直领域,积极探索创新技术,为亿万用户打造极致体验。团队自2011年成立以来全面赋能快手生态,已经建立起业内领先的大前端技术体系,支撑快手在国内外的亿万用户。
在这里你可以获得:
提升架构设计能力和代码质量
通过大数据解决用户痛点的能力
持续优化业务架构、挑战高效研发效能
和行业大牛并肩作战
我们期待你的加入!请发简历到:
app-eng-hr@kuaishou.com