随着App不断迭代,使的事务模块添加,逻辑变得复杂,集成了更多的第三方库,App 发动也会越来越慢,因而咱们期望能在事务扩张的一起,保持较好的发动速度,给用户带来良好的体会。
一、名词概念理论
为了更精确地了解 App 冷发动的流程,咱们需求把握一些根本的概念
1.1.Mach-O
Mach-O(Mach Object File Format)是一种用于记录可履行文件、对象代码、同享库、动态加载代码和内存转储的文件格式。App 编译生成的二进制可履行文件便是 Mach-O 格式的,iOS 工程一切的类编译后会生成对应的目标文件 .o
文件,而这个可履行文件便是这些 .o
文件的调集。
在 Xcode 的控制台输入以下命令,能够打印出运转时一切加载进应用程序的 Mach-O 文件。
image list -o -f
Mach-O 文件首要由三部分组成:
- Mach header:描绘 Mach-O 的 CPU 架构、文件类型以及加载命令等;
- Load commands:描绘了文件中数据的详细安排结构,不同的数据类型运用不同的加载命令;
-
Data:Data 中的每个段(segment)的数据都保存在这儿,每个段都有一个或多个 Section,它们存放了详细的数据与代码,首要包括这三种类型:
-
__TEXT
包括 Mach header,被履行的代码和只读常量(如C 字符串)。只读可履行(r-x)。
-
-
__DATA
包括全局变量,静态变量等。可读写(rw….)。
-
__LINKEDIT
包括了加载程序的元数据,比方函数的名称和地址。只读(r…)。
1.2.dylib
dylib 也是一种 Mach-O 格式的文件,后缀名为 .dylib
的文件便是动态库(也叫动态链接库)。动态库是运转时加载的,能够被多个 App 的进程共用。
假如想知道 TestDemo 中依靠的一切动态库,能够经过下面的指令完结:
otool -L /TestDemo.app/TestDemo
动态链接库分为体系 dylib 和内嵌 dylib(embed dylib,即开发者手动引进的动态库)。体系 dylib 有:
- iOS 中用到的一切体系 framework,比方 UIKit、Foundation;
- 体系级别的 libSystem(如 libdispatch(GCD) 和 libsystem_blocks(Block));
- 加载 OC runtime 办法的 libobjc;
1.2.1.dyld
dyld(Dynamic Link Editor):动态链接器,其本质也是 Mach-O 文件,一个专门用来加载 dylib 文件的库。 dyld 坐落 /usr/lib/dyld
,能够在 mac 和越狱机中找到。dyld 会将 App 依靠的动态库和 App 文件加载到内存后履行。
1.2.2.dyld shared cache
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 上程序的发动时刻。
1.2.3.images
images 在这儿不是指图片,而是镜像。每个 App 都是以 images 为单位进行加载的。images 类型包括:
- executable:应用的二进制可履行文件;
- dylib:动态链接库;
- bundle:资源文件,归于不能被链接的 dylib,只能在运转时经过
dlopen()
加载。
1.2.4.framework
framework 能够是动态库,也是静态库,是一个包括 dylib、bundle 和头文件的文件夹。
二、冷发动相关(主页为原生)
当用户按下 home 键,iOS App 不会马上被 kill,而是存活一段时刻,这段时刻里用户再翻开 App,App 根本上不需求做什么,就能还原到退到后台前的状态。咱们把 App 进程还在体系中,无需敞开新进程的发动进程称为热发动。
而冷发动则是指 App 不在体系进程中,比方设备重启后,或是手动杀死 App 进程,又或是 App 长时刻未翻开过,用户再点击发动 App 的进程,这时需求创立一个新进程分配给 App。咱们能够将冷发动看作一次完好的 App 发动进程,本文评论的便是冷发动的优化。
1.冷发动:
1.1冷发动的出处
WWDC 2016 中首次出现了 App 发动优化的话题,其间说到:
- App 发动最佳速度是400ms以内,由于从点击 App 图标发动,然后 Launch Screen 出现再消失的时刻便是400ms;
- App 发动最慢不得大于20s,否则进程会被体系杀死;(发动时刻最好以 App 所支撑的最低装备设备为准。)
1.1.1关于冷发动的两种说法:
说法一:
冷发动的整个进程是指从用户引发 App 开端到 AppDelegate 中的 didFinishLaunchingWithOptions
办法履行完毕为止,并以履行 main()
函数的时机为分界点,分为 pre-main
和 main()
两个阶段。
说法二:
也有一种说法是将整个冷发动阶段以主 UI 结构的 viewDidAppear
函数履行完毕才算完毕。这两种说法都能够,前者的界定规模是 App 发动和初始化完毕,后者的界定规模是用户视角的发动完毕,也便是首屏现已被加载出来。
留意:这儿许多文章都会把第二个阶段描绘为 main 函数之后,个人认为这种说法不是很好,简单让人误解。要知道 main 函数在 App 运转进程中是不会退出的,无论是 AppDelegate 中的
didFinishLaunchingWithOptions
办法仍是 ViewController 中的viewDidAppear
办法,都仍是在 main 函数内部履行的。
1.2.pre-main 阶段
pre-main
阶段指的是从用户引发 App 到 main()
函数履行之前的进程。
1.2.1检查阶段耗时(以xcode13为‘分水岭’)
1.2.1.1. Xcode13之前
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。
1.2.1.2在Xcode13之后上面的办法就失效了
能够选用下面的办法
代码贴出来如下
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface AppLaunchTime : NSObject
+ (void)mark;
@end
#import "AppLaunchTime.h"
#import <sys/sysctl.h>
#import <mach/mach.h>
@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
1.2.2.发动进程剖析与优化
1.2.2.1.整体发动进程剖析
发动一个应用时,体系会经过 fork()
办法来新创立一个进程,然后履行镜像经过 exec()
来替换为另一个可履行程序,然后履行如下操作:
- 把可履行文件加载到内存空间,从可履行文件中能够剖分出 dyld 的途径;
- 把 dyld 加载到内存;
- dyld 从可履行文件的依靠开端,递归加载一切的依靠动态链接库 dylib 并进行相应的初始化操作。
结合上面 pre-main
打印的结果,咱们能够大致了解整个发动进程如下图所示:
1.2.2.2.Load Dylibs
这一步,指的是动态库加载。在此阶段,dyld 会:
- 剖析 App 依靠的一切 dylib;
- 找到 dylib 对应的 Mach-O 文件;
- 翻开、读取这些 Mach-O 文件,并验证其有效性;
- 在体系内核中注册代码签名;
- 对 dylib 的每一个 segment 调用
mmap()
。
一般情况下,iOS App 需求加载 100-400 个 dylibs。这些动态库包括体系的,也包括开发者手动引进的。其间大部分 dylib 都是体系库,体系现已做了优化,因而开发者更应关怀自己手动集成的内嵌 dylib,加载它们时功能开销较大。
App 中依靠的 dylib 越少越好,Apple 官方主张尽量将内嵌 dylib 的个数维持在6个以内。
优化计划:
- 尽量不运用内嵌 dylib;
- 兼并已有内嵌 dylib;
- 检查 framework 的
optional
和required
设置,假如 framework 在当时的 App 支撑的 iOS 体系版本中都存在,就设为required
,由于设为optional
会有额外的检查;
- 运用静态库作为替代;(不过静态库会在编译期被打进可履行文件,形成可履行文件体积增大,两者各有利弊,开发者自行权衡。)
- 懒加载 dylib。(但运用
dlopen()
对功能会产生影响,由于 App 发动时是原本是单线程运转,体系会取消加锁,但dlopen()
敞开了多线程,体系不得不加锁,这样不仅会使功能下降,可能还会形成死锁及不知道的后果,不是很引荐这种做法。)
1.2.2.3.Rebase/Binding
这一步,做的是指针重定位。
在 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
段中的指针数量。
优化计划:
- 削减 ObjC 类(class)、办法(selector)、分类(category)的数量,比方兼并一些功能,删去无效的类、办法和分类等(能够凭借 AppCode 的 Inspect Code 功能进行代码减肥);
- 削减 C++ 虚函数;(虚函数会创立 vtable,这也会在
__DATA
段中创立结构。)
- 多用 Swift Structs。(由于 Swift Structs 是静态分发的,它的结构内部做了优化,符号数量更少。)
1.2.2.4.ObjC Setup
完结 Rebase 和 Bind 之后,告诉 runtime 去做一些代码运转时需求做的事情:
- dyld 会注册一切声明过的 ObjC 类;
- 将分类刺进到类的办法列表中;
- 检查每个 selector 的唯一性。
优化计划:
Rebase/Binding 阶段优化好了,这一步的耗时也会相应削减。
1.2.2.5.Initializers
Rebase 和 Binding 归于静态调整(fix-up),修改的是 __DATA
段中的内容,而这儿则开端动态调整,往堆和栈中写入内容。详细作业有:
- 调用每个 Objc 类和分类中的
+load
办法;
- 调用 C/C++ 中的结构器函数(用
attribute((constructor))
润饰的函数);
- 创立非根本类型的 C++ 静态全局变量。
优化计划:
- 尽量防止在类的
+load
办法中初始化,能够推迟到+initiailize
中进行;(由于在一个+load
办法中进行运转时办法替换操作会带来 4ms 的耗费)
- 防止运用
__atribute__((constructor))
将办法显式标记为初始化器,而是让初始化办法调用时再履行。比方用dispatch_once()
、pthread_once()
或std::once()
,相当于在第一次运用时才初始化,推迟了一部分作业耗时。:
- 削减非根本类型的 C++ 静态全局变量的个数。(由于这类全局变量通常是类或许结构体,假如在结构函数中有深重的作业,就会拖慢发动速度)
总结一下 pre-main
阶段可行的优化计划:
- 从头整理架构,削减不必要的内置动态库数量
- 进行代码减肥,兼并或删去无效的ObjC类、Category、办法、C++ 静态全局变量等
- 将不必须在
+load
办法中履行的任务推迟到+initialize
中
- 削减 C++ 虚函数
1.3.main() 阶段
关于 main()
阶段,首要丈量的便是从 main()
函数开端履行到 didFinishLaunchingWithOptions
办法履行完毕的耗时。
1.3.1.检查阶段耗时
这儿介绍两种检查 main()
阶段耗时的办法。
办法一:手动刺进代码,进行耗时计算。
第一步:在 main() 函数里用变量 MainStartTime 记录当时时刻
#import <UIKit/UIKit.h>
#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
办法二:凭借 Instruments 的 Time Profiler 东西检查耗时。
翻开方法为:Xcode → Open Developer Tool → Instruments → Time Profiler
。
操作步骤:
- 装备 Scheme。点击
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
。
1.3.2.发动优化
main()
被调用之后,didFinishLaunchingWithOptions
阶段,App 会进行必要的初始化操作,而 viewDidAppear
履行完毕之前则是做了主页内容的加载和显示。
关于 App 的初始化,除了统计、日志这种须要在 App 一发动就装备的事情,有一些装备也能够考虑推迟加载。假如你在 didFinishLaunchingWithOptions
中一起也涉及到了首屏的加载,那么能够考虑从这些视点优化:
- 用纯代码的方法,而不是 xib/Storyboard,来加载主页视图
- 推迟暂时不需求的二方/三方库加载;
- 推迟履行部分事务逻辑和 UI 装备;
- 推迟加载/懒加载部分视图;
- 防止首屏加载时很多的本地/网络数据读取;
- 在 release 包中移除 NSLog 打印;
- 在视觉可接受的规模内,压缩页面中的图片巨细;
- ……
三.主页为H5 页面优化
可参阅 VasSonic 的原理 Sonic是腾讯团队研发的一个轻量级的高功能的Hybrid结构,专注于提升页面首屏加载速度
-
终端耗时
- webView 预加载:在 App 发动时期预先加载了一次 webView,经过创立空的 webView,预先发动 Web 线程,完结一些全局性的初始化作业,对二次创立 webView 能有数百毫秒的提升。
-
页面耗时(静态页面)
- 静态直出:服务端拉取数据后经过 Node.js 进行烘托,生成包括首屏数据的 HTML 文件,发布到 CDN 上,webView 直接从 CDN 上获取;
- 离线预推:运用离线包。
-
页面耗时(经常需求动态更新的页面)
- 并行加载:WebView 的翻开和资源的请求并行;
- 动态缓存:动态页面缓存在客户端,用户下次翻开的时候先翻开缓存页面,然后再改写;
- 动态分离:将页面分为静态模板和动态数据,根据不同的发动场景进行不同的改写计划;
- 预加载:提早拉取需求的增量更新数据。
四.总结
冷发动本便是一个比较复杂的流程,它的优化没有固定的方法,咱们需求结合事务,配合一些功能剖析东西和线上监控日志,灵敏应用