图像和图形的最佳实践(WWDC 2018 session 219)

该篇博客记录观看WWDC2018中Session219《Image And Graphics Best Practices》的内容及一些理解。

该Session主要讲述了关于有效使用图形内容的一些技术和策略。主要分三个方面:

  1. 从UIImage和UIImageView入手,讲述UIKit对于图形内容的处理。
  2. 讨论使用UIKit高效的处理自定义绘图。
  3. 简单讲述在应用中使用先进的CPU和GPU技术。

在整个讨论中,主要讨论图形处理对于内存CPU的影响,这两个因素可以影响到系统的反应速度以及电池的使用寿命。

UIImage和UIImageView

UIImage在UIKit中表示一个图形的内容,而UIImageView在UIKit中用来呈现一个视图。对应到MVC模式中,UIImage是一个加载图形内容的model,而UIImageView是显示渲染图形的视图。这两者之间的关系是一种连续的一次性的简单单向联系。如下图所示:
UIImage与UIImageView关系

但是在这之外还有一个隐藏的、影响程序性能的过程,叫做解码(Decode),如下图:
Decode

Decode

在了解Decode的过程中,我们首先要了解一个概念:缓冲区(Buffer)。

  1. 缓冲区是在内存中连续的区域。
  2. 通常被视为元素序列。
    缓冲区(Buffer)

1.图像缓冲区(Image Buffer)

图像缓冲区中每个元素表示的是图像中一个像素的颜色信息。所以,该Buffer在内存中的大小与图像大小成正比。

2.帧缓冲区(Frame Buffer)

帧缓冲区是保存应用实际呈现输出的缓冲区。

在应用更新视图层次结构时,UIKit会把应用程序的Window以及子视图渲染到帧缓冲区中。这个更新频率在iPhone上是60FPS,在iPad上是120FPS。
帧缓冲区(Frame Buffer)

3.数据缓冲区(Data Buffer)

数据缓冲区为保存一系列bytes数据的缓冲区。

在图像例子中,数据缓冲区就是保存从网络下载或者保存在磁盘中的图像的数据,这些数据并不直接描述每一个像素的信息。
数据缓冲区(Data Buffer)

加载过程

现在,我们了解了Buffer以及几种与图像相关的Buffer,接下来可以分析一下图片加载过程了。

  1. 加载准备:我们准备一个图像元数据,一个UIImage,以及一个加载在视图上的UIImageView,如下:
    加载准备
  2. 获取像素信息:为了将图像中每个像素信息填充到Frame Buffer中,我们需要得到图像像素信息。UIImage会为我们处理这一点:UIImage会创建一个与图片大小一致的Image Buffer用来存储解码(Decode)之后的图像像素信息。
    获取像素信息
  3. 显示在屏幕上:UIImageView读取UIImage创建的Image Buffer数据,交由UIKit显示。UIKit会对像素数据缩放到显示大小进行显示。
    显示在屏幕上

在加载过程中,UIKit会重复多次的要求UIImageView去进行渲染,这就造成了UIImage会多次的解码Data Buffer。

为了解决这个问题,UIImage会只进行一次Data Buffer的解码,在解码之后,会挂起解码后的Image Buffer。

所以,在这种情况下,应用对于每一个UIImage解码后,都会在内存中挂起一个Image Buffer。

由于Image Buffer与图像大小成正比,所以在加载大图片时,应用程序会在内存中挂起很多大的Image Buffer,这可能会造成操作系统介入,并最终杀死应用。

下采样(downsampling)

针对于我们上述提到的问题,我们可以采用叫做下采用(downsampling)的技术。

我们注意到,我们最终显示在屏幕上的视图往往比实际图片的尺寸要小,而通常情况下,Core Animation Framework会负责缩小图像。
缩放显示

现在要做的,本质上是把缩放图像操作捕获为一个叫缩略图的对象。由于减小了Image Buffer的大小,进而减小了在加载图片中的使用的内存总大小。

同时,我们在解码后,将图片像素信息交给UIImageView去渲染至屏幕后,可以丢弃掉对应的Data Buffer,进一步减少内存的使用。

这个过程如下图:
下采样过程

接下来我们放在代码中进行一下分析,整体代码如下:
下采样整体代码
接下来我们分析几个重要的点:

  1. 创建CGImageSourceRef
    创建CGImageSourceRef
    在创建CGImageSourceRef的时候,CGImageSourceCreate方法可以接受一个选项设置,我们可以提供kCGImageSourceShouldCachefalse。这个选项设置告诉Core Graphics Frameword我们只是需要一个CGImageSourceRef来存储对应路径的文件的信息,而不需要立刻去对文件中的图像进行解码。

  2. 计算需要渲染至屏幕的真实尺寸
    计算需要渲染至屏幕的真实尺寸

  3. 获取缩略图
    获取缩略图
    在获取缩略图的方法CGImageSourceCreateThumbnailAtIndex中,我们可以指定选项设置,其中需要我们关注的是kCGImageSourceShouldCacheImmediately(iOS7.0及以上版本),该设定告诉Core Graphics Frameword,在进行创建缩略图的时候,就应该立刻创建一个解码后的Image Buffer。

通过以上的改进方式,可以大幅度的缩减内存的使用。以下为一组测试数据:
测试环境:Xcode9.4,iPhone 6s Plus, 5184*3456的5.1MB大小的JPG格式图片。

  • 不使用改进方法,消耗内存为:49.3M
  • 使用改进方法,消耗内存为:9.4M

针对滚动视图的优化

如果我们需要一个滚动视图来展示一大组质量很大的图片时,我们也会遇到内存过高的问题,接下来我们以UICollectionView加载图片来做分析和优化。

使用下采样(downsampling)的方式优化

首先我们采用我们上一步优化图片的方式来为每一个cell中的图片进行优化,代码可能如下:
下采样优化滚动视图

这样看起来很有用,因为我们为每一个cell上的图片显示进行了优化,进而使得整体内存使用有一个明显的降低。但是,这样会引起另一个问题:如果此时滚动视图,CPU可能会很快的将所需展示的内容渲染到帧缓冲区,但是如果滚动过快,CPU还需参与Core Graphics对于新图像的解帧,这个操作是非常耗时的,这样就可能会造成在硬件读取帧缓冲区数据显示至屏幕时,帧缓冲区数据并没有准备好,进而导致用户视角中的卡顿。另外对于电池来说,在CPU不稳定时,可能会影响到电池使用寿命。

进一步优化

有两种技术可以帮助我们解决这个问题。

  1. 预取(prefetching):在某一时刻,我们不需要某个cell,但是在不久的将来会需要这个cell,所以可以把某些工作提前至这个时刻来进行。

  2. 后台执行(performing work in the background)

针对于UICollectionView,我们可以做以下处理:
预取和后台执行
我们为预取的cell进行在后台即全局异步队列(global
asynchronous queues)的图像解码,这是我们提到的两种优化技术。

但是,在使用全局异步队列的时候,可能会出现线程爆炸的问题:如果我们一次性处理多张图像,但是设备只有2个CPU时,此时GCD会创建新的线程来处理解码工作。创建新线程以及在不同线程之间切换是十分消耗时间和资源的。

解决线程爆炸问题

我们可以将解码操作异步的分派到串行队列中,如下图:
解决线程爆炸问题
这样做可能使得某些图像的解码过程延后,但是更多的是减少了在线程切换过程中浪费的时间和资源。

至此,我们完成了对于滚动视图加载多张图片的优化。

图片资源的优化

接下来分析一下我们对于图片资源的处理,在目前的程序中,可以展示的图像资源来源可能有一下几种:

  1. 存储在Image Assets中
  2. 存储在Application或者Bundle的包中
  3. 存储在沙盒的Document或者Cache文件中
  4. 从网络下载的数据中

对于程序中自带的图像资源,苹果官方推荐我们使用Image Assets来存储,以下是使用该方式的几个优点:

  1. Image Assets针对基于名称和特性的查找进行了优化,它比在磁盘上搜索文件要快。
  2. 在管理缓冲区方面也有优化。
  3. 允许对设备安装包进行优化,在下载App的时候会只下载能最高效果显示在对应设备的图像资源,从而减小安装包大小。
  4. 对Vector Artwork的支持。

Vector Artwork

Vector Artwork是iOS 11引入的新特性。我们可以在Image Assets中勾选Preserve Vector Data来启用它。如果启用了Vector Artwork的话,当我们将图像显示到一个比它原始尺寸大或者小的视图中时,这个图像不会变的模糊。因为显示的图像是从矢量图形中重新光栅化而来的,从而使得图像有清晰的边缘。

启用Vector Artwork后,图像的处理方式和之前图片的处理方式类似,只是将解码阶段变为了光栅化阶段,光栅化阶段将矢量数据转换为位图数据,进而供帧缓冲区读取。

Vector Artwork读取流程

如果我们把应用中所有的图像资源进行了Preserve Vector Data处理的话,会造成一些CPU的使用。
所以Xcode对这些情况做了一些处理:如果选中了Preserve Vector Data选项,但是在视图上展示为原始尺寸大小时,Image Assets实际上已经完成了原始尺寸的光栅化,并把相关数据保存在Image Assets中了,所以这种情况可以直接对存储的数据进行解码,而不进行光栅化。

另外一个建议是:如果计划展示的尺寸为固定的几个尺寸,那么不要依赖Preserve Vector Data,而是准备好对应尺寸的图像资源,从而加快CPU对图像资源的处理。

自定义绘制内容

我们有时需要在程序中进行一些自定义的绘制,例如绘制一个如下样式的视图:
自定义控件

我们可以继承UIView,然后在draw方法中进行相应的绘制:
自定义控件实现

但是我们并不推荐这么使用,我们首先对比UIImageView和draw的实现原理来进行分析:我们都知道UIView是基于CALayer来显示内容的;而对于UIImageView来说,UIImageView会将UIImage解码后的数据交给CALayer来作为内容显示;而对于draw来说,CALayer会创建一个与图像大小成正比的backing store来存储图像数据,然后将图像数据拷贝至backing store中,然后将backing store中的内容绘制到帧缓冲区中。

由此可见,通过重写draw方法可能会造成多余的内存消耗以及数据的拷贝,接下来了解一下backing store

Backing Store

在我们重写draw方法时,会触发创建Backing Store,此时的Backing Store的大小与视图的像素大小成正比。

同时,在iOS12中,Backing Store将会使用动态增长的方式来减少内存的使用。在以前的iOS版本中,我们可以设置CALayer的contentsFromat属性来指定Core Animation Framework在绘制时用到的颜色长度,这个设置会关闭iOS12中关于Backing Store的设置。

建议实现方式

回归主题,对于我们需要自定义绘制的视图,我们应该减少Backing Store的使用。通常的做法是将一个大视图构建为多个小视图来实现;同时,系统提供的经过优化的视图属性也不会造成多余内存的使用(例如UIView的backgroundColor属性,就不会创建Backing Store。此时需要注意,使用pattern color时是一个例外,可以使用UIImageView作为UIView的子视图来替代这一效果)。

将大视图构建为多个小视图

在Live视图中,我们可以构建为以下层级:
Live视图层级

1.圆角处理

在我们要进行圆角处理时,使用CALayer的cornerRadius属性,该属性不会引起多余的内存使用。而对于使用maskView和maskLayer来说,会使用到额外的内存空间。

同时对于圆角之外的透明区域为复杂的不规则形状且cornerRadius满足不了需求时,可以使用UIImageView配合Resizeable的UIImage来进行处理。

2.Icon的实现

Icon可以使用UIImageView来显示。

为了达到UIImage的复用,UIImageView支持对单色图像着色,同时可以直接渲染到帧缓冲区中。

这个设置需要对UIImage进行renderingMode的设置或者在Image Assets中进renderingMode设置。

最后,为UIImageView设置tintColor来完成最终颜色渲染。

3.UILabel的优化

  • UILabel对于显示单色字符串做了优化处理,可以节省75%大小的Backing Store的使用。

  • UILabel对于多彩字符串以及emoji进行了动态增长Backing Store的优化。

最后,如果想离屏绘制,就是用UIGraphicsImageRenderer,而不是使用旧版本的UIGraphicsBeginImageContext。因为UIGraphicsImageRenderer支持宽颜色内容展示;同时也支持根据绘制的动作来动态的增加Image Buffer的大小。在iOS12中,UIGraphicsImageRendererFormatprefersExtendedRange属性可以告知UIKit是否需要绘制宽颜色内容。同时,UIImage中提供了imageRendererFormat属性来配合UIGraphicsImageRenderFormat来使用。

简单讲述在应用中使用先进的CPU和GPU技术。

Advanced Image Effects

  1. 实时处理图像时考虑Core Image。
  2. 尽量使用GPU来处理图像,进而解放CPU。
  3. 使用CIImage创建UIImage,并交由UIImageView去展示,这个过程会默认由GPU去处理,减少对CPU的使用。

Advanced Image Processing

  1. 使用CVPixelBuffer将数据移动到Metal, Vision和Accelerate等框架。
  2. 使用最好的初始值设定项(不要重复执行已经完成的工作)。
  3. 防止在GPU和CPU直接来回切换。
  4. 确保交给Accelerate的buffers是正确的格式。

总结

  1. 异步的进行预处理。
  2. 使用UIImageView和UILabel来减少Backing Store的使用。
  3. 不要禁止自定义绘制时系统做的新优化。
  4. 使用Image Assets来存储图像。
  5. 如果要展示相同图片的不同尺寸,不要过多依赖Preserve Vector Data。