iOS Swift App启动优化

说在前面

  • 网络上关于怎样做App启动优化的文章太多了,有些是对自己项目有用的,有些是对自己项目无用的。本文就是看了很多文章,经过自己的筛选和总结而成的。
  • 本文讲解在NXPlayer这款App中怎样做的启动优化,大家可以从AppStore上下载体验。
  • 启动优化,顾名思义就是缩短App启动的时间。本文从动态库转静态库和二进制重排两个方向入手,详细描述怎样做启动优化的。
  • 项目纯Swift编写,使用Cocoapods管理三方库。

启动时间

  • 那么怎样获取App启动所需要的时间呢,其实Xcode已经自带这个工具,名字就叫App Launch
  • Profile调整到Debug模式。如果没有调整,在App Launch界面是不能点击Record按钮,也就没有办法分析启动时间。
  • 点击App Launch后,会自动打开App,分析完毕后,会自动关闭App,并且生成启动时间相关的数据
  • 从上图可以清楚地看到每个阶段的耗时情况,我们以最后一行的时间,也就是App运行在前台的时间,作为启动总耗时。

动态库转静态库

  • 把动态库转静态库,减少了动态库数量,除了可以减小加载动态库阶段的耗时,还能额外减少包大小。
  • 并不是所有的动态库都适合转成静态库。实践中发现,如果库中有Resources文件夹,最好不要转换。转换后Bundle发生了变化,有些资源就会访问不到。当然也有解决方案:把动态库的资源都拷贝到Main Bundle中,这样也会有其它方面的问题,不在这里叙说。
  • 项目中的动态库都是Pods管理的,选择我们使用的库,然后点击Build Settings->找到Mach-O Type修改为Static Library
  • 目前的动态库很少,可以手动修改。如果动态库多,可以在Podfile里面添加下面的代码,然后执行pod install
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #填写不需要转换成静态库的动态库名字,这个需要我们手动排查。
    dynamic_frameworks = ['AMSMB2','MJRefresh','IJKMediaFramework','UnrarKit']
    post_install do |installer|
    installer.pods_project.targets.each do |target|
    if dynamic_frameworks.include?(target.name)
    next
    end
    target.build_configurations.each do |config|
    config.build_settings['MACH_O_TYPE'] = 'staticlib'
    end
    end
    end
  • Targets Support Files中找到Pods-NXPlayer-frameworks.sh脚本,把需要转换成静态库的行都注释掉。已经转换成静态库了,没有必要再往NXPlayer.app/Frameworks在拷贝一份。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    if [[ "$CONFIGURATION" == "Debug" ]]; then
    install_framework "${BUILT_PRODUCTS_DIR}/AMSMB2/AMSMB2.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/Alamofire/Alamofire.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/FilesProvider/FilesProvider.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/GCDWebServer/GCDWebServer.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/MBProgressHUD/MBProgressHUD.framework"
    install_framework "${BUILT_PRODUCTS_DIR}/MJRefresh/MJRefresh.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/PLzmaSDK/PLzmaSDK.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/SQLite.swift/SQLite.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/SSZipArchive/SSZipArchive.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework"
    install_framework "${BUILT_PRODUCTS_DIR}/UnrarKit/UnrarKit.framework"
    fi
    if [[ "$CONFIGURATION" == "Release" ]]; then
    install_framework "${BUILT_PRODUCTS_DIR}/AMSMB2/AMSMB2.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/Alamofire/Alamofire.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/FilesProvider/FilesProvider.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/GCDWebServer/GCDWebServer.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/MBProgressHUD/MBProgressHUD.framework"
    install_framework "${BUILT_PRODUCTS_DIR}/MJRefresh/MJRefresh.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/PLzmaSDK/PLzmaSDK.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/SQLite.swift/SQLite.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/SSZipArchive/SSZipArchive.framework"
    # install_framework "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework"
    install_framework "${BUILT_PRODUCTS_DIR}/UnrarKit/UnrarKit.framework"
    fi
  • 网络上关于冷启动热启动的讨论很多,App要在冷启动的情况下,测试时间才是准确的。测试不能以一次时间为准,要多几次并取平均值。具体如下:
    • 每测试完一次需要:卸载App,退出Instruments,退出Xcode
    • 再次测试需要:打开Xcode,按快捷键command + i,会自动安装App并启动Instruments,点击App Launch进行测试。
    • 本文在转静态库之前进行了6次,总耗时12.35秒;转静态库之后进行了6次,总耗时9.112秒。时间虽然相差很少,但也算是优化了启动时间。

二进制重排

简单介绍

  • 为什么重排
    • App启动会调用很多方法或函数,这些方法或函数在内存中的表现形式是地址,姑且统称为函数地址
    • 访问函数地址其实是从映射表中寻找物理内存中对应的内存地址,如果找到就直接访问,没有找到,系统就会立刻阻塞整个进程,触发中断异常Page Fault。当一个Page Fault被触发,操作系统会从磁盘中重新读取这页数据到物理内存上,然后将映射表中函数地址指向对应的物理地址。
    • 这些启动时的函数地址可能是放在很多页中的,那么启动时候就会产生多次的Page Fault。我们可以把函数地址放在一页或几页,减少Page Fault次数,减少阻塞次数,减少启动时间
  • 怎样查看Page Fault次数
    • 打开Instruments,找到System Trace打开。点击左上角Record(录制)按钮,等待App完全显示出来,点击停止按钮,会自动进行分析。
    • 分析完成后,按照下图所示,找到File Backed Page In,这个就是Page Fault的次数

启动函数

  • 上文也提到我们需要把启动时要调用的函数地址放在一页或几页,那么首先要知道启动时调用了哪些函数地址,才能放到页中。
  • 找到Build Settings搜索other swift,并添加-sanitize-coverage=func-sanitize=undefined:
  • LLVM内置了一个简单的代码覆盖率工具SanitizerCoverage,需要把它集成进项目。
    • 发现是C语言实现的,而又是纯Swift项目,不能直接支持C语言,所以新建一个SanitizerCoverage.m文件,并让它参与编译。
    • 在文件SanitizerCoverage.m里面写上如下内容,用于拦截函数、方法、Block、闭包等调用。使用Dl_info可以知道当前调用的函数名,定义了一个number可以知道启动时一共调用了多少个函数。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      #include <stdint.h>
      #include <stdio.h>
      #include <sanitizer/coverage_interface.h>
      #import <dlfcn.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.
      }

      void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
      if (!*guard) return; // Duplicate the guard check.

      void *PC = __builtin_return_address(0);
      Dl_info info;
      dladdr(PC, &info);

      static int number = 0;
      number += 1;

      printf("方法函数名%s 当前个数--%d--\n",info.dli_sname,number);

      char PcDescr[1024];
      // printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
      }
  • 经过测试,不管是静态库还是动态库里面的函数,如果在启动的时候有被调用,都会被打印。每次启动项目都会有3000多个函数被调用,并且这些函数怎么都是一些乱七八糟的符号,一脸懵逼。其实是因为Swift的名字重整机制,被重整后,就会变成这样,名字重整不在今天的讨论范围,不继续往下说。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    方法函数名main 当前个数--1--
    方法函数名$s8NXPlayer11AppDelegateCMa 当前个数--2--
    方法函数名$s8NXPlayer11AppDelegateCACycfcTo 当前个数--3--
    方法函数名$s8NXPlayer11AppDelegateCACycfc 当前个数--4--
    方法函数名$s8NXPlayer11AppDelegateCMa 当前个数--5--
    ...
    方法函数名$sSo18NSComparisonResultV8rawValueSivg 当前个数--3572--
    方法函数名block_destroy_helper.9 当前个数--3573--
    方法函数名$sSS5value_Sb8isForcedSS5titleSS7messagetSgWOy 当前个数--3574--
  • 好了,到这里已经能够找到启动时要调用的函数了,下一步需要把这些函数排在一起,方便加载。

重排函数

  • 在重排函数之前,需要知道当前的函数顺序是什么,重排之后的函数顺序又是什么。这里也很简单:
    • Build Settings中修改Write Link Map FileYES
    • 执行Command + Shift + K清空编译文件夹
    • 执行Command + B重新编译,编译后会生成一个Link Map符号表txt文件
    • 依次打开Products->NXPlayer.app->Show in Finder->Intermediates.noindex/NXPlayer.build/Debug-iphoneos/NXPlayer.build/NXPlayer-LinkMap-normal-arm64.txt
    • 内容很多,搜索# Symbols:,可以发现现在的顺序如下
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      # Symbols:
      # Address Size File Name
      0x100007420 0x00000074 [ 1] ___sanitizer_cov_trace_pc_guard_init
      0x100007494 0x000000B0 [ 1] ___sanitizer_cov_trace_pc_guard
      0x100007544 0x00000038 [ 2] _$s8NXPlayer18AboutBodyViewModelC8fileNameSSvpfi
      0x10000757C 0x00000084 [ 2] _$s8NXPlayer18AboutBodyViewModelC8fileNameSSvpACTK
      0x100007600 0x000000A8 [ 2] _$s8NXPlayer18AboutBodyViewModelC8fileNameSSvpACTk
      0x1000076A8 0x00000080 [ 2] _$s8NXPlayer18AboutBodyViewModelC8fileNameSSvg
      0x100007728 0x00000094 [ 2] _$s8NXPlayer18AboutBodyViewModelC8fileNameSSvs
      0x1000077BC 0x00000064 [ 2] _$s8NXPlayer18AboutBodyViewModelC8fileNameSSvM
      ...
  • 接下来就是正式重排了,重整后的名字都是以'$s'开头的。