本文作者: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 相关生命周期办法,到完结首屏烘托的榜首帧。 整体流程如下图所示:

    云音乐 iOS 启动性能优化「开荒篇」

  本文如涉及到时刻相关一般是以体系为 14.3 的 iPhone 8 Plus 作为基准测验设备,而且在 Debug 方式下。

2.3 冷发动的进程

  从冷发动的界说后咱们可以把整个冷发动的进程分为 T1 和 T2 两个进程,iOS 体系在两个进程中别离会在不同的节点进行相应的处理和代码的调用,后续可以针对这两个进程别离进行办理优化。

  T1 阶段发动进程如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

  从上图所示的流程中,咱们可以看到在 T1 阶段更多的是体系在为运转 App 做一些初始化的作业,所以咱们能做的便是尽量削减对体系初始化作业的影响。从整个流程看来,发动闭包之后的动态库加载、rebase&bind、Objc Init、+load、static initializer 这几个节点咱们是可以做一些针对性的办理和优化作业的。

  T2 阶段发动进程如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

  从上图所示的流程中,咱们可以看到在 T2 阶段现已根本是属于事务方的代码了,在这个阶段中往往咱们会把 Crash 相关、APP 装备信息、AB 数据、定位、埋点、网络初始化、容器预热以及二三方 SDK 初始化等一股脑的塞在里面,而针对这个阶段优化的 ROI 也是相对比较高的。

2.4 云音乐的现状

  云音乐作为一个从 2013 年开端推出的 App 有着近 10 年的事务开展和代码堆叠,在此期间对发动功用的重视和办理也比较有限,再加上云音乐除了听歌事务以外还有直播、K 歌等事务集成,所以总体来说整个发动链路上的代码是比较杂乱的。乃至因为云音乐本身开屏广告事务的特殊性,在笔者开端着手发动优化专项后发现云音乐的发动红屏由一般 App 的发动开屏页和假红屏两部分组成,整个发动流程如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

2.4.1 T1阶段各状况剖析

动态库

  从WWDC20222咱们也知道一个 App 中动态库的数量是会影响整个 T1 阶段的耗时的,因而咱们一是需求知道现在动态库对整个 T1 阶段耗时的影响,二是需求知道有哪些动态库形成了影响而且是可以优化的。通过 Xcode 供给的环境变量DYLD_PRINT_STATISTICS咱们可以大致的知道一切动态库在 T1 阶段的耗时,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

  从 Xcode 输出的成果可以看到,动态库加载的耗时占整个 pre-main 的份额还挺高的。这个时分我通过解压云音乐线上 IPA 包发现 Frameworks 目录下动态库的数量有 16 个之多。

+load办法

  iOS 开发人员对 +load 办法应该现已很熟悉了,因为 +load 办法供给了一个比较早的机遇可以让咱们前置去履行一些基础装备的代码、注册类代码或许办法交换等代码。也正是因为这个原因,咱们在不断的事务迭代中发现咱们想要找一个早一点的机遇就会想到去用 +load 办法,导致项目中 +load 过多,严重影响发动功用,云音乐工程也有这样的问题,下面咱们来看下对 +load 办法运用状况的剖析。

  咱们知道关于完结了 +load 办法的类和分类会在编译时被写入到 MachO 中__DATA段的__objc_nlclslist__objc_nlcatlist两个 section 中。因而,咱们可以通过getsectbynamefromheader办法把界说了 +load 的一切的类和分类捞取出来,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

  当然当咱们知道了一切界说了 +load 的类和分类今后,更想知道这些 +load 的耗时状况,这样好便利咱们优先优化耗时高的那部分 +load 办法。咱们想到的是 Hook +load 办法,而要可以 Hook 一切的 +load 办法肯定是需求在最早的机遇去 Hook,那么完结一个动态库,而且在动态库的 +load 中去 Hook 是最好的机遇了,一同也要保证这个动态库是最早加载的动态库,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

  因为云音乐工程现已用 Cocoapods 来完结组件化,所以只需求创立以 AAA 称号开头的库房就可以了,如 AAAHookLoad,而且在 Podfile 中引入对应的库房,就能完结动态库最早加载,这儿可以参照开源库 A4LoadMeasure3。假如仍是单工程则取什么称号都可以,只需求在工程设置Build Phases=>Link Binary With Libraries中把对应的库移到榜首个位置就可以,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

  通过 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对云音乐的影响并非瓶颈,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

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 办法功用的剖析以及火焰图的生成,款式如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

Instruments中App Launch东西剖析

  通过剖析生成的火焰图数据与实践 Debug 调试发现火焰图上对应办法的耗时也不是特别准确,会有必定的差错,可是相对占比仍是可以反映出相应办法在整个 T2 阶段的影响的。一同,火焰图只能看到整个发动链路的时刻线以及办法调用栈,线程间的状况仍是不够直观,也缺少 C/C++ 相关办法功用的检测,而且火焰图对每个具体阶段的描述也是缺少的。这个时分就需求用到 Instruments 的 App Launch 东西再来剖析一遍。

  Xcode 自带 Instruments 一系列的剖析东西,而 App Launch 剖析后会把整个发动链路的各个阶段具体展示,通过对各个阶段区间的区分可以很便利的找到每个阶段主线程的功用瓶颈以及多线程的状况,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

云音乐 iOS 启动性能优化「开荒篇」

2.4.3 广告事务现状

  在上面提到云音乐存在假红屏的现象,而这个假红屏便是由广告事务发生。在咨询了广告事务相关同学后得知,云音乐这边的广告事务是去实时恳求后实时展示的,所以在恳求之前展示假红屏页面,直到等候接口数据回来后假红屏消失,后续展示广告或许进入主页。进一步了解后知道,实时恳求是因为广告事务需求去外部广告联盟拉取实时广告,然后依据事务状况再去分发广告。因为网络的动摇和呼应时刻的存在,广告事务对发动功用的影响仍是比较大的,整体流程如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

三.实践

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,如下图:

云音乐 iOS 启动性能优化「开荒篇」

接着咱们可以从 LinkMap 上方的 Object files 区块找到对应序号的文件,发现正是云信的 IM SDK,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

因为云音乐工程依靠了云信 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_classlistsection 中存储了一切的 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 操作,咱们需求做的便是把发动链路上一切用到的办法都排在连续的页上,这样体系在加载符号的时分就可以削减相应的内存页数量的拜访,然后削减整个发动进程的耗时,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

  要完结符号的重排,一是需求咱们搜集整个发动链路上的办法和函数等符号,二是需求生成对应的 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 属性,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

在编译完结后通过验证 LinkMap 文件中 #Symbols: 部分符号次序是否和 order 文件中的符号次序一致来确认是否装备成功,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

  最终便是二进制重排后的作用验证了,从网上各类文章咱们得知 Instruments 中的 System Trace 可以看到相应的作用。重启手机后运用 System Trace 运转程序,直到主页出现后结束运转,找到主线程,而且在左下方挑选 Summary:Virtual Memory 就能看到对应的 File Backed Page In 相关的数据了,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

通过屡次重启冷发动测验咱们发现 System Trace 中 File Backed Page In 的数据并不安稳,且动摇规模比较大,二进制重排优化前后数据难以证明有优化作用。咱们想到 Instruments 中 APP Launch 或许也有 Page In 相关的数据,于是,从 App Launch 中同样找到 Main Thread 后挑选 Summary:Virtual Memory,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

不同的是,从 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库优化

  咱们从火焰图的剖析中看到一个底层库的办法被频频的调用,汇总起来就有很大的耗时,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

云音乐 iOS 启动性能优化「开荒篇」

  从扩大图上咱们可以看到被频频调用的办法[[NEHeimdall]disableOptions]。NEHeimdall 是咱们一个底层用来做运转时溃散防护的库,Hook 了包含容器类、NSString、UIVIew、NSObject 等类,并在办法中做了开关敞开判别。而像体系底层容器类 NSArray 被广泛的运用且调用频频,假如在每次的 objectAtIndex 办法中都去再次调用[[NEHeimdall]disableOptions]办法的确是更加耗时了。

  优化思路主要有两点:一是在 Hook 阶段判别开关状况来决议是否敞开防护,二是把原先[[NEHeimdall]disableOptions]办法改成 C 办法,相对能提高总的功用。因为榜首种办法改动较大且因为 AB 的存在不能保证开关的实时性,终究咱们挑选了第二种办法。

JSON解析优化

  在惯例大型 App 中 ABTest 是必不可少的组件,而 AB 缓存数据的获取肯定是在发动链路的前期,因为云音乐工程前史比较久,现在在 ABTest 数据序列化和反序列化中 JSON 数据的解析还在运用 SBJson 的库,而 SBJson 会频频的调用子办法,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

从 N 早之前网友的测评数据8来看,SBJson 库的功用是比较差的,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

从上图也可以看到,关于 JSON 数据的解析来说,体系供给的 NSJSONSerialization 库的功用反倒是最好的,所以在 ABTest 组件中,咱们主要是把 SBJson 移除而且通过 NSJSONSerialization 来做 JSON 数据的解析。工程中还有非发动链路组件对 SBJson 库有依靠,进一步需求做的便是整个工程都移除对 SBJson 库的依靠。

3.2.2 runtime遍历优化

  OC 的动态性给了开发者许多的可扩展性,因而咱们也都会在平常的开发进程中去做一些骚操作,比方 Hook 以及遍历符号等,而这些操作都是很耗功用的。

Hook优化

  云音乐工程中需求 Hook 的场景特别多,不管是通过 Method Swizzle 仍是 fishhook 这种遍历符号表的办法。而咱们在剖析火焰图和 Instrument 的时分发现两种 hook 办法都很影响功用,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

  针对 Hook 的优化想到的有两点,一是找到功用好的 Hook 库替换,可是会引入新库且有必定的改形本钱。二是把原先 Hook 的代码异步到子线程去履行,可是会遇到子线程机遇不定的问题,需求保证在对应的类在被运用之前完结 Hook 操作。咱们在办法二做了一些尝试,可是最终没有上线,后续会去对 Hook 一致办理以便削减重复 Hook 带来的耗时。

EXTConcreteProtocol优化

  咱们知道在 OC 中 protocol 是没有默许完结的,可是许多场景下假如 protocol 有默许完结的话又特别便利。而 libextobjc 库中的 EXTConcreteProtocol 可以供给协议默许完结的才能,通过 Instrument 咱们发现 ext_loadConcreteProtocol 办法特别耗时,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

通过检查源码发现 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 更新的时分主动去更新缓存,以降低对发动功用的影响,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

3.2.4 体系接口

  在剖析火焰图和 Instrument 数据的进程中,咱们也发现了一些体系接口的功用对整个发动链路的耗时很有影响,现在发现的主要有两个接口:

  • NSBundle 中的 bundleWithIdentifier: 接口;

  • UIApplication 中的 beginReceivingRemoteControlEvents 接口;

  云音乐这边拿Bundle的时分自己做了一层封装,通过podName获取对应Bundle。内部完结中先通过体系 bundleWithIdentifier: 接口的方式查找,找不到的状况下再通过 mainBundle 寻觅 URL 的办法查找。通过剖析发现体系接口 bundleWithIdentifier: 在榜首次调用时的功用很差,而通过 mainBundle获取 Bundle 的功用很高。经验证 mainBundle 办法都能获取到 Bundle,所以咱们对此进行了次序切换,优先通过 mainBundle 查找 Bundle,如下图所示:

云音乐 iOS 启动性能优化「开荒篇」

  beginReceivingRemoteControlEvents 接口的运用场景主要是需求在锁屏界面上显现相关的信息和按钮,就必须要先敞开长途控制事情(Remote Control Event)。云音乐作为一个音乐软件在播映音乐的时分就需求显现相关信息。之前的做法是播映相关的服务会在发动的时分往 IOC 中注册对应的实例。为此咱们对 IOC 底层做了改造,支撑相关实例的懒加载,把相关服务在用到的时分再去初始化实例,这样就把 beginReceivingRemoteControlEvents 接口对发动的影响延后了,对比方下图所示:

云音乐 iOS 启动性能优化「开荒篇」

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

五.参阅资料

本文发布自网易云音乐技能团队,文章未经授权禁止任何方式的转载。咱们终年接收各类技能岗位,假如你准备换作业,又刚好喜欢云音乐,那就参加咱们 grp.music-fe(at)corp.netease.com!

Footnotes

  1. developer.apple.com/videos/play… ↩

  2. developer.apple.com/videos/play… ↩

  3. github.com/tripleCC/La… ↩

  4. iosre.com/t/hookzz-ha… ↩

  5. github.com/everettjf/A… ↩

  6. mp.weixin.qq.com/s/GTbhvzMA-… ↩

  7. github.com/yulingtianx… ↩

  8. blog.csdn.net/arthurchenj… ↩