图像渲染优化

文章目录
  1. 1. 引用
  2. 2. 图像渲染流程
    1. 2.1. 尺寸问题
    2. 2.2. 色彩空间
    3. 2.3. 缩小图片 vs 向下采样

引用

Optimizing Images 翻译

WWDC 2018 session 219 Image and Graphics Best Practices

图像渲染流程

  1. 加载: iOS 获取压缩的图像并加载到 266KB 的内存。这一步没啥问题。
    1. image = UIImage.image("name")
  2. 解码: 这时,iOS 获取图像并通过 CPU 转换成 GPU 能读取和理解的方式。这里会解压图片,像上面提到那样产生 bitmap 占用 14MB。(1718 * 2048 * 4 / 1000000 = 14.07 MB)
  3. 渲染: 图像数据已经准备好以任意方式渲染。即使只是在一个 60x60pt 的 image view 中。

解码阶段:是消耗最大的。iOS 会创建一块缓冲区 - 具体来说是一块图像缓冲区,也就是图像在内存中的表示。这解释了为啥内存占用大小和图像尺寸有关,而不是文件大小。因此也可以理解,为什么在处理图片时,尺寸如此重要。

具体到 UIImage,当我们传入从网络或者其它来源读取的图像数据时,它会将数据解码到缓冲区,但不会考虑数据的编码方式(比如 PNG 或者 JPG)。然而,缓冲区实际上会保存到 UIImage 中。由于渲染不是一瞬间的操作,UIImage 会执行一次解码操作,然后一直保留 bitmap 图像缓冲区。

接着往下说 - 任何 iOS 应用中都有一整块的帧缓冲区。它会保存内容的渲染结果,也就是你在屏幕上看到的东西。每个 iOS 设备负责显示的硬件都用这里面单个像素信息逐个点亮物理屏幕上合适的像素点。

处理速度非常重要。为了达到黄油般顺滑的每秒 60 帧滑动,在信息发生变化时(比如给一个 image view 赋值一幅图像),帧缓冲区需要让 UIKit 渲染 app 的 window 以及它里面所有层级的子视图。一旦延迟,就会丢帧。

尺寸问题

我们可以很简单地将这个过程和内存的消耗可视化。我创建了一个简单的应用,可以在一个 image view 上展示需要的图像:
image 解码后渲染到 imageView 中无论imageView 尺寸多少,耗费的内存大小是一样的

色彩空间

我们的计算基于以下假设 - 图像使用 sRGB 格式,但大部分 iPhone 不符合这种情况。
你用支持宽色域的设备进行拍摄(比如 iPhone 8+ 或 iPhone X),那么内存消耗将变成两倍,反之亦然。Metal 会用仅有一个 8 位透明通道的 Alpha 8 格式。

这里有很多可以把控和值得思考的地方。这也是为什么你应该用 UIGraphicsImageRenderer 代替 UIGraphicsBeginImageContextWithOptions 的原因之一。后者总是会使用 sRGB,因此无法使用宽色域,也无法在不需要的时候节省空间。在 iOS 12 中,UIGraphicsImageRenderer 会为你做正确的选择。

不要忘了,很多图像并不是真正的摄影作品,只是一些绘图操作。如果你错过了我最近的文章,可以再阅读一遍下面的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
let circleSize = CGSize(width: 60, height: 60)

UIGraphicsBeginImageContextWithOptions(circleSize, true, 0)

// Draw a circle
let ctx = UIGraphicsGetCurrentContext()!
UIColor.red.setFill()
ctx.setFillColor(UIColor.red.cgColor)
ctx.addEllipse(in: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))
ctx.drawPath(using: .fill)

let circleImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

上面的圆形图像用的是每个像素 4 个字节的格式。如果换用 UIGraphicsImageRenderer,通过渲染器自动选择正确的格式,让每个像素使用 1 个字节,可以节省高达 75% 的内存:

1
2
3
4
5
6
7
8
9
let circleSize = CGSize(width: 60, height: 60)
let renderer = UIGraphicsImageRenderer(bounds: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))

let circleImage = renderer.image{ ctx in
UIColor.red.setFill()
ctx.cgContext.setFillColor(UIColor.red.cgColor)
ctx.cgContext.addEllipse(in: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))
ctx.cgContext.drawPath(using: .fill)
}

缩小图片 vs 向下采样

有些人可能会假设(并且确实相信)通过 UIImage 简单地缩小图片就够了。但我们前面已经解释过,缩小尺寸并不管用。而且根据 Apple 工程师 kyle Howarth 的说法,由于内部坐标转换的原因,缩小图片的优化效果并不太好。

UIImage 导致性能问题的根本原因,我们在渲染流程里已经讲过,它会解压原始图像到内存中。理想情况下,我们需要一个方法来减少图像缓冲区的尺寸。

庆幸的是,我们可以修改图像尺寸,来减少内存占用。很多人以为图像会自动执行这类优化,但实际上并没有。

让我们尝试用底层的 API 来对它进行向下采样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let imageSource = CGImageSourceCreateWithURL(url, nil)!
// 配置下采样
let options: [NSString:Any] = [kCGImageSourceThumbnailMaxPixelSize:400,
kCGImageSourceCreateThumbnailFromImageAlways:true]

if let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) {
let imageView = UIImageView(image: UIImage(cgImage: scaledImage))

imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.widthAnchor.constraint(equalToConstant: 300).isActive = true
imageView.heightAnchor.constraint(equalToConstant: 400).isActive = true

view.addSubview(imageView)
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
}