随着App不断迭代,使的业务模块增加,逻辑变得复杂,集成了更多的第三方库,App 启动也会越来越慢,因此我们希望能在业务扩张的同时,保持较好的启动速度,给用户带来良好的体验。
为了更准确地了解 App 冷启动的流程,我们需要掌握一些基本的概念
Mach-O(Mach Object File Format)是一种用于记录可执行文件、对象代码、共享库、动态加载代码和内存转储的文件格式。App 编译生成的二进制 可执行文件就是 Mach-O 格式的,iOS 工程所有的类编译后会生成对应的目标文件 .o
文件,而这个可执行文件就是这些 .o
文件的集合。
在 Xcode 的控制台输入以下命令,可以打印出运行时所有加载进应用程序的 Mach-O 文件。
image list -o -f
Mach-O 文件主要由三部分组成:
Data:Data 中的每个段(segment)的数据都保存在这里,每个段都有一个或多个 Section,它们存放了具体的数据与代码,主要包含这三种类型:
__TEXT
包含 Mach header,被执行的代码和只读常量(如C 字符串)。只读可执行(r-x)。__DATA
包含全局变量,静态变量等。可读写(rw....)。__LINKEDIT
包含了加载程序的 元数据,比如函数的名称和地址。只读(r...)。dylib 也是一种 Mach-O 格式的文件,后缀名为 .dylib
的文件就是动态库(也叫动态链接库)。动态库是运行时加载的,可以被多个 App 的进程共用。
如果想知道 TestDemo 中依赖的所有动态库,可以通过下面的指令实现:
otool -L /TestDemo.app/TestDemo
动态链接库分为 系统 dylib 和 内嵌 dylib(embed dylib,即开发者手动引入的动态库)。系统 dylib 有:
dyld(Dynamic Link Editor):动态链接器,其本质也是 Mach-O 文件,一个专门用来加载 dylib 文件的库。 dyld 位于 /usr/lib/dyld
,可以在 mac 和越狱机中找到。dyld 会将 App 依赖的动态库和 App 文件加载到内存后执行。
dyld shared cache 就是动态库共享缓存。当需要加载的动态库非常多时,相互依赖的符号也更多了,为了节省解析处理符号的时间,OS X 和 iOS 上的动态链接器使用了共享缓存。OS X 的共享缓存位于 /private/var/db/dyld/
,iOS 的则在 /System/Library/Caches/com.apple.dyld/
。
当加载一个 Mach-O 文件时,dyld 首先会检查是否存在于共享缓存,存在就直接取出使用。每一个进程都会把这个共享缓存映射到了自己的地址空间中。这种方法大大优化了 OS X 和 iOS 上程序的启动时间。
dyld shared cache 就是动态库共享缓存。当需要加载的动态库非常多时,相互依赖的符号也更多了,为了节省解析处理符号的时间,OS X 和 iOS 上的动态链接器使用了共享缓存。OS X 的共享缓存位于 /private/var/db/dyld/
,iOS 的则在 /System/Library/Caches/com.apple.dyld/
。
当加载一个 Mach-O 文件时,dyld 首先会检查是否存在于共享缓存,存在就直接取出使用。每一个进程都会把这个共享缓存映射到了自己的地址空间中。这种方法大大优化了 OS X 和 iOS 上程序的启动时间。
images 在这里不是指图片,而是 镜像。每个 App 都是以 images 为单位进行加载的。images 类型包括:
dlopen()
加载。framework 可以是动态库,也是静态库,是一个包含 dylib、bundle 和头文件的文件夹。
当用户按下 home 键,iOS App 不会立刻被 kill,而是存活一段时间,这段时间里用户再打开 App,App 基本上不需要做什么,就能还原到退到后台前的状态。我们把 App 进程还在系统中,无需开启新进程的启动过程称为 热启动。
而 冷启动则是指 App 不在系统进程中,比如设备重启后,或是手动杀死 App 进程,又或是 App 长时间未打开过,用户再点击启动 App 的过程,这时需要创建一个新进程分配给 App。我们可以将冷启动看作一次完整的 App 启动过程,本文讨论的就是冷启动的优化。
WWDC 2016 中首次出现了 App 启动优化的话题,其中提到:
说法一:
冷启动的整个过程是指从用户唤起 App 开始到 AppDelegate 中的 didFinishLaunchingWithOptions
方法执行完毕为止,并以执行 main()
函数的时机为分界点,分为 pre-main
和 main()
两个阶段。
说法二:
也有一种说法是将整个冷启动阶段以主 UI 框架的 viewDidAppear
函数执行完毕才算结束。这两种说法都可以,前者的界定范围是 App 启动和初始化完毕,后者的界定范围是用户视角的启动完毕,也就是首屏已经被加载出来。
注意:这里很多文章都会把第二个阶段描述为 main 函数之后,个人认为这种说法不是很好,容易让人误解。要知道 main 函数在 App 运行过程中是不会退出的,无论是 AppDelegate 中的
didFinishLaunchingWithOptions
方法还是 ViewController 中的viewDidAppear
方法,都还是在 main 函数内部执行的。
pre-main
阶段指的是从用户唤起 App 到 main()
函数执行之前的过程。
1.我们可以在 Xcode 中配置环境变量
Product -> Edit Scheme -> Run -> Arguments ->Environment Variables -> +
DYLD_PRINT_STATISTICS 设置为 1
这时在 iOS 10 以上系统中运行这个 Demo, pre-main
阶段的启动时间会在控制台中打印出来(备注:本人x-code 已经升级到13.3,无法打印出日志)
如果要更详细的信息,就设置 DYLD_PRINT_STATISTICS_DETAILS
为 1。
可以采用下面的方法
代码贴出来如下
#import
NS_ASSUME_NONNULL_BEGIN
@interface AppLaunchTime : NSObject
+ (void)mark;
@end
#import "AppLaunchTime.h"
#import
#import
@implementation AppLaunchTime
double __t1; // 创建进程时间
double __t2; // before main
double __t3; // didfinsh
/// 获取进程创建时间
+ (CFAbsoluteTime)processStartTime
{
if (__t1 == 0)
{
struct kinfo_proc procInfo;
int pid = [[NSProcessInfo processInfo] processIdentifier];
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(procInfo);
if (sysctl(cmd, sizeof(cmd)/sizeof(*cmd), &procInfo, &size, NULL, 0) == 0) {
__t1 = procInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + procInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
}
}
return __t1;
}
/// 开始记录:在DidFinish中调用
+ (void)mark
{
double __t1 = [AppLaunchTime processStartTime];
dispatch_async(dispatch_get_main_queue(), ^{ // 确保didFihish代码执行后调用
if (__t3 == 0)
{
__t3 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
double pret = __t2 - __t1 / 1000;
double didfinish = __t3 - __t2;
double total = __t3 - __t1 / 1000;
NSLog(@"----------App启动---------耗时:pre-main:%f",pret);
NSLog(@"----------App启动---------耗时:didfinish:%f",didfinish);
NSLog(@"----------App启动---------耗时:total:%f",total);
});
}
// 构造方法在main调用前调用
// 获取pre-main()阶段的结束时间点相对容易,可以直接取main()主函数的开始执行时间点.推荐使用__attribute__((constructor)) 构建器函数的被调用时间点作为pre-main()阶段结束时间点:__t2能最大程度实现解耦:
void static __attribute__ ((constructor)) before_main()
{
if (__t2 == 0)
{
__t2 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
}
代用运行打印
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[AppLaunchTime mark];
return YES;
}
日志打印
冷启动优化[3454:116391] ----------App启动---------耗时:pre-main:0.718716
冷启动优化[3454:116391] ----------App启动---------耗时:didfinish:0.028895
冷启动优化[3454:116391] ----------App启动---------耗时:total:0.747611
启动一个应用时,系统会通过 fork()
方法来新创建一个进程,然后执行镜像通过 exec()
来替换为另一个可执行程序,然后执行如下操作:
结合上面 pre-main
打印的结果,我们可以大致了解整个启动过程如下图所示:
这一步,指的是 动态库加载。在此阶段,dyld 会:
mmap()
。一般情况下,iOS App 需要加载 100-400 个 dylibs。这些动态库包括系统的,也包括开发者手动引入的。其中大部分 dylib 都是系统库,系统已经做了优化,因此开发者更应关心自己手动集成的内嵌 dylib,加载它们时性能开销较大。
App 中依赖的 dylib 越少越好,Apple 官方建议尽量将内嵌 dylib 的个数维持在6个以内。
优化方案:
optional
和 required
设置,如果 framework 在当前的 App 支持的 iOS 系统版本中都存在,就设为 required
,因为设为 optional
会有额外的检查;dlopen()
对性能会产生影响,因为 App 启动时是原本是单线程运行,系统会取消加锁,但 dlopen()
开启了多线程,系统不得不加锁,这样不仅会使性能降低,可能还会造成死锁及未知的后果,不是很推荐这种做法。)这一步,做的是 指针重定位。
在 dylib 的加载过程中,系统为了安全考虑,引入了 ASLR(Address Space Layout Randomization)技术和代码签名。由于 ASLR 的存在,镜像会在新的随机地址(actual_address)上加载,和之前指针指向的地址(preferred_address)会有一个偏差(slide,slide=actual_address-preferred_address),因此 dyld 需要修正这个偏差,指向正确的地址。具体通过这两步实现:
第一步: Rebase,在 image 内部调整指针的指向。将 image 读入内存,并以 page 为单位进行加密验证,保证不会被篡改,性能消耗主要在 IO。
第二步: Binding,符号绑定。将指针指向 image 外部的内容。查询符号表,设置指向镜像外部的指针,性能消耗主要在 CPU 计算。
通过以下命令可以查看 rebase 和 bind 等信息:
xcrun dyldinfo -rebase -bind -lazy_bind TestDemo.app/TestDemo
通过 LC_DYLD_INFO_ONLY 可以查看各种信息的偏移量和大小。如果想要更方便直观地查看,推荐使用 MachOView 工具。
指针数量越少,指针修复的耗时也就越少。所以,优化该阶段的关键就是减少 __DATA
段中的指针数量。
优化方案:
__DATA
段中创建结构。)完成 Rebase 和 Bind 之后,通知 runtime 去做一些代码运行时需要做的事情:
优化方案:
Rebase/Binding 阶段优化好了,这一步的耗时也会相应减少。
Rebase 和 Binding 属于静态调整(fix-up),修改的是 __DATA
段中的内容,而这里则开始动态调整,往堆和栈中写入内容。具体工作有:
+load
方法;attribute((constructor))
修饰的函数);优化方案:
+load
方法中初始化,可以推迟到 +initiailize
中进行;(因为在一个 +load
方法中进行运行时方法替换操作会带来 4ms 的消耗)__atribute__((constructor))
将方法显式标记为初始化器,而是让初始化方法调用时再执行。比如用 dispatch_once()
、 pthread_once()
或 std::once()
,相当于在第一次使用时才初始化,推迟了一部分工作耗时。:总结一下 pre-main
阶段可行的优化方案:
+load
方法中执行的任务延迟到 +initialize
中对于 main()
阶段,主要测量的就是从 main()
函数开始执行到 didFinishLaunchingWithOptions
方法执行结束的耗时。
这里介绍两种查看 main()
阶段耗时的方法。
第一步:在 main() 函数里用变量 MainStartTime 记录当前时间
#import
#import "AppDelegate.h"
CFAbsoluteTime MainStartTime;
int main(int argc, char * argv[])
{
NSString * appDelegateClassName;
MainStartTime = CFAbsoluteTimeGetCurrent();
@autoreleasepool {
appDelegateClassName = NSStringFromClass([AppDelegate class]);
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
第二步:在 AppDelegate.m 文件中用 extern 声明全局变量
第三步:在 didFinishLaunchingWithOptions 方法结束前,再获取一下当前时间,与 MainStartTime 的差值就是 main() 函数阶段的耗时
#import "AppDelegate.h"
#import "AppLaunchTime.h"
extern CFAbsoluteTime MainStartTime;
@interface AppDelegate ()
@end
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[AppLaunchTime mark];
double mainLaunchTime = (CFAbsoluteTimeGetCurrent() - MainStartTime);
NSLog(@"main() 阶段耗时:%.2fms", mainLaunchTime * 1000);
return YES;
}
//日志:
//冷启动优化[3616:126842] main() 阶段耗时:21.92ms
打开方式为: Xcode → Open Developer Tool → Instruments → Time Profiler
。
Edit Scheme
找到 Profile
下的 Build Configuration
,设置为 Debug
。 2. 配置 PROJECT。点击 PROJECT,在 Build Settings
中找到 Build Options
选项里的 Debug Information Format
,把 Debug
对应的值改为 DWARF with dSYM File
。
3. 启动 Time Profiler,点击左上角红色圆形按钮开始检测,然后就可以看到执行代码的完整路径和对应的耗时。
为了方面查看应用程序中实际代码的执行耗时和代码路径实际所在的位置,可以勾选上 Call Tree
中的 Separate Thread
和 Hide System Libraries
。
main()
被调用之后, didFinishLaunchingWithOptions
阶段,App 会进行必要的初始化操作,而 viewDidAppear
执行结束之前则是做了首页内容的加载和显示。
关于 App 的初始化,除了统计、日志这种须要在 App 一启动就配置的事件,有一些配置也可以考虑延迟加载。如果你在 didFinishLaunchingWithOptions
中同时也涉及到了 首屏的加载,那么可以考虑从这些角度优化:
可参考 VasSonic 的原理 Sonic是腾讯团队研发的一个轻量级的高性能的Hybrid框架,专注于提升页面首屏加载速度
终端耗时
页面耗时(静态页面)
页面耗时(经常需要动态更新的页面)
冷启动本就是一个比较复杂的流程,它的优化没有固定的方式,我们需要结合业务,配合一些性能分析工具和线上监控日志,灵活应用