布景
发动是用户运用一款产品的榜首印象,长时刻的发动等候将会消磨用户的耐心。依据过往试验经历,若运用的发动时刻削减,那么则能有用的下降0vv(发动后0播映量),因而发动耗时是西瓜客户端质量的核心目标之一。
发动界说
依据2019年的WWDC视频,苹果将发动分为了6个阶段,如下图。
发动每个阶段所做的作业如下:
-
System Interface:Dyld加载同享库和结构、初始化体系底层组件。
-
Runtime Init:初始化语言的runtime、调用一切类的静态初始化办法。
-
UIKit Init:初始化UIApplication和UIApplicationDelegate、开端与体系进行事情处理。
-
Application Init:调用application:willFinishLaunchingWithOptions:和application:didFinishLaunchingWithOptions,之后调用applicationDidBecomeActive:。
-
Initial Frame Render:创立、布局和制作view,提交并烘托首屏。
-
Extended:App进入前台,能够交互和响应事情。
目标界说
在优化开端前,最重要的作业是建立目标。一般状况下,运用在发动首屏之后还需求恳求和烘托主页的数据,只关怀发动首屏之前的数据是不行的。因而关于西瓜来说,需求扩展Extended阶段的概念,新增自界说事情的完结时刻点。
在西瓜发动优化前期,发动耗时目标被界说为榜首个+load到列表烘托完结。
关于该目标的开端时刻点——榜首个+load,为了保证埋点计算的精确性,需求经过一些特殊办法来获取它。依据苹果文档,调用+load办法前会进行动态库链接,而CocoasPods管理的动态库的+load办法履行次序会按动态库名字字母排序。因而只需求新增一个以AAA开头命名的动态库,并在该动态库的+load办法中记载一下时刻戳即可。
关于完毕时刻点——列表烘托完结,相同为了保证埋点计算的精确性,能够运用体系烘托原理来完结。一般,运用CATransaction是一个很好的选择,但它有一个潜在的隐患在于若改写的cell中包括永远重复的动画,那么CompletionBlock将会等候动画完毕后才回调。
[CATransaction begin];
[CATransaction setCompletionBlock:^{
//列表烘托完结
}];
[self.tableView reloadData];
[CATransaction commit];
因为该隐患的存在,发动目标曾经坏了两次。因而发动目标运用了别的一个计划,接口如下。经过hook列表的layoutSubviews办法,在该办法履行完时经过dispatch_async抛出回调。经过多次线下测验、线上数据与之前CATransaction计划的数据比照,能够证明运用该计划计算的列表烘托耗时是精确的。
[self.tableView xig_reloadDataWithCompletion:^{
//列表烘托完结
}];
在上述目标运行一段时刻后,在体感测验中发现该目标并不是一个可感知目标,无法反运用户从点击app开端到主页展现的全体时长,决议修正发动耗时的口径。将开端时刻调整为点击app,将完毕时刻点调整为列表展现。
关于开端时刻点,能够运用进程创立的时刻戳,demo如下:
+ (NSTimeInterval)time {
struct kinfo_proc kProcInfo;
NSTimeInterval processStartTime = 0;
if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
processStartTime = kProcInfo.kp_proc.p_un.__p_starttime.tv_sec + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000000.0;
processStartTime *= 1000;
}
return processStartTime;
}
+ (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;
}
关于完毕时刻点,西瓜在主页列表恳求时,有一个加载动画的View掩盖在列表上,在列表烘托完结并使加载动画隐藏时,也会有一段额定的耗时,因而西瓜发动初次改写耗时的完毕时刻点不是列表烘托完结而是列表展现。计算加载动画隐藏时刻点能够运用体系会在runloop的beforeWaiting阶段改写View机遇,再dispatch_async到下一个runloop回调即可,demo如下:
- (void)reload {
[self.tableView xig_reloadDataWithCompletion:^{
//触发加载动画封闭
[self.tableView xig_endUpdateData:NO];
//监听加载动画封闭
[self observeNextRenderWithBlock:^{
//加载动画封闭
}];
}];
}
- (void)observeNextRenderWithBlock:(dispatch_block_t)block {
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopActivity activities = kCFRunLoopBeforeWaiting | kCFRunLoopExit;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes);
CFRelease(observer);
if (block) {
dispatch_async(dispatch_get_main_queue(), block);
}
});
CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes);
}
除了计算全体耗时之外,还应重视发动首屏后分阶段的耗时,比方列表创立耗时、恳求耗时和列表烘托耗时等。终究,在App进入前台后,将会计算发恳求、列表烘托完结、列表展现、首张图片展现和播映器榜首帧展现等时刻点。关于西瓜而言,发动优化的终极目标是削减从点击运用到播映器榜首帧展现的耗时。
发动架构
因为西瓜iOS旧发动器不能很好的满意各种需求,比方低端机、新用户等场景的不同发动使命配置,依靠联系不明确,逻辑紊乱,调整发动使命会crash等问题。为了能够更好的支撑发动的安稳性、开发功率和优化功率,在发动优化的一起,对发动器进行了重构。
分阶段发动
考虑到了解本钱,发动被重新划分为4个阶段:didFinishLaunch、launchCompletion(发动首屏完毕)、homeDidRendered(主页烘托完结)和AfterPlayerFirstFrame(播映器首帧)。
将根底组件组件初始化放在didFinishLaunch发动,如APM SDK、网络库、埋点库等。将事务组件初始化放在launchCompletion发动。将列表烘托后才会运用的事务和非发动核心事务或放在homeDidRendered和AfterPlayerFirstFrame发动。
发动器规划
发动器支撑3个行列,分别是主行列、闲时行列和并发行列。在发动的每个阶段,构建DAG图来完结使命之间的依靠联系,由拓扑排序来决议发动使命履行次序。考虑到发动使命数量很大,所以数据结构运用邻接表。在拓扑排序的过程中能够查看环,查看出环后进行assert并输出构成环的使命。经过拓扑排序,能够获得一个使命行列,此时每个行列中使命履行完毕后问询使命行列是否满意可履行的需求,若满意则取出履行,示意图如下。
发动器类图如下,StartupManager用于注册使命、添加匿名使命和在事务模块中被调用来履行使命。发动使命需求完结StartupTask协议,并在AppDelegate中的application:willFinishLaunchingWithOptions:中被注册,能够依据不同类型的用户注册不同的使命。在StartupTask协议中,dependencies类办法用于返回一个数组,其间每一个元素为依靠使命的类,taskQueue决议发动使命派发的行列。
发动使命注册
西瓜发动器的注册运用了字节内部的Gaia组件,在发动时会调用一切相关函数将发动使命注册在发动器中。
Gaia运用编译器会将__attribute__((section())) 的数据写到指定数据段中的特性,在编译期间将需求调用的函数指针写入到MachO,在发动运行时经过注册_dyld_register_func_for_add_image回调来读取函数指针进行调用。
比方西瓜发动器用的Gaia注册发动使命的函数为XIGRegisterStartUpTaskFunction(),那么它翻开前后比照如下:
//翻开前
XIGRegisterStartUpTaskFunction() {
//注册XIGDemoTask发动使命
[XIGStartUp registerTaskClass:XIGDemoTask.class inStage:XIGStartUpLaunchCompletion];
}
//翻开后
typedef struct _GAIAData {
const GAIAType type;
const bool repeatable;
const char *key;
const void *value;
} GAIAData;
typedef struct _GAIAFunctionInfo {
const void *function;
const char *fileName;
const int line;
} GAIAFunctionInfo;
__attribute__((used)) static void __GAIA_ID__0(void);
static const GAIAFunctionInfo __GAIA_F_I_ID__0 = (GAIAFunctionInfo){(void *)__GAIA_ID__0, __FILE_NAME__, __LINE__};
__attribute__((used, no_sanitize_address, section("__DATA,__GAIA__SECTION"))) static const GAIAData __GAIA_ID__1 = (GAIAData){GAIATypeFunctionInfo, false, "XIGRegisterStartUpTask", &__GAIA_F_I_ID__0};
__attribute__((used, no_sanitize_address)) static void __GAIA_ID__0() {
[XIGStartUp registerTaskClass:XIGDemoTask.class inStage:XIGStartUpLaunchCompletion];
}
在发动后,调用Gaia履行如下代码注册一切发动使命。
[GAIAEngine startTasksForKey:@XIGRegisterStartUpTaskGaiaKey];
终究一个发动使命运用作用如下:
@implementation XIGDemoTask
XIGRegisterStartUpTaskFunction() {
[XIGStartUp registerTaskClass:XIGDemoTask.class inStage:XIGStartUpLaunchCompletion];
}
- (void)execute {
//履行使命
}
@end
问题阐明
发动时面对的首要问题如下:
问题 | 影响阶段 | 优先级 |
---|---|---|
履行很多+load | Runtime Init | p1 |
履行很多静态初始化 | Runtime Init | p2 |
主线程堵塞、耗时操作 | Runtime Init、Application Init | p0 |
首屏烘托耗时长 | Initial Frame Render | p0 |
发首刷恳求晚 | 首刷 | p0 |
列表创立晚 | 首刷 | p1 |
列表烘托耗时 | 首刷烘托 | p0 |
很多网络恳求抢占首刷恳求资源 | 首刷 | p0 |
很多后台线程抢占CPU资源 | 首刷 | p2 |
优化思路
在发动方向上,首要分两大块进行优化,它们分别是冷发动阶段和发动后阶段。冷发动阶段的耗时关乎到发动首屏的体会,而发动后阶段的耗时关乎到列表初次展现和播映器首帧的体会。因为西瓜发动优化的终极目标是削减播映首帧的耗时,因而在优化过程中,不能顾此失彼,单纯的将冷发动阶段的耗时使命往后放或许将发动后阶段的堵塞使命往前提,都无法有用的改进发动体会。应当以全体的眼光来看待发动耗时,做真实有用的耗时优化。
耗时使命管理
+load与静态初始化
+load与静态初始化自身或许并不耗时,可是它们会形成虚拟内存缺页中止,形成额定的耗时。在一次Trace中观察Runtime Init阶段,该阶段的全体耗时为736ms,其间虚拟内存缺页中止的耗时就高达579ms,因而管理该阶段问题收益较大。
管理的思路是替换完结,将西瓜事务范围内的+load和静态初始化函数替换为西瓜的发动使命。出于本钱考虑,现在没有管理三方库、两方库以及C++库中的+load和静态初始化。现在在西瓜的事务库中简直不存在+load办法以及__attribute__((constructor)) void润饰的办法,为了防止后续新增,在MR的静态查看中设立了约束其新增的规则。
//替换前
+ (void)load {}
__attribute__((constructor)) void demoFunc() {}
//替换后
XIGRegisterStartUpTaskFunction() {
[XIGStartUp registerTaskInStage:XIGStartUpDidFinishLaunch usingBlock:^{}];
}
主线程堵塞
主线程堵塞基本上有三类,分别是虚拟内存缺页中止、进程间通讯和等候锁。其间榜首个问题能够忽视,首要看进程间通讯和锁这两个问题。
关于进程间通讯,常见的比方是运用体系库获取一些必要数据,比方说去获取keychain中的数据,获取idfa、udid等等。关于此类问题,优化思路有两种,一种是在运用前在后台线程提早获取并缓存在内存中,当运用时直接运用缓存数据,此计划有必要需求考虑的问题是体系库是否线程安全。另一种是在榜初次获取后就写入持久化缓存,比方YYCache和MMKV等,之后运用时只需求读取本地缓存即可。
关于锁而言,需求具体问题具体剖析。比方需求警觉主线程和子线程一起调用一个线程安全的组件,这种状况下很大概率呈现主线程等候子线程上的锁导致主线程堵塞。还比方事务侧自己加的锁,这种状况需求改造逻辑,尽量经过逻辑来避免加锁,或许将调用派发到指定的串行行列来履行。最终,还会呈现一些无需优化的锁来搅扰,比方Trace中经常会呈现_objc_msgSend_uncached内的锁,这种状况就属于调用OC办法时没有办法缓存导致需求找办法而堵塞了主线程,能够疏忽。
耗时操作
耗时操作有非有必要发动和有必要发动两种。本文依据发动使命是否影响发动后的用户消费来判别一个发动使命是否有必要。比方关于WKWebview预加载使命来说,这便是一个非有必要发动使命。
针对非有必要发动使命,能够调整履行机遇、打散履行、后台线程履行或许进行懒加载。针对发动必要使命,能够经过下降其自身耗时、调整履行机遇和使其支撑子线程履行等手法来处理。
不同的产品会有不同的非必要发动使命,比方关于西瓜来说,小程序便是一个渗透率极低耗时较大的发动使命。西瓜进入小程序日均pv极少,但每个用户却都需求将其发动,完全是没有必要的。因而西瓜中的小程序服务被做成了懒加载的方式。别的,像直播和创作组件的渗透率也相对较低,未来会考虑将这些组件的发动相同做成懒加载方式。
关于必要的发动使命,除了下降其自身耗时和放在后台线程履行以外,也能够选用一些取巧的手法。比方西瓜的播映器创立耗时较久,为了能够让发动后视频能够尽快播映,质量团队做了播映器预热功用,提早创立好播映器。这个优化的首要规划难点在于怎么选取预热的机遇。经过埋点数据剖析能够承认一个机遇为弹窗弹出时,此时用户会等候一段时刻看弹窗,非常适合做资源预加载。但不是每次发动都有弹窗,因而还需求另一个机遇。看如下Trace,西瓜在发动首屏后会有一段时刻在等候网络恳求,runloop出于空闲状况,那么这个机遇就呼之欲出,在发动首屏后。
在承认机遇后,只需求运用西瓜的发动器在首屏后的闲时行列调用播映器预热即可。
@implementation XIGPlayerPreheatTask
XIGRegisterStartUpTaskFunction() {
[XIGStartUp registerTaskClass:XIGPlayerPreheatTask.class inStage:XIGStartUpLaunchCompletion];
}
- (void)execute {
//调用播映器预热
}
- (XIGStartUpTaskQueue)taskQueue {
return XIGStartUpTaskMainIdleQueue;
}
@end
最终,除了重视主线程的耗时操作之外,还应当适度重视子线程的耗时操作。在低端机设备上,CPU仅有两个核心,因而因而在发动时子线程的操作会与主线程竞赛CPU资源。将部分发动组件内部的行列调用移除后,运用ByTest对低端机进行测验,发动首刷耗时有了较显著的下降。
烘托优化
西瓜的主页视图层级如下,一般状况下榜首个cell会是重视频道,第二个cell会是引荐频道,在发动后会定位在引荐频道。因为UICollectionView的特性决议了在改写数据时无法越过重视频道直接改写引荐频道,因而长期以来西瓜发动后均会恳求两个数据流接口并烘托两个列表,存在额定的耗时开支。
处理这个问题的优化计划是在发动初次改写时,在collectionView:collectionView cellForItemAtIndexPath:中问询上层是否能够调用该cell的改写逻辑,而上层依据该cell是否是发动后需求定位的频道来决议。
网络优化
除了纯网络层面的优化如运用QUIC协议、恳求预建链和精简字段等,客户端层面还能够做一些策略来使得等候网络的耗时削减。西瓜在此方面有三个重要的优化,一个是网络调度,第二个是首刷恳求提早发送,最终是图片和视频预加载。
网络调度
经过线下剖析发现,发动时西瓜的首刷恳求需求进入到网络库中进行排队,而且许多恳求完结后处理数据也十分耗时。针对这些问题,改造每一个发动时网络恳求显然是不现实的。因而,完结一个网络调度器让西瓜在发动时能够决议网络恳求发送机遇才是一个适宜的计划,这样能够让其他恳求在首刷恳求之后建议。现在,西瓜大多数发动时发送的网络恳求都会被延迟到首刷展现之后。
当然,发动并不只有点击App一种,西瓜还存在其他发动模式,比方点击Widget发动西瓜进“我的”页面。此时用户的消费场景并不是“主页”而是“我的”页面,因而能够在发动使命中判别进入的页面来决议是否取消网络调度。
首刷恳求提早
在不优化首刷恳求的耗时状况下,想要使等候首刷恳求的时刻变少,那就能够提早恳求首刷。在优化前,首刷恳求会在列表初始化后才开端发送,而且会有多个行列与主行列交互,增加了许多线程调度本钱。优化后,首刷恳求会紧接着网络库的初始化之后发送,并在一个串行行列做好数据解析,网络条件好时,列表创立后就能够直接拿到首刷数据进行烘托。即便网络条件差一点,也会削减等候的时刻。
图片和视频预加载
西瓜的首刷数据解析后需求写入数据库,之后再回调到上层烘托和展现,为了充分运用等候数据解析和视图烘托的时刻,西瓜在数据解析后就开端提早预加载图片和榜首个视频。
防劣化与监控
随着西瓜发动功用的持续优化,西瓜的发动耗时得到了有用的管理。为了保持优化作用以及感知线上劣化状况,引入了防劣化和监控环节。
线下防劣化
全体流程
线下防劣化选用了ByTest(质量渠道)目标防劣化计划,其全体流程如下。每天会触发两次功用防劣化服务,每次触发服务后会构建一个功用防劣化包交付ByTest进行主动化测验。App在发动后上报发动相关埋点数据到Slardar(端监控渠道),ByTest在获取埋点数据后经过算法排除去反常数据,判别劣化状况并发送通知。
相关负责人收到通知后能够经过ByTest供给的数据进一步剖析劣化状况,现在ByTest供给了一套主动化剖析报告,其间会供给基准包与测验包的Trace、Settings(客户端配置服务)差异以及主页的截图。从中能够发现比较明显的劣化问题。有时问题比较复杂,主动化剖析并不一定精确,能够运用ByTrace(功用剖析渠道)调用栈或火焰图来进行辅助分。如果都不能起到帮忙,则需求本地运用Instruments进行剖析。
安稳性
为了保证主动化测验的安稳性,ByTest同学做了许多控制变量的作业,进步测验成果的置信度,减小噪音搅扰。比方在设备层面控制了CPU和GPU的频率、封闭了发动闭包、退出Apple ID和iCloud ID。在网络层面经过代理服务器完结网络恳求的录制与回放来削减了网络恳求中触及的环节,下降了网络恳求中的噪音。
除此之外,在埋点数据处理时,ByTest也会过滤掉反常点,并供给一次劣化告警的置信度来进一步保证数据的安稳性。
作用
在年初接入ByTest防劣化服务前,西瓜榜首个+load到首屏的目标存在大幅度的变化。在接入后,因为持续的防劣化消费,使得目标保持在一个相对安稳的水平。
线上目标监控
与线下防劣化相对应的,还需求建立线上目标监控。若一个发动相关功用由Settings控制,仅在发布后翻开开关,那么很大或许会在线下防劣化的过程中逃逸,因而线上目标监控能够及时发现问题。现在西瓜已经在Tea(行为剖析体系)上的看板建立了多个维度的监控,包括发动的各个链路。
二进制重排主动化
二进制重排是一个比较有收益的发动优化,但在过去很长一段时刻中,orderfile文件是手动跑出来的,比较耗费人力。在研制渠道和QA同学的帮忙下,现在更新orderfile的流程已经完全主动化,流程如下。依据研制渠道日历,每天会判别是否是一灰最终一天,若判别成功会触发二进制重排云构建打包,传包到ByTest服务进行发动测验来获取Trace文件,在服务端生成orderfile后主动建议MR。
总结
西瓜是一个迭代迅速的产品,每周在都会合入许多发动相关的代码,因而除了优化之外,还需求重视防劣化,避免一边优化一边劣化而相互抵消作用。别的,在优化与防劣化之外,架构的建造也十分重要,合理的架构能够进步App安稳性和研制功率。现在西瓜的发动架构还存在较多历史包袱,未来质量团队将会进一步重构,并持续探究发动的其他优化途径,如分场景分人群发动、首屏烘托功用等等。