iOS——App启动优化分析与总结

随着人们对App性能与实用要求越来越高,也随着大量iOS开发中的涌入,现如今已经不再是几年前会做简单App,然后开始开发,能写出一个可用功能的产品就可以了,于是,很多公司和开发者也开始关注和实战启动优化,因为App除了桌面Log,启动是也是夺得用户喜好最首要的条件,如果一个App每次启动都要几十秒甚至几分钟,你觉得你还会继续使用吗……

如果你对iOS开发中App的整个启动过程干兴趣,那么可以先看我之前分享的一个主题
iOS——App整个启动过程分析

当然关于启动优化,上面只是简单的提了一下,本文结合那篇文章进行详细分析

启动优化准备

APP启动时间:

  • t(App总启动时间) = t1(main()之前的加载时间) + t2(main()之后的加载时间)。

t1 = 系统dylib(动态链接库)和自身App可执行文件的加载;

t2 = main方法执行之后到AppDelegate类中的didFinishLaunchingWithOptions方法执行结束前这段时间,主要是构建第一个界面,并完成渲染展示。

前面我们分析了,Main之前和之后的加载过程

这里我们先简单介绍一下,之前没有详细说明的一些其中关联的技术:
  • App开始启动后,系统首先加载可执行文件(自身App的所有.o文件的集合),然后加载动态链接库dyld,dyld是一个专门用来加载动态链接库的库。
    执行从dyld开始,dyld从可执行文件的依赖开始, 递归加载所有的依赖动态链接库。

其实无论对于系统的动态链接库还是对于App本身的可执行文件而言,他们都算是image(镜像),而每个App都是以image(镜像)为单位进行加载的

什么是image

1.executable可执行文件 比如.o文件。

2.dylib 动态链接库 framework就是动态链接库和相应资源包含在一起的一个文件夹结构。

3.bundle 资源文件 只能用dlopen加载,不推荐使用这种方式加载。

注意:除了我们App本身的可行性文件,系统中所有的framework比如UIKit、Foundation等都是以动态链接库的方式集成进App中的。

不同进程之间共用系统dylib的_TEXT区,但是各自维护对应的_DATA区。

所有动态链接库和我们App中的静态库.a和所有类文件编译后的.o文件最终都是由dyld(the dynamic link editor),Apple的动态链接器来加载到内存中。
每个image都是由一个叫做ImageLoader的类来负责加载(一一对应)

是ImageLoader

image 表示一个二进制文件(可执行文件或 so 文件),里面是被编译过的符号、代码等,

ImageLoader 作用是将这些文件加载进内存,且每一个文件对应一个ImageLoader实例来负责加载。

ImageLoader加载步骤分两步走:
  • 在程序运行时它先将动态链接的 image 递归加载 (也就是上面测试栈中一串的递归调用的时刻)。
  • 再从可执行文件 image 递归加载所有符号。

真正的启动优化:

Main之前:

检测方式:Apple提供了一种测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量DYLD_PRINT_STATISTICS 设为1

pre-main阶段

1.1. 加载应用的可执行文件

1.2. 加载动态链接库加载器dyld(dynamic loader)

1.3. dyld递归加载应用所有依赖的dylib(dynamic library 动态链接库)

动态链接库的加载步骤具体分为5步:

  • load dylibs image 读取库镜像文件
  • Rebase image
  • Bind image
  • Objc setup
  • initializers

load dylibs image

在每个动态库的加载过程中, dyld需要:

  • 分析所依赖的动态库
  • 找到动态库的mach-o文件
  • 打开文件
  • 验证文件
  • 在系统核心注册文件签名
  • 对动态库的每一个segment调用mmap()
  • 通常的,一个App需要加载100到400个dylibs, 但是其中的系统库被优化,可以很快的加载。

######> 针对这一步骤的优化有:

  • 减少非系统库的依赖
  • 尽量不使用内嵌(embedded)的dylib,加载内嵌dylib性能开销较大
  • 合并已有的dylib和使用静态库(static archives),减少dylib的使用个数
  • 使用静态资源,比如把代码加入主程序
  • 懒加载dylib,但是要注意dlopen()可能造成一些问题,且实际上懒加载做的工作会更多

rebase/bind

由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址。 rebase修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。
rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。

通过命令行可以查看相关的资源指针:

xcrun dyldinfo -rebase -bind -lazy_bind myApp.App/myApp

优化该阶段的关键在于减少__DATA segment中的指针数量。

######> 我们可以优化的点有:

  • 减少ObjC类(class)、方法(selector)、分类(category)的数量
  • 减少C++虚函数数量(创建虚函数表有开销)
  • 转而使用swift stuct(其实本质上就是为了减少符号的数量)
Objc setup

这一步主要工作是:

  • 注册Objc类 (class registration)
  • 把category的定义插入方法列表 (category registration)
  • 保证每一个selector唯一 (selctor uniquing)

由于之前2步骤的优化,这一步实际上没有什么可做的。

initializers

以上三步属于静态调整(fix-up),都是在修改__DATA segment中的内容,而这里则开始动态调整,开始在堆和堆栈中写入内容。

在这里的工作有:

  • Objc的+load()函数
  • C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()
  • 非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度

Objc的load函数和C++的静态构造函数采用由底向上的方式执行,来保证每个执行的方法,都可以找到所依赖的动态库。

+load方法断点的调用堆栈和顺序:

  • dyld 开始将程序二进制文件初始化
  • 交由 ImageLoader 读取 image,其中包含了我们的类、方法等各种符号
  • 由于 runtime 向 dyld 绑定了回调,当 image 加载到内存后,dyld 会通知 runtime 进行处理
  • runtime 接手后调用 mapimages 做解析和处理,接下来 loadimages 中调用 callloadmethods 方法,遍历所有加载进来的 Class,按继承层级依次调用 Class 的 +load 方法和其 Category 的 +load 方法

######> 我们可以做的优化有:

  • 少在类的+load方法里做事情,尽量把这些事情推迟到+initiailize
  • 减少构造器函数个数,在构造器函数里少做些事情
  • 减少C++静态全局变量的个数

至此,可执行文件中和动态库所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被 runtime 所管理,再这之后,runtime 的那些方法(动态添加 Class、swizzle 等等才能生效)。

到这里整个过程:

整个事件由 dyld 主导,完成运行环境的初始化后,配合 ImageLoader 将二进制文件按格式加载到内存, 动态链接依赖库,并由 runtime 负责加载成 objc 定义的结构,所有初始化工作结束后,dyld 调用真正的 main 函数。

总结优化点:

  • 减少不必要的framework,因为动态链接比较耗时
  • check framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查
  • 合并或者删减一些OC类,关于清理项目中没用到的类,使用工具AppCode代码检查功能,查到当前项目中没有用到的类如下:
  • 删减一些无用的静态变量
  • 删减没有被调用到或者已经废弃的方法
  • 将不必须在+load方法中做的事情延迟到+initialize中
  • 尽量不要用C++虚函数(创建虚函数表有开销)

Main之后:

检测方式:测量main()函数开始执行到didFinishLaunchingWithOptions执行结束的耗时,自己插入代码到工程。

main()阶段

2.1. dyld调用main()

2.2. 调用UIApplicationMain()

2.3. 调用applicationWillFinishLaunching

2.4. 调用didFinishLaunchingWithOptions

在main()被调用之后,App的主要工作就是初始化必要的服务,显示首页内容等。
而我们的优化也是围绕如何能够快速展现首页来开展。
App通常在AppDelegate类中的didFinishLaunchingWithOptions方法中创建首页需要展示的view,
然后在当前runloop的末尾,主动调用CA::Transaction::commit完成视图的渲染。

而视图的渲染主要涉及三个阶段:

  • 准备阶段 这里主要是图片的解码
  • 布局阶段 首页所有UIView的- (void)layoutSubViews()运行
  • 绘制阶段 首页所有UIView的- (void)drawRect:(CGRect)rect运行
  • 再加上启动之后必要服务的启动、必要数据的创建和读取,这些就是我们可以尝试优化的地方

因此,对于main()函数调用之前我们可以优化的点有:

  • 不使用xib,直接视用代码加载首页视图
  • NSUserDefaults实际上是在Library文件夹下会生产一个plist文件,如果文件太大的话一次能读取到内存中可能很耗时,这个影响需要评估,如果耗时很大的话需要拆分(需考虑老版本覆盖安装兼容问题)
  • 每次用NSLog方式打印会隐式的创建一个Calendar,因此需要删减启动时各业务方打的log,或者仅仅针对内测版输出log
  • 梳理应用启动时发送的所有网络请求,是否可以统一在异步线程请求
  • 梳理各个二方/三方库,找到可以延迟加载的库,做延迟加载处理,比如放到首页控制器的viewDidAppear方法里。
  • 梳理业务逻辑,把可以延迟执行的逻辑,做延迟执行处理。比如检查新版本、注册推送通知等逻辑。
  • 避免复杂/多余的计算。
  • 避免在首页控制器的viewDidLoad和viewWillAppear做太多事情,这2个方法执行完,首页控制器才能显示,部分可以延迟创建的视图应做延迟创建/懒加载处理。
  • 采用性能更好的API。
  • 首页控制器用纯代码方式来构建。

总结:具体优化点

  • 纯代码方式而不是storyboard加载首页UI。
  • 对didFinishLaunching里的函数考虑能否挖掘可以延迟加载或者懒加载,需要与各个业务方pm和rd共同check 对于一些已经下线的业务,删减冗余代码。
  • 对于一些与UI展示无关的业务,如微博认证过期检查、图片最大缓存空间设置等做延迟加载
  • 对实现了+load()方法的类进行分析,尽量将load里的代码延后调用。
  • 上面统计数据显示展示feed的导航控制器页面(NewsListViewController)比较耗时,对于viewDidLoad以及viewWillAppear方法中尽量去尝试少做,晚做,不做

到这里之后其实已经差不多了,相信你应该有哪么写成就与收获。

除了这些,我们还可以喂项目做一些缓存优化
  • ccache 等缓存方案
  • 优化 Xcode 配置
  • 加钱堆硬件

以上优化方案出发点都是基础优化编译耗时来解决的。

哪有没有一个办法可以做到不编译就执行修改后的代码呢?

答案肯定是:有的

基于 Objective-C 的动态特性,是完全可以做到这一点的,这也是各种热修复框架的支撑原理之一。那么如果需要做到不编译就执行修改后的代码,我们可以这样做:

获取本地修改后代码 -> 转 JavaScript 或 Lua -> 模拟器执行修改后的脚本。
  • 获取本地修改代码
    • 这里也有许多方法,可以手动复制,也可以自动获取。这里我是选择利用 Xcode Editor Extension 来获取到你选中的修改代码的。
  • Objective-C 转 JavaScript
    • 由于整个流程我是基于 JSPatch 来开发的,所以是需要转为 JS 的脚本。这里我是写了个 node.js 的脚本来实现,转换算法是利用 https://github.com/bang590/JSPatchConvertor 中的开源代码。
  • 模拟器执行修改后的脚本
    • 由于已经有 JSPatch 完整的框架做支撑,这里只需要利用其中的方法 -[JPEngine evaluateScriptWithPath:] 去执行修改后的脚本即可。

当然这种有一定的缺陷,不管是针对苹果审核,还是学习成本,或者是其他意向不到的问题

使用之前还是慎重考虑,可以适当的使用作为部分模块的优化,或者作为学习。

至于后续的步骤就是,写好代码,逻辑,界面,优化……

性能优化推荐:iOS应用性能调优的25个建议和技巧
坚持原创技术分享,您的支持将鼓励我继续创作!