前言
App启动作为用户体验的第一关,直接决定着用户对App的第一印象,启动越慢用户流失的概率就越高,良好的启动速度是用户体验不可缺少的一环。
1、启动定义
启动有两种定义:
广义:点击图标到首页数据加载完毕
狭义:点击图标到 Launch Image 完全消失第一帧
不同产品的业务形态不一样,对于视频App,点击图标到首页数据加载完毕;对像多屏互动这样首页是静态的 App 来说,Launch Image 消失就是首页数据加载完成。由于标准很难对齐,所以我们一般使用狭义的启动定义:即启动终点为启动图完全消失的第一帧(若存在开屏广告则减去广告展示时间)。
启动的种类
据场景的不同,启动可以分为三种:首次启动、冷启动,热启动、升级后启动。
首次启动:安装应用后的首次启动。此时没有之前的状态,也没有本地缓存。这意味着将会出现以下两种情况中的一种:没有需要加载的内容(因此加载时间会缩短),或者需要从服务器上下载初始数据(可能需要加载时间)。
冷启动:系统里没有任何进程的缓存信息,典型示例是 重启手机直接启动 App
热启动:iOS 13之前 这是指当应用处于后台,但并未被挂起或关闭时,用户切换至应用而触发的启动;iOS 13之后 dyld3对第三方应用的放开,如果把 App 进程杀了再立刻重新启动,这次启动就是热启动,因为进程缓存还在;
升级后启动:通常而言,升级后的启动与冷启动没有差别。但是,不同的启动叫法表明了本地存储发生变化的时刻是不同的,这些变化包括模式、内容、之前版本挂起的同步操作,以及内部的 API/ 默认依赖。
2、启动流程
据考虑的角度的不同,分有两种不同的启动流程:
2.1、应用启动任务流程
加载应用的默认项(NSUserDefaults、捆绑的配置等)
• 检查私有 / 测试版本
• 初始化应用标识符,包括但不限于对匿名用户使用的供应商标识符(Identififier for
Vendor,IDFV)、广告标识符(Identififier for Advertiser,IDFA)等
• 初始化崩溃报告系统
• 建立分析方法
• 使用操作或 GCD 建立网络
• 建立 UI 基础设施(导航、主题、初始 UI)
• 显示登录提示或从服务器加载最新内容及其他更新
• 建立内存缓存(如图片缓存)
上述列举的内容只是应用在首次启动时可能执行的任务。其中一些还会在后续启动中执行。问题是,任务数量的快速增加必然会导致应用的启动速度变慢。
2.2、应用启动原理流程
- 点击图标,创建进程
- mmap 主二进制,找到 dyld 的路径 (mmap 的全称是 memory map,是一种内存映射技术,可以把文件映射到虚拟内存的地址空间里,这样就可以像直接操作内存那样来读写文件。)
- mmap dyld,把入口地址设为_dyld_start (dyld 是启动的辅助程序,是 in-process 的,即启动的时候会把 dyld 加载到进程的地址空间里,然后把后续的启动过程交给 dyld;iOS 13 开始 Apple 对三方 App 启用了 dyld3,dyld3 的最重要的特性就是启动闭包,闭包里包含了启动所需要的缓存信息,从而提高启动速度。)
- 重启手机/更新/下载 App 的第一次启动,会创建启动闭包
- 把没有加载的动态库 mmap 进来,动态库的数量会影响这个阶段
- 对每个二进制做 bind 和 rebase(Rebase:修复内部指针。这是因为 Mach-O 在 mmap 到虚拟内存的时候,起始地址会有一个随机的偏移量 slide,需要把内部的指针指向加上这个 slide。Bind:修复外部指针。这个比较好理解,因为像 printf 等外部函数,只有运行时才知道它的地址是什么,bind 就是把指针指向这个地址),主要耗时在 Page In,影响 Page In 数量的是 objc 的元数据有定义,过程:MMU 找到空闲的物理内存页面 ;触发磁盘 IO,把数据读入物理内存;如果是 TEXT 段的页,要进行解密;对解密后的页,进行签名验证)
- 初始化 objc 的 runtime,由于闭包已经初始化了大部分,这里只会注册 sel 和装载 category
- +load 和静态初始化被调用,除了方法本身耗时,这里还会引起大量 Page In
- 初始化 UIApplication,启动 Main Runloop
- 执行 will/didFinishLaunch,这里主要是业务代码耗时
- Layout,viewDidLoad 和Layoutsubviews 会在这里调用,Autolayout 太多会影响这部分时间
- Display,drawRect 会调用
- Prepare,图片解码发生在这一步
-
Commit,首帧渲染数据打包发给 RenderServer,启动结束
注:Apple 在 MetricsKit 里对启动终点定义是第一个CA::Transaction::commit();
关于Commit ,iOS 的渲染是在一个单独的进程 RenderServer 做的,App 会把 Render Tree 编码打包给 RenderServer,RenderServer 再调用渲染框架(Metal/OpenGL ES)来生成 bitmap,放到帧缓冲区里,硬件根据时钟信号读取帧缓冲区内容,完成屏幕刷新。CATransaction 就是把一组 UI 上的修改,合并成一个事务,通过 commit 提交。
渲染可以分为四个步骤
Layout(布局),源头是 Root Layer 调用[CALayer layoutSubLayers],这时候 UIViewController 的 viewDidLoad 和 LayoutSubViews 会调用,autolayout 也是在这一步生效
Display(绘制),源头是 Root Layer 调用[CALayer display],如果 View 实现了 drawRect 方法,会在这个阶段调用
Prepare(准备),这个过程中会完成图片的解码
Commit(提交),打包 Render Tree 通过 XPC 的方式发给 Render Server
3、优化实践
3.1、启动任务拆分优化
(1) 确定在展示 UI 前必须执行的任务。
如果应用是第一次启动,那么没有必要加载任何用户偏好,如主题、刷新间隔、缓存大小等。此时是没有任何自定义值的。初始缓存肆意增长也是没问题的,因为它的增长不会超过最终的限制值。崩溃报告系统应第一个被初始化。
(2) 按顺序执行任务。
排序是非常重要的,因为任务之间可能具有相互依赖性,同时,排序还可以节省用户的宝贵时间。例如,如果先触发了访问令牌的验证操作,那么其他任务可能会并行执行,因为验证过程需要进行网络连接。但是这样就会导致一种情况:如果其他任务先完成,而验证还未完成,应用就必须等待验证完成才能继续执行。
(3) 将任务拆分为两类:一类是必须在主线程中执行的任务,另一类是可以在其他线程中执行的任务 ,然后分别执行。还可以进一步将在非主线程中执行的任务分为可以并发执行的和不能并发执行的。
(4) 其他任务可以在加载 UI 后执行或异步执行。
延迟其他子系统(如记录仪和分析方法)的初始化。在应用的后续阶段将一些操作(例如,写日志消息或跟踪事件)放入队列中,直到子系统完全完成初始化。
3.2、基于启动原理优化
t(App 总启动时间) = t1(main 调用之前的加载时间) + t2(main 调用之后的加载时间),分main函数之前和main函数之后的相关方法优化
3.2.1、监控方法
主要是通过埋点监控实时的启动时间,点位主要是:
iOS13(含)以上的系统采用 runloop 中注册一个 kCFRunLoopBeforeTimers 的回调获取到的 App 首屏渲染完成的时机更准确。
iOS13 以下的系统采用 CFRunLoopPerformBlock 方法注入 block 获取到的 App 首屏渲染完成的时机更准确。
|
//注册block
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopPerformBlock(mainRunloop,NSDefaultRunLoopMode,^(){
NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];
NSLog(@``"runloop block launch end:%f"``,stamp);
});
//注册kCFRunLoopBeforeTimers回调
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopActivity activities = kCFRunLoopAllActivities;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, ``0``, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
if
(activity == kCFRunLoopBeforeTimers) {
NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];
NSLog(@``"runloop beforetimers launch end:%f"``,stamp);
CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes);
}
});
CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes);
|
为什么这么监控呢,首屏渲染完成时间我们希望和 MetricKit 对齐,即获取到 CA::Transaction::commit()方法被调用的时间;而通过 Runloop 源码分析和线下调试,我们发现 CA::Transaction::commit(),CFRunLoopPerformBlock,kCFRunLoopBeforeTimers 这三个时机的顺序从早到晚依次是:CFRunLoopPerformBlock -> CA::Transaction::commit -> kCFRunLoopBeforeTimers 。
3.2.2、检测工具
1、目前使用的检测工具为美图输出的MTHawkeye工具
MTHawkeye
def hawkeye
pod 'MTHawkeye/DefaultPluginsWithoutLog', :configurations => 'Debug'
pod 'FLEX', :configurations => ['Debug']
pod 'fishhook', :configurations => ['Debug']
pod 'MTAppenderFile', :configurations => ['Debug']
end
2、获得 main() 方法执行前的耗时比较简单,通过 Xcode 自带的测量方法既可以。将 Xcode 中 Product -> Scheme -> Edit scheme -> Run -> Environment Variables 将环境变量 DYLD_PRINT_STATISTICS
或 DYLD_PRINT_STATISTICS_DETAILS
设为 1
即可获得执行每项耗时:
Total pre-main time: 1.2 seconds (100.0%)
dylib loading time: 147.51 milliseconds (12.0%)
rebase/binding time: 112.82 milliseconds (9.2%)
ObjC setup time: 45.94 milliseconds (3.7%)
initializer time: 919.07 milliseconds (75.0%)
slowest intializers :
libSystem.B.dylib : 6.79 milliseconds (0.5%)
libMainThreadChecker.dylib : 34.62 milliseconds (2.8%)
libglInterpose.dylib : 353.67 milliseconds (28.8%)
TCLTV : 944.10 milliseconds (77.0%)
3、 System Trace: 这个模板提供了系统行为的全面信息。它显示线程的调度、系统线程的转化和内存使用情况。这个模板可以使用在OS X或iOS中。在分析启动时间时,可以结合Time Profiler 一起来使用。
3.2.3、优化方法
1)在t1
阶段加快App
启动:
- 尽量使用静态库,减少动态库的使用,动态链接比较耗时,如果要用动态库,尽量将多个
dylib
动态库合并成一个 - 尽量避免对系统库使用
optional linking
,如果App
用到的系统库在你所有支持的系统版本上都有,就设置为required
,因为optional
会有些额外的检查 - 减少
Objective-C Class、Selector、Category
的数量,可以合并或者删减一些OC
类(怎样删减无用代码:ViewConteroller 渗透率,hook 对应的声明周期方法即可统计;Class 渗透率,遍历运行时的所有类,通过 Objective C Runtime 的标志位判断类是否被访问;行级渗透率,需要用编译期插桩,对包大小和执行速度均有损。) - 删减一些无用的静态变量,删减没有被调用到或者已经废弃的方法
- 将不必须在
+load
中做的事情尽量挪到+initialize
中,+initialize
是在第一次初始化这个类之前被调用,+load
在加载类的时候就被调用;或者做load方法迁移 - 尽量不要用
C++
虚函数,创建虚函数表有开销 - 不要使用
__attribute__((constructor))
将方法显式标记为初始化器,而是让初始化方法调用时才执行。比如使用dispatch_once(),pthread_once()或 std::once()
- 在初始化方法中不调用
dlopen(),dlopen()
有性能和死锁的可能性 - 在初始化方法中不创建线程
2)在t2
阶段加快App
启动:
- 尽量不要使用
xib/storyboard
,而是用纯代码作为首页UI
,如果要用xib/storyboard
,不要在xib/storyboard
中存放太多的视图 - 使用简单的广告页作为过渡,将首页的计算操作及网络请求放在广告页展示时异步进行。
- 对
application:didFinishLaunchingWithOptions:
里的任务尽量延迟加载或懒加载 - 不要在
NSUserDefaults
中存放太多的数据,NSUserDefaults
是一个plist
文件,plist
文件会被反序列化一次 - 避免在启动时打印过多的
log
,少用NSLog
,因为每一次NSLog
的调用都会创建一个新的NSCalendar
实例 - 为了防止使用
GCD
创建过多的线程,解决方法是创建串行队列,或者使用带有最大并发数限制的NSOperationQueue
- 不要在主线程执行
磁盘、网络、Lock或者dispatch_sync、发送消息给其他线程
等操作
参考
3、基于二进制文件重排的解决方案 APP启动速度提升超15%
4、《高性能iOS应用开发》