美文网首页iOS开发技术博客
iOS App启动优化方案

iOS App启动优化方案

作者: Xcoder_ | 来源:发表于2022-09-18 15:40 被阅读0次

    冷启动

    • 热启动:系统里面存在APP的进程缓存信息,比如杀掉APP后短时间内重启APP。

    • 冷启动:系统里面没有APP的进程缓存信息,例如重启手机打开应用、APP长时间不用系统替换掉已有的进程缓存。

    APP的启动流程图如下:

    image.png

    pre-main 阶段

    pre-main 阶段指的是从用户唤起 App 到 main() 函数执行之前的过程。
    对于pre-main阶段,Xcode9之后,Apple提供了一种测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量DYLD_PRINT_STATISTICS设为1 。

    如下图所示,包含 main 函数执行之前各项的加载时间,我们可以多次运行取一下平均值,苹果推荐这个时间应在 400ms 以内

    Total pre-main time: 354.21 milliseconds (100.0%)
             dylib loading time:  25.52 milliseconds (7.2%)
            rebase/binding time:  12.70 milliseconds (3.5%)
                ObjC setup time: 152.74 milliseconds (43.1%)
               initializer time: 163.24 milliseconds (46.0%)
               slowest intializers :
                 libSystem.B.dylib :   7.98 milliseconds (2.2%)
       libBacktraceRecording.dylib :  13.53 milliseconds (3.8%)
        libMainThreadChecker.dylib :  41.11 milliseconds (11.6%)
                          TestDemo :  88.76 milliseconds (25.0%)
    

    pre-main 阶段所干的事大概可以总结为:

    • dyld(动态库加载):动态链接器会把所有的可执行文件所依赖的动态库dylib递归加载到内存中,打开、读取这些 Mach-O 文件,并验证其有效性 。
      系统的动态库存在于共享缓存,但是自定义的动态库就要通过依赖关系一个一个的加载

    • rebase/bindging(重定位/符号绑定)
      rebase(指针重定位) 是指调整image(镜像)内部的指针,采用ASLR技术,保证地址的随机化,加强了内存访问的安全性。
      binding(符号绑定)是指绑定外部函数的指针。使用外部符号,编译时无法找到函数地址。在运行时,dyld加载共享缓存,加载链接动态库之后,进行binding操作,重新绑定外部符号。

    • ObjC Setup:通知 Runtime 去做一些代码运行时需要做的事情,比如注册所有声明过的 ObjC 类、将Category分类插入到类的方法列表中、通过IMP到SEL的映射表,检查每个 selector 的唯一性。

    • Initializers:调用每个 ObjC 类和分类中的 +load方法、调用 C/C++ 中的构造器函数(用 attribute((constructor)) 修饰的函数)、初始化C++ 静态全局变量

    ASLR(地址空间配置随机加载)

    ASLR(Address space layout randomization):地址空间配置随机加载,是一种防范内存损坏漏洞被利用的计算机安全技术。

    地址空间配置随机加载利用随机方式配置数据地址空间,使某些敏感数据配置到一个恶意程序无法事先获知的地址,令攻击者难以进行攻击。

    总结为如下图:

    image.png

    pre-main 阶段的优化方案:

    • 减少不必要的内置动态库数量
    • 减少 ObjC类(class)方法(selector)分类(category)的数量,比如合并一些功能,删除无效的类、方法和分类等(可以借助 AppCode 的 Inspect Code 功能进行代码瘦身)
    • 减少C++ 虚函数(虚函数会创建 vtable,这也会在 __DATA 段中创建结构。)
    • 多用 Swift Structs(因为 Swift Structs 是静态分发的,它的结构内部做了优化,符号数量更少。)
    • 将不必须在+load方法中执行的任务延迟到+initialize 中, +initialize可能会多次加载,可以配合dispatch_once控制一次加载逻辑

    main() 阶段

    对于 main() 阶段,主要测量的就是从 main()函数开始执行到 didFinishLaunchingWithOptions方法执行结束的耗时。

    image.png

    对于main() 阶段时间,比较好测量,我们可以在 main 函数开始执行和 applicationDidBecomeActive: 方法执行末尾时分别记录一个时间点,然后计算两者时间差即可,大致如下:

    image.png

    其中,关于 StartupTimeMonitor 的定义如下:

    #import <Foundation/Foundation.h>
    
    @interface StartupTimeMonitor : NSObject
    
    + (instancetype)sharedMonitor;
    
    - (void)appWillStartLoading;
    - (void)appDidFinishLoading;
    
    @end
    
    
    #import "StartupTimeMonitor.h"
    
    @interface StartupTimeMonitor () {
     CFAbsoluteTime _startTime;
     CFAbsoluteTime _stopTime;
    }
    
    @end
    
    @implementation StartupTimeMonitor
    
    + (instancetype)sharedMonitor {
     static StartupTimeMonitor *sharedMonitor = nil;
     static dispatch_once_t onceToken;
     dispatch_once(&onceToken, ^{
     sharedMonitor = [[StartupTimeMonitor alloc] init];
     });
     return sharedMonitor;
    }
    
    - (void)appWillStartLoading {
     _startTime = CFAbsoluteTimeGetCurrent();
    }
    
    - (void)appDidFinishLoading {
     _stopTime = CFAbsoluteTimeGetCurrent();
    
     NSUInteger milliseconds = (NSUInteger)((_stopTime - _startTime) * 1000);
     NSLog(@"Loading done in %lu ms", milliseconds);
    }
    
    @end
    

    main() 阶段的优化方案:

    纯代码的方式,而不是 xib/Storyboard,来加载首页视图
    延迟暂时不需要的三方库加载;
    延迟执行部分业务逻辑和 UI 配置,首屏渲染完成前只处理首屏相关的业务;
    延迟加载 / 懒加载部分视图;
    避免首屏加载时大量的本地/网络数据读取;

    二进制重排优化启动

    什么是二进制重排

    重新排列函数符号的位置,降低Mach-O文件载入物理内存时触发的PageFault次数,这个叫二进制重排

    物理内存&虚拟内存

    • 物理内存:指的是通过物理内存条获得的内存空间。
    • 虚拟内存:跟物理内存相反,虚拟内存指的一种计算机系统内存管理技术,它使得应用程序认为它拥有连续可用的内存,实际上它通常被分隔成多个物理内存碎片。

    虚拟内存的概述图如下:

    image.png

    虚拟内存的技术出现之后,每个进程并不是直接全部扔进物理内存,而是给每个应用分配一个虚拟的内存,虚拟内存通过虚拟页表来把相应数据放进物理内存里面。

    虚拟内存的技术出现之后,也有了内存分页的概念,虚拟页表把一个进程分成若干页,比如:Page1、Page2、Page3…,当启动进程1的时候,只需要把Page1装载进物理内存,以此类推,如上图。

    缺页中断(PageFault)

    假设在启动时期我们需要调用两个函数 method1 与 method4 ,函数编译在 mach-o 中的位置是根据 ld ( Xcode 的链接器) 的编译顺序并非调用顺序来的 ,因此很可能这两个函数分布在不同的内存页上 。

    image

    那么启动时 , page1 与 page2 则都需要从无到有加载到物理内存中 , 从而触发两次 page fault 。

    而二进制重排的做法就是将 method1 与 method4 放到一个内存页中 ,那么启动时则只需要加载 page1 即可 ,也就是只触发一次 page fault ,达到优化目的 。

    实际项目中的做法是将启动时需要调用的函数放到一起 ( 比如 前10页中 ) 以尽可能减少 page fault ,达到优化目的 , 而这个做法就叫做 : 二进制重排 。

    缺页中断时间消耗的检测

    前面我们已经提到了缺页中断,接下来我们通过Profile来检测一下缺页中断的发生。
    Xcode顶部菜单Product->Profile->Instruments->System Trace

    image

    可以看到我们的项目冷启动时,缺页次数大概是1200多次,耗时130毫秒,如果项目再大一些,缺页发生的更多那么也是一个不小的影响启动时间的一个因素。

    为什么二进制重排能优化启动时间

    我们先来举个例子,一个应用启动需要调用方法1、方法3、方法4、方法6、方法7,其中方法1在Page1页上,方法6在Page2页上,方法3、方法7在Page3页上,方法4在Page4页上,如下图所示:

    image.png

    二进制排列后,如下图所示:

    image.png

    经过二进制重排之后,我们把启动需要调用的方法全部集中在了Page1里面,这样在启动时只需要装载Page1即可,相比之前减少了Page2、Page3、Page4的装载,这就减少了PageFault的次数,节省的时间大约为:0.5ms * 3 = 1.5ms。

    这也就解释了为什么二进制重排能够优化启动时长。

    Link Map File

    链接映射文件:Link Map File,是编译期间产生的产物,里面记录的是每个类所生成的可执行文件的路径、CPU架构、符号等信息,可以简单的理解为这个文件告诉了我们一个应用的可执行文件的排列顺序。

    BuildSetting - Write Link Map File设置为YES

    image.png

    修改完毕后 clean 一下 , 运行工程 , Products --> Show Build folder in Finder, 找到 macho 的上上层目录.

    image.png

    link map文件的文件名为: xxxDemo-LinkMap-normal-x86_64.txt

    image.png

    编译项目之后根据上图的地址找到我们需要的Link Map 文件,如图所示:

    image.png

    文件资源的编译顺序如下图所示:

    image.png

    从上图可以看出一个项目可执行文件的排列顺序为:

    • 先按照项目 - Build Phases - Compile Sources中的顺序排列
    • 再按每个文件里面从上至下的方法顺序排列

    Order File

    在项目根目录通过touch link.order生成link.order文件,这里面就是方法符号的排序

    image.png

    然后通过Target -> Build Setting -> Linking -> Order File 设置 order file 的路径

    image.png

    编写order_file

    我们尝试修改函数顺序,link.order文件里写入以下的内容,

    -[ViewController viewDidLoad]
    +[ViewController boringColor]
    -[ViewController learningGCD]
    
    image.png

    command + Kcommand + B 再查看一下Link Map File,顺序已经换过来了

    image.png

    可以看到 , 我们所写的这三个方法已经被放到最前面了 , 至此 , 生成的 macho 中距离首地址偏移量最小的代码就是我们所写的这三个方法 , 假设这三个方法原本在不同的三页 , 那么我们就已经优化掉了两个 page fault。

    到这里为止,二进制重排的整个核心我们就分析得差不多了,但是这个二进制重排有个最大的问题,那就是:我们如何才能准确获取项目启动时刻调用的方法顺序,换句话说我怎么知道我这个项目启动需要调用到哪些方法。

    Clang插桩

    LLVM内置了一个简单的代码覆盖率检测工具(SanitizerCoverage)。它在函数级、基本块级和边缘级插入对用户定义函数的调用,并提供了这些回调的默认实现。在认为启动结束的位置添加代码,就能够拿到启动到指定位置调用到的所有函数符号。

    看一看 ~ clang文档

    image.png
    • Xcode配置
      在项目Buiding Setting中Other C Flags里面添加 -fsanitize-coverage=func,trace-pc-guard标识,如下:
      image.png

    Swift混编的项目,在Buiding Setting中Other Swift Flags里面添加

    • -sanitize-coverage=func
    • -sanitize=undefined
      如下:
    image.png
    • 添加Hook代码

    在项目启动首页后的地方调用一下下面的代码,生成OrderFile文件

    #import "dlfcn.h"
    #import <libkern/OSAtomic.h>
    
    void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
         static uint64_t N;  // Counter for the guards.
         if (start == stop || *start) return;  // Initialize only once.
         printf("INIT: %p %p\n", start, stop);
         for (uint32_t *x = start; x < stop; x++)
           *x = ++N;  // Guards should start from 1.
    }
    
    //初始化原子队列
    static OSQueueHead list = OS_ATOMIC_QUEUE_INIT;
    //定义节点结构体
    typedef struct {
        void *pc;   //存下获取到的PC
        void *next; //指向下一个节点
    } Node;
    
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
         void *PC = __builtin_return_address(0);
         Node *node = malloc(sizeof(Node));
         *node = (Node){PC, NULL};
         // offsetof() 计算出列尾,OSAtomicEnqueue() 把 node 加入 list 尾巴
         OSAtomicEnqueue(&list, node, offsetof(Node, next));
    }
    
    - (void)startCreateOrderFile {
        NSLog(@"开始...");
        NSMutableArray *arr = [NSMutableArray array];
        while(1){
            //有进就有出,这个方法和 OSAtomicEnqueue() 类比使用
            Node *node = OSAtomicDequeue(&list, offsetof(Node, next));
            //退出机制
            if (node == NULL) {
                break;
            }
            //获取函数信息
            Dl_info info;
            dladdr(node->pc, &info);
            NSString *sname = [NSString stringWithCString:info.dli_sname encoding:NSUTF8StringEncoding];
            printf("%s \n", info.dli_sname);
            //处理c函数及block前缀
            BOOL isObjc = [sname hasPrefix:@"+["] || [sname hasPrefix:@"-["];
            //c函数及block需要在开头添加下划线
            sname = isObjc ? sname: [@"_" stringByAppendingString:sname];
            
            //去重
            if (![arr containsObject:sname]) {
                //因为入栈的时候是从上至下,取出的时候方向是从下至上,那么就需要倒序,直接插在数组头部即可
                [arr insertObject:sname atIndex:0];
            }
        }
        
        //去掉 touchesBegan 方法 启动的时候不会用到这个
        [arr removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
        //数组合成字符串
        NSString * funcStr = [arr  componentsJoinedByString:@"\n"];
        //写入文件
        NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"link.order"];
        NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
        NSLog(@"结束...");
        NSLog(@">> 生成的文件路径为:%@", filePath);
        [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    }
    
    image.png

    运行代码后记录 link.order 的路径,本demo生成的文件路径为:/Users/xxxx/Library/Developer/CoreSimulator/Devices/D5B9DEA2-86A4-4A9F-8E71-EF6C18449D80/data/Containers/Data/Application/F938AF62-2A5A-4C62-969D-65DA8987D620/tmp/link.order

    Finder 前往路径取出 order file

    image.png

    放在项目根目录,修改函数调用顺序


    image.png

    如何统计pod库的函数调用

    由于我们是通过编译选项去做的插桩,它只会生效于有该选项的工程,而pod库则是单独的工程,我们只需要在Podfile文件后面加上下面这段

    post_install do |installer|
      installer.pods_project.targets.each do |target|
        target.build_configurations.each do |config|
            if config.name == 'Debug'
              # 将依赖的pod项目的Other C Flags加上’-fsanitize-coverage=func,trace-pc-guard‘选项
              config.build_settings['OTHER_CFLAGS'] ||= ['$(inherited)', '-fsanitize-coverage=func,trace-pc-guard']
              config.build_settings['OTHER_SWIFT_FLAGS'] ||= ['$(inherited)', '-fsanitize-coverage=func,trace-pc-guard']
            end
          #end
        end
      end
    end
    

    APP启动的监控手段

    为了可以监控到日常开发过程中启动耗时变化,监控了启动过程中的方法调用耗时,通过每天构建对比当天版本和昨天版本的差异分析耗时原因,流程如下:

    image.png
    • Jenkins 编译构建,构建完成后,上报 LinkMap
    • 打包完成后,通过 ios-deploy,真机安装 App
    • 启动 Appium, 用于多次启动 App
    • 运行测试脚本,通过控制 Appium, Appium 控制设备,重复冷启动多次,上报数据,取平均值,减少浮动影响
    • 分析数据,耗时新增,减少,增加和 Diff 等
    • 分析结果邮件发送
    • 优化代码

    统计pre-main耗时:

    • 启动时间:通过当前进程标识(NSProcessInfo\processIdentifier),读取进程信息内的进程创建时间(__p_starttime)为启动时间。

    pre-main耗时 = 进入main 函数的时间 - 进程创建时间,以下是获取进程创建时间实现

    + (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc *)procInfo {
        int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
        size_t size = sizeof(*procInfo);
        return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
    }
    
    + (NSTimeInterval)processStartTime {
        struct kinfo_proc kProcInfo;
        if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
            return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
        } else {
            NSAssert(NO, @"无法取得进程的信息");
            return 0;
        }
    }
    
    • 1、定时抓取主线程上的方法调用堆栈,计算一段时间里各个方法的耗时
    • 2、对 objc_msgSend 方法进行hook 来掌握所有方法的执行耗时。

    参考:
    BLStopwatch
    ios-deploy
    iOS 启动优化 + 监控实践

    相关文章

      网友评论

        本文标题:iOS App启动优化方案

        本文链接:https://www.haomeiwen.com/subject/achjnltx.html