70

Image and Graphics Best Practices

 5 years ago
source link: http://joakimliu.github.io/2019/03/02/wwdc-2018-219/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

本文是对 Image and Graphics Best Practices 的一个学习记录文章,主要讲了图像加载的过程,要怎么优化内存和 CPU ,构建更流畅的应用程序。

Memory and CPU

它们是最重要的资源,它们都有各自独立的检测工具,它们是错综复杂联系在一起的。当 app 使用更多的 CPU 时,对电池寿命和响应有负面影响。不那么明显的是,当你的 App 和系统上其他 App 消耗更多的内存时,也会导致更多的 CPU 使用率,对电池寿命和性能有一定的不利影响。所以要减少内存和 CPU 的使用。

UIImage and UIImageView

在 MVC 风格上, UIImage 是一个 model , UIImageView 则是 view 。它们的责任就是, UIImage 负责加载图像内容, UIImageView 负责显示渲染它,如下图所示

zqUbuq6.png!web

其实它们的关系不像上图那样单一,实际上会更加复杂一点,如下图所示,中间还有一个隐藏的阶段:解码(decoding),当然渲染是一个持续的过程。

NFfYf2A.png!web

为了讨论解码,我们先讨论一个叫做缓冲区(buffer)的概念,它是一段连续的内存区域。习惯用缓冲区来表示由 一系列元素组成的内存 ,这些元素具有相同尺寸,并通常具有相同的内部结构。

zqiMbyi.png!web

Image Buffers

图像缓冲区(Image Buffers)是一种特殊缓冲区。

  • 在内存中以图像的形式展现
  • 每个元素描述了图像中每个像素的颜色和透明度
  • 此缓冲区的内存大小与它包含图像的大小成正比

3yA3Irz.png!web

The frame buffer

帧缓冲区(frame buffer)负责在 App 中保存实际渲染后的输出。因此,当你的 App 更新其视图层级结构时, UIKit 将重新渲染 App 的窗口及其所有子视图到帧缓冲区。该帧缓冲区提供每个像素的 颜色信息 ,显示硬件将读取这些信息,以便点亮显示器上对应的像素。 最后一部分(即显示器读取)以固定的时间间隔发生, 60Hz(每 1/60s(约 16.67hm) 一次) ,在配备 ProMotion Display 的 iPad 上是 120Hz 。如果 App 没有任何改变,则显示硬件会将它上次看到的相同数据从帧缓冲区取出。当改变时,例如改变 image , UIKit 会重新渲染,走之前的流程。流程图如下:

rq2ei2V.png!web

Data Buffers

数据缓冲区(data buffer),一种包含一系列字节的缓冲区。 包含图像文件的数据缓冲区通常以某些元数据开头,这些元数据描述了存储在数据缓冲区中的图像大小。(ps:有一个无需下载图像就能知道图像大小的第三方库 ImageSizeFetcher )

Pipeline in Action

下图讲了显示图像设置管道。

mM3AZ3Y.png!web

有一个图像视图(UIImageView) ,右边白色实线标记是它的帧缓冲区区域(该区域将由图像进行渲染填充,在此图中,已被帧缓冲区填满),我们为图像视图设置了一个图像(UIImage 从磁盘读取或者网络请求得到的),它有一个表示图像的数据缓冲区。但是我们需要用像素数据来填充帧缓冲区,所以图像将分配一个图像缓冲区(其大小等于包含在数据缓冲区中的图像的大小,图像的内存大小怎么算呢?见下面的 ps),并执行解码操作(将 JEPG 或者 PNG 或者其他编码的图像数据转换为每个像素的图像信息)。然后取决于我们的图像视图的内容模式,当 UIKit 要求图像视图进行渲染时,它将复制并缩放图像缓冲区中的图像数据,同时将其复制到帧缓冲区中。

(ps: Practical Drawing for iOS Developers(Quartz 2D Programming Guide) 里面 MyCreateBitmapContext 有讲到, pixelsWide*4*pixelsHigh (像素宽 4 像素高,因为位图中的每个像素由4个字节(byte)表示; 红色,绿色,蓝色和 alpha 各 8 位(bit)),用代码就是 CGImageGetHeight(thumbImage.CGImage) * CGImageGetBytesPerRow(thumbImage.CGImage) )

ZrieI33.png!web

在解码阶段是 CPU 密集型的,特别是对于大型图像。因此,不是每次 UIKit 要求图像视图渲染时都执行一次这个过程,图像绑定在该图像缓冲区上,因此它 只能执行一次这个过程 。 因此,对于每个被解码的图像, App 可能会持续存在大量的内存分配。这种内存分配 与输入图像的大小成正比 ,而与帧缓冲区中实际渲染的 图像视图的大小没有必然联系 。 所以对性能会产生一些负面影响。 App 地址空间中的大块内存分配可能会迫使其他相关内容远离它想要引用的内容,这种情况被称为碎片,滥用内存的后果。 最终,如果 App 开始占用越来越多的内存,操作系统将会介入,并开始透明的压缩物理内存(physical memory)的内容。CPU 需要参与到这个操作中,因此除了你自己 App 对 CPU 的使用外,你可能会增加 无法控制的 全局 CPU 使用率。最终,你的 App 可能会消耗更多的物理内存,以至于操作系统需要启动终止进程,它将从低优先级的后台进程开始。如果你的 App 消耗了达到特定数量的内存,你的 App 可能会被终止。而其中被终止的后台进程可能正执行某些重要的工作,因此,它们可能一终止就立即重新启动。所以,即使你的 App 可能只会在短时间内消耗内存,它可能对 CPU 使用率产生深远的影响。所以,我们希望减少 App 的内存使用量,可以用一种称为向下采样(downsampling)的技术来实现这一目标。

zQ3iqe2.png!web

我们要显示图像的图像视图实际上比要显示的图像小。 Core animation 在渲染阶段将负责缩小该图像,但我们可以节省一些内存(因为我们将有一个较小的图像缓冲区)。就是上图所示的 Thumbnail 处理。

func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
    let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
    let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
    let downsampleOptions =
    [kCGImageSourceCreateThumbnailFromImageAlways: true,
             kCGImageSourceShouldCacheImmediately: true,
       kCGImageSourceCreateThumbnailWithTransform: true,
              kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
    let downsampledImage =
    CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)!
    return UIImage(cgImage: downsampledImage)
}

kCGImageSourceShouldCache = false 这就告诉了 Core Graphics ,我们只是在创建一个对象,来表示存储在该 URL 的文件中的信息。不要立即解码这个图像,只需创建一个表示它的对象,我们将需要 来自此 URL 中的 信息。

kCGImageSourceShouldCacheImmediately = true 告诉 Core Graphics ,当我要求你创建缩略图时,这就是你应该为我创建解码图像缓冲区的确切时刻,因此,我们可以确切的控制何时调用 CPU 来进行解码。

ENFbQbE.png!web

func collectionView(_collectionView: UICollectionView, cellForItemAt indexPath: IndexPath)
-> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell",
                                                  for: indexPath) as! MyCollectionViewCell
    cell.layoutIfNeeded() // Ensure imageView is its final size.
    
    let imageViewSize = cell.imageView.bounds.size
    let scale = collectionView.traitCollection.displayScale
    cell.imageView.image = downsample(imageAt: imageURLs[indexPath.item],
                                      to: imageViewSize, scale: scale)
    return cell
}

如上图和上述代码所示,在列表滚动的时候进行解码,当我们滚动页面时, CPU 相对比较空闲,或者它所做的工作可以在显示硬件需要帧缓冲区的下一个副本之前完成。所以当帧缓冲区被更新时,我们能够看到流动的效果,并且显示硬件能够及时获取到新帧。 当我们滑动的时候,即将显示另一行图像,在将单元格交回给 UICollectionView 之前,我们要求 Core Graphics 解码这些图像。这将会使用大量的 CPU 时间,以至于我们不得不重新渲染帧缓冲区,但显示器硬件按固定的时间间隔运行(下一帧获取的时候,还没渲染好)。因此,从用户的角度来看 App 好像卡住(stuttered)了一样,影响很慢,并且对电池寿命也有影响。

我们可以使用两种技术来平滑我们的 CPU 使用率。

  • Prefetching: 基本思想是它允许 CollectionView 告知我们的数据源,它当前不需要一个单元格,但它在不久的将来需要。所以,如果你有任何工作要做,也许现在就可以提前开始,这允许我们 随时间推移分摊 CPU 使用率,因此我们减少了 CPU 使用的峰值大小
  • Background: 既然随着时间分散了工作量,我们也可以将这些工作分散到可用的 CPU 上,代码如下。
func collectionView(_collectionView: UICollectionView,
                    prefetchItemsAt indexPaths: [IndexPath]) {
    // Asynchronously decode and downsample every image we are about to show
    for indexPath in indexPaths {
        DispatchQueue.global(qos: .userInitiated).async {
            let downsampledImage = downsample(images[indexPath.row])
            DispatchQueue.main.async { self.update(at: indexPath, with: downsampledImage) }
        }
    }
}

上面的代码会有一个问题, 线程爆炸 。当我们要求系统去做比 CPU 能够做的工作 更多的工作时就会发生这种情况。如果我们要显示一定数量的图像,比如同时显示 6 张或者 8 张图片,但是我们在只有两个 CPU 的设备上运行,我们不能一次完成所有的工作,因为我们无法在不存在的 CPU 上进行并行处理。 避免向一个全局队列中异步的分配任务时发生死锁, GCD 将创建新线程来捕捉我们要求它所做的工作,然后 CPU 将花费大量时间在这些线程之间进行切换,尝试在所有工作上取得 我们要求操作系统为我们做的 渐进式进展。来回切换,实际上是相当大的开销(要减少)。如果一个或者多个 CPU 有机会能一次处理完图片,效果会更好。 所以,我们现在创建串行队列,异步将工作分派给该队列。

let serialQueue = DispatchQueue(label: "Decode queue")
func collectionView(_collectionView: UICollectionView,
                    prefetchItemsAt indexPaths: [IndexPath]) {
    // Asynchronously decode and downsample every image we are about to show
    for indexPath in indexPaths {
        serialQueue.async {
            let downsampledImage = downsample(images[indexPath.row])
            DispatchQueue.main.async { self.update(at: indexPath, with: downsampledImage) }
        }
    }
}

YZfyQzM.png!web 图片来源有很多种,如下图所示,但是推荐用 Image assets ,有以下优点

  • Optimized name- and trait-based lookup
  • Smarter buffer caching
  • Per-device thinning
  • Vector artwork

Vector Artwork

从 iOS 11 开始, image assets 支持保存向量数据(Vecotr Data),它能在绘制比他本身大小不相等的情况下,避免模糊和抗锯齿。 向量数据的工作方式与我们之前看到的管道非常相似(看下图),只是这里不是一个解码阶段,而是一个栅格化(rasterize)阶段,它负责获取矢量数据,并将其转换为可复制到帧缓冲区的位图数据。

ymuAVfu.png!web

如果需要不同尺寸时,建议在 image assets 中放置不同尺寸的矢量数据,如果不这样处理的处理的话,会消耗更多的 CPU 资源。

Custom drawing with UIKit

不建议重写 draw 方法来实现自定义视图,因为会造成额外的内存开销。先看两者的绘制过程,如下图所示

VRbUfye.png!web

每个视图实际上都是依赖 Core animation 运行时的 CALayer 实现的,真正渲染的是 CALayer ,我们可以将渲染好的位图赋值给它的 contents 属性。

对于我们的 UIImageView 创建时,要求 图像创建解码图像缓冲区 ,然后,将解码后的图像交给 CALayer ,用作其所在图层上内容。

对于我们重写 draw 得到的自定义视图,它们的过程很相似,但略有不同。 layer 负责创建 Backing Store (即连接到 CALayer 的图像缓冲区),来保存我们 draw 方法的内容, view 执行 draw 函数并填充该图像缓冲区的内容,这些内容接着根据显示硬件的需要,被复制到帧缓冲区中。

开销大原因是, 我们这里使用的 Backing Store ,其大小与我们正在显示的 视图大小成正比 , 计算方法是 view.size*view.contentsScale

我们在 iOS12 中引入了一项新功能和优化,即 Backing Store 中元素的大小,实际上会动态增长,取决于你是否绘制任何有颜色的内容,以及该颜色的内容是否在标准色彩范围之内或之外。因此如果你使用扩展的 SRGB 颜色绘制广色域内容(wide color content), Backing Store 实际上会比 仅适用 0-1 范围内的颜色的 Backing Store 大。在之前的 iOS 版本中,你可以通过设置 CALayer 的内容格式属性(contents format property)来作为对 Core Animation 的一个提示,即“我知道我需要(或者不需要)在这个视图中支持广色域内容”。如果你这样做,实际将将会禁用 iOS12 中引入的优化。所以,得检查 layerWillDraw 的实现。但我们可以做的比仅仅 提示 除了是否需要一个支持广色域的 Backing Store 更好 ,我们实际上可以 减少 App 所需的 Backing Store 总量。

Reducing Backing Store Use

减少 Backing Store 使用的方法有以下几个:

  • Refactor larger views into subview hierarchies
  • Reduce or eliminate overrides of draw(_:)
  • Eliminate duplicate copies of image data
  • Use optimized view properties and subclasses

说第一条和第二条减少了 Backing Store 的总量,可以帮助我们实现第三和第四条。

Alternatives to custom drawing

  • Overriding draw(_:) opts into backing store
  • UIView.backgroundColor can render directly to frame buffer without a backing store
    • …except for pattern colors
    • Use UIImageView with tiling image instead

第一条,因为重写 draw 方法将需要创建一个 Backing Store 以与 CALayer 一起使用。但是 View 的一些属性,即使你不重写 draw 方法,仍然可以工作,比如 backgroundColor ,除了 pattern color 。所以,建议不要在 UIView 中使用 pattern color 作为 backgroundColor 。相反,用 UIImageView with tiling image 替代。

Masking versus corner radius

  • UIView.maskView and CALayer.maskLayer render view hierarchy into temporary image buffer
  • CALayer.cornerRadius does not require any image buffer
  • Consider UIImageView with resizable image instead of masking for transparent backgrounds

corner 建议用 CALayer.cornerRadius 。因为 Core Animation 能够渲染圆角,而不需要额外的内存分配。用 maskView 或者 maskLayer ,需要额外分配内存来存储它们。 如果有更加复杂透明区域的背景,不能用 cornerRadius 完成,应该考虑用 UIImageView ,将这些信息存储在 asset catalog 中或者运行时渲染它,然后作为图像提供给 UIImageView ,而不是用 maskView 或者 maskLayer 。

Eliminating duplicate image data

  • UIImageView can colorize monochrome images while rendering directly into frame buffer
  • UIImage.withRenderingMode(_:) or Rendering Mode popup in asset inspector
  • Set tintColor of image view to any solid color

UIImageView 能够对单色图稿(monochrome artwork)着色,而不需要额外的内存分配。 在 asset 中设置渲染模式属性为 always template 或者代码 UIImage.withRenderingMode 方法实现。 然后将 Image 赋值给 UIImageView ,再设置 UIImageView 的 tintColor 为你想要的颜色。 在 UIImage 将图像渲染到帧缓冲区的过程中,它会在复制操作中使用纯色,而不需要持有一个有纯色图像的 单独副本

UILabel optimizations for rendering text

  • UILabel is optimized for monochrome strings
  • Uses 75% smaller backing store when possible
  • Automatically upgrades to larger backing store for multicolor strings, emoji

推荐用 UILabel 。

Drawing Off-Screen

当我们为了离屏渲染,要创建 image buffer 时,我们通常会使用 UIGraphicsBeginImageContext ,但是最好还是用 UIGraphicsImageRenderer ,它的性能更好、更高效,并且支持广色域。自动支持广色域的方式跟前面说的 Vector Artwork 相似,这里有一个中间地带,如果你主要将图像渲染到图形图像渲染器(graphic image render)中,该图像可能使用超出 SRGB 色域的色彩空间值,但实际上 并不需要更大 的元素来存储这些信息。所以 UIImage 有一个可以用来获取预构建的 UIGraphicsImageRendererFormat 对象的 image renderer format 属性,该对象用于重新渲染图像时进行最优化存储。

Advanced CPU and GPU techniques

Advanced Image Effects

图片效果推荐用 Core Image

  • Consider Core Image for realtime effects
  • Executes on GPU, freeing up CPU
  • UIImageView renders CIImages efficiently UIImage.init(ciImage:)

Advanced Image Processing

  • Use CVPixelBuffer to move data to frameworks like Metal, Vision, and Accelerate
  • Use the best initializer—don’t unwind work that’s already been done
  • Guard against moving work between GPU and CPU
  • Ensure buffers are correct format for Accelerate

CVPixelBuffer 是 Metal Vision Accelerate 这些框架中常见的数据类型之一,它用来表示在 CPU 或 GPU 上正在使用或者尚未使用的 缓冲区。

不要展开任何解码操作,这些工作已经由现有的 UIImage 或者 CGImage 实现完成。

Summary

  • Implement prefetching to prepare asynchronously
  • Reduce backing store usage by using UIImageView and UILabel
  • Don’t accidentally disable new optimizations for custom drawing (这是 iOS12 引入的新特性)
  • Prefer image assets for bundled artwork
  • Avoid over-reliance on Preserve Vector Data (提供不同尺寸的矢量数据,而不要让一个矢量数据支撑多个尺寸)

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK