app冷启动优化方案

App启动过程

app启动分为冷启动和热启动,热启动是App刚结束后再启动,有部分在内存但没有进程存在。我们所做的优化都是针对于冷启动,即app第一次启动或是kill掉之后重新打开,不在内存里也没有进程存在。
启动过程主要分为main函数前和main函数后。

pre-main阶段

指的是从用户唤起 App 到 main 函数执行之前的过程。

Mach-O文件理解

简单总结一张图


image.png

启动流程

image.png

概述为

加载可执行文件

exec()系统调用。 系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(因为使用ASLR,使得不能根据起始地址+偏移量找到函数的地址,保证了安全性)。并将起始位置到0x000000这段范围的进程权限都标记为不可读写不可执行。
加载dyld到App进程。 当内核完成映射进程的工作后会将名字为dyld的Mach-O文件映射到进程中的随机地址,它将PC寄存器设为dyld的地址并运行。
加载动态链接库,进行rebase指针调整和bind符号绑定
dyld加载dylib文件。
1)从主执行文件的header获取到需要加载的所依赖动态库列表,然后它需要找到每个dylib
2)然后打开dylib文件读取文件起始位置,接着会找到代码前面并将其注册到内核。
3)然后在dylib文件的每个segment上调用mmap()(实现文件的懒加载),应用所依赖的dylib文件可能会再依赖其他的dylib,所以dyld所需要加载的是动态库列表的一个递归依赖的集合。一般应用会加载100到400个dylib文件,但大部分都是系统dylib,它们会被预先计算和缓存到dyld shared cache,加载速度很快。
Rebase & Bind。 由于应用使用了ASLR和Code Sign两种技术来保证其安全性,导致了起始地址不固定、不能直接修改Image。而dyld做的就是修正(fix-up)指针和数据,fix-up有两种类型:rebasing和binding。
1) ReBasing:在镜像内部调整指针的指向。
2) Binding:将指针指向镜像外部的内容。
ASLR:程序被影射到逻辑地址空间的初始位置是随机的。
Code Sign:Code sign时对每一个Page进行加密哈希,保证了在dyld进行加载的时候,可以对每一个page进行独立的验证。
Objc运行时的初始化。包括Objc相关类注册、category注册、selector唯一性检查等。
在执行main函数之前,当加载一个 dylib 时,会把类的信息注册到一个全局的Table中,也会把Category中的方法注册到对应的类中,同时会唯一Selector,因此Cagegory实现了类中同名的方法后,类中的方法会被覆盖。
Initializers。 dyld开始运行程序的初始化函数,调用每个Objc类和分类的+load方法,调用C/C++ 中的构造器函数(用attribute((constructor))修饰的函数),和创建非基本类型的C++静态全局变量
最后dyld会调用main()函数,main()会调用UIApplicationMain()
main函数后

Application 初始化,到 applicationDidFinishLaunchingWithOptions 执行完
初始化帧渲染,到 viewDidAppear 执行完
启动时间检测

pre-main阶段的时间检测

Xcode通过添加环境变量可以获取到pre-main阶段的时间。
Xcode 中提供了测量 pre-main 的时间 Edit scheme -> Run -> Auguments 添加环境变量 DYLD_PRINT_STATISTICS,value设为YES。

点击Run运行后,会在控制台打印出耗时。

可以清楚的看到pre-main阶段启动各个过程的耗时。

main函数之后的时间检测

Xcode Developer Tool:Xcode自带的Time Profiler检测耗时。
通过Xcode工具栏中Product->Profile(command+i)可以启动,(也可以通过Xcode->Open Developer Tool->Instruments)启动后界面如下:

选择Time Profiler,打开后,

点击左上角红色按钮运行,勾选左下角Call Tree中Separate Thread和Hide System Libraries,等到第一个页面显示出来的之后,点击左上角暂停按钮,下面就会统计出每个步骤的耗时情况。我们就可以清楚的看到每个方法的耗时。
客户端计算统计: 通过 hook 关键函数的调用,计算获得性能数据。或是在代码中设置开始时间,根据结束时间计算出消耗的时间。为了方便起见,我们选择的就是这种计算开始、结束时间的方法。封装一个C方法,用来打印方法名、方法行数、总耗时和上一次打印到这次的耗时,可以方便的计算出想要检测的任何一个方法或代码块的耗时。
录屏:使用截屏、录屏、高速摄像机录像等方法,记录移动设备屏幕上的变化,分析启动的起止点,获取 app 启动的耗时。但是对于我们的代码优化是做不到方法耗时的监听。
启动结束判断

由于开屏广告页的存在,存在两种启动结束点的定义:

点击图标到广告页面调用获取广告接口前,是狭义的定义启动结束
无广告时,点击图标到首页主页面渲染完成,这一过程是用户感知的启动结束点
对于以上两种情况,我们分别做了统计分析,只是第二种情况是在第一种情况的基础上做的进一步优化。

实际过程中优化的点

pre-main阶段的优化

对于pre-main阶段,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量DYLD_PRINT_STATISTICS 设为1 ,运行后会在控制台输出各过程的耗时。通过pre-main的启动过程可以总结出优化项:

减少动态链接库。合并优化由私有Pod建立的同类动态库
查看项目中的动态链接库:在Products 文件夹中找到 “xxxx.app” 文件,到当前所在的目录下,执行命令otool -L xxxx.app/xxxx即可查看。可能由于之前已进行过启动优化,暂未发现无用的动态链接库。
检查 framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查。
不支持懒加载dylib,dlopen()可能造成一些问题。
减少无用方法和文件。
调研了一下目前流行的几种检测无用方法和类的技术优缺点:
运用基于otool+mach-o的检测技术:通过otool命令提取可执行文件的classlist section 和 classref section。形成classlist和classref的地址差集。在通过otool 命令获取到地址和类名的映射关系,解析获取差集中的类名。
脚本直接分析源代码,如何处理空格、换行、注释、子串、分类等问题存在一定的难度
基于linkmap+clang的检测技术。通过linkmap文件获取到项目中的类集合,通过在Xcode上集成自定义的Clang插件,在代码编译时通过拦截遍历抽象语法树的VisitDecl函数和VisitStmt函数,可以获取到在哪个方法中有哪些表达式,即可得知哪些方法中调用了哪些函数。
针对framework目标文件优化的技术方案。通过脚本剥离动态库中的目标文件,生成新的framework进行链接,如果不报错则可认为该文件没有被使用。
基于otool+mach-o的检测技术步骤:
通过otool -v -s __DATA __objc_classrefs获取到引用类(明确用到的)的地址
通过otool -v -s __DATA __objc_classlist获取所有类的地址。
用所有类信息减去引用类的信息,此时我们可以拿到未使用类的地址信息。
通过nm -nm命令可以得到地址和对应的类名字。
分析以上四种方案,可以看出基于otool+mach-o的检测技术经过链接器的链接后,程序对自身定义的类和系统定义的类做了明确的划分,并且程序自身已经记录了自身一共有多少类,哪些类是被引用的类,因此选择了基于otool+mach-o的检测技术,但在检测无用方法和类的过程中,不管是用了基于otool+mach-o的检测技术还是运用基于linkmap+clang的检测技术等方法都无法检测runtime动态调用的类和方法,因此在检测出无用的类和方法之后必须人工审核确认项目中确实不用了才能删除。
合并多个Category的功能类似的类。
比如:UIView+Frame,UIView+AutoLayout…合并为一个。
减少ObjC的+load方法,尽量不用+load方法。
通过pre-main的打印信息可以看到initializer time占了pre_main50%以上的耗时,因此对此部分进行了重点的排查。利用Xcode生成的Link Map文件可以分析App编译之后生成的可执行文件大致的组成结构。Link Map对于App的调优至关重要
1) 生成Link Map。 在xcode中设置编译选项Write Link Map File
XCode -> Project -> Build Settings -> 搜map -> 把Write Link Map File选项设为yes,并指定好linkMap的存储位置,编译之后即可生成link map.txt文件
2)查看 Link Map File。
通过生成的linkMap文件分析,在Section区中
1) 查找__objc_nlclslist对应的Address起始位置,发现含有+load的类一共47个,其中第三方占据27个。 对含有+load的类,对以下15个类的load方法提取到了appdelegate中执行。
2) 查找__objc_nlcatlist对应的Address起始位置,含有+load的Category的类15个,其中第三方占据10个。
3) 查找__mod_init_func对应的Address起始位置,含有static initializer的102个,全部为第三方,其中穿山甲广告大约占据了50%。
不要使用 atribute((constructor)) 将方法显式标记为初始化器,而是让初始化方法调用时才执行。
比如使用 dispatch_once(),pthread_once() 或 std::once()。
二进制重排。
原理:重新排列Link Map方法符号的顺序 ,中心思想就是把启动用到的代码挪到前面的位置加载。Xcode连接器中有连接器的Order File可以将二进制包按照这个文件中的符号顺序进行生成对应的 Mach-O,从而解决 多次Page Fault耗时。可利用Clang插装来获取所有符号顺序。
效果:对于我们的项目,简单测试了一下,没发现明显的时间优化。
获取APP启动时候调用的所有方法:
通过静态扫描和运行时 Trace。参考:抖音研发实践基于二进制文件重排的解决方案 APP启动速度提升超15%
1)设置条件触发流程
2)工程注入Trace动态库,选择release模式编译出.app/linkmap/中间产物
3)运行一次App到启动结束,Trace动态库会在沙盒生成Trace log
4)以Trace Log,中间产物和linkmap作为输入,运行脚本解析出order_file
基于llvm插桩的方案
利用Clang 内置的一个代码覆盖工具SanitizerCoverage。在 build settings 里的 “Other C Flags” 中添加 -fsanitize-coverage=func,trace-pc-guard。如果含有 Swift 代码的话,还需要在 “Other Swift Flags” 中加入 -sanitize-coverage=func 和 -sanitize=undefined。所有链接到 App 中的二进制都需要开启 SanitizerCoverage,这样才能完全覆盖到所有调用
AppOrderFiles 是一个已封装好的工具,CocoaPods 接入,程序启动完成函数一行调用,生成 Order File。
main函数后的优化

Appdelegate中所有的启动项全部放在application didFinishLaunching中,将所有启动项梳理并分类。分别在不同的阶段执行,首页加载之前无须启动的可异步执行。
执行阶段 启动项
mian方法之前 runtime swizzle objc
mian方法 main函数后启动时间记录、其他需统计main方法的第三方sdk的初始化
Appdelegate须启动 日志埋点、崩溃保护、数据统计等
首页加载之前须启动 网络请求、 页面布局框架初始化等
首页加载之前无须启动 各种缓存配置、本地数据存储等
由于App启动后首先展示的是开屏广告页,将开屏广告页设置为RootViewController,将首页提前在application didFinishLaunching初始化,避免开屏广告页结束之后再初始化首页,减少耗时。将开屏广告页的接口请求提前,减少等待广告请求时间。
启动时获取navigator.userAgent增加缓存机制。
首页用storyboard形式加载会加大耗时,将storyboard布局改为代码布局。
首页有多个childViewController,默认值加载当前的childViewController。
对首页的viewDidLoad、viewWillAppear、viewDidAppear分别进行Time Profiler耗时检测,梳理耗时较长的操作根据情况进行相应的优化。
优化效果

简单的统计了一下优化的效果,总体看来此次优化的效果还是比较明显的。

pre-main阶段,大约优化了100ms
main函数后
1) 点击图标到广告页面调用获取广告接口前,大约优化了了100ms
2) 无广告时,点击图标到首页主页面渲染完成,大约优化了900ms

参考:https://blog.csdn.net/u013093099/article/details/116016340

你可能感兴趣的:(app冷启动优化方案)