iOS启动时间监控

2,122 阅读5分钟

一. 启动时间的定义

Tip: 我们知道iOS中的启动分为冷启动, 热启动, 后台回到前台三种形式, 我们这里只做冷启动的讨论.

启动时间有着不同的理解和定义, 大致可以分为2种

  1. 用户侧(广义): 点击图标 -> 首页数据加载完毕
  2. 开发侧(狭义): 进程创建 -> Launch Image完全消失后的第一帧

对于第一帧的概念, 之前理解的是 rootViewController的viewDidApper完成, 其实苹果在MetricKit中给出了官方定义: 第一个CA::Transaction::commit()

Tip: commit()是Render Server中的一个关键流程, 具体可以参考 laoqingcai.com/ios-screen-…

二. 启动过程的各个阶段

1.png

2.1 加载dyld

Tip: iOS12及之前是dyld2, 之后是dyld3. dyld3最重要的特性是启动闭包. 启动闭包其实是App的缓存, 可以直接加快启动速度

  1. 加载dyld 用户点击icon后, 系统会创建相关进程, 然后将二进制文件读取到内存, 然后读取LC_LOAD_DYLINKER, 找到dyld的路径, 将dyld加载到虚拟内存, 找到dyld的入口函数. 然后剩下的App启动就会交给dyld来做
  2. 创建启动闭包 dyld会在重启/更新/下载App后的第一次创建启动闭包, 闭包实际是一个缓存, 用来提升启动速度. 主要包含了:
    • dependends,依赖动态库列表
    • fixup:bind & rebase 的地址
    • initializer-order:初始化调用顺序
    • optimizeObjc: Objective C 的元数据
    • 其他:main entry, uuid…

2.2 rebase & bind

  1. rebase 因为ASLR(Address space layout randomization: 通过对虚拟内存头部添加随机的地址空间偏移, 可以让进程数据更加安全)的存在, 所以的符号地址都需要将原地址进行一次偏移来得到真正的地址.
let str = "123" // 0x1000
// ASLR的偏移量是 0x10
// rebase就是要将 str的地址改为 0x1010
  1. bind 因为在代码中有很外部函数的调用, bind就是在运行时将正确的指针与符号对应
print("123")
// print函数是一个库函数, 只有在运行时才能知道这个函数的具体地址是多少. bind就是将具体的地址与代码段中的符号进行绑定
  • 2.3 Objc Setup 初始化objc runtime, 注册sel, 加载category等. 这里没有加载Objc类方法等信息是因为启动闭包已经缓存了

  • 2.4 Load & Static initializer 进行最后的初始化, 主要是+load方法以及Static initializer

  • 2.5 main函数执行 dyld此时会将启动流程交给App执行main函数, 主要是初始化UIKit, 包括UIApplication, 启动主线程Runloop

  • 2.6 LifeCycle UIKit启动后, 我们就可以拿到UIApplicationDelegate回调

Tip: LifeCycle的方法是基于Runloop的Source0, 所以main()中启动的Runloop非常重要. 我们可以根据Runloop做一些事情

  • 2.7 First Frame Render Server将第一帧的数据渲染, 启动结束

三. 启动时间的监控

  • 3.1 线程启动的时间 可以使用系统函数sysctrl来获取
+ (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;
}
  • 3.2 第一个dyld执行load的开始时间 因为静态库是按顺序加载的, 我们可以在pods库中添加一个AAA开头的库, 保证他是第一个加载
@implementation AAALaunchTime
+ (void)load {
    double time = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
    NSLog(@"----------App启动---------第一个Post时间: %f",time);
}
@end
  • 3.3 main函数开始执行的时间 可以使用__attribute__((constructor))来进行获取
void static __attribute__((constructor)) before_main() {
    if (__t2 == 0) {
        __t2 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
    }
    NSLog(@"----------App启动---------Main开始时间: %f", __t2);
}

constructor / destructor / cleanup 相关文档 www.jianshu.com/p/29eb7b5c8…

  • 3.4 didFinishLaunch开始的时间 直接在方法开始时计算
double time = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
NSLog(@"----------App启动---------FinishLaunch开始时间: %f",time);
  • 3.5 FirstFrame时间

      1. rootViewController的viewDidApper中记录 (有很大的侵入性)
      1. 寻找离CA::Transaction::commit()最近的时间点

    参考抖音发布的文章, 发现 commit()和主线程Runloop有下边的关系CFRunLoopPerformBlock -> CA::Transaction::commit() -> kCFRunLoopBeforeTimers

      // Timers
      CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
      CFRunLoopActivity activities = kCFRunLoopAllActivities;
      CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
          if (activity == kCFRunLoopBeforeTimers) {
              double time = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
              NSLog(@"----------App启动---------BeforeTimers时间: %f",time);
              CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes);
          }
      });
      CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes);
      
      
      // block
      CFRunLoopPerformBlock(mainRunloop,NSDefaultRunLoopMode,^(){
          double time = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
          NSLog(@"----------App启动---------PerformBlock时间: %f",time);
      });
    
    
  • 3.6 测试

线程启动时间:          1645524638.134531
第一个load时间:        1645524641.022360
Main开始时间:         1645524641.136349
FinishLaunch开始时间: 1645524641.336565
BeforeTimers时间:    1645524641.925697 
PerformBlock时间:    1645524641.925818 
hookSend开始时间:     1645524642.330300
viewDidAppear时间:   1645524642.6753511



线程启动时间:                1646135630.267325
第一个Post时间:             1646135631.050456
Main开始时间:               1646135631.051349
FinishLaunch开始时间:       1646135631.092039
BeforeTimers时间:          1646135631.095622
PerformBlock时间:          1646135631.095717
hookSend开始时间:           1646135631.104491
viewdidAppear时间:         1646135631.105872

四. 其他监控手段

  • 4.1 iOS15之前 DYLD_PRINT_STATISTICS 可以获取pre-main的时间
Total pre-main time: 345.41 milliseconds (100.0%)
         dylib loading time:  29.70 milliseconds (8.6%)
        rebase/binding time:  23.67 milliseconds (6.8%)
            ObjC setup time:  23.26 milliseconds (6.7%)
           initializer time: 268.77 milliseconds (77.8%)
           slowest intializers :
             libSystem.B.dylib :   4.65 milliseconds (1.3%)
   libBacktraceRecording.dylib :   8.28 milliseconds (2.3%)
    libMainThreadChecker.dylib :  49.42 milliseconds (14.3%)
           libMTLCapture.dylib :  19.33 milliseconds (5.5%)
                       CamExam : 296.20 milliseconds (85.7%)
  • 4.2 Instruments的App Launch工具

  • 4.3 UITest

UITest会启动6次, 并且跳过第一次的值取5次获取平均值

self.measure(metrics: [XCTApplicationLaunchMetric()]) {
    XCUIApplication.init().launch()
}

// Test Case '-[CamExamUITests.CamExamUITestsLaunchTests testLaunch]' measured [Duration (AppLaunch), s] average: 2.507, relative standard deviation: 2.177%, values: [2.484232, 2.459668, 2.551949, 2.450120, 2.589970]
  • 4.4 Xcode Organizer (数据量大以后会有)
  • 4.5 MetricKit 官方的性能监控库, 包含启动、电量、内存等

五. 优化方案

根据启动过程中的各个阶段, 做对应的处理进行优化

  • 5.1 main之前
    • 动态库: 减少/合并动态库, 官方建议小于6个
    • rebase&bind: 减少无用代码
    • +load(): 做迁移
  • 5.2 main之后
    • didFinishLaunch: 根据业务调整
  • 5.3 首屏渲染
    • rootVC的视图: 根据业务调整
  • 5.4 其他

六. 参考资料

  1. iOS渲染相关 laoqingcai.com/ios-screen-…

  2. 抖音品质建设 - iOS启动优化《实战篇》: url.cy/Kg1tz3

  3. CoderStar - iOS 启动优化 juejin.cn/post/705193…

  4. 酷酷的哀殿 - 如何监控 iOS 的启动耗时 ai-chan.top/code/launch…