iOS启动优化看我就够了

5,853 阅读10分钟

背景

iOS启动优化是指在iOS设备上提升应用程序的启动速度和响应性能的一系列技术和方法。随着移动设备的普及和应用程序的功能复杂化,用户对应用程序的响应速度要求也越来越高,因此启动优化成为了开发者们关注的重要领域。

说到启动,这其实是一个非常重要的用户体验场景,早期有些博主喜欢拿安卓和苹果打开某个应用的时间来对比设备之间的差异,也通过这个启动某个软件的时间这个指标判断两者系统的优劣。

当然对于用户来说在某些场景也是影响很大,拿我之前遇到一个场景来说,当时和多年未见的朋友见面,说好了请对方吃饭,到了付钱的时候,我的朋友也抢着去付钱,在这个时候,我准备点开微信app 启动的时候,忽然卡住了,然后最后让朋友抢先一步付了钱,这是不是很尴尬。

话说回来,苹果对于启动时间过长会有一个看门狗机制,如果启动时间过长,看门狗将终止它,这对用户来说也是致命的。

通常情况下,如果应用程序是他们常规工作流程的一部分,用户一天会多次启动该应用程序,而较长的启动时间会导致执行任务的延迟。

当用户点击主屏幕上的应用程序图标时,iOS 会在将控制权移交给应用程序进程之前准备启动应用程序。该应用程序然后运行代码以准备将其 UI 绘制到屏幕上。即使在应用的 UI 可见之后,应用可能仍在准备内容或用最终控件替换间隙界面。这些步骤中的每一个都会影响应用程序的总感知启动时间。

启动流程

下面就先从启动的整个流程来看看启动到底都做了哪些事情

iOS 启动优化.png

从几个大的阶段来划分

可以看出主要分为以下几个大阶段

1.加载Mach-O阶段

2.dyld 阶段

3.main 之后阶段:加载启动项到最终的viewDidAppear调用加载第一帧。

从dyld和runtime角度来划分

如果结合dyld 和runtime 这两个核心模块来划分,启动阶段主要由他们两者协作完成。

Launch Time.webp image2.png

详细阶段步骤

那么整体的流程可以梳理为以下步骤

一.内核态加载mach-o文件和可执行文件

关于mach-o文件

Mach-O文件简介

  • Mach object的缩写,是Mac、iOS上用于存储程序、库的标准格式 ,Mach-O文件是一种叫法,就像以 .text 结尾的文件,被叫做为text文件

常见的Mach-O文件有:

  • MH_OBJECT:目标文件(.o)、静态库文件(.a) 静态库其实就是N个.o合并在一起
  • MH_EXECUTE:可执行文件 .app/xx
  • MH_DYLIB:动态库文件 .dylib 或 .framework/xx
  • MH_DYLINKER:动态链接编辑器 /usr/lib/dyld
  • MH_DSYM:存储着二进制文件符号信息的文件 .dSYM/Contents/Resources/DWARF/xx(常用于分析APP的崩溃信息)

可执行文件:

  • 平时编写的代码最终会被编译成为一个Mach-O格式的文件
  • 开发过程中所用到的动态库(比如:UIKit、Foundation) 依赖信息也会存储在可执行文件中

二.dyld 阶段

简单介绍下dyld是什么

"dyld" 是苹果操作系统中的一个重要组件,它是动态链接器(dynamic linker)的缩写。动态链接器是操作系统加载和链接可执行文件所需的共享库的核心组件之一。

dyld 的主要功能是在程序启动时加载和链接程序所依赖的共享库,并将其映射到进程的内存空间中。它负责解析和处理共享库之间的符号依赖关系,以及处理运行时的符号重定位。

具体来说,dyld 的工作流程如下:

  1. 加载:当一个可执行文件(如应用程序)启动时,dyld 负责加载可执行文件和它所依赖的共享库到内存中。
  2. 符号解析:dyld 解析可执行文件和共享库中的符号引用,找到对应的符号定义,以便正确地链接和运行程序。
  3. 符号重定位:在加载和链接过程中,dyld 会处理符号重定位,将程序中的符号引用指向正确的地址。
  4. 启动程序:完成加载和链接后,dyld 将控制权转交给程序的入口点,使其开始执行。

dyld 的存在使得应用程序可以动态地加载和链接共享库,从而实现了代码的共享和重用。这也是为什么在 iOS 开发中,我们可以使用各种系统提供的框架和库来构建应用程序。dyld 是苹果操作系统中负责动态加载和链接共享库的组件,它在应用程序启动时发挥着关键的作用,确保程序能够正确地加载和执行所需的代码和库。

关于动态库:

  • 程序运行时由系统动态加载到内存,而不是复制,供程序调用。
  • 系统只加载一次,多个程序共用,节省内存。因此,编译内容更小,而且因为动态库是需要时才被引用,所以更快。 简单认识:系统的UIKit框架最终被dyld以动态库的形式加载到内存 !

dyld阶段所做的事情

Dyld Steps.png

load dylibs > rebase bind > objc(Notify ObjC Runtime) > initializers

1.load dylibs

装载app的可执行文件,同时会递归加载所有依赖的动态库。

  • Parse image(解析图像):在这个步骤中,dyld 解析可执行文件或共享库的二进制格式。它会读取可执行文件的头部和段(segments),以及共享库的符号表和重定位信息等。通过解析图像,dyld 能够了解文件的结构、符号引用和重定位需求。

  • Map image(映射图像):在这一阶段,dyld 将可执行文件或共享库映射到进程的内存空间中。它会分配适当的内存区域,并将二进制文件的内容加载到这些内存区域中。通过映射图像,dyld 将文件中的代码、数据和资源加载到内存,为后续的重定位和符号绑定做准备。

当dyld加载完可执行文件 和动态库 之后通知runtime进行下一步操作。

2.Rebase + bind
  • Rebase image(重定位图像):在此步骤中,dyld 处理可执行文件和共享库中的重定位信息。重定位信息描述了代码和数据的位置相对于内存中的基地址的偏移量。dyld 根据基地址和重定位信息来计算并更新代码和数据的绝对地址,以确保它们在内存中正确定位。

  • Bind image(符号绑定图像):在这个阶段,dyld 解析可执行文件和共享库中的符号引用,并将它们绑定到相应的符号定义。符号绑定是将符号引用与符号定义相关联的过程,确保程序能够正确地访问和执行所需的符号。通过符号绑定,dyld 确保程序能够正确链接并执行依赖的函数和变量。

3.objc(Notify ObjC Runtime)
  • mapimages 对二进制文件内容解析处理。
  • runtime在此处初始化,对class和category进行注册。
  • 进行各种objc结构的初始化(objc 类被定义和注册)。
  • 分类被插入到方法列表中。
  • selector唯一性判断。
4.Initializers
  • loadimages 调用 call_load_images 加载 类和 分类的 load方法
  • 调用c++静态初始化器和__attribute(construct)修饰的函数

至此可执行文件和动态库的符号sel  class protocol IMP 都已经按需加载到内存中了,被runtime管理最后,Dyld calls main()

三.进入main函数

  • 接下来就是 UIApplicationMain 函数,相关的调用了,Appdelegate会依次执行 对应的生命周期方法。

  • 创建整个app的autoreleasepool,初始化初始window,app界面开始展。

  • 指定rootviewcontroller,调用业务代码,完成各阶段业务。

  • main页面viewDidAppear 完成页面第一帧渲染。至此启动完成。

关于启动标准

苹果的标准

  • 针对启动时间的最佳标准 (400ms 是一个很好的目标)

  • 最坏的情况 (不要超过20秒否则应用程序将被杀死)

关于优化方案

优化方案从各个阶段来考虑

1.加载mach-o阶段

重新排列函数符号位置,降低MACH-O文件载入内存时PageFault缺页中断频率 - 二进制重排

  • 一种是抖音的方案二进制重排。(官方说会有百分之30提升,自己尝试并没有太大提升。)
  • 另外是苹果推出的pgo。(大概有百分之10左右的提升)

两者类似

原理

二进制重排实际上是在windows和linux上就存在的技术,旨在将启动用到的函数方法尽可能的放置在二进制文件加载的前面,并且是将函数符号地址连续的编译在一起,以减少Page Fault的次数和频率,加快启动速度。现在这项技术已经移植运用到了移动端app上。

操作系统为了解决安全问题和效率问题,抽象出了虚拟内存页的概念。内存都是分页访问的。这里的page指的就是内存页。(就像磁盘存储的最小单位 磁盘簇,大小是4k一样) MacOS 、linux (4K为一页) iOS(16K为一页)

PageFault就是缺页中断:当app调用一个方法,发现该方法没有在内存中,此时操作系统就会立刻阻塞整个app进程,触发一个缺页中断。操作系统会从磁盘中读取这页数据到物理内存上 , 然后再将其映射到虚拟内存上 ( 如果当前内存已满 , 操作系统会通过置换页算法 找一页数据进行覆盖, 这也是为什么开再多的应用也不会崩掉 , 但是之前开的应用再打开时 , 就重新启动了的根本原因 )。

假如,app启动时期需要调用 method1、method5和method6,这三个方法分布在page1、page2和page3上。每装载一个内存页page都会发生一次PageFault(缺页终端)。通常一个PageFault的处理时间是0.1ms~1ms,取0.5ms计算。这三次处理PageFault时间是 3 * 0.5ms = 1.5ms。

page1.webp

page1.png

2.针对dyld阶段

  • 减少动态库 合并动态库 (定期清理不必要的动态库)

  • 减少oc 类 分类 方法 sel(定期清理不必要的类 分类)

  • 减少c++虚函数数量

  • swift尽量使用struct

3.针对objc 和 initialize 可以看做是 runtime  阶段

  • 用+initialize方法和dispatch_once取代所有的__attribute__((constructor))、C++静态构造器、ObjC的+load

4.针对 main函数之后阶段

  • 在不影响用户体验的前提下,尽可能将一些操作延迟,不要全部都放在finishLaunching方法中
  • 启动任务的顺序调整优化
  • 降低初始视图的复杂性
  • 按需加载

关于测试(建议在支持的最慢设备上测试)

1.推荐2个github 工具

2.Xcode 和 instrument自带工具

  • lldb调试工具设置 Edit scheme -> Run -> Arguments 中将环境变量 DYLD_PRINT_STATISTICS 设为 1统计结果会在打印窗口输出

image.png

image.png

  • 通过instrument 的 launching

3.线上监控

主动埋点 分为pre-main 和 main 之后

v2-d46b6f2bd967c78f704cccbb05584caa_1440w.webp

通过苹果的metric

image.png

总结

以上基本涵盖了iOS 启动各个阶段的详细流程,以及优化方案,当然启动优化也是一个长期需要关注的稳定性指标,也要结合当前项目情况分步优化,通过优化>监控>优化 形成闭环,发现问题并解决问题,最终持续下去肯定会有收益的。