深刻探索 iOS 启动速度优化

 

 

介绍

App 的启动时间是体现其性能优劣的一个重要指标,启动时间越快用户的等待时间就越短,提高用户体验感,大厂应用甚至会作到“ 毫秒必究 ”。git

咱们将 App 启动方式分为:github

名称 说明
冷启动 App 启动时,应用进程不在系统中(初次打开或程序被杀死),须要系统分配新的进程来启动应用。
热启动 App 退回后台后,对应的进程还在系统中,启动则将应用返回前台展现。

本篇文章主要针对冷启动方式进行优化分析,介绍经常使用的检测工具及优化方法。面试

冷启动流程

Apple 官方的《WWDC Optimizing App Startup Time》 将 iOS 应用的启动可分为 pre-main 阶段和 main 两个阶段,最佳的启动速度是400ms之内,最慢不得大于20s,不然会被系统进程杀死(最低配置设备)。swift

为了更好的区分,笔者将整个启动流程分为三个阶段, App总启动流程 = pre-main + main函数代理(didFinishLaunchingWithOptions)+ 首屏渲染(viewDidAppear),后两个阶段都属于 main函数 执行阶段。缓存

pre-main 执行内容

此时对应的 App 页面是闪屏页的展现。性能优化

  • 加载可执行文件网络

    加载 Mach-O 格式文件,既 App 中全部类编译后生成的格式为 .o 的目标文件集合。数据结构

  • 加载动态库架构

    dyld 加载 dylib 会完成以下步骤:app

    1. 分析 App 依赖的全部 dylib。
    2. 找到 dylib 对应的 Mach-O 文件。
    3. 打开、读取这些 Mach-O 文件,并验证其有效性。
    4. 在系统内核中注册代码签名。
    5. 对 dylib 的每个 segment 调用 mmap()。

    系统依赖的动态库因为被优化过,能够较快的加载完成,而开发者引入的动态库须要耗时较久。

  • Rebase和Bind操做

    因为使用了ASLR 技术,在 dylib 加载过程当中,须要计算指针偏移获得正确的资源地址。 Rebase 将镜像读入内存,修正镜像内部的指针,消耗 IO 性能;Bind 查询符号表,进行外部镜像的绑定,须要大量 CPU 计算。

  • Objc setup

    进行 Objc 的初始化,包括注册 Objc 类、检测 selector 惟一性、插入分类方法等。

  • Initializers

    往应用的堆栈中写入内容,包括执行 +load 方法、调用 C/C++ 中的构造器函数(用 attribute((constructor)) 修饰的函数)、建立非基本类型的 C++ 静态全局变量等。

main函数代理执行内容

从 main() 函数开始执行到 didFinishLaunchingWithOptions 方法执行结束的耗时。一般会在这个过程当中进行各类工具(监控工具、推送、定位等)初始化、权限申请、判断版本、全局配置等。

首屏渲染执行内容

首屏 UI 构建阶段,须要 CPU 计算布局并由 GPU 完成渲染,若是数据来源于网络,还需进行网络请求。

优化方案

pre-main阶段

检测方法

得到 main() 方法执行前的耗时比较简单,经过 Xcode 自带的测量方法既能够。将 Xcode 中 Product -> Scheme -> Edit scheme -> Run -> Environment Variables 将环境变量 DYLD_PRINT_STATISTICS 或 DYLD_PRINT_STATISTICS_DETAILS 设为 1 便可得到执行每项耗时:

// example 
// DYLD_PRINT_STATISTICS
Total pre-main time: 383.50 milliseconds (100.0%)
         dylib loading time: 254.02 milliseconds (66.2%)
        rebase/binding time:  20.88 milliseconds (5.4%)
            ObjC setup time:  29.33 milliseconds (7.6%)
           initializer time:  79.15 milliseconds (20.6%)
           slowest intializers :
             libSystem.B.dylib :   8.06 milliseconds (2.1%)
    libMainThreadChecker.dylib :  22.19 milliseconds (5.7%)
                  AFNetworking :  11.66 milliseconds (3.0%)
                  TestDemo :  38.19 milliseconds (9.9%)

// DYLD_PRINT_STATISTICS_DETAILS
  total time: 614.71 milliseconds (100.0%)
  total images loaded:  401 (380 from dyld shared cache)
  total segments mapped: 77, into 1785 pages with 252 pages pre-fetched
  total images loading time: 337.21 milliseconds (54.8%)
  total load time in ObjC:  12.81 milliseconds (2.0%)
  total debugger pause time: 307.99 milliseconds (50.1%)
  total dtrace DOF registration time:   0.07 milliseconds (0.0%)
  total rebase fixups:  152,438
  total rebase fixups time:   2.23 milliseconds (0.3%)
  total binding fixups: 496,288
  total binding fixups time: 218.03 milliseconds (35.4%)
  total weak binding fixups time:   0.75 milliseconds (0.1%)
  total redo shared cached bindings time: 221.37 milliseconds (36.0%)
  total bindings lazily fixed up: 0 of 0
  total time in initializers and ObjC +load:  43.56 milliseconds (7.0%)
                         libSystem.B.dylib :   3.67 milliseconds (0.5%)
               libBacktraceRecording.dylib :   3.41 milliseconds (0.5%)
                libMainThreadChecker.dylib :  21.19 milliseconds (3.4%)
                              AFNetworking :  10.89 milliseconds (1.7%)
                              TestDemo :   2.37 milliseconds (0.3%)
total symbol trie searches:    1267474
total symbol table binary searches:    0
total images defining weak symbols:  34
total images using weak symbols:  97

优化点

  • 合并动态库,并减小使用 Embedded Framework,即非系统建立的动态 Framework,若是对包体积要求不严格还可使用静态库代替。

  • 删除无用代码(未使用的静态变量、类和方法等)并抽取重复代码。

  • 避免在 +load 执行方法,使用 +initialize 代替。

  • 避免使用 attribute((constructor)),可将要实现的内容放在初始化方法中配合 dispatch_once 使用。

  • 减小非基本类型的 C++ 静态全局变量的个数。(由于这类全局变量一般是类或者结构体,若是在构造函数中有繁重的工做,就会拖慢启动速度)

main函数代理阶段

检测方法

  • 手动插入代码计算耗时

    在 man() 函数开始执行时就开始时间:

    CFAbsoluteTime StartTime;  //  记录全局变量
    int main(int argc, char * argv[]) {
        @autoreleasepool {
            StartTime = CFAbsoluteTimeGetCurrent();
            return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
        }
    }

    再在 didFinishLaunchingWithOptions 返回以前获取结束时间,二者的差值即为该阶段的耗时:

    extern CFAbsoluteTime startTime; // 申明全局变量
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
        //...
        //...
    
        double launchTime = (CFAbsoluteTimeGetCurrent() - startTime);
        return YES;
    }

    经过这种手动埋点的方式也能够对每一个函数进行埋点获取耗时,但当函数多时也须要不小的工做量,且后续上线还须要移除代码,不可复用。

  • Time Profiler

    Xcode自带的工具,原理是定时抓取线程的堆栈信息,经过统计比较时间间隔之间的堆栈状态,计算一段时间内各个方法的近似耗时。精确度取决于设置的定时间隔。

    经过 Xcode → Open Developer Tool → Instruments → Time Profiler 打开工具,注意,需将工程中 Debug Information Format 的 Debug 值改成 DWARF with dSYM File,不然只能看到一堆线程没法定位到函数。

 

经过双击具体函数能够跳转到对应代码处,另外能够将 Call Tree 的 Seperate by Thread 和 Hide System Libraries 勾选上,方便查看。

 

正常Time Profiler是每1ms采样一次, 默认只采集全部在运行线程的调用栈,最后以统计学的方式汇总。因此会没法统计到耗时太短的函数和休眠的线程,好比下图中的5次采样中,method3都没有采样到,因此最后聚合到的栈里就看不到method3。

 

咱们能够将 File -> Recording Options 中的配置调高,便可获取更精确的调用栈。

 
  • System Trace

    有时候当主线程被其余线程阻塞时,没法经过 Time Profiler 一眼看出,咱们还可使用 System Trace,例如咱们故意在 dyld 连接动态库后的回调里休眠10ms:

    static void add(const struct mach_header* header, intptr_t imp) {
        usleep(10000);
    }
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
    {
        dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            _dyld_register_func_for_add_image(add);
        });
      ....
    }
 

能够看到整个记录过程耗时7s,但 Time Profiler 上只显示了1.17s,且看到启动后有一段时间是空白的。这时经过 System Trace 查看各个线程的具体状态。

 

能够看到主线程有段时间被阻塞住了,存在一个互斥锁,切换到 Events:Thread State观察阻塞的下一条指令,发现0x5d39c 执行完成释放锁后,主线程才开始执行。

 

接着咱们观察 0x5d39c 线程,发如今主线程阻塞的这段时间,该线程执行了屡次10ms的 sleep 操做,到此就找到了主线程被子线程阻塞致使启动缓慢的缘由。

 

从此,当咱们想更清楚的看到各个线程之间的调度就可使用 System Trace,但仍是建议优先使用 Time Profiler,使用简单易懂,排查问题效率更高。

  • App Launch

    Xcode11 以后新出的工具,功能至关于 Time Profiler 和 System Trace 的整合。

  • Hook objc_msgSend

    能够对 objc_msgSend 进行 Hook 获取每一个函数的具体耗时,优化在启动阶段耗时多的函数或将其置后调用。实现方法可查看 经过objc_msgSend实现iOS方法耗时监控

优化点

  • 经过检测工具找到耗时多的函数,拆分其功能,将优先级低的功能延后执行。
  • 梳理业务逻辑,把能够延迟执行的逻辑,作延迟执行处理。好比检查新版本、注册推送通知等逻辑。
  • 梳理各个二方/三方库,找到能够延迟加载的库,作延迟加载处理,好比放到首页控制器的viewDidAppear方法后。

首屏渲染阶段

检测方法

记录首屏 viewDidLoad 开始时间和viewDidAppear 开始时间,二者的差值即为整个首屏渲染耗时,若是要得到具体每一个步骤耗时,则可同main函数代理阶段使用 Time Profiler 或 Hook objc_msgSend

优化点

  • 使用简单的广告页做为过渡,将首页的计算操做及网络请求放在广告页展现时异步进行。
  • 涉及活动需变动页面展现时(例如双十一),提早下发数据缓存。
  • 首页控制器用纯代码方式来构建,而不是 xib/Storyboard,避免布局转换耗时。
  • 避免在主线程进行大量的计算,将与首屏无关的计算内容放在页面展现后进行,缩短 CPU 计算时间。
  • 避免使用大图片,减小视图数量及层级,减轻 GPU 的负担。
  • 作好网络请求接口优化(DNS 策略等),只请求与首屏相关数据。
  • 本地缓存首屏数据,待渲染完成后再去请求新数据。

其它优化

二进制重排

去年年末二进制重排的概念被宇宙厂带火了起来,我的以为噱头大于效果,详细内容可参考文章

总结

启动优化不该该是一次性的,最好的方案也不是在出现才去解决,而应该包括:

  • 解决现存的问题
  • 后续开发的管控
  • 完整的监控体系

只有在开发的前中后同时介入,才能保证 App 的出品质量,毕竟开发是前人挖坑给后人填坑的过程 😂。

部分工具

  • Xcode自带工具 Time Profiler 和 System Trace

  • Xcode11 以后新增工具 App Launch

  • Static Initializer Tracing

  • AppCode 的 Inspect Code 扫描无用代码

  • fui 扫描无用的类

  • TinyPNG 压缩图片,减小 IO 操做量

参考资料

推荐👇:

  • 020 持续更新,精品小圈子每日都有新内容,干货浓度极高。

  • 结实人脉、讨论技术 你想要的这里都有!

  • 抢先入群,跑赢同龄人!(入群无需任何费用)

  • (直接搜索群号:789143298,快速入群)
  • 点击此处,与iOS开发大牛一块儿交流学习

申请即送:

  • BAT大厂面试题、独家面试工具包,

  • 资料免费领取,包括 数据结构、底层进阶、图形视觉、音视频、架构设计、逆向安防、RxSwift、flutter,

     

做者:SimonYe
连接:https://juejin.im/post/5e950106f265da47b725eaff

相关文章
相关标签/搜索