茅山道长 發表於 2020-8-24 22:13:00

iOS优化篇之App启动时间优化

<h1 id="前言">前言</h1>
<p>最近由于体验感觉我们的app启动时间过长,因此做了APP的启动优化。本次优化主要从三个方面来做了启动时间的优化,<strong>main之后的耗时方法优化</strong>、<strong>premain的+load方法优化</strong>、<strong>二进制重排优化premain时间</strong>。</p>
<p>通常我们对于启动时间的定义为从用户点击app到看到首屏的时间。因此对于启动时间优化就是遵循一个原则:尽早让用户看到首页内容。</p>
<h3 id="app启动过程">app启动过程</h3>
<p>iOS应用的启动可分为pre-main阶段和main()阶段,pre-main阶段为main函数执行之前所做的操作,main阶段为main函数到首页展示阶段。其中系统做的事情为:</p>
<h4 id="premain">premain</h4>
<ul>
<li>加载所有依赖的Mach-O文件(递归调用Mach-O加载的方法)</li>
<li>加载动态链接库加载器dyld(dynamic loader)</li>
<li>定位内部、外部指针引用,例如字符串、函数等</li>
<li>加载类扩展(Category)中的方法</li>
<li>C++静态对象加载、调用ObjC的 +load 函数</li>
<li>执行声明为<strong>attribute</strong>((constructor))的C函数</li>
</ul>
<h4 id="main">main</h4>
<ul>
<li>调用main()</li>
<li>调用UIApplicationMain()</li>
<li>调用applicationWillFinishLaunching</li>
</ul>
<p>通常的premain阶段优化即为删减无用的类方法、减少+load操作、减少<strong>attribute</strong>((constructor))的C函数、减少启动加载的动态库。而main阶段的优化为将启动时非必要的操作延迟到首页显示之后加载、统计并优化耗时的方法、对于一些可以放在子线程的操作可以尽量不占用主线程。</p>
<blockquote>
<p><strong>作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:413038000,不管你是大牛还是小白都欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!</strong></p>
</blockquote>
<h4 id="推荐阅读">推荐阅读</h4>
<h4 id="ios开发最新-bat面试题合集持续更新中">iOS开发——最新 BAT面试题合集(持续更新中)</h4>
<h2 id="一耗时方法优化">一、耗时方法优化</h2>
<h3 id="1统计启动时的耗时方法">1.统计启动时的耗时方法</h3>
<p>我们可以通过Instruments的TimeProfile来统计启动时的主要方法耗时,Call Tree-&gt;Hide System Libraries过滤掉系统库可以查看主线程下方法的耗时。</p>
<p><img src="https://upload-images.jianshu.io/upload_images/12311242-2fe368b669cfb002.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240"></p>
<p>也可以通过打印时间的方式来统计各个函数的耗时。</p>
<pre><code>double launchTime = CFAbsoluteTimeGetCurrent();
;
NSLog(@"launchTime = %f秒", CFAbsoluteTimeGetCurrent() - launchTime);
复制代码
</code></pre>
<p>这一阶段就是需要对启动过程的业务逻辑进行梳理,确认哪些是可以延迟加载的,哪些可以放在子线程加载,以及哪些是可以懒加载处理的。同时对耗时比较严重的方法进行review并提出优化策略进行优化。</p>
<h2 id="二load方法优化以及删减不用的类">二、+load方法优化以及删减不用的类</h2>
<h3 id="21-load方法统计">2.1 +load方法统计</h3>
<p>同样的我们可以通过Instruments来统计启动时所有的+load方法,以及+load方法所用耗时</p>
<p><img src="https://upload-images.jianshu.io/upload_images/12311242-560c7d4b5facbb19.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240"></p>
<p>我们可以对不必要的+load方法进行优化,比如放在+initialize里。不必要的+load进行删减。</p>
<h3 id="22-使用__attribute优化load方法">2.2 使用__attribute优化+load方法</h3>
<p>由于在我们的工程中存在很多的+load方法,而其中一大部分为cell模板注册的+load方法(我们的每一个cell对应一个模板,然后该模板对应一个字符串,在启动时所有的模板方法都在+load中注册对应的字符串即在字典中存储字符串和对应的cell模板,然后动态下发展示对应的cell)。</p>
<p><strong>即存在这种场景,在启动时需要大量的在+load中注册key-value。</strong></p>
<p>此时可以使用__attribute((used, section("__DATA,"#sectname" ")))的方式在编译时写入"TempSection"的DATA段一个字符串。此字符串为key:value格式的字典转json。对应着key和value。</p>
<pre><code>#ifndef ZYStoreListTemplateSectionName
#define ZYStoreListTemplateSectionName "ZYTempSection"
#endif

#define ZYStoreListTemplateDATA(sectname) __attribute((used, section("__DATA,"#sectname" ")))

#define ZYStoreListTemplateRegister(templatename,templateclass) \
class NSObject; char * k##templatename##_register ZYStoreListTemplateDATA(ZYTempSection) = "{ \""#templatename"\" : \""#templateclass"\"}";
/**
通过ZYStoreListTemplateRegister(key,classname)注册处理模板的类名(类必须是ZYStoreListBaseTemplate子类)
【注意事项】
该方式通过__attribute属性在编译期间绑定注册信息,运行时读取速度快,注册信息在首次触发调用时读取,不影响pre-main时间
该方式注册时‘key’字段中不支持除下划线'_'以外的符号
【使用示例】
注册处理模板的类名:@ZYStoreListTemplateRegister(baseTemp,ZYStoreListBaseTemplate)
**/
复制代码
</code></pre>
<p>在使用时@ZYStoreListTemplateRegister(baseTemp,ZYStoreListBaseTemplate)即为在编译期间绑定注册信息。</p>
<p><strong>读取使用__attribute在编译期间写入的key-value字符串。</strong> 关于__attribute详情可以参考__attribute黑魔法</p>
<pre><code>#pragma mark - 第一次使用时读取ZYStoreListTemplateSectionName的__DATA所有数据
+ (void)readTemplateDataFromMachO {
    //1.根据符号找到所在的mach-o文件信息
    Dl_info info;
    dladdr((__bridge void *), &amp;info);

    //2.读取__DATA中自定义的ZYStoreListTemplateSectionName数据
    #ifndef __LP64__
      const struct mach_header *mhp = (struct mach_header*)info.dli_fbase;
      unsigned long templateSize = 0;
      uint32_t *templateMemory = (uint32_t*)getsectiondata(mhp, "__DATA", ZYStoreListTemplateSectionName, &amp;templateSize);
    #else /* defined(__LP64__) */
      const struct mach_header_64 *mhp = (struct mach_header_64*)info.dli_fbase;
      unsigned long templateSize = 0;
      uint64_t *templateMemory = (uint64_t*)getsectiondata(mhp, "__DATA", ZYStoreListTemplateSectionName, &amp;templateSize);

    #endif /* defined(__LP64__) */

    //3.遍历ZYStoreListTemplateSectionName中的协议数据
    unsigned long counter = templateSize/sizeof(void*);
    for(int idx = 0; idx &lt; counter; ++idx){
      char *string = (char*)templateMemory;
      NSString *str = ;
      if(!str)continue;

      //NSLog(@"config = %@", str);
      NSData *jsonData = ;
      NSError *error = nil;
      id json = ;
      if (!error) {
            if (] &amp;&amp; .count) {
                NSString *templatesName = ;
                NSString *templatesClass= ;
                if (templatesName &amp;&amp; templatesClass) {
                  ;
                }
            }
      }
    }
}
复制代码
</code></pre>
<p>这样我们就可以优化大量的重复+load方法。而且使用__attribute属性为编译期间绑定注册信息,运行时读取速度快,注册信息在首次触发调用时读取,不影响pre-main时间。</p>
<h2 id="三二进制重排">三、二进制重排</h2>
<p>自从抖音团队分享了这篇 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 启动优化文章后 , 二进制重排优化 pre-main 阶段的启动时间自此被大家广为流传。</p>
<p><strong>当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次 缺页中断(Page Fault)。</strong></p>
<p><strong>二进制重排,主要是优化我们启动时需要的函数非常分散在各个页,启动时就会多次Page Fault造成时间的损耗。</strong></p>
<h3 id="31-获取order-file">3.1 获取Order File</h3>
<p>本次主要是通过Clang静态插桩的方式,获取到所有的启动时调用的函数符号,导出为OrderFile。</p>
<p><code>Target -&gt; Build Setting -&gt; Custom Complier Flags -&gt; Other C Flags</code>添加 <code>-fsanitize-coverage=func,trace-pc-guard</code>参数</p>
<p>然后实现hook代码获取所有启动的函数符号。启动后在首页显示之后,可以通过触发下边-getAllSymbols方法获取所有符号。</p>
<pre><code>#import "dlfcn.h"
#import &lt;libkern/OSAtomic.h&gt;
复制代码
</code></pre>
<pre><code>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 &lt; stop; x++)
      *x = ++N;// Guards should start from 1.
}

//原子队列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
static BOOL isEnd = NO;
//定义符号结构体
typedef struct{
    void * pc;
    void * next;
}SymbolNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //if (!*guard) return;// Duplicate the guard check.
    if (isEnd) {
      return;
    }
    void *PC = __builtin_return_address(0);

    SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC,NULL};

    //入队
    // offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
    OSAtomicEnqueue(&amp;symboList, node, offsetof(SymbolNode, next));
}

- (void)getAllSymbols {
    isEnd = YES;
    NSMutableArray&lt;NSString *&gt; * symbolNames = ;
    while (true) {
      //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
      SymbolNode * node = OSAtomicDequeue(&amp;symboList, offsetof(SymbolNode, next));
      if (node == NULL) break;
      Dl_info info;
      dladdr(node-&gt;pc, &amp;info);

      NSString * name = @(info.dli_sname);

      // 添加 _
      BOOL isObjc = || ;
      NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];

      //去重
      if (!) {
            ;
      }
    }

    //取反
    NSArray * symbolAry = [ allObjects];
    NSLog(@"%@",symbolAry);

    //将结果写入到文件
    NSString * funcString = ;
    NSString * filePath = ;
    NSData * fileContents = ;
    BOOL result = [ createFileAtPath:filePath contents:fileContents attributes:nil];
    if (result) {
      NSLog(@"linkSymbol result %@",filePath);
    }else{
      NSLog(@"linkSymbol result文件写入出错");
    }
}
复制代码
</code></pre>
<p>由于我们的工程为pod工程,如果只在主工程里添加other c flags只能获取到主工程层下的所有启动函数,如果要获取所有的包含依赖pod中启动函数符号则需要在每一个pod target设置other c flags参数。</p>
<p>我们可以通过添加pod脚本来对每一个target添加other c flags参数。</p>
<p>在podfile最后添加脚本来为每一个target添加编译参数。注意可以过滤掉Debug环境才加载的库。</p>
<pre><code>post_install do |installer|
    pods_project = installer.pods_project
    build_settings = Hash[
    'OTHER_CFLAGS' =&gt; '-fsanitize-coverage=func,trace-pc-guard'
#    ,'OTHER_SWIFT_FLAGS' =&gt; '-sanitize=undefined -sanitize-coverage=func'
    ]

    pods_project.targets.each do |target|
#      if !target.name.include?('Pods-')
      if !target.name.include?('Pods-') and target.name != 'LookinServer' and target.name != 'DoraemonKit' and target.name != 'DoraemonKit-DoraemonKit'
      # 修改build_settings
      target.build_configurations.each do |config|
            build_settings.each do |pair|
                key = pair
                value = pair
                if config.build_settings.nil?
                  config.build_settings = ['']
                end
                if !config.build_settings.include?(value)
                  config.build_settings &lt;&lt; value
                end
            end
      end

      puts ': ' + target.name + ' success.'
      end
    end
end
复制代码
</code></pre>
<p>重新install之后所有的pod target都会添加上other c flags参数。然后就可以获取到所有的函数符号(注意如果是二进制库则还是会获取不到)。</p>
<h3 id="31-设置order-file">3.1 设置Order File</h3>
<p>通过objc的源码可以看到objc也是通过设置order file设置编译顺序的。</p>
<p>我们可以在主工程的<code>Target -&gt; Build Setting -&gt; Linking -&gt; Order File</code>添加上述步骤导出的函数符号列表linkSymbols.order。</p>
<p><code>$(SRCROOT)/linkSymbols.order</code> 这里可以根据根目录路径然后寻找,不必把orderfile添加到工程bundle里。如果添加到工程里则会被打包到ipa里。我们可以只是放在工程文件夹下,只在编译的时候根据路径引用就可以了。</p>
<p>设置完orderfile之后我们可以通过设置write link map file属性为YES来找到编译时生成的符号<code>($Project)-LinkMap-normal-arm64.txt</code>。 修改完毕后 clean 一下 , 运行工程 , Products - show in finder, 找到 macho 的上上层目录。 找到结尾为arm64.txt的文件并打开。</p>
<p><code>Intermediates -&gt; project_ios.build -&gt; Debug-iphoneos -&gt; project_ios.build -&gt; project_ios-LinkMap-normal-arm64.txt</code></p>
<p><code>($Project)-LinkMap-normal-arm64.txt</code>文件里在<code>#Symbols</code>之后为函数符号链接的顺序,可以验证一下重排是否成功。</p>
<p>最后可以看一下我们重排之后的效果,Instruments下System Trace下Page Fault的次数和耗时:</p>
<p><img src="https://upload-images.jianshu.io/upload_images/12311242-db924c3ab4b6b6fd.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240"></p>
<p><img src="https://upload-images.jianshu.io/upload_images/12311242-03de73811b3f0bb6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240"></p>
<h2 id="总结">总结</h2>
<p>最后在看一下本次优化的效果。图中为iPhone6s Plus重启后第一次启动的优化前后截屏。</p>
<p><img src="https://upload-images.jianshu.io/upload_images/12311242-cbd00e6960b06a88.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240"></p>
<p>参考文章:</p>
<p>iOS 优化篇 - 启动优化之Clang插桩实现二进制重排</p>
<p>iOS App启动优化</p>
<blockquote>
<p><strong>作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:413038000,不管你是大牛还是小白都欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!</strong></p>
</blockquote>
<h4 id="推荐阅读-1">推荐阅读</h4>
<h4 id="ios开发最新-bat面试题合集持续更新中-1">iOS开发——最新 BAT面试题合集(持续更新中)</h4>
<p>作者:橘子不酸丶<br>
链接:https://juejin.im/post/6861917375382929415<br>
来源:掘金</p><br><br>
来源:https://www.cnblogs.com/iOSer1122/p/13556731.html
頁: [1]
查看完整版本: iOS优化篇之App启动时间优化