脉脉iOS如何启动秒开
共 12344字,需浏览 25分钟
·
2021-08-13 16:25
李扬,脉脉客户端高级开发工程师。2018年加入脉脉,目前作为脉脉平台组iOS开发,负责移动端平台开发,平台类基础设施建设、维护、性能调优和新技术探索。
https://www.zhihu.com/column/p/396550853
前言
启动是 App 给用户的第一印象,启动越慢,用户流失的概率就越高,良好的启动速度是用户体验不可缺少的一环。
通过调研业内现有的启动优化方案,针对启动各个阶段,结合脉脉自身app的情况,总结出了具体的可行性建议和可优化的项目。
加上后期不断的调优和实践,最终在app启动过程涉及到现有复杂业务环境下,实现了900ms的秒开成绩。
防劣化,建立健全app启动监控体系。通过监控大盘,及时发现问题解决问题并总结经验 。
二、认识 App是如何启动的
启动过程
启动过程以main为界限,分为pre-main和main之后两部分
pre-main
加载dyld
动态库载入过程,会去装载app使用的动态库。而每一个动态库有它自己的依赖关系,会消耗时间去查找和读取。
rebase&binding
rebase:主要是调整镜像内部的指针,这里使用了ASLR(Address Space Layout Randomization 地址空间布局随机化)。程序每次启动后地址都会随机变化,这样程序里的所有代码地址都需要重新进行计算修复
binding:修复指向外部的指针。比如app中调用了NSLog函数打印信息,NSLog是系统函数,在程序开始运行的时候app是不知道NSLog函数指针是多少,此时就需要通过dyld_stub_binder技术找到NSLog指针地址进行调用。
Objc setup
runtime在此处初始化,对class和category进行注册,selector唯一性判断
load&constructor&initialize
调用所有类的load的方法,初始化C&C++的静态化变量,然后调用 constructor 函数
main之后
main函数
创建整个app的autoreleasepool,初始化初始window,app界面开始展示
LifeCyle
指定rootviewcontroller,调用业务代码,完成各阶段业务
First Frame
main页面viewDidAppear 完成页面第一帧渲染。至此启动完成。
三、衡量 App启动时间
打点系统监控
上图具体指明了目前能够做到的打点的地方,可以简化为下图形式:
进程创建
通过 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
{
return 0;
}
}
最早的 +load
和上面的分阶段监控一样,通过 AAA 为前缀命名 Pod,让 +load 第一个被执行
didFinishLaunching
此处监控可以使用第三方SDK,也可以手动加入打点来衡量
另外,对于pre-main阶段,Apple提供了一种测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量 DYLD_PRINT_STATISTICS 设为1 。之后控制台会输出类似内容,我们可以清晰的看到每个耗时:
如果将 Edit scheme -> Run > Auguments 将环境变量 DYLD_PRINT_STATISTICS_DETAILS 设为1,则可以更多详细的pre-main阶段的耗时:
工具
TimeProfiler
Time Profiler是Xcode自带的时间性能分析工具,正常Time Profiler会1ms采样一次,默认只采集所有在运行线程的调用栈,最后以统计学的方式汇总。通过统计比较时间间隔之间的堆栈状态,来推算某个方法执行了多久,并获得一个近似值。Time Profiler的使用方法网上有很多使用教程,这里我们也不过多介绍,附上一篇使用文档:Instruments Tutorial with Swift: Getting Started。
取从开始启动后的2s内的时间为启动样本
通过展开 Main Thread 经过分析发现,耗时方法在一个内存泄露检测模块,修改之后如下图:
当然其他线程也有在做并发的处理,也要注意线程个数的控制
System Trace
System Trace一直作为Instruments中一个默默无闻的功能出现,模板提供了系统行为的全面信息。它显示线程的调度、系统线程的转化和内存使用情况。这个模板可以使用在OS X或iOS中。简单点说就是记录一个App运行过程中所有底层系统线程、内存的调度使用过程的工具。
脉脉iOS分析案例
现象
脉脉iOS在蜂窝网络数据状态下Debug,每次启动,相比WiFi状态下的启动时间都要长了2s左右
诊断过程
首先选中 System Load 选取时间线,到第一次出现 脉脉 线程的点,从这个时间点开始脉脉app正式启动,开始处理app内部逻辑。也就是main之后的阶段。
统计活跃的高优线程数量和CPU核心数对比,如果高于核心数量会显示成黄色,小于等于核心数量会是绿色。这个工具是用来帮助调试线程的优先级的。
系统维护了 5 个不同的线程优先级/QoS: background,utility,default,user-initiated,user-interactive。
切换到主线程 Main Thread 看到一大块block的灰色状态,选取发现2.01s的卡顿,难道这是巧合?大胆猜测,这就是我们要找的卡顿2s
不要移动时间线,切换到 Events:Thread States,可以看到线程切换的每一个事件,和状态切换的发生的原因,观察这个事件的下一个事件,因为下个事件通常是锁被释放,线程重新进入可执行的状态。发现卡顿的下一步执行时 CPU0 上的 0x90998 的线程,换句话说是 0x90998 的线程使得主线程结束卡顿重新进入了可执行的状态。
切换到 0x90998 线程,选中主线程block状态释放开始执行的时间点,可以看到 parked waiting for new woirk for dispatch,在往上追溯基本都是这句话的重复调用,可以大胆猜测基本上是在等待一个信号量。
再次来到 Main Thread Block位置,双击右侧的堆栈栈顶那句话,可以看到具体的代码位置。
的确是在等待一个信号量,并且是 RCT_DEV 环境下才会生效,线上不受影响。通过Debug发现,这里请求的URL是 http://localhost:8081。
如果我们把这段代码注释,WiFi和5G流量下,启动速度相差无几。之前的猜测卡主2s是在等待信号的想法得到印证。
这也说明,有时就是要 敢猜敢想敢做!
通过SystemTrace工具 Debug 蜂窝数据流量环境下应用启动会卡主2s的问题终于查明!
手动下点分析
通过插桩代码,我们发现使用 OpenUDID 获取udid的时候耗时有时竟然达到400ms。分析后发现,读取 UIPasteboard 非常耗时,根据调研 UIPasteboard 使用场景在脉脉中基本可以忽略,故考虑去掉 UIPasteboard 读取逻辑。
但在 Time Profiler 里检测,OpenUDID 获取udid在所在的子线程,只消耗了 7ms。依靠 Time Profiler分析也有一定的局限性。
四、制定 App启动优化方案
整体思路
删掉启动项,把不需要的过时的直接删除
如果不能删除,尝试延迟,延迟包括第一次访问以及启动结束后找个合适的时间加载
不能延迟的可以尝试并发,利用好多核多线程。但也要注意控制好线程的数量和优先级
如果并发也不行,可以尝试让代码执行更快。比如,频繁访问的可以只获取一次就存下来
pre-main
动态库
防止劣化,需要严格管控动态库的引入
减少动态库
Apple官方建议尽量少的使用自定义的动态库,或者考虑合并多个动态库,其中一个建议是当大于6个的时候,则需要考虑合并它们。
自有动态库转静态库,或者合并动态库
源码形式的是可以通过CocoaPods命令转静态库的,如下
# CocoaPods 打包静态库 命令
# 其中 –library 指定打包成.a文件,如果不带上将会打包成.framework文件。–force 是指强制覆盖。
pod package xxxx.podspec --force
CocoaPods不使用 use_frameworks! 字段,全部引入静态库
rebase&binding 和 Objc setup:
减少代码量
1、基于Mach-O文件分析
objcselrefs 和 objcclassrefs 存储了所有引用到的 sel方法签名 和 class
__objc_classlist 存储了所已有的 sel 和 class
二者做个差集就知道哪些类和哪些类的 sel 用不到,但objc 支持运行时调用,删除之前还要在二次确认
2、通过打点SDK,收集代码使用数据情况,再决定要不要删除某些代码
脉脉会定期通过数据库脚本统计出日活小于10uv的页面vc和相关的view,和相关的业务线确认后会进行删除,以确保代码的有效性和简洁性。
3、通过脉脉自研的解耦合分析工具(后面会考虑开源,可以关注作者github: Andy.Li)
大致原理:数学集合运算
假如只有两个类a和类b,分析出类a提供的方法列表记为集合A,再分析出类b使用的类a的方法列表记为集合B。
集合A和集合B做差集C,集合C就类a中不再被类b使用的方法集合
此时就可以根据集合C从类a中删除对应的方法。
如果类a的所有方法都没有被类b使用,也就是类a完全不被类b使用,则可以直接删除类a。
类比到到项目中所有的类,做遍历递归差集,就可以得到全部的未被使用的类和方法,考虑删除之。
重新排列函数符号位置,降低MACH-O文件载入内存时PageFault缺页中断频率 - 二进制重排
原理
二进制重排实际上是在windows和linux上就存在的技术,旨在将启动用到的函数方法尽可能的放置在二进制文件加载的前面,并且是将函数符号地址连续的编译在一起,以减少Page Fault的次数和频率,加快启动速度。现在这项技术已经移植运用到了移动端app上。
如何理解PageFault缺页中断
操作系统为了解决安全问题和效率问题,抽象出了虚拟内存页的概念。内存都是分页访问的。这里的page指的就是内存页。(就像磁盘存储的最小单位 磁盘簇,大小是4k一样)
MacOS 、linux (4K为一页)
iOS(16K为一页)
PageFault就是缺页中断:当app调用一个方法,发现该方法没有在内存中,此时操作系统就会立刻阻塞整个app进程,触发一个缺页中断。操作系统会从磁盘中读取这页数据到物理内存上 , 然后再将其映射到虚拟内存上 ( 如果当前内存已满 , 操作系统会通过置换页算法 找一页数据进行覆盖,这也是为什么开再多的应用也不会崩掉 , 但是之前开的应用再打开时 , 就重新启动了的根本原因 )。
假如,app启动时期需要调用 method1、method5和method6,这三个方法分布在page1、page2和page3上。每装载一个内存页page都会发生一次PageFault(缺页终端)。通常一个PageFault的处理时间是0.1ms~1ms,取0.5ms计算。这三次处理PageFault时间是 3 * 0.5ms = 1.5ms。
二进制重排后
method1、method5和method6全都集中在了page1,这样只需装载page1就可以了。相比之前少了page2和page3的装载。少了两次处理PageFault时间。这次消耗的时间是 1 * 0.5ms = 0.5ms。节省了1ms
iOS App之所以能够使用二进制重排,是因为Xcode 已经提供好这个机制 , 并且 libobjc 实际上也是用了二进制重排进行优化 .
获取启动加载所有的函数的符号
只有准确获取了app启动所用到的函数方法,对其进行重新排列,才能做到启动加速,那么如何获取这些函数符号呢?
Hook
oc 或者 swift @objc dynamic 修饰的方法,调用都会通过 objc_MsgSend 发送消息,hook objc_MsgSend 可以做到这个方法的检测。但如果是可变参数个数,则需要汇编来获取参数
二进制静态扫描
Mach-O文件在特定段Segment和Section里存储着符号及函数数据,通过静态扫描Mach-O文件,主要是分析获取load方法和c++ constructor 构造方法。
clang 汇编插桩
clang 本身已经提供了一个代码覆盖率检测机制(SanitizerCoverage),来实现我们获取所有符号的需求
前两种都或多或少存在一些问题,并不是完美的状态,网上的资料有很多,可以自行查阅。接下来主要是通过clang 插桩的方式来hook所有的函数符号
clang插桩
Xcode如何配置
在目标工程 Target -> Build Settings -> Other C Flags 添加 -fsanitize-coverage=func, trace-pc-guard。
如果有swfit代码,也要在 Other Swift Flags 添加 -sanitize-coverage=func 和 __-sanitize=undefined__
(如果有源码编译的Framework也要添加这些配置。CocoaPods引入的第三方库不建议添加上述配置)
添加hook代码
LLVM内置了一个简单的代码覆盖率检测(SanitizerCoverage)。它在函数级、基本块级和边缘级插入对用户定义函数的调用,并提供了这些回调的默认实现。在认为启动结束的位置添加代码,就能够拿到启动到指定位置调用到的所有函数符号。
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_t N; // Counter for the guards.
if (start == stop || *start) return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N; // Guards should start from 1.
}
//原子队列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入队
// offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
运行工程,通过Hopper反编译工具可以看到在函数内部一开始就添加了 额外方法的汇编代码,这样就做到了 静态插桩
脉脉自研二进制重排预分析工具(已开源)
此工具详细介绍了如何生成 linked_map.txt 和 lb.order文件,以及如何预验证重排效果。
目的
在没有上线之前可以分析出对App启动优化节省的大致时间,起到指导作用。(具体能优化多少,以线上数据为准)
建议
因为工程的代码随着开发的进行会不断的改变位置或者删减,之前已经排好的顺序有些会失效。
每隔三个月执行一次二进制重排更新,确保 PageFault 次数维持在一个较低的稳定的水平。
输出示例
---> 分析结果:
linked map __Text(链接文件):
起始地址:0x100006A60
结束地址:0x1021E75E8
分配的虚拟内存页个数:2169
order symbol(重排文件):
需要重排的符号个数:4630
分布的虚拟内存页个数:392
二进制重排后分布的虚拟内存页个数:99
内存缺页中断减少的个数:293
预估节省的时间:146ms
如何反向验证
用二进制重排之后的工程,再次分别编译出 linked_map.txt 和 lb.order 文件,使用此工具再次运行检查。可以得到如下结果
---> 分析结果:
linked map __Text(链接文件):
起始地址:0x100006A60
结束地址:0x1021E75E8
分配的虚拟内存页个数:2169
order symbol(重排文件):
需要重排的符号个数:4630
分布的虚拟内存页个数:99
二进制重排后分布的虚拟内存页个数:99
内存缺页中断减少的个数:0
预估节省的时间:0ms
可以看出重排后的二进制文件已经不需要再次进行重排了。至此,二进制重排线下预评估结束。
工具开源地址:https://github.com/lyandy/Linked_Order_Analyze
load&constructor&initialize
+load 尽量不要使用
在 pre-main 时期,objc 会向 dyld 注册一个 init 回调,当 dyld 将要执行载入 image 的 initializers 流程时 (依赖的所有 image 已走完 initializers 流程时),init 回调被触发,在这个回调中,objc 会按照父类-子类-分类顺序调用 +load 方法。因为 +load 方法执行地足够早,并且只执行一次,所以我们通常会在这个方法中进行 method swizzling 或者自注册操作。也正是因为 +load 方法调用时间点的特殊性,导致此方法的耗时监测较为困难,而如何使监测代码先于 +load 方法执行成为解决此问题的关键点。
脉脉自研了一套 hook 监测 +load 执行时间方案,并结合 CocoaPods 实现了一行代码集成耗时监测的功能。(后续会开源)
__attribute__((constructor)) 尽量不要使用
如果函数被设定为 constructor 属性,则该函数会在 main 函数执行之前被自动的执行。执行时间太长,会大大增加启动时间。
C/C++静态化变量迁移
1、std:string 转换成 const char *
2、静态变量移动到方法内部
因为方法内部的静态变量会在方法第一次调用的时候初始化
main 之后
启动器
启动是需要一个框架来管控的,脉脉采用的是流控制方案。
为什么需要启动器呢?
全局并发调度
比如 AB 任务并发,C 任务等待 AB 执行完毕,框架调度还能减少线程数量和控制优先级
延迟执行
提供一些时机,业务可以做预热性质的初始化
精细化监控
所有任务的耗时都能监控到,线下自动化监控也能受益
管控
启动任务的顺序调整,新增/删除都能通过 Code Review 管控
脉脉自研的流控制器方案流程
脉脉的启动流程大致分为三个阶段
流控制器
从Task1到Task8分为两个Flow Category,基础支撑Category和业务流Category。这些业务流flow中有广告、新手引导、资料补全引导、业务拉新等众多逻辑。解耦合和精细化管控每个Task所执行的代码,启动流程得以规范化治理,启动速度也大大加快。
主容器加载
包含了底部5个tab容器的加载,其中首页tab会被优先加载,其他4个懒加载。
RN首页首帧渲染
通常首页容器加载完毕,就认为是启动结束的点,如上图虚线的位置。但脉脉的启动结束点是首页RN Feed流第一帧渲染结束的点。
优化方式
三方SDK
有些三方 SDK 的启动耗时很高,将第三方SDK延后或并发。有些SDK已经分布在了不同的flow里并发,但flow内部还可以做并发,但要注意线程数量的控制
高频次方法
有些方法的单个耗时不高,但是在启动路径上会调用很多次的,这种累计起来的耗时也不低,比如读 Info.plist 里面的配置:
+ (NSString *)plistChannel
{
return [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CHANNEL_NAME"];
}
锁
线程间一些信号量等待锁,有可能会长时间卡主启动流程的方法,需要移除或者换种方式去做。
线程数量
线程的数量和优先级都会影响启动时间。在介绍 System Trace 工具的环节,有讲到高优先级线程和CPU数量的关系。瞬时开启过多的线程,占用了太多的内存和CPU,反而会拖慢启动速度
图片
启动难免会用到很多图,有没有办法优化图片加载的耗时呢?
用 Asset 管理图片而不是直接放在 bundle 里。Asset 会在编译期做优化,让加载的时候更快。
此外在 Asset 中加载图片是要比 Bundle 快的,因为 UIImage imageNamed 要遍历 Bundle 才能找到图。
加载 Asset 中图的耗时主要在在第一次张图,因为要建立索引,可以通过把启动的图放到一个小的 Asset 里来减少这部分耗时。每次创建 UIImage 都需要 IO,在首帧渲染的时候会解码。所以可以通过提前子线程预加载(创建 UIImage)来优化这部分耗时。
Fishhook
fishhook 是一个用来 hook C 函数的库,但这个库的第一次调用耗时很高,最好不要带到线上。fishhook 是遍历 Mach-O 的多个段来找函数指针和函数符号名的映射关系,带来的副作用就是要大量的 Page In,对于大型 App 来说在 iPhone X 冷启耗时 200ms+。
如果不得不用 fishhook,请在子线程调用,且不要在在 dyldregister_func_for_add_image 直接调用 fishhook。因为这个方法会持有 dyld 的一个全局互斥锁,主线程在启动的时候系统库经常会调用 dlsym 和 dlopen。其内部也需要这个锁,造成上文提到的子线程阻塞主线程。
首帧渲染
不同 App 的业务形态不同,优化方式也相差的比较多,几个常见的优化点:
LottieView
lottie 是 airbnb 用来做 AE 动画的库,但是加载动画的 json 和读图是比较慢的,可以先显示一帧静态图,启动结束后再开始动画,或者子线程预先把图和 json 设置到 lottie cache 里
Lazy 初始化 View
不要先创建设置成 hidden,这是很不好的习惯
AutoLayout
AutoLayout 的耗时也是比较高的,但这块往往历史包袱比较重,可以评估 ROI 看看要不要改成 frame
Loading 动画
App 一般都会有个 loading 动画表示加载中,这个动画最好不要用 gif,线下测量一个 60 帧的 gif 加载耗时接近 70ms
五、验证 脉脉App启动优化效果
pre-main
二进制重排效果
应用启动90分位耗时降低 600ms,且非常稳定
未重排的版本:5.3.64、5.3.66
已重排的版本:5.3.70、5.3.74
main 之后
启动耗时再次降低 500ms,实现了秒开
优化前的版本:6.0.62、6.0.64
优化后的版本:6.0.70、6.0.72
总体启动耗时native部分的优化,在2021年6月10号 6.0.70 版本上线后,由之前的600ms降到了270ms,降了接近300ms多。
同时通过统计从feed vc didappear到RN首帧渲染也降低了200ms左右。
从Xcode自带的统计工具Organizer也可以看出,6.0.70版本启动时间90分位为900ms, 实现秒开
六、补充 非常规优化手段
+load 方法迁移
+load 除了方法本身的耗时,还会引起大量 PageFault,
另外 +load 的存在对 App 稳定性也是冲击,因为 Crash 了捕获不到。
举个例子,很多 容器需要把协议绑定到类,所以需要在启动的早期(+load)里注册
+ (void)load
{
[ProtocolClass registerClass:IMPClass forProtocol:@protocol(myProcotol)]
}
本质上只要知道协议和类的对应关系即可,利用 clang attribute,这个过程可以迁移到编译期, 在Mach-O文件的末尾再添加一个Section段,Mach-O装载进内存加载Section段的时候,再去做Class和Protocol的对应关系。这种方式已经运用到脉脉自动化解耦合工具里。
typedef struct{
const char * cls;
const char * protocol;
}_mm_pair;
#if DEBUG
#define MM_SERVICE(PROTOCOL_NAME,CLASS_NAME)\
__used static Class<PROTOCOL_NAME> _MM_VALID_METHOD(void){\
return [CLASS_NAME class];\
}\
__attribute((used, section(_MM_SEGMENT "," _MM_SECTION ))) static _mm_pair _MM_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#else
__attribute((used, section(_MM_SEGMENT "," _MM_SECTION ))) static _mm_pair _MM_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#endif
当时脉脉iOS做解耦合的时候采用的,添加 ProtocolSect Section Data段
__Text段 重命名迁移
App Store 会对上传的 App 的 TEXT 段加密,在发生 PageFault 的时候会解密,解密的过程是很耗时的。
既然会 TEXT 段加密,那么直接的思路就是把 TEXT 段中的内容移动到其它段,ld 也有个参数 rename_section 支持重命名。
不建议使用此种方式优化,原因如下:
1、__TEXT 段迁移最难解决的问题是ld链接失败问题,是由 CPU 对寻址范围的限制以及 ld64 链接器的缺陷导致。
2、被迁移的__TEXT 段段,无法配合dSYM文件做符号化
PGO优化启动时间
PGO是苹果官方提供的工具,具体使用方法是点击xcode工具栏
Product -> Perform Action -> Generate Optimization Profile 按xcode提示操作即可
不建议使用此种方式优化,原因如下:
1、如果项目中有 swift 代码,那么这种方式就不能用了,因为 swift 不支持 PGO。
2、代码发生变更,Xcode 会提示 profdata file out of date,需要每个版本或者每隔一段时间重新生成