前言
随着产品的迭代,产品功能越来越多, App 大小越来越大,导致越来越多的体验和性能问题,其中用户首先感知的肯定是启动速度。传统的启动优化有减少不必要代码,懒加载动态库,任务优先级划分等,此类相关优化的策略已经很普遍了,这些优化主要是从减少主线程任务的角度来出发,很难再做出大的提升。
App 启动时都做了些什么?
一般而言,App 的启动时间,指的是从用户点击 App 开始,到用户看到第一个界面之间的时间。启动主要包括三个阶段:
- main() 函数执行前;
- main() 函数执行后;
- 首屏渲染完成后。
main函数之前
WWDC 2016 Session 406优化应用程序启动时间详细介绍了每个步骤以及改进时间的提示,以下是简要的总结说明:
- dylib loading time: 动态加载程序查找并读取应用程序使用的依赖动态库。每个库本身都可能有依赖项。虽然苹果系统框架的加载是高度优化的,但加载嵌入式框架可能会很耗时。为了加快动态库的加载速度,苹果建议您使用更少的动态库,或者考虑合并它们
- 建议的目标是六个额外的(非系统)框架
- Rebase/binding time:修正调整镜像内的指针(重新调整)和设置指向镜像外符号的指针(绑定)。为了加快重新定位/绑定时间,我们需要更少的指针修复。
- 如果有大量(大的是20000)Objective-C类、选择器和类别的应用程序可以增加800ms的启动时间。
- 如果应用程序使用C++代码,那么使用更少的虚拟函数。
- 使用Swift结构体通常也更快。
- ObjC setup time:Objective-C运行时需要进行设置类、类别和选择器注册。我们对重新定位绑定时间所做的任何改进也将优化这个设置时间。
- initializer time:运行初始化程序。如果使用了Objective-C的 +load 方法,请将其替换为 +initialize 方法。
main函数
main函数执行后的阶段,指的是从 main函数执行开始,到 AappDelegate 的 didFinishLaunchingWithOptions 方法里首屏渲染相关方法执行完成。
首屏渲染完成后
首屏渲染后的这个阶段,主要完成的是,非首屏其他业务服务模块的初始化、监听的注册、配置文件的读取等。
减少main函数前的dyld的加载
如何统计各个阶段的执行时间?
Xcode 为我们提供了一个参数DYLD_PRINT_STATISTICS
,配置完成后,就可以获取 pre-main 各个阶段的时间。
运行一下工程,控制台会输出以下内容:
Total pre-main time: 1.1 seconds (100.0%)
dylib loading time: 296.28 milliseconds (25.3%)
rebase/binding time: 135.08 milliseconds (11.5%)
ObjC setup time: 71.43 milliseconds (6.1%)
initializer time: 664.75 milliseconds (56.9%)
slowest intializers :
libSystem.B.dylib : 14.72 milliseconds (1.2%)
YYReader : 668.40 milliseconds (57.2%)
在减少十个动态库后,使用 iPhone 7 测试十组启动数据:
看出来,使用静态库确实可以减少一部分的启动时候,而且减少的动态库越多,效果越明显。
动态库和静态库对比
库类型 | 优点 | 缺点 |
---|---|---|
静态库 | 1、链接时会完整的复制到 mach-o 中 2、由系统一次性加载,效率更高 |
1. 会使主 mach-o 文件增大 |
动态库 | 1、 无需拷贝到主 mach-o 文件中,主 mach-o 体积小 2、可运多个应用程序共享内存中得同一份库文件,节省资源 |
1. 需要额外加载动态库,造成性能损耗 2、动态库在程序运行时由系统动态加载到内存,供程序调用,如果环境缺少动态库或者库的版本不正确,就会导致程序无法运行 |
动态库如何修改为静态库?
- 在公司的git仓库中创建一个私有项目,用于存放私有podspec
- 在
Alamofire
开源代码中找到原作者提供的podspec
文件
Pod::Spec.new do |s|
s.name = 'Alamofire'
s.version = '4.8.1'
s.license = 'MIT'
s.summary = 'Elegant HTTP Networking in Swift'
s.homepage = 'https://github.com/Alamofire/Alamofire'
s.social_media_url = 'http://twitter.com/AlamofireSF'
s.authors = { 'Alamofire Software Foundation' => 'info@alamofire.org' }
s.source = { :git => 'https://github.com/Alamofire/Alamofire.git', :tag => s.version }
s.documentation_url = 'https://alamofire.github.io/Alamofire/'
s.ios.deployment_target = '8.0'
s.osx.deployment_target = '10.10'
s.tvos.deployment_target = '9.0'
s.watchos.deployment_target = '2.0'
s.source_files = 'Source/*.swift'
end
- 拷贝作者的podspec文件,在文件中加入支持静态库打包的代码
Pod::Spec.new do |s|
s.name = 'Alamofire'
s.version = '4.8.1'
s.license = 'MIT'
s.summary = 'Elegant HTTP Networking in Swift'
s.homepage = 'https://github.com/Alamofire/Alamofire'
s.social_media_url = 'http://twitter.com/AlamofireSF'
s.authors = { 'Alamofire Software Foundation' => 'info@alamofire.org' }
s.source = { :git => 'https://github.com/Alamofire/Alamofire.git', :tag => s.version }
s.documentation_url = 'https://alamofire.github.io/Alamofire/'
s.ios.deployment_target = '8.0'
s.osx.deployment_target = '10.10'
s.tvos.deployment_target = '9.0'
s.watchos.deployment_target = '2.0'
# 支持静态库打包
s.static_framework = true
s.source_files = 'Source/*.swift'
end
- 将新生成的文件放入私有仓库中 为了与线上版本进行准确区分可以搞一个特殊的版本号
- 在Pod中引入私有仓库 pod集成特定版本的项目即可
Clang插桩实现二进制重排
自从抖音分析了抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%文章, 二进制重排优化 pre-main 阶段的启动时间自此被大家广为流传 。抖音采用的是静态扫描+运行时trace的方案,目前仍存一些问题:hook Objc_msgSend 无法解决的 纯swift , block , c++ 方法。
通过调研,我们发现clang 插桩的方式可以完美解决抖音遇到问题。
原理
- Page Fault
进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存又进行分页(Page)。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读取数据。
通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比想象的要多:
查看 Page Fault 的数量
我们用到 Instruments 中的 System Trace工具
- 重排
编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数
但如果我们把method1、method5、method6排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理
iOS App之所以能够使用二进制重排,是因为Xcode 已经提供好这个机制 , 并且 libobjc 实际上也是用了二进制重排进行优化 .
- 获取启动加载所有的函数的符号
如何获取所有的符号信息?- Hook: oc 或者 swift @objc dynamic 修饰的方法,调用都会通过 objc_MsgSend 发送消息,hook objc_MsgSend 可以做到这个方法的检测。但如果是可变参数个数,则需要汇编来获取参数
- 二进制静态扫描: Mach-O文件在特定段Segment和Section里存储着符号及函数数据,通过静态扫描Mach-O文件,主要是分析获取load方法和c++ constructor 构造方法。
- clang 汇编插桩: clang 本身已经提供了一个代码覆盖率检测机制SanitizerCoverage
实施
- clang 插桩
使用 clang 自带的 SanitizerCoverage 工具
新建一个测试工程:Xcode -> Build Setting -> Other C Flags,添加-fsanitize-coverage=trace-pc-guard
注: 如果项目内使用了 Swift, 需要在 Other Swift Flags-sanitize-coverage=func 和 -sanitize=undefined__
在 ViewController.m 中添加SanitizerCoverage中代码
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
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.
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
// void *PC = __builtin_return_address(0);
char PcDescr[1024];
// __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
运行工程,查看打印
代码命名 INIT 后面打印的两个指针地址叫 start 和 stop . 那么我们通过 lldb 来查看下从 start 到 stop 这个内存地址里面所存储的到底是啥 .
发现存储的是从 1 到 14 这个序号 . 那么我们来添加一个 oc 方法 .
- (void)test{
}
发现从 0e 变成了 0f . 也就是说存储的 1 到 14 这个序号变成了 1 到 15 .
在添加一个 一些函数 ,oe 变成了 11,也就十进制的 17
void(^block)(void) = ^(void){
};
void test()
{
block();
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
test();
}
也就说明这个stop内存保存的就是工程所有函数的个数.
我们发现,每点击一次屏幕就有3个打印。我们在touchesBegan:touches withEvent:开头设置一个点断,并开启汇编显示(菜单栏Debug→Debug Workflow→Always Show Disassembly)
可以看出,编译器在touchesBegan前面调用了 __sanitizer_cov_trace_pc_guard函数。
通过断点调试,我们发现每次函数的调用,都会先到__sanitizer_cov_trace_pc_guard函数中来,所以我们可以在 __sanitizer_cov_trace_pc_guard 获取 PC 寄存器,根据 PC 寄存器地址从而获取方法的名称。
拿到了全部的符号之后需要保存,但是由于方法会在各个线程执行,所以不能用数组直接保存数据。所以可以把获取到的数据保存在原子队列中,然后我们从队列获取调用的方法名称。
#import <libkern/OSAtomic.h>
#import <dlfcn.h>
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.
for (uint32_t *x = start; x < stop; x++)
*x = (uint32_t)++N; // Guards should start from 1.
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
// 获取函数__sanitizer_cov_trace_pc_guard调用时下个寄存的地址
void *PC = __builtin_return_address(0);
// 根据地址获取调用的方法名称
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC, NULL};
// offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
}
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
typedef struct {
void *pc;
void *next;
} SymbolNode;
+ (BOOL)export {
NSMutableArray <NSString *>* symbolNames = [NSMutableArray array];
while (YES) {
SymbolNode *node = OSAtomicDequeue(&symbolList, offsetof(SymbolNode, next));
if (node == NULL) {
break;
}
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
// 判读是否是 oc 的方法
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
// c 方法前加 _
NSString * symbolName = isObjc? name : [@"_" stringByAppendingString:name];
// 去重
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
// 取反
NSMutableArray *funcs = [[symbolNames reverseObjectEnumerator] allObjects].mutableCopy;
// 去除当前方法的 symbols
[funcs removeObject:[NSString stringWithFormat:@"%s", __FUNCTION__]];
//将结果写入到文件
NSString * funcString = [funcs componentsJoinedByString:@"\n"];
NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
// 写入指定文件夹
return [[NSFileManager defaultManager] createFileAtPath:[NSHomeDirectory() stringByAppendingString:@"/lb.roder"] contents:fileContents attributes:nil];
}
-
从真机上获取order文件
我们把order文件存在了真机上的tmp文件夹中,要怎么拿到呢?
在Window→Devices And Simulators
(快捷键⇧+⌘+2)中:
-
Xcode 是用的链接器叫做 ld , ld 有一个参数叫 Order File , 我们可以通过这个参数配置一个 order 文件的路径,在这个 order 文件中 , 将你需要的符号按顺序写在里面 ,当工程 build 的时候 , Xcode 会读取这个文件 , 打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O
-
校验是重排成功
Build Settings中修改Write Link Map File为YES编译后会生成一个Link Map符号表txt文件
执行⌘ + B构建后,选择Product中的App,在Finder中打开,选择Intermediates.noindex文件夹,找到LinkMap文件,这里是*-LinkMap-normal-arm64.txt。
对比 txt 文档和 order 文件是否可以匹配
同时可以在 System Trace 工具中查看是否减少。
优化
由于每次都要要导入重排的源代码,操作很复杂,所以我把上述的一些代码封装成了一个组件,方便后续的重排文件的生产。
使用起来也很简单
// 第一步
use_frameworks!
platform :ios, '9.0'
target 'QMTracingPCs_Example' do
pod 'QMTracingPCs'
end
// 第二步
// 如果包含三方库,可以添加如下配置
post_install do |installer|
require './Pods/QMTracingPCs/QMTracingPCs/Classes/target_track.rb'
target_track(installer)
end
// 第三步
// 在项目内启动成功的地方,添加如何代码
override func viewDidLoad() {
super.viewDidLoad()
let filePath = NSTemporaryDirectory().appending("/YYTracingPCs.order")
QMTracingPCs.exportSymbols(filePath: filePath)
}
同时在这个工具中添加了二进制重排预分析工具,只需要切换 target 就可以使用
使用方式:
- 在 Target -> Build Settings 下,找到 Write Link Map File 来设置输出与否 , 默认是 no .
- 修改完毕之后,clean 一下,运行工程,Products -> Show in Finder,在mach-o文件上上层目录 __Intermediates.noindex__文件下找到一个txt文件。将其重命名为linked_map.txt
- 从沙盒路径获取 order 文件,
- 把 txt 文件和 order 放在 Desktop 文件夹下,配置路径
// 链接文件和order文件根目录 注意:换成自己的路径字符串
static let BASE_PATH: String = "/Users/mumu/Desktop"
// 链接文件名
static let LINKED_MAP: String = "linked_map.txt"
// order 文件名
static let LB_ORDER: String = "lb.order"
控制台输出
linked map __Text(链接文件):
起始地址:0x1000051ec
结束地址:0x101a38c58
分配的虚拟内存页个数:1677
order symbol(重排文件):
需要重排的符号个数:%:1774
分布的虚拟内存页个数:238
二进制重排后分布的虚拟内存页个数:21
内存缺页中断减少的个数:217
预估节省的时间:108.5 ms
使用二进制重排之后的工程,再次分别编译出 linked_map.txt 和 lb.order 文件,使用此工具再次运行检查,确认重排效果。
linked map __Text(链接文件):
起始地址:0x1000060ac
结束地址:0x102b0c658
分配的虚拟内存页个数:2754
order symbol(重排文件):
需要重排的符号个数:%:3570
分布的虚拟内存页个数:71
二进制重排后分布的虚拟内存页个数:71
内存缺页中断减少的个数:0
预估节省的时间:0.0 ms
PGO 优化
PGO(Performance Guided Optimization), 是 Xoode 提供一种优化方案,但苹果本身的方案放在我们这些采用 CI 工具构建的大型 app 上部署和使用起来较为麻烦,且不利于我们自己去发现分析问题。同时 PGO 是针对 Objective-C 的一种优化方案,对 Swift支持不太好,所有我们就不再做次方面的优化了。
总结
- 通过将动态库转为静态库,我们优化了dylib loading time
- 通过二进制重排,让启动需要的方法排列更紧凑,减少了Page Fault的次数
- 由于代码逻辑的改动,建议三个月做一次重排,生成一次 order 文件。
这篇文章主要讲 pre-mian 之前的一些优化操作,其实 mian 之后也有一些优化操作,比如启动是不必要代码的减少,动态库的懒加载,以及一些非必要启动任务的延后等等。