05-iOS底层原理|稳定性治理——页面卡顿(因CPU、GPU资源消耗导致的卡顿)【性能优化】

3,980 阅读19分钟

[TOC]

前言

概述

移动终端屏幕成像与卡顿原理中我们了解了iOS画面卡顿的原因,但是程序卡顿不仅是在成像渲染阶段的工作流程会引起,在其它不合理分配使用CPU、GPU计算资源的场景也会使得程序卡顿。
在本篇文章中,我们会三节展开,分别介绍常见的CPU资源消耗导致卡顿的原因和对应的解决方案GPU资源消耗导致卡顿的原因和对应的解决方案
此外,我还会独立一个章节向大家推荐一个Facebook的工程师(原苹果公司的系统应用程序开发者之一)的开源库:AsyncDisplayKit。通过这个优秀的开源库,我们可以较轻松地解决不少屏幕渲染卡顿的问题。
那么,就让我们马上进入正题吧!

卡顿发生的原因

卡顿: 所谓卡顿,就是App在主线程阻塞,页面交互无法响,用户体验差的现象。
如果一个App出现了长时间的卡顿,那么极有可能流失大量用户;所以卡顿对App的负面影响巨大,是我们必须要面对并解决的问题;

卡顿的发生通常有以下几个原因:

  • UI过于复杂,图文混排的绘制量过大;
  • 在主线程上进行同步的网络请求
  • 在主线程上进行大量的IO操作;
  • 函数运算量过大,持续占用较高的CPU
  • 死锁和主子线程抢锁
  • ......

监控卡顿的指标: FPS(帧率)

FPS是一秒钟显示的帧数,也就是一秒内画面变化的数量。如果按照我们经常看的动画片来说,那么动画片的FPS就是24,是达不到满帧的60的。也就是对于动画片来说,24帧虽然不足60帧,也没有60帧来的流畅,但是对于我们来说已经是连贯的了,所以并不是24帧就会卡顿,少于60帧更不能算是卡顿;\

监控卡顿的最佳方案

对于我们iOS开发来说,监控卡顿就是要去确定在主线程上都做了什么事情?我们知道,线程的消息事件都是依赖与NSRunLoop的,所以从NSRunLoop入手,我们就能够知道在主线程上都调用了哪些方法。我们通过监听NSRunLoop的状态,就能够发现调用方法是否执行时间过长,从而判断出是否会出现卡顿。

我们监控卡顿现象,首推的方案就是:通过监控NSRunLoop的状态来判断是否发生了卡顿;

我们此次不对监控卡顿铺展开来讲述,我们主要讨论因业务代码使用不佳导致CPU、GPU引起卡顿的常见情景以及其对治手段:

一、CPU 资源消耗原因和解决方案

CPU作为端设备的计算和控制单元若我们在写代码的时候,因为代码质量不佳,导致过分占用CPU的计算资源,就会使得程序发生卡顿。接下来我们会列举几个常见的导致CPU资源消耗不合理的场景

1.1 对象内存管理

1.1.1 对象创建

  • 对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗 CPU 资源。
    • 尽量用轻量的对象代替重量的对象,可以对性能有所优化。比如 CALayer 比 UIView 要轻量许多,那么不需要响应触摸事件的控件,用 CALayer 显示会更加合适。
    • 如果对象不涉及 UI 操作,则尽量放到后台线程去创建,但可惜的是包含有 CALayer 的控件,都只能在主线程创建和操作。
    • 通过 Storyboard 创建视图对象时,其资源消耗会比直接通过代码创建对象要大非常多,在性能敏感的界面里,Storyboard 并不是一个好的技术选择
  • 尽量推迟对象创建的时间,并把对象的创建分散到多个任务中去。
    • 尽管这实现起来比较麻烦,并且带来的优势并不多,但如果有能力做,还是要尽量尝试一下。

    • 如果对象可以复用,并且复用的代价比释放、创建新对象要小,那么这类对象应当尽量放到一个缓存池里复用

1.1.2 对象调整

  • 对象的调整也经常是消耗 CPU 资源的地方。

    • 这里特别说一下 CALayer:CALayer 内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod 为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate、创建动画等等,非常消耗资源。
    • UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。
    • 对此我们在应用中,应该尽量减少不必要的属性修改。
  • 当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,所以在优化性能时,应该尽量避免调整视图层次、添加和移除视图

1.1.3 对象销毁

  • 对象的销毁虽然消耗资源不多,但累积起来也是不容忽视的。
    • 通常当容器类持有大量对象时,其销毁时的资源消耗就非常明显。
    • 同样的,如果对象可以放到后台线程去释放,那就挪到后台线程去。
    • 这里有个小 Tip:把对象捕获到 block 中,然后扔到后台队列去随便发送个消息以避免编译器警告,就可以让对象在后台线程销毁了。

1.2 页面布局

1.2.1 布局计算

  • 视图布局的计算是 App 中最为常见的消耗 CPU 资源的地方。

    • 如果能在后台线程提前计算好视图布局、并且对视图布局进行缓存,那么这个地方基本就不会产生性能问题了。
  • 不论通过何种技术对视图进行布局,其最终都会落到对 UIView.frame/bounds/center 等属性的调整上。

    • 1.1.2中也说过,对这些属性的调整非常消耗资源,所以尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性。

1.2.2 Autolayout

  • Autolayout 是苹果本身提倡的技术,在大部分情况下也能很好的提升开发效率,但是 Autolayout 对于复杂视图来说常常会产生严重的性能问题。

  • 随着视图数量的增长,Autolayout 带来的 CPU 消耗会呈指数级上升。具体数据可以看这个文章:pilky.me/36/

  • 如果你不想手动调整 frame 等属性,你可以用一些工具方法替代(比如常见的 left/right/top/bottom/width/height 快捷属性),或者使用 ComponentKitAsyncDisplayKit 等框架。

1.3 绘制与渲染

1.3.1 文本计算

  • 如果一个界面中包含大量文本(比如微博微信朋友圈等),文本的宽高计算会占用很大一部分资源,并且不可避免。

  • 如果我们对文本显示没有特殊要求,可以参考下 UILabel 内部的实现方式:

    • [NSAttributedString boundingRectWithSize:options:context:] 来计算文本宽高
    • [NSAttributedString drawWithRect:options:context:] 来绘制文本
    • 尽管这两个方法性能不错,但仍旧需要放到后台线程进行以避免阻塞主线程
  • 如果我们用 CoreText 绘制文本,那就可以先生成 CoreText 排版对象,然后自己计算了,并且 CoreText 对象还能保留以供稍后绘制使用。

1.3.2 文本渲染

  • 屏幕上能看到的所有文本内容控件,包括 UIWebView,在底层都是通过 CoreText 排版、绘制为 Bitmap 显示的。

    • 常见的文本控件 (UILabel、UITextView 等),其排版和绘制都是在主线程进行的,当显示大量文本时,CPU 的压力会非常大。
  • 对此解决方案只有一个,那就是自定义文本控件用 TextKit 或最底层的 CoreText 对文本异步绘制

    • 尽管这实现起来非常麻烦,但其带来的优势也非常大,CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整 UILabel 大小时算一遍、UILabel 绘制时内部再算一遍);
    • CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染。

1.3.3 图片的解码

  • 当我们用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。
    • 图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。
    • 这一步是发生在主线程的,并且不可避免。
    • 如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。
    • 目前常见的网络图片库都自带这个功能。

1.3.4 图像的绘制

  • 图像的绘制通常是指用那些以 CG 开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。
    • 这个最常见的地方就是 [UIView drawRect:] 里面了
    • 由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以很容易的放到后台线程进行。
    • 一个简单异步绘制的过程大致如下(实际情况会比这个复杂得多,但原理基本一致): image.png

二、GPU 资源消耗原因和解决方案

  • 相对于 CPU 来说,GPU 能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合并渲染,然后输出到屏幕上
  • 通常我们所能看到的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类

2.1 纹理的渲染

  • 所有的 Bitmap,包括图片、文本、栅格化的内容,最终都要由内存提交到显存,绑定为 GPU Texture。

    • 不论是提交到显存的过程,还是 GPU 调整和渲染 Texture 的过程,都要消耗不少 GPU 资源。
    • 当在较短时间显示大量图片时(比如 TableView 存在非常多的图片并且快速滑动时),CPU 占用率很低,GPU 占用非常高,界面仍然会掉帧。
    • 避免这种情况的方法只能是尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示
  • 当图片过大,超过 GPU 的最大纹理尺寸时,图片需要先由 CPU 进行预处理,这对 CPU 和 GPU 都会带来额外的资源消耗。

    • 目前来说,iPhone 4S 以上机型,纹理尺寸上限都是 4096×4096,更详细的资料可以看这里:iosres.com
    • 所以,尽量不要让图片和视图的大小超过这个值。

2.2 视图的混合 (Composing)

  • 当多个视图(或者说 CALayer)重叠在一起显示时,GPU 会首先把他们混合到一起。
    • 如果视图结构过于复杂,混合的过程也会消耗很多 GPU 资源。
    • 为了减轻这种情况的 GPU 消耗,应用应当尽量减少视图数量和层次,并在不透明的视图里标明 opaque 属性以避免无用的 Alpha 通道合成
    • 当然,这也可以用上面的方法,把多个视图预先渲染为一张图片来显示

2.3 图形的生成

  • CALayer 的 border、圆角、阴影、遮罩(mask),CASharpLayer 的矢量图形显示,通常会触发离屏渲染(offscreen rendering),而离屏渲染通常发生在GPU中。
    • 当一个列表视图中出现大量圆角的 CALayer,并且快速滑动时,可以观察到 GPU 资源已经占满,而 CPU 资源消耗很少。这时界面仍然能正常滑动,但平均帧数会降到很低。
    • 为了避免这种情况,可以尝试开启 CALayer.shouldRasterize 属性,但这会把原本离屏渲染的操作转嫁到 CPU 上去。
    • 对于只需要圆角的某些场合,也可以用一张已经绘制好的圆角图片覆盖到原本视图上面来模拟相同的视觉效果。
    • 最彻底的解决办法,就是把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性

三、保持页面流畅的辅助库:AsyncDisplayKit

AsyncDisplayKit 是 Facebook 开源的一个用于保持 iOS 界面流畅的库

3.1 ASDK 的由来

  • ASDK 的作者是 Scott Goodson (Linkedin)
  • 他曾经在苹果工作,负责 iOS 的一些内置应用的开发,比如股票、计算器、地图、钟表、设置、Safari 等,当然他也参与了 UIKit framework 的开发。后来他加入 Facebook 后,负责 Paper 的开发,创建并开源了 AsyncDisplayKit。目前他在 Pinterest 和 Instagram 负责 iOS 开发和用户体验的提升等工作。
  • ASDK 自 2014 年 6 月开源,10 月发布 1.0 版。目前 ASDK 即将要发布 2.0 版。
  • V2.0 增加了更多布局相关的代码,ComponentKit 团队为此贡献很多。
  • 现在 Github 的 master 分支上的版本是 V1.9.1,已经包含了 V2.0 的全部内容。

3.2 ASDK 的基本原理

  • ASDK 认为,阻塞主线程的任务,主要分为上面这三大类。文本和布局的计算渲染解码绘制都可以通过各种方式异步执行,但 UIKit 和 Core Animation 相关操作必需在主线程进行。

    • ASDK 的目标,就是尽量把这些任务从主线程挪走,而挪不走的,就尽量优化性能。
  • 为了达成这一目标,ASDK 尝试对 UIKit 组件进行封装:

  • 这是常见的 UIView 和 CALayer 的关系:View 持有 Layer 用于显示,View 中大部分显示属性实际是从 Layer 映射而来;

    • Layer 的 delegate 在这里是 View,当其属性改变、动画产生时,View 能够得到通知。UIView 和 CALayer 不是线程安全的,并且只能在主线程创建、访问和销毁
  • ASDK 为此创建了 ASDisplayNode 类,包装了常见的视图属性(比如 frame/bounds/alpha/transform/backgroundColor/superNode/subNodes 等),然后它用 UIView->CALayer 相同的方式,实现了 ASNode->UIView 这样一个关系。

  • 当不需要响应触摸事件时,ASDisplayNode 可以被设置为 layer backed,即 ASDisplayNode 充当了原来 UIView 的功能,节省了更多资源。

  • 与 UIView 和 CALayer 不同,ASDisplayNode 是线程安全的,它可以在后台线程创建和修改。

    • Node 刚创建时,并不会在内部新建 UIView 和 CALayer,直到第一次在主线程访问 view 或 layer 属性时,它才会在内部生成对应的对象。
    • 当它的属性(比如frame/transform)改变后,它并不会立刻同步到其持有的 view 或 layer 去,而是把被改变的属性保存到内部的一个中间变量,稍后在需要时,再通过某个机制一次性设置到内部的 view 或 layer。
  • 通过模拟和封装 UIView/CALayer,开发者可以把代码中的 UIView 替换为 ASNode,很大的降低了开发和学习成本,同时能获得 ASDK 底层大量的性能优化。

    • 为了方便使用, ASDK 把大量常用控件都封装成了 ASNode 的子类,比如 Button、Control、Cell、Image、ImageView、Text、TableView、CollectionView 等。
    • 利用这些控件,开发者可以尽量避免直接使用 UIKit 相关控件,以获得更完整的性能提升。

3.3 ASDK 的图层预合成

  • 有时一个 layer 会包含很多 sub-layer,而这些 sub-layer 并不需要响应触摸事件,也不需要进行动画和位置调整。
    • ASDK 为此实现了一个被称为 pre-composing 的技术,可以把这些 sub-layer 合成渲染为一张图片。
    • 开发时,ASNode 已经替代了 UIView 和 CALayer;直接使用各种 Node 控件并设置为 layer backed 后,ASNode 甚至可以通过预合成来避免创建内部的 UIView 和 CALayer。
  • 通过这种方式,把一个大的层级,通过一个大的绘制方法绘制到一张图上,性能会获得很大提升。
    • CPU 避免了创建 UIKit 对象的资源消耗,GPU 避免了多张 texture 合成和渲染的消耗,更少的 bitmap 也意味着更少的内存占用。

3.4 ASDK 异步并发操作

  • 自 iPhone 4S 起,iDevice 已经都是双核 CPU 了,现在的 iPad 甚至已经更新到 3 核了。
    • 充分利用多核的优势、并发执行任务对保持界面流畅有很大作用。
    • ASDK 把布局计算、文本排版、图片/文本/图形渲染等操作都封装成较小的任务,并利用 GCD 异步并发执行。
    • 如果开发者使用了 ASNode 相关的控件,那么这些并发操作会自动在后台进行,无需进行过多配置。

3.5 Runloop 任务分发

  • Runloop work distribution 是 ASDK 比较核心的一个技术,ASDK 的介绍视频和文档中都没有详细展开介绍,所以这里我会多做一些分析。

  • iOS 的显示系统是由 VSync 信号驱动的,VSync 信号由硬件时钟生成,每秒钟发出 60 次(这个值取决设备硬件,比如 iPhone 真机上通常是 59.97)。

    • iOS 图形服务接收到 VSync 信号后,会通过 IPC 通知到 App 内。App 的 Runloop 在启动后会注册对应的 CFRunLoopSource 通过 mach_port 接收传过来的时钟信号通知,随后 Source 的回调会驱动整个 App 的动画与显示。
  • Core Animation 在 RunLoop 中注册了一个 Observer,监听了 BeforeWaiting 和 Exit 事件。这个 Observer 的优先级是 2000000,低于常见的其他 Observer。

    • 当一个触摸事件到来时,RunLoop 被唤醒,App 中的代码会执行一些操作,比如创建和调整视图层级、设置 UIView 的 frame、修改 CALayer 的透明度、为视图添加一个动画;
    • 这些操作最终都会被 CALayer 捕获,并通过 CATransaction 提交到一个中间状态去(CATransaction 的文档略有提到这些内容,但并不完整)。
    • 当上面所有操作结束后,RunLoop 即将进入休眠(或者退出)时,关注该事件的 Observer 都会得到通知。
    • 这时 CA 注册的那个 Observer 就会在回调中,把所有的中间状态合并提交到 GPU 去显示;如果此处有动画,CA 会通过 DisplayLink 等机制多次触发相关流程。
  • ASDK 在此处模拟了 Core Animation 的这个机制:所有针对 ASNode 的修改和提交,总有些任务是必需放入主线程执行的。

    • 当出现这种任务时,ASNode 会把任务用 ASAsyncTransaction(Group) 封装并提交到一个全局的容器去。
    • ASDK 也在 RunLoop 中注册了一个 Observer,监视的事件和 CA 一样,但优先级比 CA 要低。
    • 当 RunLoop 进入休眠前、CA 处理完事件后,ASDK 就会执行该 loop 内提交的所有任务。具体代码见这个文件:ASAsyncTransactionGroup
  • 通过这种机制,ASDK 可以在合适的机会把异步、并发的操作同步到主线程去,并且能获得不错的性能。

3.6其他

  • ASDK 中还有封装很多高级的功能,比如滑动列表的预加载、V2.0添加的新的布局模式等。
    • ASDK 是一个很庞大的库,它本身并不推荐把整个 App 全部都改为 ASDK 驱动把最需要提升交互性能的地方用 ASDK 进行优化就足够了
    [TOC]

参考

01-iOS 保持界面流畅的技巧

文章推荐

关于进一步了解ASDK的设计原理和使用:

相关阅读(共计14篇文章)

iOS相关专题

webApp相关专题

跨平台开发方案相关专题

阶段性总结:Native、WebApp、跨平台开发三种方案性能比较

Android、HarmonyOS页面渲染专题

小程序页面渲染专题