多作者合集,非商业行为,为自己学习巩固。特此声明
启动APP的时候就会花费较长的时间,用户体验很不好。所以针对APP启动时间的优化很有必要的。
关于APP启动时间的分析和优化可以以main()为分界点,分为main()方法执行之前的加载时间(pre-main time)和main()之后的加载时间。那么,如何定量的测量这两个阶段具体的执行时间呢,下面先给出测量方法,看一下自己项目启动时间是否合理:
一、main()方法之前启动时间的测量方法:
在Xcode中添加环境变量参数DYLD_PRINT_STATISTICS即可,这样运行APP时在控制台就会打印出pre-main花费的时间,如下图:
想打印详细的pre-main中各个过程花费的时间,可以再添加一个DYLD_PRINT_STATISTICS_DETAILS参数,这时你会看到pre-main time和total time的值不一样,其实下边的total time - debugger pause time就和上边的pre-main time大概一致了。如下图:
PS:如果你想打印dyld装载动态库的顺序,可以设置这个环境变量 DYLD_PRINT_LIBRARIES
二、main()方法之后的启动时间的测量方法:
在main.m文件中,定义一个全局变量:
CFAbsoluteTime startTime;
int main(int argc, char * argv[]) {
startTime = CFAbsoluteTimeGetCurrent();
在AppDelegate.m文件中didFinishLaunchingWithOptions方法return之前计算时间差:
double launchTime = CFAbsoluteTimeGetCurrent() - startTime;
NSLog(@"launchTime = %f秒",launchTime);
三、main()方法调用之前启动过程的解析:
App开始启动后,系统内核(XNU)首先加载可执行文件(自身App的所有.o文件的集合),然后加载动态链接器dyld,dyld是一个专门用来加载动态链接库的库。 执行从dyld开始,dyld从可执行文件的依赖开始, 递归加载所有的依赖动态链接库。
动态链接库包括:iOS 中用到的所有系统 framework,加载OC runtime方法的libobjc,系统级别的libSystem,例如libdispatch(GCD)和libsystem_blocks (Block)。
main()方法调用前启动过程大体分为如下步骤:
1、内核加载可执行文件
2、load dylibs image (加载程序所需的动态库镜像文件)
3、Rebase image / Bind image (由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内 存中的加载地址每次启动都不固定,所以需要修复镜像中的资源指针)
4、Objc setup (注册Objc类、将Category中的方法插入方法列表)
5、initializers (调用Objc类的+load()方法、调用C++类的构造函数)
针对上边各个启动过程,我们可以做的优化有:
1、减少动态库的引用,将项目中不使用的Framework及时删除,将Xcode配置中General -> Linked Frameworks and Libraries中使用不到的系统库不再引用。
2、合并动态库。
3、尽量不使用内嵌(embedded)的dylib,加载内嵌dylib性能开销较大。
4、清理项目中冗余的类、category。对于同一个类有多个category的,建议进行合并。
5、将不必须在+load方法中做的事情延迟到+initialize中。
6、尽量不要用C++虚函数(创建虚函数表有开销),不要在C++构造函数中做大量耗时操作。
四、main()方法调用之后过程的解析:
main()方法调用之后,主要是didFinishLaunchingWithOptions方法中初始化必要的服务,显示首页内容等操作。这时候我们可以做的事情主要有:
1、将一些不影响首页展示的服务放到其他线程中去处理,或者延时处理和懒加载。延时处理可以监听Runloop的状态,当进入kCFRunLoopBeforeWaiting(即将休眠状态)再去处理任务,最大限度的利用CPU等系统资源。
2、使用Xcode的Instruments的Time Profiler工具,分析启动过程中比较耗时的方法和操作,然后,进行具体的优化。
3、重点关注TabBarController和首页的性能,保证尽快的能展示出来。这两个控制器及里边的view尽量用代码进行布局,不使用storyboard和xib,如果在布局上想更进一步的优化,那就连autolayout(Massonry)都不要使用,直接使用frame进行布局。
4、本地缓存。首页的数据离线化,优先展示本地缓存数据,等待网络数据返回之后更新缓存并展示。
APP启动优化 检测启动Page Faults次数
在App启动的时候Page Faults次数多了会影响启动速度;那么如何查看我们的app启动的时候Page Faults的次数以及它对app启动的耗时影响了。本文就一起来学习下使用Instruments
的System Trace
来检测
检测app首次打开的场景
启动Instruments
使用Xcode选择要检测的app,Cmd+i(Profile),启动Instruments-
选择System Trace,点击Choose
-
录制
我们记录app启动到首屏展示的这个过程
按停止之后等待工具生成数据
-
查看数据
在搜索框搜索main
,可以看到我们检测的app,点击展开选中Main Thread
,下面的看板选择Summary:Virtual Memory
,如下图所示:
查看系统数据File Backed Page In
就记录了启动的时候发生Page In的次数了
- 我们可以看到,Page In的平均每次耗时是140.79us,积少成多当可以看到3699次的时候,影响的时间就到了百毫秒的级别了520.79ms
- 假如你的项目很庞大,启动的时候有很多模块加载之类的。那么这个次数就是以千计万计了,这个Page Faults产生的耗时就是比较明显了;这样当你做了二进制重排就有明显的提升了
检测app退到后台再打开
可以看到,app从后台回来Page In的次数很少
检测杀掉app再打开
测试了一两次发现跟从后台打开一样很少或者没有
检测杀掉app然后打开多个其他的app
杀掉app打开多个其他app(我打开了大概十来个吧),来看看Page In的次数,发现有200来次
这里就有疑问了,app首次打开的时候Page Fault的次数很多,打开之后再打开的话就比较少,当打开多个其他app的时候,在打开检测的app发现也会有不少Page Faults
这是由于操作系统的机制,当应用杀掉了,他所访问的物理内存不是立马就清空;它所访问的物理内存,需要通过其他app申请开辟覆盖释放掉,可以通过多打开几个应用来验证发现Page Fault次数变多了的。
至此我们已经知道怎么去检测app的PageFault是的次数了。那么如果影响很大的话,就可以采用一些优化的方式[二进制重排、PGO]来提升app的启动时间了
app启动优化可以参考:二进制重排和Profile Guided Optimization
针对main函数到首页展示之间的耗时
- 避免在这之间有阻塞主线程的任务
- 避免大量的IO操作
- 能延后的任务就延后再处理
针对main函数之前的耗时
1.pre-main耗时检测
我们可以通过设置环境变量来统计pre-main的耗时
- 操作路径:Edit Scheme -- 选择一个Scheme(比如:Run)-- 选择Arguments -- Environment Variables -- 点击添加 -- 设置 name:
DYLD_PRINT_STATISTICS
value :${DEBUG_ACTIVITY_MODE}
具体如下图所示:
启动app看下打印如下:
Total pre-main time: 276.51 milliseconds (100.0%)
dylib loading time: 43.44 milliseconds (15.7%)
rebase/binding time: 170.22 milliseconds (61.5%)
ObjC setup time: 24.74 milliseconds (8.9%)
initializer time: 37.80 milliseconds (13.6%)
slowest intializers :
libSystem.B.dylib : 8.75 milliseconds (3.1%)
libMainThreadChecker.dylib : 13.70 milliseconds (4.9%)
libViewDebuggerSupport.dylib : 6.38 milliseconds (2.3%)
通过日志我们发现,在main之前有哪些时间消耗
- dylib loading :加载可执行文件(App 的.o 文件的集合), 加载动态链接库;
- rebase/binding :对动态链接库进行 rebase 指针调整和 bind 符号绑定;
- Objc setup :Objc 运行时的初始处理,包括 Objc 相关类的注册、category 注册、selector 唯一性检查等;
- initializer:包括了执行 +load() 方法、attribute((constructor)) 修饰的函数的调用、创建 C++ 静态全局变量
那么我们知道在pre-main有哪些时间消耗阶段,针对性的对症下药就可以优化一些启动的时间:
- 减少动态库的个数,如果太多就使用合并的方式控制,这样可以节约
dylib loading
及rebase/binding
的时间 - 清理项目中未用到的类、类别、方法等,这样可以节约
Objc setup
的时间 - 对于可以不在
+load
中处理的逻辑可以放到其他的函数中去处理,比如:+initialize
;控制 C++ 全局变量的数量;这样可以节约initializer
的时间
当我们做了以上的工作,对pre-main的时间有所优化之后,如果还想再进行优化,那就需要借助LLVM为我们提供的优化方式了,下面就介绍两种方式:二进制重排、PGO
2.二进制重排
二进制重排,主要是优化我们启动时需要的函数非常分散在各个页,启动时就会多次Page Fault造成时间的损耗
2.1 Page Fault
进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存又进行分页(Page)。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。
通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比想象的要多:
2.2 如何查看app启动产生的Page Faults
2.3 重排
编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数。
静态库文件.a就是一组.o文件的ar包,可以用ar -t查看.a包含的所有.o
简化问题:假设我们只有两个page:page1/page2,其中绿色的method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。
但如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。
2.4 Xcode配置Order
那么我们需要将启动时候调用的函数进行重排,让它们尽可能的分配在同一个页;比如load方法我们就将其找出来,放到一起;LLVM支持我们通过设置order来达到这个效果
2.4.1 首先打开Write Link Map File查看
Link Map File中文直译为链接映射文件,它是在Xcode生成可执行文件的同时生成的链接信息文件,用于描述可执行文件的构造部分,包括了代码段和数据段的分布情况
我们可以在Xcode的配置中将Write Link Map File
设置为YES来生成Map File
Run下一app,查看Map File
Map File路径:
$(TARGET_TEMP_DIR)/$(PRODUCT_NAME)-LinkMap-$(CURRENT_VARIANT)-$(CURRENT_ARCH).txt
可以选中app,Show In Finder -- 找到build目录 -- 按照下面的举例的路径就可以找到:
Build/Intermediates.noindex/RuntimeLearning.build/Debug-iphoneos/RuntimeLearning.build/RuntimeLearning-LinkMap-normal-arm64.txt
或者这种方式定位map file:
设置好之后再次编译不需要 run, 找出 link map 文件, 找到 products 下的 app, 然后 show in finder, 往回两级是 products 文件夹, 再找到与 products 同级的 Intermediates.noindex , 按下图找到 link map 文件, txt 格式
打开Map File可以看到load方法分散的很开,当启动执行的函数很多的话,那就可能load分散在不同的页了。
2.4.2 将load方法设置order再看看Map File
- 新建xxx.order文件
- Xcode的Build Setting中设置Order文件
- 编辑order文件如下:
+[UIViewController(Test) load]
+[UIFont(Test) load]
+[SubTestUnsafeSwizzle load]
+[TestCategorySwizzle(Log) load]
+[TestCategorySwizzle(EventTrack) load]
+[TestCategorySwizzle(ClassMethod) load]
+[NSObject(RLSafe) load]
-[AppDelegate application:didFinishLaunchingWithOptions:]
-[AppDelegate applicationWillResignActive:]
-[AppDelegate applicationDidEnterBackground:]
-[AppDelegate applicationWillEnterForeground:]
-[AppDelegate applicationDidBecomeActive:]
-[AppDelegate applicationWillTerminate:]
-[AppDelegate window]
-[AppDelegate setWindow:]
-[AppDelegate .cxx_destruct]
-
再次运行,查看Map File文件
可以看到Map File已经按照我们order配置的顺序了;这里只是讲述了如何使用order,具体的细节、原理和实践可以参照抖音二进制重排实践;他们的数据是启动优化了15%。
3. PGO
Profile Guided Optimization简称PGO,这个也是LLVM提供的一个优化,我们可以直接在Xcode中进行配置;它是一种改进应用程序的编译器优化的方法。PGO利用应用程序的特殊工具构建来生成有关最常用代码路径和方法的配置文件信息。然后,编译器使用此配置文件信息将优化工作集中在最常用的代码上,从而利用有关程序通常如何表现的额外信息来更好地完成优化工作
配置文件引导式优化(PGO)是一项高级功能,可让您从应用中获取所有性能的最后一点点。它并不难使用,但是需要一些额外的构建步骤和一些注意事项来收集良好的配置文件信息。根据您应用程序代码的性质,PGO可以将性能提高5%到10%,但并非所有应用程序都会从中受益。如果您对性能敏感的代码需要进行额外的优化,则PGO可以提供帮助。
原理上简单说如下:
- 编译一个所有方法插桩了的可执行文件,
- 运行可执行文件(启动一次app),此时插桩的方法会把执行过的方法都记录下来,并记录方法的执行频率。例如下图的profdata文件。
- 重新build(原则上只是链接时需要profdata),让链接器按照profdata的信息把(启动中用到的、或者频率高的方法)放到一起。
此时这个新的可执行文件就完成了二进制文件重排
,减少了page交换(加载)的次数,提高了系统加载和运行app二进制文件的IO性能,加快了执行速度;这个相比较order的方式,这个优化还考虑了函数调用频率的问题
Xcode配置
-
打开Use Optimization Profile
-
选择Action生成Profile
-
Run之后,会在根目录自动创建一个文件
OptimizationProfiles
PGO也是官方提供的一个优化手段,具体细节可以参照苹果官方文档:Xcode Profile Guided Optimization;对于有多大的提升我这边跑了几次发现每次数据都有些出入,加上demo比较小,看着也不是很明显,感兴趣的可以在自己的项目中使用这项LLVM的优化
如何获取app启动都调用了哪些函数
iOS App启动时间优化--Clang插桩获取启动调用的函数符号
参考文档
重排目的
二进制重排就是为了减少启动时的缺页异常Page Fault
从而减少启动时间
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
查看 Page Falut
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
我们可以看到图中项目的Page Fault
数量并不多,这是因为当前项目是一个demo,代码和文件都极少。当代码多起来的话,Page Fault的
数量和加载耗时都会随着代码增加而增加。
二进制重排 可以很好优化这个问题,其中心思想是重新排列 方法符号的顺序, 使启动的相关方法排在最前面从而减少启动Page Falut
的数量。
我们先来看看原来的符号顺序,这需要用到 链接映射文件 Link Map File
。
Link Map File
- Link Map File 是什么?
- Link Map File 怎么获取?
- Link Map File 有什么用?
请移步 Link Map File 文件说明
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
Link Map File
里可以看到方法符号的排序。知道了原来符号排序,开发者怎么去设置自己想要的顺序呢?
order_file
Xcode
提供了排列符号的设置给开发者,设置 order_file
即可。苹果也一直身体力行,objc
源码就采用了二进制重排优化。
设置order_file
在根目录生成link.order
文件,这里面就是方法符号的排序
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
Target -> Build Setting -> Linking -> Order File
设置 order file
的路径
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
编写order_file
虽然知道了可以通过设置 .order
文件调整符号的位置,但是并不知道怎么编写 order_file
。下载objc-750源码
(源码下载地址),查看其 order_file
。
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
打开 libobjc.order
,原来只需要填写符号即可。
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
Link Map File
现实原来是先加载 AppDelegate application:didFinishLaunchingWithOptions:
后加载 [ViewController viewDidLoad]
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
编写一下link.order
试试
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
command + K
后 command + B
再查看一下 Link Map File
,顺序已经换过来了
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
一个个方法写进去很容易出现笔误,那么当这个文件里面出现异常的时候编译会出问题吗?
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
再编译一下查看一下 Link Map File
,编译没有出现问题,不存在的方法直接被忽略掉了,没有出现在文件中。
<figcaption style="margin: 0px; padding: 0px; display: block;"></figcaption>
自动生成order_file
全手写一定是不可取的,想实现自动化就要解决下列问题:
- 保证不遗漏方法
- 保证不方法符号正确
- 保证方法符号顺序正确
解决方案可见 《抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%》
抖音团队使用的是 静态扫描+运行时trace的方案, 能够覆盖到80%~90%的符号。但是上述的方法也存在性能瓶颈
- initialize hook不到
- 部分block hook不到
- C++通过寄存器的间接函数调用静态扫描不出来
为了解决这个瓶颈,我打算尝试一下在文末提到的 编译期插桩
编译期插桩
顾名思义,编译插桩就是在代码编译期间修改已有的代码或者生成新代码。
具体可见 iOS App启动优化(四):编译期插桩