iOS 性能优化系列篇之“列表流畅度优化”

卡顿产生的原因

在总体原则篇中提到,五大原则中的其中一个就是要理解优化任务的底层运行机制,因为只有深入了解底层机制才能更好的有针对性的提出更优的解决方案,所以在进行列表流畅度优化前,我们一定要弄清楚一个 view 从创建到显示到屏幕上都经历了那些过程,在这些过程中那些方面可能会导致性能瓶颈,以及造成卡顿的底层原因是什么。

我们知道 iOS 设备大部分情况下,屏幕刷新频率是 60hz(ProMotion 下是 120hz),也就是每隔 16.67ms 会进行一次屏幕刷新。每次刷新时,需要 CPU 和 GPU 配合完成一次图像显示。其主要流程如下:

应用内:

  • 布局。CPU 创建 view,设置其属性(frame、background color 等等)
  • 创建 backing images。setContents 将一个 image 传給 layer 或者通过 drawRect:或 drawLayer:inContext 绘制
  • 准备。Core Animation 将 layer 发送到 render server 前的一些准备工作,比如图片解码等。
  • 提交。Core animation 将 layers 打包通过 IPC (Inter-Process Communication) 发送到 render server

应用外(render server):

  • 设置用来渲染的 OpenGL triangles(如果是有动画,还需计算动画 layer 的属性的中间值)。
  • 渲染这些可见的 triangles,将结果提交到视频缓冲区
  • 视频控制器以 60hz 频率读取缓冲区内容显示到显示器,如果在 16.67ms 内没有完成提交,则会被丢弃。

null

从上面的图中可以看到,在 view 显示的过程中,CPU 和 GPU 都各自承担了不同的任务,CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。所以优化方法也需要分别对 CPU 和 GPU压力进行评估和优化,在 CPU 和 GPU 压力之间找到性能最优的平衡点, 无论过度优化哪一方导致另一方压力过大都会造成整体 FPS 性能的下降。而寻找平衡点的过程则因项目特点不同而不同,并没有一套通用的方法,需要我们用 instrument 等性能评测工具,根据实际 app 的性能度量结果去做优化,不能凭空乱猜。

CPU 优化

我们先看 table view 在滑动过程中 CPU 占用的情况。

instruments 截图

从上图可以看出,在滑动过程中 CPU 占用特点是:

  • 滑动时 CPU 占用率高、空闲时 CPU 占用率底
  • 主线程 CPU 占用高、子线程 CPU 占底

根据上述特点我们可以做如下优化:

预加载,空间换时间

为什么要预加载:

  • 滑动时 CPU 占用过高,16.67ms 内无法完成内容提交—> 导致卡顿
  • 滑动时 CPU 占用率高,但空闲时 CPU 占用率底—>CPU 占用分布特点
  • 利用 CPU 空闲时间预加载,降低滑动时 CPU 占用峰值—> 解决卡顿

通过预加载我们希望达到的 CPU 理想占用效果如下:

null

预加载内容:

静态资源预加载

  • 如何预加载:创建列表前找时机加载。如启动时、viewDidLoad、runloop 空闲时等等
  • 加载内容:缓存在磁盘的网络数据、图片、其他滑动时需要的耗时的资源
  • 注意事项:在预加载带来的滑动性能提升和内存占用增加之间权衡

动态资源预加载

  • 如何预加载:
    • 在 iOS10 以后,UITableView 和 UICollectionView 提供了预加载机制,iOS12 开始 prefeatching 做了优化,不再与 cell 的加载同时并发进行,而是 cell 加载完成之后串行开始 prefeatch,从而优化了流畅度
    • iOS10 以前,也可以自己实现类似机制,主要利用的机制有:
      • UIScrollViewDelegate 提供滑动开始、结束、速度时机回调
      • indexPathsForRowsInRect 和 layoutAttributesForElementsInRect 提供预加载的 indexPath
      • 可根据滑动速度动态调整加载的量
  • 加载内容:
    • Cell 的高度、subView 的布局计算
    • 拉取网络数据
    • 网络图片
    • 其他耗时的资源
  • 注意事项:
    • 在预加载带来的滑动性能提升和内存占用增加之间权衡
    • 注意数据过期的问题

WWDC 2018 中讲到了一个 iOS12 的底层优化点,苹果工程师在性能调优的时候发现一个导致丢帧的奇怪 case,在没有其他后台线程运行、只有滑动的情况下,会比有少量的后台线程的情况更容易掉帧。通过调研 CPU 的调度算法发现,在仅有滑动的情况下,为了省电,CPU 占用会保持比较底,但是这样 CPU 会花更多的时间来计算,就会导致可能错过这一帧。所以 iOS12 中,会把 UIKit 框架上所有的信息(滑动信息以及滑动 frame 的关键时间点)传递给底层 CPU 性能控制器,这样 CPU 可以更智能调度以在 frame 截止的时机内完成 CPU 计算。这部分属于系统底层的优化,对于应用开发者只要应用运行在 iOS12 就可以获得这部分优化。

多线程

为什么要多线程:

  • UIKit 大部分 API 只能在主线程调用, 特别是一些耗时的操作,如 view 的创建,布局和渲染默认都是在主线程上完成
  • 主线程任务过多,16.67ms 内无法完成,导致卡顿
  • 将非主线程必须的任务,移到子线程中,减轻主线程负担
  • 多核处理器,多线程可以发挥多核并发优势,提高性能

最终通过多线程,我们希望 CPU 占用达到如下效果:

null

使用多线程注意事项:

  • 主线程最大程度上减少非主线程必须的任务
  • 控制子线程数量在合理的范围内,防止线程爆炸,一定要根据项目实际 CPU 占用特点,有针对的使用多线程。

可在子线程中进行的任务

  • 图片解码
  • 文本渲染,UILabel 和 UITextview 都是在主线程渲染的,当显示大量文本时,CPU 的压力会非常大。特别是对于一些资讯类应用,这部分耗时相当大,对流畅度的影响也十分明显。对此可以自定义文本控件,用 TextKit 或最底层的 CoreText 对文本异步绘制。尽管这实现起来非常麻烦,但其带来的优势也非常大,CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整 UILabel 大小时算一遍、UILabel 绘制时内部再算一遍);CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染。用 [NSAttributedString boundingRectWithSize:options:context:] 来计算文本宽高,用 -[NSAttributedString drawWithRect:options:context:] 来绘制文本。尽管这两个方法性能不错,但仍旧需要放到后台线程进行以避免阻塞主线程。
  • UIView 的 drawRect, 由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以很容易的放到后台线程进行
  • 耗时的业务逻辑

缓存

缓存的内容可以是

  • UIView。 view 的创建代价很大,一些可以复用的 view 可以 cache。例如 UITableView 为我们实现的了 cell 的复用。
  • 图片。 图片涉及磁盘 IO 和解码,十分耗时,可以考虑缓存。
  • 布局。其实不仅仅是 cell 的高度可以缓存,如果 cell 里面有大量的文字图片等复杂元素,cell 的 subView 的布局也可以在第一次计算好,用 Model 的 key 来缓存。避免频繁多次的调整布局属性。在滑动列表(UITableView 和 UICollectionView)中强烈不建议使用 Autolayout。随着视图数量的增长,Autolayout 带来的 CPU 消耗会呈指数级上升。具体数据可以看这个文章:pilky.me/36/。在 WWDC20…
  • 数据, 网络拉取的数据或者 db 中的数据
  • 其他创建耗时,可重复利用的资源。 如 NSDateFormatter 等

更优的实现方式

这里说的更优的实现方式,主要是指为了实现同一功能或者效果,CPU 占用更小的实现方式。这部分包括的内容其实非常多,也很杂。受限于篇幅和水平有限,这里笔者仅罗列一些比较常见的点,并针对其中比较重要的 drawRect 优化和图片优化内容做进一步的讲解。

  • drawRect 优化
  • 图片优化
  • 算法的时间复杂度优化。我们知道算法的时间复杂 O(1) < O(log n) < O (n) < O(n2)… 大家可能觉得 iOS 开发过程中使用的算法并不多,算法对性能影响并不明显。其实不然,举 iOS 中的一个例子:IGListDiff 采用空间换时间的方式,使得比较的算法复杂度从 O(n2)变成 O(n)。IGListKit-diff- 实现简析 。还比如不同容器的选择,会带来不同的查找、插入、删除的时间复杂度,在大的数据量下也会带来不同的性能表现。
  • storyboard VS 代码创建 view
  • frame VS autolayout autolayout 性能度量iOS12 优化了 autolayout 的性能,耗时由指数变为线性耗时
  • UIView VS CAlayer 后者更轻量,在不需要处理触摸事件的场景可以考虑使用 CAlayer。UIView 层级太多,会导致创建、布局等较耗时,可以尽量扁平化,甚至可以异步在子线程画到一个 Image 上。
  • UIImageView animationImages VS CAAnimation
  • NSDateFormatter dateFromString VS NSDate dateWithTimeIntervalSince1970:
  • 更优的业务逻辑。大家平时在性能优化的时候,已经要优先去排查业务逻辑这块,仔细梳理。个人经验很多性能问题都是由不合理的业务逻辑导致的。使用 Instruments 的 time profiler 工具仔细观察耗时的业务逻辑,做好梳理和优化工作。
  • 其他

下面详细讲下 drawRect 优化和图片优化

drawRect 优化

  • 首选使用 CAShapeLayer 替代 drawRect,在大多数场景下,都可以使用 CAShapeLayer 替代 drawRect。二者对比:
    • CAShapeLayer 使用 GPU 硬件加速,更快。GPU 对高度并行的浮点运算做了优化。而 drawRect 使用 CPU 绘图,相比之下会很慢,而且十分耗 CPU
    • CAShapeLayer 占用内存更少。因为不会创建寄宿图,因此无论多大都不会占用太多内存。而 drawRect 图层每次重绘的时候都需要重新抹掉内存然后重新分配,十分占用内存。详见内存恶鬼 drawRect
    • CAShapeLayer 不会被图层边界剪裁掉
    • CAShapeLayer 不会出现像素化,通过矢量图绘制而不是 bitmap
    • CAShapeLayer 有很多属性可以方便的做动画,比如使用 strokeStart 和 strokeEnd 可以做出了很漂亮的动画
  • 异步绘制。可以使用异步绘制的方式,在子线程绘制好获得 image,然后交给主线程。
  • Dirty Rectangles: 可以使用 setNeedsDisplayInRect 标记 Dirty Rectangles,仅重绘指定区域,也会极大提升性能。

图片优化

在大多数 app 中,图片绝对是使用最频繁的资源之一,我们知道磁盘和网络的加载速度和内存比要慢很多,而一般图片都比较大,I/O 十分耗时。而且图片还涉及解码,也是一项十分消耗 CPU的工作,因此图片的优化对 app 的性能有着十分关键的作用。谈谈 iOS 中图片的解压缩

在之前将的优化总体原则的时候,我们说过需要理解优化对象的运行机制,我们先了解下图片显示原理:

  • 从磁盘或者网络加载一张图片,此时图片未解码
  • 图片赋值给 UIImageView
  • 在主线程中解码,非常耗时的 CPU 操作
  • CATransaction 捕捉到 layer tree 的变化
  • 在 main run loop, 提交 transaction:
    • 如果图片数据没对齐,Core Animation 会拷贝一份数据,进行字节对齐
    • GPU 处理位图数据,进行渲染

针对上面的过程,我们的优化手段主要有:

  • 异步下载 / 读取图片,这样可以防止这项十分耗时的操作阻塞主线程。
  • 预处理图片大小。如果 UIImage 大小和 UIImageview 的 size 不同的话,CPU 需要提前预处理,这是一项十分消耗 CPU 的工作,特别是在一些缩略图的场景下,如果使用了十分大的图片,不仅会带来很大的 CPU 性能问题,还会导致内存问题。我们可以用 instruments Core Animation 的 Misaligned Image debug 选项来发现此问题。这里可以使用 ImageIO 中的 CGImageSourceCreateThumbnailAtIndex 等相关方法进行后台异步 downsample,可以在 CPU 和内存上获得很好的性能。
  • UIImageView frame 取整。视图或图片的点数 (point),不能换算成整数的像素值(pixel),导致显示视图的时候需要对没对齐的边缘进行额外混合计算,影响性能。借助 ceilf()、floorf()、CGRectIntegral() 等将小数点后数据除去即可。我们可以用 instruments Core Animation 的 Misaligned Image debug 选项来发现此问题
  • 使用 mmap,避免 mmcpy。解码图片 iOS 从磁盘加载一张图片,使用 UIImageVIew 显示在屏幕上,需要经过以下步骤:从磁盘拷贝数据到内核缓冲区、从内核缓冲区复制数据到用户空间。使用 mmap 内存映射,省去了上述第 2 步数据从内核空间拷贝到用户空间的操作,具体可以参考 FastImageCache 的实现
  • 子线程解码。如果我们使用 imgView.image = img; 如果图片没有解码,则会在主线程进行解码等操作,会极大影响滑动的流畅性。
  • 字节对齐,如果数据没有字节对齐,Core Animation 会再拷贝一份数据,进行字节对齐,也是十分消耗 CPU。
  • iOS 12 引入了 Automatic Backing Store 这项技术。通过在保证色彩不失真的基础上,使用更少的数据量,去表达一个像素的颜色。在 UIView.draw()、UIGraphicsImageRenderer、UIGraphicsImageRenderer.Range 中是默认开启的。其实我们自己可以针对图片的特点,采用更少的 byte 来标示一个像素占用的空间,FastImageCache 就是使用这种优化手段,有兴趣的读者可以去了解一下。
  • 我们日常开发中可以使用一些比较经典的图片缓存库,比如 SDWebImage、 FastImageCache、YYImage 等。这些第三方库替我们完成的大部分优化的工作,而且接口也十分友好。我们可也使用这些第三方库帮助我们获得更好的性能体验。

GPU 优化

CPU 和 GPU 之所以大不相同,是由于其设计目标的不同,它们分别针对了两种不同的应用场景。CPU 需要很强的通用性来处理各种不同的数据类型,同时又要逻辑判断又会引入大量的分支跳转和中断的处理。这些都使得 CPU 的内部结构异常复杂。而 GPU 面对的则是类型高度统一的、相互无依赖的大规模数据和不需要被打断的纯净的计算环境。所以CPU 擅长逻辑控制,串行的运算。和通用类型数据运算不同,GPU 擅长的是大规模并发计算,这也正是密码破解等所需要的。所以 GPU 除了图像处理,也越来越多的参与到计算当中来。参考

iOS 中 GPU 在显示方面的工作主要是:接收提交的纹理(Texture)和顶点描述(三角形),进行变换(transform)、混合并渲染,然后输出到屏幕上。屏幕上的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。一般来说,CALayer 的大多数属性都是使用 GPU 来绘制的。虽然 GPU 在处理图像等渲染是速度很快,但如果开发过程中使用不当,仍会导致 GPU 占用过高,渲染速度跟不上屏幕刷新导致卡顿。

对 GPU 消耗比较高的操作有:

  • 纹理的渲染

    所有的 Bitmap,包括图片、文本、栅格化的内容,最终都要由内存提交到显存,绑定为 GPU Texture。不论是提交到显存的过程,还是 GPU 调整和渲染 Texture 的过程,都要消耗不少 GPU 资源。当在较短时间显示大量图片时(比如 TableView 存在非常多的图片并且快速滑动时),CPU 占用率很低,GPU 占用非常高,界面仍然会掉帧。避免这种情况的方法只能是尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示。

    图片过大,超过 GPU 的最大纹理尺寸时,图片需要先由 CPU 进行预处理,这对 CPU 和 GPU 都会带来额外的资源消耗。目前来说,iPhone 4S 以上机型,纹理尺寸上限都是 4096x4096,更详细的资料可以看这里:iosres.com。所以,尽量不要让图片和视图的大小超过这个值。

  • 视图的混合 (Composing)

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

  • 图形的生成

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

常用优化手段

  • 减少视图数量和层次,可把多个视图预先渲染为一张图片

  • 不要让图片和视图超过 GPU 可渲染的最大尺寸

  • 视图不透明

  • 防止离屏渲染 OpenGL 中,GPU 屏幕渲染有以下两种方式:

    • On-Screen Rendering 意为当前屏幕渲染,指的是 GPU 的渲染操作是在当前用于显示的屏幕缓冲区中进行。
    • Off-Screen Rendering 意为离屏渲染,指的是 GPU 在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。

    相比于当前屏幕渲染,离屏渲染的代价是很高的,主要体现在两个方面:

    • 创建新缓冲区 要想进行离屏渲染,首先要创建一个新的缓冲区。
    • 上下文切换 离屏渲染的整个过程,需要多次切换上下文环境:先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上有需要将上下文环境从离屏切换到当前屏幕。而上下文环境的切换是要付出很大代价的。

    所以在图形生成的步骤我们要尽可能的避免离屏渲染

优化工具

iOS 开发中,在 GPU 优化上,我们一般使用 instruments 中的 Core Animation 工具来进行滑动流畅度优化,在 Core Animation 中我们可也看到列表滑动过程中的 FPS,其中有一些很有用的 debug 选项,帮助我们找到代码中有性能问题的代码。下面是一些常用的选项:

  • Color Blended Layers

    Color Blended Layers 是用来检测个半透明图层的混合区,渲染程度对屏幕中的混合区域进行绿到红的高亮。因为计算混合区的颜色时,导致 overdraw,消耗一定的 GPU 资源,是导致滑动性能的一个因素。所以尽量要尽量避免

    在开发过程中,避免 Blended Layers 的手段有:

    • 设置 opaque 属性 YES
    • View 背景颜色不透明
    • Image 不含有透明通道
    • 需要特别注意的是,在 iOS8 之后,UILabel 使用的是 CALayer 作为底图层,而在 iOS8 开始,UILabel 的底图层变成了 _UILabelLayer,绘制文本也有所改变。UILabel 显示中文时,还需 masksToBounds = YES。
  • Color Hits Green and Misses Red Color Hits Green and Misses Red 用来检测是否正确使用 shouldRasterize,当缓存需要重新生成时,红色高亮 rasterized layers,当设置 shouldRasterize=YES,会将 layer 预先渲染成位图,并缓存。以提高性能。但是如果 cache 频繁重复地生成,表示 shouldRasterize 可能带来的是负面的性能影响。因此 shouldRasterize适用于渲染耗时、图像内容不变的情况,在列表中由于内容要频繁变化,因此不推荐使用此属性
  • Color Copied Images

    大多数时,Core Animation 只需要提交原始图片的指针到 render server,不涉及内存 copy。但是一些情况下,Core Animation 不得不 copy 一份图片发送到 render server。苹果的GPU 只解析 32bit的颜色格式,如果图片颜色格式不对,CPU 会预先格式转换。copy images 是非常耗 CPU 的操作,一定要避免。

  • Color Misaligned Images 被拉伸缩放的图片、无法正确对齐到像素的图片(可能有不是整数的的坐标)。是耗 CPU 的操作

  • Color Offscreen-Rendered Yellow

    GPU 在当前屏幕缓冲区外开辟新的缓冲区进行渲染, 屏幕外缓冲区和当前屏幕缓冲区上下文切换是十分耗时的操作

    引起 Offscreen-Rendered 的操作有:

     - 圆角 cornerRadius masksToBounds同时设置
      - 设置shadow
      - 开启光栅化 shouldRasterize=YES.CALayer 有一个 shouldRasterize 属性,将这个属性设置成 true 后就开启了光栅化。开启光栅化后会将图层绘制到一个屏幕外的图像,然后这个图像将会被缓存起来并绘制到实际图层的 contents 和子图层,对于有很多的子图层或者有复杂的效果应用,这样做就会比重绘所有事务的所有帧来更加高效。但是光栅化原始图像需要时间,而且会消耗额外的内存。光栅化也会带来一定的性能损耗,是否要开启就要根据实际的使用场景了,图层内容频繁变化时不建议使用。最好还是用 Instruments 比对开启前后的 FPS 来看是否起到了优化效果。
      - 图层蒙板
    复制代码
    

避免 Offscreen-Rendered 的方式可以其他方式实现圆角、shadow + shadowPath 等。