航母iOS启动优化实践小记

前言

随着应用的不断迭代和功能的增加,我可能会发现应用的启动时间变得更长。特别是在iOS平台上,启动速度是用户体验的一个非常关键的指标。如果应用启动时间过长,可能会导致用户流失,因为他们不愿意等待这么长的时间。为此,本文将介绍我针对航母的iOS启动时长优化实践。

基本概念

启动相关概念及优化方向确定

启动的定义

启动通常被定义为两种:

  • 广义:从用户点击应用图标到应用的首页数据完全加载完成。
  • 狭义:从用户点击应用图标到Launch Image完全消失的那一刻。

对于“航母”来说,其首页数据加载完毕的标志是视频的第一帧开始播放。而对于其他首页主要展示静态内容的App来说,当Launch Image消失时,就可以认为首页数据已经加载完成。由于不同App的启动标准难以统一,我通常采用狭义的定义:启动的终点是当启动图完全消失后的首帧

Tips:理想的启动时间应该控制在400ms以内,因为标准的启动动画时长为400ms。

启动的种类

根据不同的启动场景,启动可以划分为三大类:冷启动、热启动和回到前台。

  • 冷启动:App在系统中没有任何进程缓存信息的情况下启动,如重启手机后首次打开App。
  • 热启动:当App进程被终止,但随后很快再次启动,这种启动被称为热启动,因为进程的缓存信息仍然存在。
  • 回前台:这通常不被定义为启动,因为此时App进程仍然存在,只是从suspended状态转到了active状态。

实际上,针对线上用户的启动类型,到底是冷启动占比较大还是热启动占比较大,这与产品的使用频次密切相关。如果一个App的使用频次非常高,则热启动的比例相对较大。航母作为一款头部音乐类应用,打开频次较高,故热启动比例高。

启动流程 引用自 抖音品质建设 - iOS启动优化《原理篇》

  • 点击图标,创建进程:用户点击App图标后,系统开始为App分配进程资源。
  • mmap 主二进制,找到 dyld 的路径:系统映射应用的主二进制到内存,并查找动态链接编辑器的路径。
  • mmap dyld,把入口地址设为_dyld_start:系统将dyld加载到内存,并设置其入口地址。
  • 重启手机/更新/下载 App 的第一次启动,会创建启动闭包:在特定条件下,系统会为应用创建启动闭包,用于加速之后的启动。
  • 把没有加载的动态库 mmap 进来,动态库的数量会影响这个阶段:应用需要的所有未加载的动态库被映射到内存。
  • 对每个二进制做 bind 和 rebase,主要耗时在 Page In,影响 Page In 数量的是 objc 的元数据:进行地址绑定和基址重定位,其中Page In的数量受到Objective-C元数据的影响。
  • 初始化 objc 的 runtime,由于闭包已经初始化了大部分,这里只会注册 sel 和装载 category:Objective-C运行时进行初始化,包括注册选择器和加载类别。
  • +load 和静态初始化被调用,除了方法本身耗时,这里还会引起大量 Page In:调用类的+load方法和进行其他静态初始化。
  • 初始化 UIApplication,启动 Main Runloop:UIApplication对象被初始化,并启动主RunLoop。
  • 执行 will/didFinishLaunch,这里主要是业务代码耗时:调用应用代理的启动相关方法,此时主要的耗时是由业务代码导致的。
  • Layout,viewDidLoad 和 Layoutsubviews 会在这里调用,Autolayout 太多会影响这部分时间:界面布局开始,相关的界面生命周期方法被调用。
  • Display,drawRect 会调用:界面进行渲染,视图的drawRect方法被调用。
  • Prepare,图片解码发生在这一步:图片资源进行解码。
  • Commit,首帧渲染数据打包发给 RenderServer,启动结束:界面的首帧渲染数据被发送给RenderServer,此时应用启动完成。

启动阶段及确定优化方向

iOS应用的启动过程可以大致分为两个阶段:pre-main阶段和main函数之后到didFinishLaunchingWithOptions阶段,所以优化方向可以基本确定在这两个阶段。

1. pre-main 阶段:

这一阶段是从应用启动到main函数被调用之前的时间段。在这个阶段,大部分时间被用于动态链接器加载和初始化应用的动态库,解析Objective-C的runtime数据结构,以及调用类和分类的+load方法。

优化方向:

  • 减少动态库数量: 每一个动态库的加载都会增加启动时间。考虑合并多个小的动态库,或者将动态库转化为静态库。

    这一步航母动态库数量有限,且影响较大的是Flutter和Zego

  • 避免过多的类和分类加载: 减少使用+load方法,或者将这些初始化工作延迟到应用启动后。

    除安全函数的hook外,其他耗时的已延后放到 initialize(这个被证实无用,因为操作的类initialize时间还是在启动阶段内,只是换了个时机),且已尝试将40多个安全函数的load换到同一个地方调用(即使用一个load),仅仅节约了4ms,考虑到收益比,故而还原了此修改。

  • 启动闭包: 因为使用了dyld3\dyld4,会创建启动闭包来加速启动,但这需要确保应用的动态库和数据结构在每次更新后都没有太大变化。

    尝试了启动阶段二进制重排,实测缺页次数大大减少,但是总体启动时间并没有多大变换(热启动下)。

  • 优化Objective-C的runtime数据结构: 减少C++静态初始化、减少Objective-C类和分类的数量。

    工作量太大,要减少大量的类和分类才有收益,有业内测试,20000个类,800ms左右,故而没尝试。

2. main 函数之后到 didFinishLaunchingWithOptions 阶段:

这一阶段是从main函数被调用到application:didFinishLaunchingWithOptions:方法返回之前的时间段。在这个阶段,主要是应用框架和业务代码的初始化工作。

优化方向:

  • 延迟初始化: 不是所有的初始化工作都需要在应用启动时完成。考虑使用dispatch_once, dispatch_after或其他技术延迟初始化。

  • 并行初始化: 对于不依赖其他模块的初始化工作,可以考虑在后台线程并行初始化。

    这两点KGStartUpTaskManager已经做得比较好了,任务分发这里,耗时不长,优化空间不大。

  • 优化UI渲染: 减少首屏UI元素的数量,使用轻量级的视图和图形,避免在启动时做复杂的布局计算。

    首页显示耗时任务已让对应业务修改

  • 缓存策略: 对于一些重复的、耗时的计算,考虑使用缓存策略。

    优化了部分代码实现,已单元测试对比性能,如图片resize,文字绘图方法等。

  • 利用RunLoop: 可以利用RunLoop在首屏渲染完成后再进行一些非关键的初始化工作。

    KGStartUpTaskManager 采用的便是此策略

总的来说,iOS应用的启动优化需要从多个方面入手。航母作为一个千万级DAU的产品,已经历多次启动优化迭代,pre-main阶段的优化收益目前来看比较有限,而main函数之后的优化主要是从应用业务逻辑和UI渲染的角度考虑,KGStartUpTaskManager分发目前来看,尚还合理,需要在这个方向上细细抠出启动时长,需要时间和各个业务配合修改,在通用技术层面,能进行修改的,是一些耗时的通用方法优化,比如图片着色、高斯模糊等。

dyld2、dyld3、dyld4: iOS的动态链接器进化史

iOS的启动流程是一个复杂且关键的过程,而在其中,dyld即链接扮演着至关重要的角色。作为Apple的动态链接器,dyld负责管理应用程序和它所依赖的库之间的链接。我来看一下,从dyld2到dyld4,这个组件是如何发展的。

dyld2

dyld2在iOS 3.1中首次亮相,它支持到iOS 12。这个版本的主要亮点是:

  • dyld shared cache:为了提高库的加载速度,dyld2引入了一个技术,将系统的核心库(如UIKit)合并为一个大的缓存文件。
  • 初步的优化:在这个阶段,Apple开始关注启动时间,并通过预加载和其他技术,尝试减少启动延迟。

dyld3

从iOS 13开始,Apple为第三方应用启用了dyld3。这个版本引入了许多关键的改进:

  • 启动闭包:与之前版本不同,dyld3为每个应用程序创建一个“启动闭包”。这个闭包包含了应用程序启动所需的所有信息,大大减少了启动时的IO操作和磁盘访问。
  • 更智能的优化:通过对启动过程进行详细的分析,dyld3能够更智能地决定哪些库应该预加载,从而进一步提高启动速度。
  • 更紧凑的内存布局:dyld3优化了应用程序和库在内存中的布局,减少了页面错误,提高了性能。

dyld4

dyld4 在保持相同的 mach-o 解析器的基础上改进 dyld3,并在 non-customer 情况下通过支持不需要预构建闭包的即时加载来做得更好。

主要特点与改进:

  • 代码组织结构:dyld4 的代码组织包括 dyld、libdyld、缓存构建工具、其他工具、常用代码、公共头文件、测试、文档等部分。
  • 启动流程:内核通过将所有的 argc、argv、envp 和 apple 参数推到堆栈并跳到 dyld 的入口点来启动进程。在 dyld4 中,有几行汇编代码来对齐堆栈,并跳入C++代码。
  • 全局状态管理:所有的 dyld 状态都存储在 dyld 中(而不是在 libdyld 中),并分为两个类进行管理:DyldProcessConfig 和 DyldRuntimeState。
  • Loader 对象:每个加载的 mach-o 文件都由一个 dyld4::Loader 对象跟踪。在 dyld4 中,加载的图像列表是 DyldRuntimeState 中的一个 Array<dyld4::Loader*>
  • MachOAnalyzer:dyld4::Loader 对象不直接解析/处理 mach-o 文件,而是建立在 dyld3::MachOAnalyzer 层上的薄对象。
  • 统一的 Prebuilt 和 JustInTime 模型:dyld4 为每个在进程中加载的 mach-o 文件实例化了一个新的抽象基类 Loader。它有两个具体的子类:PrebuiltLoader 和 JustInTimeLoader。
  • libdyld.dylib 的变化:libdyld.dylib 是很小的,几乎所有的代码都在 dyld 中。但它还有一个特别之处,那就是
  • _dyld_process_info 例程,它不使用任何当前的 dyld 状态,而是检查另一个进程的 dyld 状态。

启动闭包介绍

启动闭包优化是在dyld3和之后的版本中引入的一种策略,它的目标是减少应用启动时间。启动闭包,简而言之,是一个预先计算的数据结构,其中包含了应用启动时所需的所有信息,如需要加载的动态库、链接信息、初始化顺序等。闭包的引入减少了启动时的I/O操作和不必要的计算,从而大大加速了应用的启动速度。

以下是启动闭包优化的主要内容和过程:

  1. 依赖的动态库列表
    在传统的动态链接过程中,dyld需要解析Mach-O文件头,查找它所依赖的所有动态库。在启动闭包中,这个列表是预先计算好的,所以dyld可以立即知道需要加载哪些库,而不用再次解析二进制文件。

  2. Bind和Rebase信息
    在传统启动流程中,dyld会执行两个主要任务:binding和rebasing。binding是解析并链接外部符号的过程,而rebasing是更新程序内部地址引用的过程。启动闭包中预先计算了这些操作的结果,因此dyld可以直接应用这些操作,而无需再次计算。

  3. 初始化顺序
    不同的库和模块可能有不同的初始化代码,它们需要在特定的顺序中执行。启动闭包预先计算了这个顺序,确保每个初始化函数都按正确的顺序执行。

  4. 元数据和其他信息
    除了上述关键信息,启动闭包还可能包含其他优化数据,例如Objective-C的类、选择器、协议等的元数据,以及其他与特定应用或库相关的优化数据。

创建和使用启动闭包的过程:
  • 创建:启动闭包是在应用编译和链接时创建的。在这个过程中,链接器收集所有必要的信息,如依赖关系、bind/rebase操作等,并将它们存储在闭包中。
  • 存储:闭包存储在App的沙盒目录下,通常在tmp/com.apple.dyld路径下。这确保了闭包在应用更新或设备重启时都可以轻松地被重新创建。
  • 加载:当用户启动应用时,dyld首先检查是否有可用的启动闭包。如果找到闭包,dyld会使用它来加速启动过程。如果没有找到,dyld会回退到传统的启动流程。
  • 更新:如果应用更新或其依赖发生变化,启动闭包需要被重新创建。dyld会自动检测这些更改并在必要时重新生成闭包。

总的来说,启动闭包优化是dyld在iOS应用启动过程中引入的一种重要优化。通过预先计算和存储关键启动信息,它大大减少了启动时间,为用户提供了更流畅的体验。

结论:

从dyld2到dyld4,Apple一直在努力优化iOS的启动流程。每个新版本都引入了新的技术和特性,旨在为用户提供更快、更流畅的体验。这也再次证明了Apple对用户体验的执着追求。

MachO介绍

当我谈论iOS中的Mach-O(Mach Object),我实际上是在讨论macOS和iOS上用于表示可执行文件、目标代码、共享库和动态加载代码的文件格式。Mach-O是macOS和iOS的原生二进制文件格式。

Mach-O结构

Mach-O文件由三部分组成:

  • Header:描述文件的总体属性,例如其目标架构(如 arm64、x86_64)和文件类型(如可执行文件、共享库或目标代码)。
  • Load Commands:告诉操作系统如何加载和运行该文件。这些命令提供了有关文件中段和节的信息、需要的动态链接共享库和初始化例程的信息等。例如,LC_SEGMENT是一个加载命令,描述了内存中的段的位置和大小。
  • Data:实际的数据,这可能是代码、常量、符号等。

Segments 和 Sections

Mach-O文件进一步被划分为段(Segments)和节(Sections):

  • Segments:是文件中的连续字节块,它们在内存中作为一个单元被加载。每个段可以包含零个或多个节。
  • Sections:定义了段内的具体内容和目的。例如,一个段可以有一个文本节来存储可执行代码,和一个数据节来存储常量。

常见的段和节有:

  • __TEXT段:包含代码和常量。这个段的常见节有:
  • __text:实际的机器代码。
  • __cstring:C-风格的字符串。
  • __DATA段:包含数据变量。常见节有:
  • __data:包含初始化的全局变量。
  • __bss:包含未初始化的全局变量(在内存中,但不占用磁盘空间)。

Symbols

Mach-O文件包含一个符号表,列出了所有的函数和变量的名字。这对于调试和动态链接非常有用。

Mach-O的作用

  • 执行:Mach-O文件定义了如何在一个特定的架构(如arm64)上执行代码。
  • 链接:编译器和链接器使用Mach-O文件格式来生成和链接代码。动态链接器dyld使用Mach-O文件的信息来动态加载和链接共享库。
  • 调试:Mach-O文件中的信息(例如符号表)使得调试工具能够理解和操作代码。
  • 二进制分析:安全研究员和其他利益相关者可以使用Mach-O文件格式进行二进制分析,了解代码的工作方式、查找漏洞或进行逆向工程。

总结

Mach-O是macOS和iOS的核心文件格式,用于表示各种类型的二进制文件。它的结构和信息使得操作系统能够加载、执行、链接和调试代码,同时也为二进制分析提供了有价值的信息。

RunLoop介绍

iOS的RunLoop是一个核心组件,负责管理应用中的事件循环,如触摸事件、定时器事件、I/O事件等。理解RunLoop的工作原理有助于我编写出更高效、更流畅的应用程序。此外,RunLoop与应用启动优化也有关联。

1. RunLoop的结构:

  • Modes: RunLoop可以在不同的模式下运行。每种模式定义了一组输入源和计时器。例如,NSDefaultRunLoopMode、UITrackingRunLoopMode等。
  • Sources: 输入源是与外部事件对应的对象。有两种类型:Source0 和 Source1。Source0 通常用于应用内部事件,如UIEvent或自定义事件。Source1 用于与其他线程或Mach端口的通信。
  • Timers: 计时器用于在指定的时间或间隔执行代码。例如,使用NSTimer。
  • Observers: 观察者可以在RunLoop的不同阶段得到通知,如进入、退出、睡眠等。

2. RunLoop的工作流程:

以下是RunLoop的简化工作流程:

  • 通知Observers:即将进入RunLoop。
  • 通知Observers:即将处理Timers。
  • 通知Observers:即将处理Sources。
  • 处理未处理的消息。
  • 如果没有消息,进入休眠,等待消息。
  • 通知Observers:即将退出RunLoop。

这个循环会一直进行,直到应用退出。

3. 使用RunLoop进行启动优化:

应用启动优化的目的是减少首次启动时用户需要等待的时间。以下是使用RunLoop进行启动优化的方法:

  • 延迟初始化: 可以利用RunLoop在首屏渲染完成后,再进行一些非关键的初始化工作。例如,某些SDK的初始化、某些功能模块的预加载等。

    KGStartUpTaskManager 实现了这部分逻辑

  • 利用Observer:使用CFRunLoopObserver在RunLoop的特定时机执行任务,如在首次RunLoop循环结束后。

  • 分散开销:将一些耗时的操作,如大量的数据计算或I/O操作,分散到多个RunLoop循环中,以避免阻塞UI。

  • 优先处理UI事件:确保UI事件,如用户的触摸、动画等,总是在其他任务之前得到处理,以确保流畅的用户体验。

  • 后台预加载:可以创建一个后台线程,利用其RunLoop预加载一些资源或进行预计算。

为了使您对RunLoop有更直观的理解,以下是一个简单的图示:

+--------------------------+
|     RunLoop开始          |
+--------------------------+
          |
          V
+--------------------------+
|  处理所有Source0类型源   |
+--------------------------+
          |
          V
+--------------------------+
|  处理所有Source1类型源   |
+--------------------------+
          |
          V
+--------------------------+
|   处理所有的计时器事件   |
+--------------------------+
          |
          V
+--------------------------+
|   处理消息,如UI事件     |
+--------------------------+
          |
          V
+--------------------------+
|     RunLoop结束          |
+--------------------------+

总的来说,通过理解和合理利用RunLoop,我不仅可以优化应用的启动时间,还可以更好地管理应用的事件处理和资源分配,从而提供更好的用户体验。

航母问题处理

使用 Instruments 的 App Launch 和 Time Profiler,查看不同阶段的耗时占用及某个阶段的耗时明细

动态库加载时长

这部分时长,主要耗时在 Flutter.framework 和 ZegoLiveRoom,这两个库都不好转静态库,所以这部分时长没有尝试优化

load 阶段耗时

优化前(依赖CPU繁忙度,卡顿概率高)

85.00 ms    0.2%	85.00 ms	 	  +[_AFURLSessionTaskSwizzling load]
90.00 ms    1.3%	0 s	 	  			+[GDTCCTUploader load]
75.00 ms    0.2%	70.00 ms	 	  +[UIActionSheet(kgSafe) load]
105.00 ms    0.9%	105.00 ms	 	  +[UIAlertView(kgSafe) load]
75.00 ms    0.2%	70.00 ms	 	  +[UIActionSheet(kgSafe) load]
75.00 ms    0.2%	70.00 ms	 	  +[AppDelegate(UIScene) load]
75.00 ms    0.2%	70.00 ms	 	  +[UIWindow(UIScene) load]
75.00 ms    0.2%	70.00 ms	 	  +[UIApplication(UIScene) load]
50.00 ms    0.7%	0 s	 	  		+[KGTrackingNotificationManager load]

优化后

这些已在开发阶段处理,Initializing Time 减少了60ms左右

+[UIActionSheet(kgSafe) load]
+[UIAlertView(kgSafe) load]
+[UIActionSheet(kgSafe) load]
+[AppDelegate(UIScene) load]
+[UIWindow(UIScene) load]
+[UIApplication(UIScene) load]
+[KGTrackingNotificationManager load]

另外几个延后到initialize,pre-main 时间减少,APM上报的启动时长不变(延后)

耗时方法(依赖CPU繁忙度,卡顿概率高)

20.00 ms    0.2%	7.00 ms	 	       +[UIImage(KGAssetsExtension) kg_imageNamed:moduleName:] 已优化,查找bundle内存缓存
14.00 ms    0.1%	4.00 ms	 	      -[KGHomeTabItem drawTextImageWithText:color:font:] 已优化,2倍
195.00 ms    0.6%	20.00 ms	 	   -[LOTLayerContainer display] 已优化,启动播放最后一帧
30.00 ms    0.0%		30.00 ms	 	   -[KGCarPlayManage configureTabItems] 已找对应负责开发处理
120.00 ms    0.4%	110.00 ms	 	   -[FXBlurView snapshotOfUnderlyingView] 已找对应负责开发处理
15.00 ms    0.0%	15.00 ms	 	      +[NSData(DES) DESDecrypt:WithKey:] 不修改,试了 openssl 版本,收益不高风险高
50.00 ms    0.0%	50.00 ms	 	      +[LOTAnimationView animationFromJSON:] 已找对应负责开发处理
65.00 ms    0.4%	65.00 ms	 			Flutter  _GLOBAL__sub_I_timeline.cc Flutter 内部调用,不可避免
20.00 ms								+[KGNetworkTools kgRequestOrginCall] 已优化,DEBUG 阶段4倍
40.00 ms  100.0%	40.00 ms	 	 	+[UIImageTools colorizeImage:withColor:forBlendModel:] 待优化,尝试一天多,达不到效果
20.00 ms  100.0%	20.00 ms	 	 	-[UIImage(YYAdd) imageByResizeToSize:] 已优化,1.5倍
20.00 ms  100.0%	20.00 ms	 	 	+[KGPublicCommonTools connectedToNetwork] 风险高,不修改
>  BOOL didRetrieveFlags = SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags);

已优化的方法部分在耗时占用上,用例测试内测试执行效率提高了一些,减少启动阶段因这些方法导致卡顿耗时的可能。具体数据因本地测试样本太小,数据波动较大,得线上数据验证。

结论

经过对这轮启动优化的研究和实践,我得出了以下几点关键发现:

  • 在pre-main阶段,我成功地移除了部分耗时的加载操作,并将这些时间转移到了initialize阶段,但值得注意的是,这并没有对APM的启动时长造成实质性的变化
  • 在main之后,KGStartUpTaskManager在启动执行任务划分方面已经比较大程度减少启动阶段主线程的卡顿,且有些任务需要通过与业务部门的协作来推动修改,所以这一阶段只能尝试去优化一些捕获的较大概率容易导致卡顿的方法。
  • 由于本地测试的样本量有限,要获取更准确的数据,还需进一步在线上环境进行验证。我期待在实际线上环境中进一步确认这些优化措施的效果,并继续完善启动优化的策略。

参考链接或相关工具

reducing-your-app-s-launch-time
Apple Core Foundation
Apple dyld
Apple MachO
emergetools
https://github.com/maniackk/TimeProfiler
抖音品质建设 - iOS启动优化《原理篇》