李峰峰博客

APP 启动优化 2-优化方案

2021-04-10

一、启动时长监控

1、阶段划分

一般而言,对于冷启动时长,一般是统计从用户点击图标到首屏展示出来这段时间的时长。这个过程可以粗粒度分为下面两个阶段进行统计:

这里一般是使用 root viewController 的 viewDidAppear 时间作为首屏渲染完成时间,也就是 APP 首屏加载完成用户可以交互的时间。

Pre Main 阶段,进程创建到执行 main() 的时间:

  • 加载动态库:加载系统动态库及三方动态库
  • Rebase & Bind:修复 Mach-O 内外指针偏移
  • Objc setup:初始化 objc runtime、注册 sel、加载 category 等
  • Initializers:调用 +load 方法,调用 static initializer 相关方法等

After Main 阶段,执行 main() 到首屏渲染完成的时间:

  • SDK 注册
  • 业务初始化
  • 首屏数据加载 & UI 绘制

当然这只是较粗粒度的划分,基本大部分 APP 都是划分成类似阶段。部分 APP 的 After Main 阶段定义为“执行 main()”到 “didFinishLuanching 执行结束”,这不是非常好的划分方式,因为对于用户来说,用户真正等待的时间是点击图标到 APP 首页展示完成能够交互的这段时间,而“didFinishLuanching 执行结束”时首页未必渲染完成。

对于 After Main 阶段,由于可能会有大量 SDK 注册、业务初始化等任务。为了能准确定位各任务耗时情况,一般需要对这些任务进行集中管理,监控任务执行耗时。

部分 APP 为了能够更加细致地分析各阶段耗时情况,可能会把 APP 冷启动划分为更加细粒度的几个阶段,例如抖音将冷启动划分成了下面这些阶段:

2、线上耗时监控

(1) 节点时间上报

只要启动阶段划分完成了,对于监控耗时情况就比较容易了,只需要获取各阶段节点发生的时间进行计算和上报即可。根据前面我们对 Pre Main 和 After Main 两个阶段的划分,对于“执行 mian()”和“首屏渲染完成”这两个节点,我们只需要在对应时刻插入统计逻辑即可。对于“进程创建时间”,可以通过 sysctl 系统调用拿到进程创建的时间戳:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 需要先导入头文件
#import <sys/sysctl.h>

+ (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 {
NSAssert(NO, @"无法取得进程的信息");
return 0;
}
}

+ (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;
}

iOS 9 之前 sysctl 还能获取到其他进程的信息,后来 Apple 为了隐私安全禁止了 sysctl 获取其他进程的信息,但是仍然允许获取当前进程的信息。

(2) 无侵入监控方案

无侵入监控的好处是降低使用者的学习成本,无需更改 APP 的现有逻辑。无侵入埋点对冷启动的定义仍然可以是“进程创建”到“首屏渲染完成”这段时间,这里可以参考字节的 APM 团队提供的一种无侵入的启动监控方案,方案将启动流程拆分成几个粒度比较粗的与业务无关的阶段:进程创建,最早的 +loaddidFinishLuanching 开始和首屏首次绘制完成。

前三个时间点无侵入获取较为简单

  • 进程创建:通过 sysctl 系统调用拿到进程创建的时间戳(实现方式前面提到了)。
  • 最早的 +load+load、initializer 的调用顺序和链接顺序有关,链接顺序默认按照 CocoaPod 的 Pod 命名升序排列,所以将组件命名为 AAA 开头既可以让某个 +load、initializer 第一个被执行。
  • didFinishLaunching:监控 SDK 初始化一般在启动的很早期,用监控 SDK 的初始化时间作为 didFinishLaunching 的时间。

那么问题来了:
如何无侵入获取“首屏渲染完成”时间?

iOS 13 开始,Apple 提供了 MetricKit 用于统计 APP 的性能数据,包括 APP 启动时间、电池使用、磁盘 IO 等性能数据,iOS 系统会自动收集收集这些性能数据进行上报,在 Xcode 11 版本我们可以在 Window - Organizer 中看到 APP 的这些数据:

MetricKit 官方的统计的冷启动结束时间(首屏渲染完成时间)是完成第一个 CA::Transaction::commit 的时间,这点我们可以与之对齐。而我们如何准确获取到这个时间呢?

CATransaction 的 setCompletionBlock: 方法会在 CATransaction commit 结束被调用,所以我们可以利用这个 API 获取完成第一个 CA::Transaction::commit 的时间:

1
2
3
4
5
6
[CATransaction setCompletionBlock:^{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// 首屏渲染完成
});
}];

经过测试可以发现,该 Block 回调时间在首个 ViewController(例如 TabBarViewController) 的 viewWillAppear 和 viewDidAppear 之间,相对来说还是比较准确的。

关于 MetricKit 工具,除了系统自动收集上报前面提到的那些数据外,MetricKit 还会统计上报 OSSignpost 事件持续的时间,所以我们可以借助 OSSignpost 完成一些自定义指标的收集。MetricKit 还会在一天结束后,将过去 24 小时搜集的性能数据归集在一起,然后在下一次启动 App 后,在 delegate 的回调中提供给我们(开发阶段可以手动触发:Xcode > Debug > Simulate MetricKit Payloads),这些数据已经做了聚合计算,划分为 50 分位数、90 分位数和 95 分位数的统计数据,拿到这些数据后我们可以直接用柱状图展示。所以我们也可以借助 MetricKit 相关 API 完成关键数据的收集,关于 MetricKit 用法网上有较多资料,这里不再补充。

3、调试工具

(1) Xcode 打印 pre-main 时间

Xcode 提供了一个很便捷的方式打印 pre-main 阶段耗时情况,开启方式为:在 Edit scheme -> Run -> Arguments -> Environment Variables 中将环境变量 DYLD_PRINT_STATISTICS 设为 1,就可以看到 main 之前各个阶段的时间消耗。

1
2
3
4
5
6
7
8
9
10
11
12
13
Total pre-main time:  80.58 milliseconds (100.0%)
dylib loading time: 145.27 milliseconds (180.2%)
rebase/binding time: 126687488.6 seconds (128083892.1%)
ObjC setup time: 136.30 milliseconds (169.1%)
initializer time: 91.84 milliseconds (113.9%)
slowest intializers :
libSystem.B.dylib : 6.23 milliseconds (7.7%)
libBacktraceRecording.dylib : 6.51 milliseconds (8.0%)
libobjc.A.dylib : 11.04 milliseconds (13.7%)
CoreFoundation : 5.64 milliseconds (7.0%)
Foundation : 4.91 milliseconds (6.1%)
libMainThreadChecker.dylib : 28.61 milliseconds (35.5%)
libLLVMContainer.dylib : 24.80 milliseconds (30.7%)

如果将将环境变量 DYLD_PRINT_STATISTICS_DETAILS 设为 1,可以看到更加详细的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
total time: 2.9 seconds (100.0%)
total images loaded: 341 (334 from dyld shared cache)
total segments mapped: 21, into 383 pages
total images loading time: 2.7 seconds (94.8%)
total load time in ObjC: 8.29 milliseconds (0.2%)
total debugger pause time: 2.5 seconds (88.5%)
total dtrace DOF registration time: 0.00 milliseconds (0.0%)
total rebase fixups: 16,242
total rebase fixups time: 1.69 milliseconds (0.0%)
total binding fixups: 496,287
total binding fixups time: 92.61 milliseconds (3.1%)
total weak binding fixups time: 0.01 milliseconds (0.0%)
total redo shared cached bindings time: 159.41 milliseconds (5.4%)
total bindings lazily fixed up: 0 of 0
total time in initializers and ObjC +load: 48.24 milliseconds (1.6%)
libSystem.B.dylib : 5.00 milliseconds (0.1%)
libBacktraceRecording.dylib : 6.48 milliseconds (0.2%)
libMainThreadChecker.dylib : 30.63 milliseconds (1.0%)
total symbol trie searches: 1168220
total symbol table binary searches: 0
total images defining weak symbols: 35
total images using weak symbols: 89

(2) App Launch

App Launch 是 Xcode 11 后新出的模板,同时包含了 Time Profile 以及 System Trace 的功能,可以用于查看 App 的启动过程,从而可以针对性的对启动速度进行优化,使用方式如下:

  • 首先在 Xcode 的 build settings 中 Debug Information Format 设置为 DWARF with dsYM File (用于符号化地址)
  • Xcode 编译运行项目
  • 通过 Xcode –> Open Developer Tool –> Instruments –> APP Launch 启动应用,App Launch 会启动应用 5 秒后自动关闭应用。

之后就可以查看主线程被阻塞的情况,再借助 Time Profile 查看调用堆栈定位耗时任务进行优化即可。

(3) OSSignpost

OSSignpost 是 iOS 12 推出的可以用于在 instruments 里标记时间段的 API,性能非常高,可以认为对启动无影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <os/signpost.h>

// 创建一个 log 对象
os_log_t log = os_log_create("com.demo.signpost", "mySignpost");

// 创建 os_signpost 的 ID
os_signpost_id_t spid = os_signpost_id_generate(log);

// 标记开始,或者使用 MXSignpostIntervalBegin(log, event_id, name, ...)
os_signpost_interval_begin(log, spid, "task");

// 要监控的逻辑放到 begin 和 end 之间

// 标记开始,或者使用 MXSignpostIntervalEnd(log, event_id, name, ...)
os_signpost_interval_end(log, spid, "task");

其中 os_log_create 函数有两个参数:

  • 第一个参数 subsystem:标识,反向 DNS 格式,例如 com.demo.signpost
  • 第二个参数 category:分类

os_signpost_interval_begin 和 os_signpost_interval_end 用于标记开始和结束,第三个参数是给时间段的事件名。并且还可以增加第四、五个参数用于携带元数据,例如:

1
2
os_signpost_interval_begin(log, spid, "task", "Start");
os_signpost_interval_end(log, spid, "task", "Finished with size %d", 10);

除此之外,还可以利用 os_signpost_event_emit 函数添加兴趣点,例如:

1
os_signpost_event_emit(log, spid, "task", "testEmit");

除了 MetricKit 会统计使用 OSSignpost 的自定义事件时长数据外,我们还可以借助 Instruments 工具来查看这些数据,先看下 demo 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
os_log_t log = os_log_create("com.demo.signpost", "mySignpost");
os_signpost_id_t spid = os_signpost_id_generate(log);

dispatch_queue_t queue1 = dispatch_queue_create("com.demo.queue1", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue2 = dispatch_queue_create("com.demo.queue2", DISPATCH_QUEUE_SERIAL);

for(int i = 0; i < 10; i++){
dispatch_async(queue1, ^{
[self randomSleep];
os_signpost_interval_begin(log, spid, "task");
[self randomSleep];
if (i == 3) {
// 兴趣点
os_signpost_event_emit(log, spid, "task", "testEmit");
}
os_signpost_interval_end(log, spid, "task");
});

dispatch_async(queue2, ^{
[self randomSleep];
// 可以携带元数据
os_signpost_interval_begin(log, spid, "task2", "xxxxxx");
[self randomSleep];
os_signpost_interval_end(log, spid, "task2");
});
}

运行 demo,然后打开 Instruments 选择 Blank:

点击右上角加号,选择 os_signpost 双击或拖动到左边:

开始调试就可以看到相关数据了:

所以,可以借助这种方式调试启动过程中代码逻辑耗时情况。

(4) 火焰图

火焰图用来分析时间相关的性能瓶颈非常有用,可以直接把业务代码的耗时绘制出来。本质上是使用 hook objc_msgSend 或者编译期插桩在方法的开始和末尾打两个点,以计算这个方法的耗时,然后转换成 Chrome 的标准的 json 格式就可以分析了,例如:

1
2
3
4
CFTimeInterval begin = CACurrentMediaTime();
// do something
CFTimeInterval end = CACurrentMediaTime();
NSLog(@"cost = %@",(end - begin));

可以看到上面是使用 CACurrentMediaTime() 获取的时间,那么这种方式与 NSDateCFAbsoluteTimeGetCurrent() 有何区别呢?

区别:

  • NSDate 属于 Foundation 框架、CFAbsoluteTimeGetCurrent() 属于 CoreFoundation框架、CACurrentMediaTime() 属于 QuartzCore 框架

    • QuartzCore 框架提供了图形处理和视频图像处理的能力,Core Animation 就属于 QuartzCore 框架
  • NSDateCFAbsoluteTimeGetCurrent() 返回网络时间同步的时钟时间。

  • CACurrentMediaTime()mach_absolute_time() 是系统时间,是基于内建时钟的,能够更精确更原子化地测量,并且不会因为外部时间变化而变化(例如时区变化、夏时制、秒突变等),但它和系统的 uptime 有关,系统重启后 CACurrentMediaTime() 会被重置。

应用场景:

  • NSDateCFAbsoluteTimeGetCurrent() 常用于日常时间、时间戳的表示,与服务器之间的数据交互,其中 CFAbsoluteTimeGetCurrent() 相当于[[NSDate data] timeIntervalSinceReferenceDate]
  • CACurrentMediaTime() 常用于测试代码的效率;

知道了如何计算代码的执行时间,那如何以可视化方式展示数据呢?有两种方式,一种是借助 Chrome 内置的 chrome://tracing 工具,我们将统计的数据格式转成该工具所需的 格式即可导入展示。

另一种方式是借助开源工具 AppleTrace,该工具会生成可直观展示数据的 html,实现原理和使用方式可以查看该 github 中贴出的文章《AppleTrace 性能分析工具》。该工具最终生成的火焰图效果如下:

二、优化实践

1、Pre Main 阶段优化

前面已经提到 Pre Main 阶段的任务:

  • 加载动态库
  • Rebase & Bind
  • Objc setup
  • Initializers

我们可以针对这些任务进行针对性优化:

(1) 减少动态库数量

减少动态库数量可以加减少 dyld 3 启动闭包创建和加载动态库阶段的耗时,官方建议动态库数量小于 6 个。减少动态库可以合并动态库或者将动态库转为静态库,相较而言,转为静态库在操作上更容易些,是首选方案。

cocoapods 默认使用的就是静态库,但是如果 Podfile 中加入 use_frameworks! 选项将会使用动态库。

(2) 动态库懒加载

实现动态库懒加载主要分为两步:

  • 第一步:在 Build Phases 的 Link Binary With Libraries 中去掉需要懒加载的动态库;
  • 第二步:运行时使用 dlopen 对动态库按需加载。

苹果系统 API <dlfcn.h> 中提供了几个操作动态库的方法,包括 dlopendlerrordlsymdlclose 等,我们可以使用 dlopen 对启动阶段不需要的动态库在运行时手动加载。

需要注意的是,使用 dlopen 懒加载动态库时,dlopen 会调用 dlopen_preflight 先校验动态库签名,如果动态库没有打包进 APP(和 APP 使用相同签名),真机会加载失败并报签名错误。所以动态库懒加载核心思想是使动态库只参与签名不参与链接,在需要动态库的时候我们手动加载动态库。

非系统的动态库路径是固定的,均位于 “xxx.app/Frameworks/“ 目录下,可以通过 [NSBundle mainBundle].bundlePath 获取 “xxx.app” 所在的目录,然后在必要时候手动加载动态库:

1
2
3
4
5
6
7
8
9
10
11
12
- (char *)dlopenFramework:(NSString *)frameworkName {
// 根据动态库名称获取动态库所在路径
NSString *path = [NSString stringWithFormat:@"%@/Frameworks/%@.framework/%@",
[NSBundle mainBundle].bundlePath,
frameworkName,
frameworkName];
// 调用 dlopen 加载动态库
void* fp = dlopen(path.UTF8String, RTLD_LAZY);
// 获取 dlopen 产生的错误信息
char* err = dlerror();
return err;
}

动态库懒加载也可以使用 [NSBundle loadAndReturnError:][NSBundle load] 这两个 API,他们本质上调用的是底层的 dlopen,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (BOOL)loadFramework:(NSString *)frameworkName {
NSString *path = [NSString stringWithFormat:@"%@/%@", [NSBundle mainBundle].privateFrameworksPath, frameworkName];
NSBundle *bundle = [NSBundle bundleWithPath:path];
if (!bundle) {
NSLog(@"%@ not found", frameworkName);
return NO;
}

NSError *error;
if (![bundle loadAndReturnError:&error]) {
NSLog(@"Load %@ failed: %@", frameworkName, error);
return NO;
} else {
NSLog(@"Load %@ success", frameworkName);
}

return YES;
}

如果将动态库配置成懒加载,就无法直接调用其中的类和方法,因为其符号并不包含在运行内存中。面对这样的情况,我们可以把动态库中提供给外部调用的逻辑收口,使用特定的协议或接口,通过组件化框架在调用接口时尝试加载动态库,再进行实际逻辑的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (Class<FeatureBridge>)getBridgeWithProtocol:(Protocol *)protocol {
NSString *bridgeName = [self getClassNameWithProtocol:protocol];
Class bridgeClass = NSClassFromString(bridgeName);
if (bridgeClass) {
// 已加载:直接返回
return bridgeClass;
} else {
// 未加载:加载动态库后再返回
NSString *frameworkName = [self getFrameworkNameWithProtocol:protocol];
[self dlopenFramework:frameworkName];
return NSClassFromString(bridgeName);
}
}

经验证,手动调用 dlopen 的耗时大于系统 dyld 加载耗时,所以需要避免在主线程直接调用 dlopen 操作。一个有效的解决手段是在启动阶段或者启动完成后,异步预加载所有懒加载的动态库。

对于使用 Cocoapods 管理的项目也可以配置动态库懒加载:在 pod install 之后,会生成 Pods-xxx-frameworks.sh 和 Pods-xxx.release.xcconfig 这两个文件,其中 Pods-xxx-frameworks.sh 文件脚本负责架构剔除和重签名等功能,而 Pods-xxx.xcconfig 文件则负责静态库和动态库的链接配置,我们自定义的动态库想要进行懒加载,只需要修改 xxx.xcconfig 配置文件,将需要懒加载的动态库从配置文件中移除,这样保证懒加载的动态库参与签名和拷贝,但是不参与链接。

需要注意的是使用动态库懒加载是有一定风险的,而且 Apple 官方也不建议懒加载动态库。懒加载动态库的前提是不做频繁升级和变更,但是一旦升级则可能引起灾难性的后果,因为 API 发生变更,编译能正常通过,因为编译是依赖协议进行的,API 发生了变更很可能负责升级的同学并没有对协议进行升级,可以在 debug 环境下利用 Runtime 对相关 API 进行检测。

(3) 移除无用代码

移除无用代码可以减少 Rebase & Bind & Runtime 初始化的耗时,可通过静态扫描方式查找无用代码,最容易的静态扫描是使用 AppCode,但是项目大了之后 AppCode 的索引速度非常慢,另外的一种静态扫描是基于 Mach-O 的:

  • _objc_selrefs_objc_classrefs 存储了引用到的 selclass
  • __objc_classlist 存储了所有的 selclass

二者做个差集就知道那些 class/sel 用不到,实现方式可以参考:objc_cover,由于 OC 支持运行时调用,删除之前还要再二次确认。

(4) +load 优化

过多的 +load 方法会拖慢启动速度,一种优化方式是将 +load 方法中的任务迁移到 +initialize 方法中:

1
2
3
4
5
6
+ (void)initialize {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// ...
});
}

对于有些任务确实需要在启动的早期(+load)里注册的,例如各个组件经常在 +load 方法中注册自己提供的 Service,即 protocol 和 class 对应关系,可以使用 clang attribute 代替。Clang 提供了很多的编译器函数,它们可以完成不同的功能。其中一种就是 section() 函数,section() 函数提供了二进制段的读写能力,它可以将一些编译期就可以确定的常量写入数据段,例如:

1
__attribute__((used, section ("xxx段,xxx节")))

通过使用 __attribute__((section("xxx段,xxx节"))) 来指明将数据存储到哪个段哪个节。__attribute__((used)) 告诉编译器该静态变量即使没有被使用也要保留,防止链接器在优化时将其删除。

借助 clang attribute 取代 +load 进行 Service 注册的 demo 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <dlfcn.h>
#include <mach-o/getsect.h>

struct ServiceInfo {
char *class;
char *protocol;
};

#define ServiceRegister(_class_,_protocol_)\
__attribute__((used, section ("__DATA,__services__"))) static struct ServiceInfo ServiceInfo##_class_ =\
{\
.class = #_class_,\
.protocol = #_protocol_,\
};

使用时:

1
2
ServiceRegister(AAAclass, AAAprotocol);
ServiceRegister(BBBclass, BBBprotocol);

取数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1.根据符号找到所在的 mach-o 文件信息
Dl_info info;
dladdr((__bridge void *)[self class], &info);

// 2.读取 __DATA 中自定义的 __services__ 数据
#ifndef __LP64__
const struct mach_header *mhp = (struct mach_header*)info.dli_fbase;
unsigned long serviceSize = 0;
uint32_t *serviceMemory = (uint32_t*)getsectiondata(mhp, "__DATA", "__services__", &serviceSize);
#else
const struct mach_header_64 *mhp = (struct mach_header_64*)info.dli_fbase;
unsigned long serviceSize = 0;
uint64_t *serviceMemory = (uint64_t*)getsectiondata(mhp, "__DATA", "__services__", &serviceSize);

#endif

// 3.遍历 __services__ 中的数据
unsigned long serviceCount = serviceSize/sizeof(struct ServiceInfo);
struct ServiceInfo *items = (struct ServiceInfo*)serviceMemory;
for(int idx = 0; idx < serviceCount; ++idx) {
NSString *class = [NSString stringWithUTF8String:items[idx].class];
NSString *protocol = [NSString stringWithUTF8String:items[idx].protocol];;
NSLog(@"class:%@,protocol:%@", class, protocol);
}

打印结果:

1
2
class:AAAclass,protocol:AAAprotocol
class:BBBclass,protocol:BBBprotocol

我们使用 MachOView 工具查看 Mach-O 可以看到我们存储的数据:

(5) 减少静态初始化方法的使用

例如减少 __attribute__(constructor) 的使用,这里没有太多需要总结的。
(使用 __attribute__((constructor)) 修饰的函数可以在 main 函数之前调用)。

2、After Main 阶段优化

After Main 阶段主要耗时的地方在于 didFinishLuanching 中的大量启动项,比如 SDK 注册、业务初始化等。所以这一阶段最佳优化方式是对启动项集中管理,并且根据启动项的重要和紧急程度,提供三种启动时机:

  • 同步启动
  • 异步启动
  • 首屏渲染完成后启动

当然也可以根据自己 APP 需求提供其他启动时机,对于那些不是立刻能用到的 SDK 或者某些任务,比如微信分享 SDK,放到首屏渲染完成后启动,这样能有效减少启动项带来的耗时。

对于启动项注册主要有两种方式:

  • .plist 中集中注册启动项;
  • 启动项自注册,即由需要设置启动项的组件自行注册启动项;

对于 .plist 中集中注册启动项的方式,启动项、组件之间产生了耦合,当这个组件被另个 APP 依赖的时候,又需要重新注册启动项,启动项注册逻辑无法复用。而启动项自注册的方式,恰好解决了这个问题。那组件如何自行注册启动项呢?一般是在 +load 方法中进行注册,但是 +load 方法会增加 Pre Main 阶段耗时,所以应当使用前面提到的 clang attribute 相关函数进行注册。

为了准确知道各启动项耗时情况,还应当对每个启动项进行监控,记录耗时时长并进行上报,在开发阶段和上线后跟踪启动项的耗时,及时进行优化。对于获取启动项耗时可以使用前面提到的在任务开始、结束分别使用 CACurrentMediaTime() 获取时间计算差值即可。

而对于首页渲染耗时,则需要根据业务逻辑进行优化,例如缓存必要数据、子线程预加载动画文件等。

参考:
抖音品质建设 - iOS启动优化
从探索到实践,58动态库懒加载实录

Tags: 优化