抖音品质建设 - iOS启动优化《原理篇》

Posted 字节跳动技术团队

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了抖音品质建设 - iOS启动优化《原理篇》相关的知识,希望对你有一定的参考价值。

前言

启动是 App 给用户的第一印象,启动越慢用户流失的概率就越高,良好的启动速度是用户体验不可缺少的一环。启动优化涉及到的知识点非常多面也很广,一篇文章难以包含全部,所以拆分成两部分:原理和实战。

本文从基础知识出发,先回顾一些核心概念,为后续章节做铺垫;接下来介绍 IPA 构建的基本流程,以及这个流程里可用于启动优化的点;最后大篇幅讲解 dyld3 的启动 pipeline,因为启动优化的重点还在运行时。

基本概念启动的定义

启动有两种定义:

  • 广义:点击图标到首页数据加载完毕
  • 狭义:点击图标到 Launch Image 完全消失第一帧
  • 不同产品的业务形态不一样,对于抖音来说,首页的数据加载完成就是视频的第一帧播放;对其他首页是静态的 App 来说,Launch Image 消失就是首页数据加载完成。由于标准很难对齐,所以我们一般使用狭义的启动定义:即启动终点为启动图完全消失的第一帧

    以抖音为例,用户感受到的启动时间:

    Tips:启动最佳时间是 400ms 以内,因为启动动画时长是 400ms。

    这是从用户感知维度定义启动,那么代码上如何定义启动呢?Apple 在 MetricKit 中给出了官方计算方式:

  • 起点:进程创建的时间
  • 终点:第一个CA::Transaction::commit()
  • Tips:CATransaction 是 Core Animation 提供的一种事务机制,把一组 UI 上的修改打包,一起发给 Render Server 渲染。

    启动的种类

    根据场景的不同,启动可以分为三种:冷启动,热启动和回前台。

  • 冷启动:系统里没有任何进程的缓存信息,典型的是重启手机后直接启动 App
  • 热启动:如果把 App 进程杀了,然后立刻重新启动,这次启动就是热启动,因为进程缓存还在
  • 回前台:大多数时候不会被定义为启动,因为此时 App 仍然活着,只不过处于 suspended 状态
  • 那么,线上用户的冷启动多还是热启动多呢?

    答案是和产品形态有关系,打开频次越高,热启动比例就越高。

    Mach-O

    Mach-O 是 ios 可执行文件的格式,典型的 Mach-O 是主二进制和动态库。Mach-O 可以分为三部分:

  • Header
  • Load Commands
  • Data
  • Header 的最开始是 Magic Number,表示这是一个 Mach-O 文件,除此之外还包含一些 Flags,这些 flags 会影响 Mach-O 的解析。

    Load Commands 存储 Mach-O 的布局信息,比如 Segment command 和 Data 中的 Segment/Section 是一一对应的。除了布局信息之外,还包含了依赖的动态库等启动 App 需要的信息。

    Data 部分包含了实际的代码和数据,Data 被分割成很多个 Segment,每个 Segment 又被划分成很多个 Section,分别存放不同类型的数据。

    标准的三个 Segment 是 TEXT,DATA,LINKEDIT,也支持自定义:

  • TEXT,代码段,只读可执行,存储函数的二进制代码(__text),常量字符串(__cstring),Objective C 的类/方法名等信息
  • DATA,数据段,读写,存储 Objective C 的字符串(__cfstring),以及运行时的元数据:class/protocol/method…
  • LINKEDIT,启动 App 需要的信息,如 bind & rebase 的地址,代码签名,符号表…
  • dyld

    dyld 是启动的辅助程序,是 in-process 的,即启动的时候会把 dyld 加载到进程的地址空间里,然后把后续的启动过程交给 dyld。dyld 主要有两个版本:dyld2 和 dyld3。

    dyld2 是从 iOS 3.1 引入,一直持续到 iOS 12。dyld2 有个比较大的优化是 循环,在图中橙色部分的 mach_msg_trap 就是触发一个系统调用,让线程休眠,等待事件到来,唤醒 Runloop,继续执行这个 while循环。

    Runloop 主要处理几种任务:Source0,Source1,Timer,GCD MainQueue,Block。在循环的合适时机,会以 Observer 的方式通知外部执行到了哪里。那么,Runloop 与启动又有什么关系呢?

  • App 的 LifeCycle 方法是基于 Runloop 的 Source0 的
  • 首帧渲染是基于 Runloop Block 的
  • Runloop 在启动上主要有几点应用:

  • 精准统计启动时间
  • 找到一个时机,在启动结束去执行一些预热任务
  • 利用 Runloop 打散耗时的启动预热任务
  • Tips : 会有一些逻辑要在启动之后 delay 一小段时间再回到主线程上执行,对于性能较差的设备,主线程 Runloop 可能一直处于忙的状态,所以这个 delay 的任务并不一定能按时执行。

    AppLifeCycle

    UIKit 初始化之后,就进入了我们熟悉的 UIApplicationDelegate 回调了,在这些会调里去做一些业务上的初始化:

  • willFinishLaunch

  • didFinishLaunch

  • didFinishLaunchNotification

  • 要特别提一下 didFinishLaunchNotification,是因为大家在埋点的时候通常会忽略还有这个通知的存在,导致把这部分时间算到 UI 渲染里。

    First Frame Render

    一般会用 Root Controller 的 viewDidApper 作为渲染的终点,但其实这时候首帧已经渲染完成一小段时间了,Apple 在 MetricsKit 里对启动终点定义是第一个CA::Transaction::commit()

    什么是 CATransaction 呢?我们先来看一下渲染的大致流程

    iOS 的渲染是在一个单独的进程 RenderServer 做的,App 会把 Render Tree 编码打包给 RenderServer,RenderServer 再调用渲染框架(Metal/OpenGL ES)来生成 bitmap,放到帧缓冲区里,硬件根据时钟信号读取帧缓冲区内容,完成屏幕刷新。CATransaction 就是把一组 UI 上的修改,合并成一个事务,通过 commit 提交。

    渲染可以分为四个步骤

  • Layout(布局),源头是 Root Layer 调用[CALayer layoutSubLayers],这时候 UIViewControllerviewDidLoadLayoutSubViews 会调用,autolayout 也是在这一步生效
  • Display(绘制),源头是 Root Layer 调用[CALayer display],如果 View 实现了 drawRect 方法,会在这个阶段调用
  • Prepare(准备),这个过程中会完成图片的解码
  • Commit(提交),打包 Render Tree 通过 XPC 的方式发给 Render Server
  • 启动 Pipeline

    详细回顾下整个启动过程,以及各个阶段耗时的影响因素:

    1. 点击图标,创建进程
    2. mmap 主二进制,找到 dyld 的路径
    3. mmap dyld,把入口地址设为_dyld_start
    4. 重启手机/更新/下载 App 的第一次启动,会创建启动闭包
    5. 把没有加载的动态库 mmap 进来,动态库的数量会影响这个阶段
    6. 对每个二进制做 bind 和 rebase,主要耗时在 Page In,影响 Page In 数量的是 objc 的元数据
    7. 初始化 objc 的 runtime,由于闭包已经初始化了大部分,这里只会注册 sel 和装载 category
    8. +load 和静态初始化被调用,除了方法本身耗时,这里还会引起大量 Page In
    9. 初始化 UIApplication,启动 Main Runloop
    10. 执行 will/didFinishLaunch,这里主要是业务代码耗时
    11. Layout,viewDidLoadLayoutsubviews 会在这里调用,Autolayout 太多会影响这部分时间
    12. Display,drawRect 会调用
    13. Prepare,图片解码发生在这一步
    14. Commit,首帧渲染数据打包发给 RenderServer,启动结束
    dyld2

    dyld2 和 dyld3 的主要区别就是没有启动闭包,就导致每次启动都要:

  • 解析动态库的依赖关系
  • 解析 LINKEDIT,找到 bind & rebase 的指针地址,找到 bind 符号的地址
  • 注册 objc 的 Class/Method 等元数据,对大型工程来说,这部分耗时会很长
  • 总结

    本文回顾了 Mach-O,虚拟内存,mmap,Page In,Runloop 等基础概念,接下来介绍了 IPA 的构建流程,以及两个典型的利用编译器来优化启动的方案,最后详细的讲解了 dyld3 的启动 pipeline。

    之所以花这么大篇幅讲原理,是因为任何优化都一样,只有深入理解系统运作的原理,才能找到性能的瓶颈,下一篇我们会介绍下如何利用这些原理解决实际问题,姓名 - 工作年限 - 抖音 - 基础技术 - iOS/android 

    相关链接

    1.  dyld shared cache
    http://iphonedevwiki.net/index.php/Dyld_shared_cache
    2.  order_file
    http://lists.llvm.org/pipermail/llvm-dev/2019-January/129268.html
    3.  https://developer.apple.com/support/app-store/

    更多分享

    iOS 性能优化实践:头条抖音如何实现 OOM 崩溃率下降50%+

    字节跳动全链路压测(Rhino)的实践

    Fastbot:行进中的智能 Monkey

    今日头条品质优化 - 图文详情页秒开实践





    欢迎关注「 字节跳动技术团队 

    简历投递联系邮箱「 tech@bytedance.com 


     点击阅读原文,快来加入我们吧!


    抖音 Android 性能优化系列:启动优化之理论和工具篇

    Rhea指占用 CPU 进行计算所花费的时间绝对值,中断、挂起、休眠等行为是不会增加 CPU Time 的,所以因 CPU Time 开销占比高导致的不合理耗时点往往是逻辑本身复杂冗长需要消耗较多 cpu 时间片才能处理完。比较常见的高 CPU 占用是循环,比如抖音启动时遇到过一个 so 加载耗时,最后定位原因是在解压 so 的时候,遍历 ZipEntry 的次数过多导致,一个可行的优化策略就是可以把 so 所在的 ZipEntry 提前,遍历完 so 的 ZipEntry 之后可以提前中止遍历,而不需要遍历剩下的无效 ZipEntry。除循环之外,反射也是导致 CPU Time 的重要原因,像在序列化/反序列化、View Inflate 时,都有大量的反射操作,反射的耗时主要是字符串去查找 Method 或者 Field,这个优化策略也可以考虑提前查找 Method 和 Field 缓存起来,或者是通过内联来降低 Field 数量等。另外一个常见的 CPU 耗时是类加载,类的加载过程包括:Load,从 Dex 文件里读取类的信息,可通过类重排优化;Verify,验证指令是否合法等,通过关掉 Class Verify 可以优化该过程,同时高版本的 vdex 也是为了优化 verify 过程而设计,在 dex2oat 的时候做 verify,verify 之后的结果保存成 vdex,后续只需要加载 vdex;Link,给 Field、Method 分配内存,按照名字排序以方便后续反射的时候查找 Field、Method 等,这个过程的优化,art 虚拟机采用了 ImageSpace 的方案进行了优化,将 Link 后的内存保存为 image 文件,后续可以直接 load 这个 image 文件,省去了 Link 过程;Init,类的初始化。

  • CPU Schedule 在分析时主要针对主线程,是指主线程处于可执行状态但获取不到 cpu 时间片,这类耗时可能和线程调度等有关,最终导致分配给主线程的 cpu 时间片不足以及时处理完其内任务。由于主线程的线程优先级比其他线程的优先级要高很多,通常影响并不大,事实上抖音做了线上用户的启动耗时统计,这部分的耗时占比也是不大的。不过有一个场景需要关注,就是渲染,渲染是需要 RenderThread 提交 GPU 的渲染命令,而 RenderThread 并没有主线程那么高的优先级,因此比较容易受 CPU 的负载的影响,导致渲染耗时,这个对于启动来说影响并不算大,启动只有一次首页的渲染,占整体时间的比例不算大,但对于流畅度的影响就会比较大。这类耗时的优化主要还是从降低 CPU 的负载的角度考虑,比如业务降级、业务打散等手段。抖音还通过对 RenderThread 优先级的提升优化,拿到了不错的收益。
  • IO Wait 指发生了 IO 操作需要等待 IO 返回结果,这类耗时可能发生在读取资源和文件,类加载,甚至在内存不足时的 PageFault 都会导致 IO Wait。Resources 的相关的操作耗时,主要是需要从 apk 里读取资源文件,优化策略可以有预加载、资源重排、资源异步加载等。类加载的 IO Wait 和 Resources 类似,也可以通过类的重排、预加载等优化方案。文件读写导致的 IO Wait 又分为业务文件和系统文件,业务文件指业务逻辑的读写文件,一般都可以通过异步来解决,而系统文件的例子是 dex 的读写,抖音的 IO Wait 很大一块是它贡献的,目前的思路还是做 dex 的重排和 IO 的预读来尝试优化。
  • Lock Wait 也是主要针对主线程,指其处于等锁状态,等待被其他线程唤醒或自己超时唤醒,导致这类耗时的问题种类多样,大体也是可以分为业务锁和系统锁,业务锁主要是被主线程等待的业务逻辑未能及时处理完,优化思路一般是移除主线程的锁等待逻辑或者加快被等待的业务逻辑的执行速度。系统锁主要有:String InternTable Lock,ClassLinker Lock,GC Wait Lock 等,目前抖音正在尝试优化这几类的锁耗时。
  • IPC 指进程间通信,操作系统大都含有相应的机制,Android 中所特有的 IPC 机制是 Binder,由于进行 IPC 调用往往需要等待通信结果本质上这也算是一种 Lock Wait,但 Android 特有 Binder 机制所以单独列出,这类耗时可采用减少或替代 Binder 调用等手段来优化。
  • 综合前述的五大耗时成因,这里举一个分析启动阶段 UI 耗时成因的例子作为实践参考,根据 UI 界面的生命周期(一般划分)——UI 构建、数据绑定、View 显示三个阶段分别进行分析:

  • UI 构建阶段中首先要对界面布局的 xml 文件进行解析,这会导致 IO Wait 耗时,在接下来要解析 xml 文件中的 TagName 从而获取对应 View 的 class 会用到反射、创建各子 View 实例并生成 View 树又会用到循环递归,两部分都会增加 CPU Time 的开销。
  • 然后是数据绑定阶段,该阶段主要分两部分,一部分是对数据做请求、解析、适配,另一是部分是将适配好的数据填充进 UI 中,前一部分往往会涉及到 Json 解析成 Data Class 实例,这里就可能涉及反射、循环遍历嵌套的数据类结构等增加 CPU Time 的操作。
  • 最后是View 显示阶段,常见的 measure、layout、draw 三大渲染 View 的步骤就在其中,它们同样会产生递归遍历父子 View 的耗时,此外这里还涉及将应用层计算好的渲染 View 的数据传递给系统层做最终的像素点排布,那么必然又会产生 IPC 耗时。
  • 从这个例子可见即使再复杂的场景只要我们进行细粒度的分析,都能将耗时点归入前述某一成因中。

    咨询相关信息或者直接发送简历内推!

    以上是关于抖音品质建设 - iOS启动优化《原理篇》的主要内容,如果未能解决你的问题,请参考以下文章

    抖音研发效能建设 - CocoaPods 优化实践

    iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+

    前端性能优化--图片篇

    MySQLMySQL体系结构与内部组件工作原理解析(原理篇)(MySQL专栏启动)

    抖音 Android 性能优化系列:Java 内存优化篇

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