本文作者:Lazyyuuuuu
一. 背景
App 启动作为用户应用利用的第一个体验点,间接决定着用户对 App 的第一印象。云音乐作为一个有着近 10 年倒退历史的 App,随着各种业务不停的倒退和简单场景的重叠,不同的业务和需要不停地往启动链路上减少代码,这给 App 的启动性能带来了极大的挑战。而随着云音乐用户基数的不断扩大和深度应用,越来越多的用户反馈启动速度慢,况且启动速度过慢更甚至会升高用户的留存志愿。因而,云音乐 iOS App 急须要进行一个专项针对启动性能进行优化。
二. 剖析
2.1 启动的定义
大家都晓得在 iOS13 之后,苹果全面将 dyld3 代替之前的 dyld21,并且在 dyld3 中减少了启动闭包的概念,在下载 / 更新 App、零碎更新或者重启手机后的第一次启动 App 时创立。所以 iOS13 前后对冷启动的概念会有所区别。
iOS13 之前:
- 冷启动:App 点击启动前,零碎中不存在 App 的过程,用户点击 App,零碎给 App 创立过程启动;
-
热启动:App 在冷启动后用户将 App 退回后盾,App 过程还在零碎中,用户点击 App 从新返回 App 的过程;
iOS13 及之后:
- 冷启动:重启手机零碎后,零碎中没有任何 App 过程的缓存信息,用户点击 App,零碎给 App 创立过程启动;
- 热启动:用户把 App 过程杀死,零碎中存在 App 过程的缓存信息,用户点击 App,零碎给 App 创立过程启动;
- 回前台:App 在启动后用户将 App 退回后盾,App 过程还在零碎中,用户点击 App 从新返回 App 的过程;
在云音乐 App 启动治理过程中始终以 iOS13 之后的冷启动为对齐规范,不论是以用户视角测量的启动工夫还是用 Instrument 中 App Launch 测量的启动工夫都是在手机重启后进行的。
2.2 冷启动的定义
一般而言,大家把 iOS 冷启动的过程定义为:从用户点击 App 图标到启动图齐全隐没后的第一帧渲染实现。整个过程能够分为两个阶段:
- T1 阶段:main() 函数之前,包含零碎创立 App 过程,加载 MachO 文件到内存,创立启动闭包,再到 dyld 解决一系列的加载、符号绑定、初始化等工作,最初跳转到执行 main() 之前。
-
T2 阶段:跳转到 main() 函数之后,开始执行 App 中 UI 场景的创立以及 Delegate 相干生命周期办法,到实现首屏渲染的第一帧。
整体流程如下图所示:
本文如波及到工夫相干个别是以零碎为 14.3 的 iPhone 8 Plus 作为基准测试设施,并且在 Debug 模式下。
2.3 冷启动的过程
从冷启动的定义后咱们能够把整个冷启动的过程分为 T1 和 T2 两个过程,iOS 零碎在两个过程中别离会在不同的节点进行相应的解决和代码的调用,后续能够针对这两个过程别离进行治理优化。
T1 阶段启动过程如下图所示:
从上图所示的流程中,咱们能够看到在 T1 阶段更多的是零碎在为运行 App 做一些初始化的工作,所以咱们能做的就是尽量减少对系统初始化工作的影响。从整个流程看来,启动闭包之后的动静库加载、rebase&bind、Objc Init、+load、static initializer 这几个节点咱们是能够做一些针对性的治理和优化工作的。
T2 阶段启动过程如下图所示:
从上图所示的流程中,咱们能够看到在 T2 阶段曾经根本是属于业务方的代码了,在这个阶段中往往咱们会把 Crash 相干、APP 配置信息、AB 数据、定位、埋点、网络初始化、容器预热以及二三方 SDK 初始化等一股脑的塞在外面,而针对这个阶段优化的 ROI 也是绝对比拟高的。
2.4 云音乐的现状
云音乐作为一个从 2013 年开始推出的 App 有着近 10 年的业务倒退和代码重叠,在此期间对启动性能的关注和治理也比拟无限,再加上云音乐除了听歌业务以外还有直播、K 歌等业务集成,所以总体来说整个启动链路上的代码是比较复杂的。甚至因为云音乐本身开屏广告业务的特殊性,在笔者开始着手启动优化专项后发现云音乐的启动红屏由个别 App 的启动开屏页和假红屏两局部组成,整个启动流程如下图所示:
2.4.1 T1 阶段各状况剖析
动静库
从 WWDC20222咱们也晓得一个 App 中动静库的数量是会影响整个 T1 阶段的耗时的,因而咱们一是须要晓得目前动静库对整个 T1 阶段耗时的影响,二是须要晓得有哪些动静库造成了影响并且是能够优化的。通过 Xcode 提供的环境变量 DYLD_PRINT_STATISTICS
咱们能够大抵的晓得所有动静库在 T1 阶段的耗时,如下图所示:
从 Xcode 输入的后果能够看到,动静库加载的耗时占整个 pre-main 的比例还挺高的。这个时候我通过解压云音乐线上 IPA 包发现 Frameworks 目录下动静库的数量有 16 个之多。
+load 办法
iOS 开发人员对 +load 办法应该曾经很相熟了,因为 +load 办法提供了一个比拟早的机会可能让咱们前置去执行一些根底配置的代码、注册类代码或者办法替换等代码。也正是因为这个起因,咱们在不停的业务迭代中发现大家想要找一个早一点的机会就会想到去用 +load 办法,导致我的项目中 +load 过多,重大影响启动性能,云音乐工程也有这样的问题,上面咱们来看下对 +load 办法应用状况的剖析。
咱们晓得对于实现了 +load 办法的类和分类会在编译时被写入到 MachO 中 __DATA
段的 __objc_nlclslist
和__objc_nlcatlist
两个 section 中。因而,咱们能够通过 getsectbynamefromheader
办法把定义了 +load 的所有的类和分类捞取进去,如下图所示:
<img src=”https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/15973148132/0272/54f6/0d23/032c4b2c3a67bb09656c3c46d91dfa31.png”>
当然当咱们晓得了所有定义了 +load 的类和分类当前,更想晓得这些 +load 的耗时状况,这样好不便咱们优先优化耗时高的那局部 +load 办法。咱们想到的是 Hook +load 办法,而要可能 Hook 所有的 +load 办法必定是须要在最早的机会去 Hook,那么实现一个动静库,并且在动静库的 +load 中去 Hook 是最好的机会了,同时也要保障这个动静库是最先加载的动静库,如下图所示:
因为云音乐工程曾经用 Cocoapods 来实现组件化,所以只须要创立以 AAA 名称结尾的仓库就能够了,如 AAAHookLoad,并且在 Podfile 中引入对应的仓库,就能实现动静库最先加载,这里能够参照开源库 A4LoadMeasure3。如果还是单工程则取什么名称都能够,只须要在工程设置 Build Phases=>Link Binary With Libraries
中把对应的库移到第一个地位就能够,如下图所示:
通过 Hook +load 办法后,咱们发现在云音乐工程中竟有靠近 800 处调用,并且整个耗时达到了 550ms+ 的级别,可见 +load 办法的乱用对整个启动性能的影响有多大。
static initializer
对于同一个二进制文件来说执行完 +load 办法就会进入 static initializer 阶段,一般来说一个以 OC 为主开发语言的 App 绝对比拟少的会去用到 static initializer 的代码,但也不排除有些底层库会用到。以下几种代码类型会导致动态初始化:
-
C/C++ 构造函数
__attribute__((constructor))
,如:__attribute__((constructor)) static void test() {NSLog(@"test"); }
-
非根本类型的 C++ 动态全局变量,如:
class Test1 {static const std::string testStr1;}; const std::string testStr2 = "test"; static Test1 test1;
-
须要运行时进行初始化的全局变量,如:
bool test2 () {NSLog(@"is a test func"); return false; } bool g_testFlag = test2();
其实咱们能够看到,不能在编译期间确定值的全局变量的初始化都能够认为是在这个阶段执行的。
对于 static initializer 的剖析来说,MachO 中__DATA
段的__mod_init_func
这个 section 中存储着初始化相干的函数地址。跟 +load 一样,咱们只须要 Hook 掉对应的函数指针就能获取到对应函数的耗时。在云音乐工程中 static initializer 相干函数比拟少,且耗时也不显著,这块就没有重点去关注。Page In 的影响
当用户点击 App 启动的时候,零碎会创立过程并为过程申请一块虚拟内存,虚拟内存和物理内存是须要映射的。当过程须要拜访的一块虚拟内存页还没有映射对应的物理内存页时,就会触发一次缺页中断 Page In。这个过程中会产生 I/O 操作,将磁盘中的数据读入到物理内存页中。如果读入的是 Text 段的页,还须要解密,并且零碎还会对解密后的页进行签名验证。所以,如果在启动过程中频繁的产生 Page In 的话,Page In 引起的 I/O 操作以及解密验证操作等的耗时也是影响很大的。须要留神的是,iOS13 及当前苹果对这个过程进行了优化,Page In 的时候不再须要解密了。
Page In 的具体情况咱们能够通过 Instruments 中的 System Trace 工具来剖析,其中找到 Main Thread 过程,再抉择 Summary:Virtual Memory 选项,上面看到的 File Backed Page In 就是对应的缺页中断数据了,从数据上看 Page In 对云音乐的影响并非瓶颈,如下图所示:
2.4.2 T2 阶段状况剖析
T2 阶段次要是 Main 之后的办法执行,要剖析这个阶段能够用到两个工具,一个是 Hook objc_msgSend 函数后输入对应的火焰图,另一个是利用苹果提供的 Instruments 中的 App Launch 工具剖析整个启动流程。通过这两个工具咱们能够从工夫线、办法调用堆栈、不同线程的执行状态等各个细节点动手找到须要优化的点。
火焰图(Flame Graph)是由 Linux 性能优化大师 Brendan Gregg 创造的,和所有其余的 profiling 办法不同的是,火焰图以一个全局的视线来对待工夫散布,它从顶部往底部,列出所有可能导致性能瓶颈的调用栈。
Hook objc_msgSend 生成火焰图
咱们晓得 OC 是一种动静语言,所有运行时的 OC 办法都会通过 objc_msgSend 来实现执行,objc_msgSend 会依据传入的对象和对应办法的 selector 去查找对应的函数指针执行。所以,咱们只有通过 Hook 掉 objc_msgSend,并且在原办法前后退出耗时统计代码再执行原办法就能失去对应的办法名以及耗时。个别想到要 Hook objc_msgSend 就会想到是 fishhook,因为 objc_msgSend 应用汇编实现的,所以用 fishhook 去 hook 的话还要解决寄存器的数据现场。其实通过 HookZz4 这个库也能够 hook objc_msgSend 并且比 fishhook 更不便。
这里咱们通过开源库 appletrace5 来实现对 objc_msgSend 办法性能的剖析以及火焰图的生成,款式如下图所示:
Instruments 中 App Launch 工具剖析
通过剖析生成的火焰图数据与理论 Debug 调试发现火焰图上对应办法的耗时也不是特地准确,会有肯定的误差,然而绝对占比还是可能反映出相应办法在整个 T2 阶段的影响的。同时,火焰图只能看到整个启动链路的工夫线以及办法调用栈,线程间的状态还是不够直观,也不足 C/C++ 相干办法性能的检测,并且火焰图对每个具体阶段的形容也是不足的。这个时候就须要用到 Instruments 的 App Launch 工具再来剖析一遍。
Xcode 自带 Instruments 一系列的剖析工具,而 App Launch 剖析后会把整个启动链路的各个阶段具体展现,通过对各个阶段区间的划分能够很不便的找到每个阶段主线程的性能瓶颈以及多线程的状态,如下图所示:
2.4.3 广告业务现状
在下面提到云音乐存在假红屏的景象,而这个假红屏就是由广告业务产生。在征询了广告业务相干同学后得悉,云音乐这边的广告业务是去实时申请后实时展现的,所以在申请之前展现假红屏页面,直到期待接口数据返回后假红屏隐没,后续展现广告或者进入首页。进一步理解后晓得,实时申请是因为广告业务须要去内部广告联盟拉取实时广告,而后依据业务状况再去散发广告。因为网络的稳定和响应工夫的存在,广告业务对启动性能的影响还是比拟大的,整体流程如下图所示:
三. 实际
3.1 T1 阶段治理
3.1.1 动静库治理
动静库数量的增多不仅会影响零碎创立启动闭包的工夫,同时也会减少动静库加载阶段的耗时,苹果官网对于动静库数量的倡议是放弃在 6 个以内。而云音乐目前共有 16 个动静库,可见压力之大。对于动静库的治理,次要有以下几种形式:
- 动静库转动态库,举荐以这种形式治理,还能优化包大小;
- 合并动静库,因为动静库的提供方有三方也有二方,要让几方一起解决实操难度很大;
- 动静库懒加载,这种形式的收益很显著,然而须要各业务方革新并且对立入口;
云音乐在动静库的治理当中还是主张把动静库转成动态库,更适宜一个利用的久远倒退。在动静库转动态库的过程中发现很多的动静库是因为须要用到 OpenSSL,而工程中曾经有库用到 OpenSSL 了会导致符号抵触,所以不得己做成了动静库,对于这种状况首先就是找到 OpenSSL 符号抵触的库,其次是全工程对立 OpenSSL 版本。
寻找 OpenSSL 符号抵触起因
通过集成 OpenSSL 动态库以及把一个动静库转成动态库后发现因为局部符号在链接的时候没有正确链接,导致运行时解体。查找到对应的符号为 _RC4_set_key,通过 LinkMap 发现 _RC4_set_key 链接到了公司外部二方 SDK。
关上 LinkMap.txt 文件首先查找到 _RC4_set_key 符号,而后看到后面对应的 file 所在的序号为 2333,如下图:
接着咱们能够从 LinkMap 上方的 Object files 区块找到对应序号的文件,发现正是云信的 IM SDK,如下图所示:
因为云音乐工程依赖了云信 4 个动静库,所以咱们查看了 4 个库的符号,发现有两个库都有依赖 OpenSSL。上面咱们要做的工作就是使 OpenSSL 符号正确的链接到云音乐本人的 OpenSSL 库。
解决 OpenSSL 符号链接问题
通过查看工程配置发现,OpenSSL 符号的链接程序跟 Other Linker Flags 中的程序无关,而 Other Linker Flags 中的程序是依据 Cocoapods 中 Pods 的 xcconfig 中 OTHER_LDFLAGS 的程序来的。通过理论批改 xcconfig 中 OTHER_LDFLAGS 的程序验证 OpenSSL 符号的链接问题失去解决。据此,有两种办法能解决 OpenSSL 符号连贯问题:
- 通过批改 Podfile 在链接阶段优先链接白名单内的库;
- 让除了 OpenSSL 库以外的其余动静库暗藏相干的 OpenSSL 符号;
在思考了后续久远倒退以及防止后续链接存在隐患,咱们抉择了第二种办法,让云信导出本身库的时候都暗藏第三方库的符号。
通过 OpenSSL 符号的对立,咱们把相干的 4 个动静库转成了动态库。同时,咱们移除了一个曾经不在用到的动静库。有 3 个库因为 ffmpeg 相干符号抵触并且涉及面较广作为长期指标优化。依赖的一个迅雷网络库作为下次优化指标。动静库这一块目前总的优化 5 个,收益有 200ms 左右。
3.1.2 +load 办法治理
从原则上来说,咱们在开发过程中不应该应用 +load,很多大厂在建设标准后也都禁用掉了 +load 办法。+load 办法的影响如下:
- +load 的运行机会十分靠前,利用 Crash 检测 SDK 的初始化工作都还没实现,一旦 +load 中的代码呈现问题,SDK 都没法捕捉相应的问题;
- +load 的调用程序和对应文件的链接程序相干,如果有一些注册业务写在其中,而当其余 +load 相干业务在获取时,可能注册业务的 +load 还没执行;
- 执行 +load 时的代码都是在主线程运行的,利用所有 +load 的运行都会加长整个启动的耗时,而 +load 能够随便在相应的业务类中增加,业务开发无心的代码增加说不定就会造成耗时的重大减少;
-
从 Page In 的角度登程,执行一次 +load 不仅须要加载 +load 这个符号,还须要加载其中须要执行的符号,这也减少了不必要的耗时;
针对 +load 办法的优化,次要是采纳如下几种计划:- 删除不必要的代码;
- +load 中代码提早到 main 之后子线程解决或者首页显示之后;
- 底层库设计专有的初始化 API 对立去初始化;
- 业务代码接口懒加载;
- 改为 initialize 中执行,针对 initialize 中解决须要留神的是分类 initialize 会笼罩主类 initialize 以及有子类后 initialize 执行屡次的问题,须要应用 dispatch_once 来保障代码只执行一次;
在具体分析了云音乐中的局部 +load 办法的用途后发现,云音乐中很多底层库都是通过应用宏定义来在 +load 中实现一些注册行为,或者就只提供注册接口,业务应用方就会抉择在 +load 中去调用注册接口。针对这种状况,咱们优化了几个库的注册形式。通过去中心化注册,集中式对立初始化准则,不仅能够让注册机会对立,也可能更好的管控业务应用方,为当前的监控做铺垫。去中心化注册利用 attribute 个性在编译期间把相应的结构化数据写到 DATA 段指定的 section 中:
#define _MODULE_DATA_SECT(sectname) __attribute((used, section("__DATA," sectname) ))
#define _ModuleEntrySectionName "_ModuleSection"
typedef struct {const char *className;} _ModuleRegisterEntry;
#define __ModuleRegisterInternal(className) \
static _ModuleRegisterEntry _Module##className##Entry _MODULE_DATA_SECT(_ModuleEntrySectionName) = { \
#className \
};
同时,咱们提供了一个对立初始化的接口,在接口实现中把数据中对应的 section 中捞进去并通过原有接口对立注册:
size_t dataLength = sizeof(_ModuleRegisterEntry);
for (id headerItem in appImageHeaders) {const ne_mach_header *mach_header = (__bridge const ne_mach_header *)(headerItem);
unsigned long size = 0;
void *dataPtr = getsectiondata(mach_header, SEG_DATA, _ModuleEntrySectionName, &size);
if (!dataPtr) {continue;}
size_t count = size / dataLength;
for (size_t i = 0; i < count; ++i) {void *data = &dataPtr[i * dataLength];
if (!data) {continue;}
_ModuleRegisterEntry *entry = data;
// 调用原有注册接口
}
}
针对于原有应用宏定义在 +load 注册的形式,咱们另外减少了办法废除的标注,这样能让业务开发同学在应用过程中感知应用姿态的扭转:
static inline __attribute__((deprecated("NEModuleHubExport is deprecated, please use'ModuleRegister'"))) void func_loadDeprecated (void) {}
#define NEModuleHubExport \
+(void)load { \
// 调用原有注册接口 \
func_loadDeprecated(); \}\
因为存量 +load 数量太多,咱们在第一阶段只针对耗时 2ms 以上的前 30 个重点 +load 办法进行了优化解决,咱们会在后续的启动防劣化相干工作中做针对 +load 的监控,并且推动业务方优化治理。
3.1.3 无用代码清理
从后面的剖析章节咱们晓得,不论是 rebase&bind 还是 Objc Init 阶段,工程中类及分类的代码量都会影响这几个阶段的耗时,尤其是大型 App 中一直倒退的业务导致代码量巨多,而很多业务和代码在上线后并没有用到,所以对于这些无用代码的清理也能缩小启动耗时。另外,无用代码清理对于包大小的收益更大,云音乐在包大小优化中做了无用代码的清理6。
那么,如何能力找出哪些代码没有被用到呢?个别能够分为动态代码扫描和线上大数据统计两种形式。动态代码扫描还是从 MachO 登程,MachO 中的 _objc_selrefs
和_objc_classrefs
两个 section 中存储了援用到的 sel 和 class,而在__objc_classlist
section 中存储了所有的 sel 和 class,通过比拟两者数据的差集就能够获取没有被用到的类。而咱们晓得 OC 是一门动静语言,所以很多类都是运行时调用,在删除类之前须要确保没有被真正地调用。线上大数据统计则采纳类元数据中相应的标记为是否被初始化来统计。咱们晓得,在 OC 中,每个类都有本人的元数据,在元数据中的一个标记位存储着本人是否被初始化,这个标记位不受任何因素影响,只有有被初始化就会打标记,在 OC 的源码中获取标记位的形式如下:
struct objc_class : objc_object {bool isInitialized() {return getMeta()->data()->flags & RW_INITIALIZED;}
}
但这个办法咱们是无奈间接调用的,它是 OC 的办法。然而,要晓得类的元数据结构是不会变的,所以咱们能够通过本人模仿构建类的元数据结构来获取 RW_INITIALIZED 标记位数据,从而来确定某个类是否曾经初始化,代码如下:
#define FAST_DATA_MASK 0x00007ffffffffff8UL
#define RW_INITIALIZED (1<<29)
- (BOOL)isUsedClass:(NSString *)cls {Class metaCls = objc_getMetaClass(cls.UTF8String);
if (metaCls) {uint64_t *bits = (__bridge void *)metaCls + 32;
uint32_t *data = (uint32_t *)(*bits & FAST_DATA_MASK);
if ((*data & RW_INITIALIZED) > 0) {return YES;}
}
return NO;
}
通过下面的代码能够获取到某个类是否被初始化过,从而统计利用类的应用状况,进一步通过大数据统计分析哪些类能够清理。通过这种形式,咱们统计出数千多个类未被应用,在后续的清理中通过排除 AB 测试及业务预埋等业务侧代码外,咱们清理了 300+ 个类。
3.1.4 二进制重排
从前面对 Page In 的剖析晓得,在启动过程中过多的 Page In 会产生过多的 I/O 操作以及解密验证操作,这些操作的耗时影响也会比拟大。针对 Page In 的影响,咱们能够通过二进制重排来缩小这个过程的耗时。咱们晓得过程在拜访虚拟内存的时候是以页为单位的,而启动过程中的两个办法如果在不同的页,零碎就会进行两次缺页中断 Page In 操作来加载这两个页。而如果启动链路上的办法扩散在不同的页的话,整个启动的过程就会产生十分多的 Page In 操作。为了能缩小零碎因缺页中断产生的 Page In 操作,咱们须要做的就是把启动链路上所有用到的办法都排在间断的页上,这样零碎在加载符号的时候就能够缩小相应的内存页数量的拜访,从而缩小整个启动过程的耗时,如下图所示:
要实现符号的重排,一是须要咱们收集整个启动链路上的办法和函数等符号,二是须要生成对应的 order 文件来配置 ld 中的 Order File 属性。当工程在编译的时候,Xcode 会读取这个 order 文件,在链接过程中会依据这个文件中的符号程序来生成对应的 MachO。个别业界中收集符号的计划有两种:
- Hook objc_msgSend,只能拿到 OC 以及 swift @objc dynamic 的符号;
- Clang 插桩,能完满拿到 OC、C/C++、Swift、Block 的符号;
因为云音乐工程曾经进行了组件化工作,并且二进制化后全源码编译还有点问题,为了疾速验证问题,咱们先抉择了应用 Hook objc_msgSend 的形式去收集符号。Hook objc_msgSend 的形式能够参照下面火焰图生成时的计划。通过 Hook objc_msgSend 形式收集了启动链路上一万四千多去重后的符号,并且配置主工程 Order File 属性,如下图所示:
在编译实现后通过验证 LinkMap 文件中 #Symbols: 局部符号程序是否和 order 文件中的符号程序统一来确定是否配置胜利,如下图所示:
最初就是二进制重排后的成果验证了,从网上各类文章咱们得悉 Instruments 中的 System Trace 能够看到相应的成果。重启手机后应用 System Trace 运行程序,直到首页呈现后完结运行,找到主线程,并且在左下方抉择 Summary:Virtual Memory 就能看到对应的 File Backed Page In 相干的数据了,如下图所示:
通过屡次重启冷启动测试咱们发现 System Trace 中 File Backed Page In 的数据并不稳固,且稳定范畴比拟大,二进制重排优化前后数据难以证实有优化成果。咱们想到 Instruments 中 APP Launch 可能也有 Page In 相干的数据,于是,从 App Launch 中同样找到 Main Thread 后抉择 Summary:Virtual Memory,如下图所示:
不同的是,从 App Launch 咱们发现 File Backed Page In 的数据量级比 System Trace 大很多,绝对也稳固很多,并且 App Launch 能够抉择对应的 App LifeCycle 阶段来查看对应的数据,因而咱们能够只看第一帧渲染进去之前的数据。通过咱们屡次的测试比拟取平均数发现,优化后只比优化前缩小了 50ms 不到。至此,咱们非常狐疑二进制重排的成果。剖析了下测试条件,发现咱们有两个点能够改良,一是苹果对 iOS13 做过优化,所以咱们筹备了一台 iOS12 的设施进行测试,二是 Hook objc_msgSend 符号不能全笼罩的问题,所以咱们花了点工夫修复了工程全源码编译,并且通过 Clang 插桩的模式导出启动链路上的符号。
Clang 插桩次要通过利用 Xcode 自带的 SanitizerCoverage 工具进行。SanitizerCoverage 是 LLVM 内置的一个代码覆盖率检测工具,通过配置,在编译时它可能依据相应的编译配置,在每一个自定义的函数外部插入 __sanitizer_cov_trace_pc_guard
回调函数,通过实现该函数就能在运行期间拿到被插入该函数的原函数地址,通过函数地址解析出对应的符号,从而可能收集整个启动过程中的函数符号。通过在 Other C Flags 中配置 -fsanitize-coverage=func, trace-pc-guard ;
能够收集 C、C++、OC 办法对应的符号。而如果工程中有 Swift 代码的话也须要在 Other Swift Flags 中配置 -sanitize-coverage=func; -sanitize=undefined ;
这样就能收集 Swift 办法的符号了。对于应用 Cocoapods 来治理代码的工程来说,能够参考开源我的项目 AppOrderFiles7 的实现。另外须要留神的是,AppOrderFiles 中的实现是先通过函数地址解析出对应的符号再进行去重,而对于中大型工程来说,启动过程中的符号调用数量可达几百万级别,所以这个过程特地的久,能够改为先进行去重再进行函数地址解析符号的形式节省时间。同时,因为云音乐工程曾经开启了 Cocoapods 中的 generate_multiple_pod_projects
个性,所以相应的 Podfile 中的配置也须要批改为如下代码能力无效配置所有子工程的 Other C Flags/Other Swift Flags,代码如下:
post_install do |installer|
installer.pod_target_subprojects.flat_map {|project| project.targets}.each do |target|
target.build_configurations.each do |config|
config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
end
end
end
通过 Clang 插桩的形式,咱们收集了启动链路上总共 2 万左右通过去重后的符号,并且在一台零碎版本为 iOS12.5.4 的 iPhone 6 Plus 设施上测试。通过屡次测试取平均值,发现二进制重排后有 180ms 左右的优化。通过后果数据可见,二进制重排的成果被神话了,并且 iOS13 之前苹果对 Page In 过程的解密验证操作才是耗时的大头,符号的重排影响较小。
3.2 T2 阶段治理
T2 阶段的治理次要从各个启动工作的配置和初始化、首页加载两个方向登程,这一块的优化空间也是最大的。从后面可知,因为云音乐业务的特殊性,广告业务的影响在 T2 阶段占了很大的比重,所以咱们在 T2 阶段还对广告业务做了治理。目前,云音乐首页曾经做了缓存,且因为广告业务的存在,所以首页在整个启动过程中并不是瓶颈,咱们把治理的重点放在了各个启动工作下面。
而云音乐除了在 AppDelegate 初始化中的局部代码没有去治理以外,其余的启动工作都曾经通过一个启动工作治理框架治理。所以,在 T2 阶段咱们次要是通过 Hook objc_msgSend 生成火焰图和 Instruments 中 App Launch 工具联合启动工作治理框架来剖析整个启动链路的性能,通过剖析以及后续的优化,咱们总结了以下几个可优化的方向:
3.2.1 高频 OC 办法优化
OC 是一门动静语言,所有运行时的办法都会通过 objc_msgSend 转发,从而咱们实现了火焰图来剖析各办法的性能。大家都晓得动静语言的劣势就是灵便,然而随同而来的是性能绝对会差些,尤其是在底层库的利用中影响和范畴也更显著。
NEHeimdall 库优化
咱们从火焰图的剖析中看到一个底层库的办法被频繁的调用,汇总起来就有很大的耗时,如下图所示:
从放大图上咱们能够看到被频繁调用的办法 [[NEHeimdall]disableOptions]
。NEHeimdall 是咱们一个底层用来做运行时解体防护的库,Hook 了包含容器类、NSString、UIVIew、NSObject 等类,并在办法中做了开关开启判断。而像零碎底层容器类 NSArray 被宽泛的利用且调用频繁,如果在每次的 objectAtIndex 办法中都去再次调用[[NEHeimdall]disableOptions]
办法确实是更加耗时了。
优化思路次要有两点:一是在 Hook 阶段判断开关状态来决定是否开启防护,二是把原先 [[NEHeimdall]disableOptions]
办法改成 C 办法,绝对能晋升总的性能。因为第一种形式改变较大且因为 AB 的存在不能保障开关的实时性,最终咱们抉择了第二种形式。
JSON 解析优化
在惯例大型 App 中 ABTest 是必不可少的组件,而 AB 缓存数据的获取必定是在启动链路的后期,因为云音乐工程历史比拟久,目前在 ABTest 数据序列化和反序列化中 JSON 数据的解析还在应用 SBJson 的库,而 SBJson 会频繁的调用子办法,如下图所示:
从 N 早之前网友的测评数据 8 来看,SBJson 库的性能是比拟差的,如下图所示:
从上图也能够看到,对于 JSON 数据的解析来说,零碎提供的 NSJSONSerialization 库的性能反倒是最好的,所以在 ABTest 组件中,咱们次要是把 SBJson 移除并且通过 NSJSONSerialization 来做 JSON 数据的解析。工程中还有非启动链路组件对 SBJson 库有依赖,进一步须要做的就是整个工程都移除对 SBJson 库的依赖。
3.2.2 runtime 遍历优化
OC 的动态性给了开发者很多的可扩展性,因而大家也都会在平时的开发过程中去做一些骚操作,比方 Hook 以及遍历符号等,而这些操作都是很耗性能的。
Hook 优化
云音乐工程中须要 Hook 的场景特地多,不论是通过 Method Swizzle 还是 fishhook 这种遍历符号表的形式。而咱们在剖析火焰图和 Instrument 的时候发现两种 hook 形式都很影响性能,如下图所示:
针对 Hook 的优化想到的有两点,一是找到性能好的 Hook 库替换,然而会引入新库且有肯定的革新老本。二是把原先 Hook 的代码异步到子线程去执行,然而会遇到子线程机会不定的问题,须要确保在对应的类在被利用之前实现 Hook 操作。咱们在形式二做了一些尝试,然而最初没有上线,后续会去对 Hook 对立治理以便缩小反复 Hook 带来的耗时。
EXTConcreteProtocol 优化
咱们晓得在 OC 中 protocol 是没有默认实现的,然而很多场景下如果 protocol 有默认实现的话又特地不便。而 libextobjc 库中的 EXTConcreteProtocol 能够提供协定默认实现的能力,通过 Instrument 咱们发现 ext_loadConcreteProtocol 办法特地耗时,如下图所示:
通过查看源码发现 ext_loadConcreteProtocol 也是通过 runtime 遍历去达到协定领有默认实现的能力,思考到现有业务只有一个中央应用到了 EXTConcreteProtocol,然而对启动耗时的影响又特地大,所以对 EXTConcreteProtocol 的优化就是移除依赖,革新业务代码实现,通过对 NSObject 减少分类并继承协定也能达到协定有默认实现的能力。
3.2.3 网络相干优化
在云音乐工程中,波及到网络相干影响启动性能的次要有两点:Cookies 设置同步问题、UserAgent 生成和应用。
Cookies 设置同步优化
对惯例 App 来说都会有三方跳转到 H5 的需要,在云音乐中之前为了同步 Cookies 会在启动链路上事后生成一个 WKWebview 的对象,而 WKWebview 实例的创立是十分耗时的。针对这一块,咱们次要是做了懒加载来优化,把 WKWebview 对象的创立放到了真的有 H5 页面关上的时候,并且在创立的时候再去同步 Cookies。
UserAgent 每次生成优化
UserAgent 对于申请来说是必不可少的参数,而在云音乐中 UserAgent 又是通过长期创立 UIWebView 对象并通过执行 navigator.userAgent 来获取的,并且每次启动的时候都会去从新创立后从新获取,耗时点次要也是在 UIWebView 对象的创立。通过查看 UserAgent 具体内容发现,除了零碎版本号和 App 版本号会随着降级更新以外,其余的内容都不会变。因而,咱们针对 UserAgent 的应用做了缓存,并且在每次零碎更新或者 App 更新的时候被动去更新缓存,以升高对启动性能的影响,如下图所示:
3.2.4 零碎接口
在剖析火焰图和 Instrument 数据的过程中,咱们也发现了一些零碎接口的性能对整个启动链路的耗时很有影响,目前发现的次要有两个接口:
- NSBundle 中的 bundleWithIdentifier: 接口;
- UIApplication 中的 beginReceivingRemoteControlEvents 接口;
云音乐这边拿 Bundle 的时候本人做了一层封装,通过 podName 获取对应 Bundle。外部实现中先通过零碎 bundleWithIdentifier: 接口的模式查找,找不到的状况下再通过 mainBundle 寻找 URL 的形式查找。通过剖析发现零碎接口 bundleWithIdentifier: 在第一次调用时的性能很差,而通过 mainBundle 获取 Bundle 的性能很高。教训证 mainBundle 形式都能获取到 Bundle,所以咱们对此进行了程序切换,优先通过 mainBundle 查找 Bundle,如下图所示:
beginReceivingRemoteControlEvents 接口的应用场景次要是须要在锁屏界面上显示相干的信息和按钮,就必须要先开启近程管制事件(Remote Control Event)。云音乐作为一个音乐软件在播放音乐的时候就须要显示相干信息。之前的做法是播放相干的服务会在启动的时候往 IOC 中注册对应的实例。为此咱们对 IOC 底层做了革新,反对相干实例的懒加载,把相干服务在用到的时候再去初始化实例,这样就把 beginReceivingRemoteControlEvents 接口对启动的影响延后了,对比方下图所示:
3.2.5 广告业务优化
在对广告业务的深入分析当前,咱们发现目前云音乐的广告投放对象包含会员和非会员用户。会员用户投放的广告比拟少,个别是外部经营流动,而外部经营流动是不须要去广告联盟拉取数据的。并且从代码层面来说,广告业务的接口申请机会要等到执行到广告业务代码才会去收回,机会曾经偏晚了。针对这两个状况,咱们对广告业务做了相应的优化:
- 会员用户广告业务接口申请开关动静配置;
- 广告业务接口机会前置;
外部经营流动个别会是经营配置,并且会有投放对象的选项,所以把这个开关动静配置的能力放到了后端,当经营配置的流动投放对象须要有会员的时候才会把对应的开关关上,非经营活动状态开关都是敞开状态,会员用户不会去申请接口。同时,对于非会员用户来说广告业务的影响也是不能忍的,在目前状态根底上咱们把广告业务接口的申请机会前置到了网络库初始化之后即收回,能够缩短申请时长对启动的影响,从灰度数据来看均匀能优化 300~400ms 左右。
3.2.6 其余业务层面优化
另外有一些业务拓展或者说性能新增带来的对启动性能有影响的点,比方 iPhone 反对一键登录后号码的读取。云音乐在反对一键登录的需要后会通过 SDK 去读取运营商是否反对一键登录并获取号码,在之前的设计中,不论用户是否登录都会去判断并获取,从 SDK 获取也有肯定的耗时,咱们改成了只在未登录用户的状况下获取。
还有一些非共性的业务代码应用姿态的问题咱们也做了很多优化,就不在这里一一列举了。
四. 总结
通过阶段性的启动性能专项优化,云音乐 App 的启动性能相比之前是有了肯定的晋升,到目前为止性能晋升 30%+。不过对于启动性能优化来说,所有优化的措施只是针对目前 App 遇到的状况解决的。而惯例大型 App 的业务迭代十分的频繁,业务需求量也特地的多,在日常开发阶段如何可能检测、拦挡对启动性能有影响的代码,App 在上线后如何可能疾速定位到新版本有劣化且劣化后的归因,甚至如何感知单用户对启动性能的体感数据。这是在通过了一阶段启动治理之后须要去思考和实际的,咱们目前也正在欠缺整个启动性能的防劣化零碎,等到上线并稳固运行后也会进一步的分享一些防劣思路。
从后面咱们也能够晓得,广告业务对云音乐 App 整个启动性能的影响是特地大的,尤其是接口响应工夫的不确定性,而广告又波及到支出,所以这块的短期改变比拟难,尽管咱们这次针对会员用户做了优化,后续还会进一步的剖析广告业务并做肯定的优化。还有一些业务层面的优化比方 tabbar 懒加载、首页加载,以及惯例的 +load 等方面会进一步的治理。
PS:附上云音乐优化实际小总结表:
阶段 | 优化方向 | 可能性收益 | 剖析工具 / 办法 |
---|---|---|---|
T1/pre-main | 动静库转动态库 | 均匀 20-30ms/ 库 | 解包 /Xcode 环境变量 |
+load | 看具体业务 | Hook load 汇总 | |
无用代码清理 | 看具体业务 | 大数据统计类使用率 | |
二进制重排 | 50-200ms | Hook objc_msgSend/Clang 插桩 | |
T2/post-main | 高频 OC 办法 | 200-300ms | 火焰图 |
runtime 符号遍历 | 300-500ms | 火焰图 /Instrument | |
网络相干 | 200-300ms | 火焰图 /Instrument | |
零碎接口 | 100-200ms | 火焰图 /Instrument | |
业务影响 | 300-400ms | 火焰图 /Instrument |
五. 参考资料
- https://developer.apple.com/v… ↩
- https://developer.apple.com/v… ↩
- https://github.com/tripleCC/L… ↩
- https://iosre.com/t/hookzz-ha… ↩
- https://github.com/everettjf/… ↩
- https://mp.weixin.qq.com/s/GT… ↩
- https://github.com/yulingtian… ↩
- https://blog.csdn.net/arthurc… ↩