图像渲染的一些方法

“你是怎样对一张图片进行渲染的?”

作者:Mattt,原文链接,原文日期:2019-05-06
译者:Perphet

引言

长期以来,iOS开发者都一直被一个奇怪的问题困扰着:

“你是怎么渲染图片的?”

这个令人困扰的问题是由于开发者和平台互相不信任导致的,Stack Overflow 充满了各种各样的代码例子,而且他们每个人都声称自己的方法是 One True Solution™ — 只有我是对的,其他的都是错的。

在本周的文章中,我们将介绍5种不同的iOS图像渲染技巧(在macOS上合适的 UIImage -> NSImage )。相比于每一种情况都固定一个方法,我们将人体工程学与性能基准进行权衡,以更好的了解何时将一种方法用于另一种情况。

你可以通过下载、编译、运行这些项目代码示例来尝试每一种进行图像渲染的方法。

下载

图像渲染的时机和理由

在一切之前,我们先讨论一下为什么需要对图像进行渲染。毕竟 UIImageView 会自动根据 contentmode 切割、缩放图片。在绝大多数情况下,.scaleAspectFit 或者 .scaleAspectFill.scaleToFIll 已经能够满足你的需要。

1
2
imageView.contentMode = .scaleAspectFit
imageView.image = image

所以什么时候我们应该渲染一张图片呢?

当它明显大于 UIImageView 显示尺寸的时候

考虑这张令人赞叹的地球照片,来自NASA视觉地球相册集锦。

原图点击链接查看,放出的图为压缩后的图。

image-resizing-earth-5eaad58ee8c9b4f79595ef7271d19afa50f2240f128465746b3c930c1d420524.jpg

想要完整渲染这张宽高为 12,000 px 的图片,需要高达 20 MB 的空间。你可能会觉得当今的硬件水平不会在意这么少兆字节的占用,但那只是它压缩后的尺寸。为了展示它,UIImageView 首先需要把 JPEG 数据解码成位图 ,如果要在 UIImageView 上原样设置这张完整的图片,你的应用内存占用将会激增到几百兆,很明显这对用户没有什么用(毕竟,屏幕能显示的像素有限)。但只要在设置 UIImageViewimage 属性之前,将图像渲染的尺寸调整成 UIImageView 的大小,你用到的内存就会少一个数量级:

内存消耗 (MB)
无向下采样 220.2
向下采样 23.7

这个技巧就是众所周知的 向下采样,在这些情况下,它可以有效地优化你应用的性能表现。如果你对更多关于向下采样的知识或者其它图形图像的最佳实践感兴趣,请参照 WWDC 2018 的精彩课程

现在,很少有应用程序会尝试一次性加载这么大的图像了…但是也跟我从设计师那里拿到的资源不会差多。(认真的吗,一张颜色渐变的 PNG 图片要 3 MB?) 考虑到这一点,让我们来看看有什么不同的方法,可以让你用来对图像进行优化或者下采样。

本可以不提,但这里所有从 URL 加载的示例图像都是针对本地文件。记住,在应用的主线程同步使用网络请求图像绝不是什么好主意。

图像渲染技巧

优化图像渲染的方法有很多种,每种都有不同的功能和性能特性。我们在本文看到的这些例子,架构层次跨度上从底层的 Core GraphicsvImageImage I/O 到上层的 Core ImageUIKit 都有。

  1. 绘制到 UIGraphicsImageRenderer
  2. 绘制到 Core Graphics Context 上
  3. 使用 Image I/O 创建缩略图像
  4. 使用 Core Image 进行 Lanczos 重采样
  5. 使用 vImage 优化图片渲染

为了统一,以下的每种技术共用一个公共接口:

1
2
func resizeImage(at url: URL, for size: CGSize) -> UIImage? { … }
iamgeView.iamge = resizedImage(at: url, for: size)

这里 size 的计量单位是 point 而不是pixel , 想要计算出你调整大小后图像的等效尺寸,用主 UIScreenscale,等比例放大你 UIImageViewsize 大小:

如果你是在进行异步加载一张大图,使用过渡渐变动画展示图片,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ViewController: UIViewController {
@IBOutlet var imageView: UIImageView!
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let url = Bundle.main.url(forResource: "Blue Marble West",
withExtension: "tiff")!
DispatchQueue.global(qos: .userInitiated).async {
let image = resizedImage(at: url, for:
self.imageView.bounds.size)
DispatchQueue.main.sync {
UIView.transition(with: self.imageView,
duration: 1.0,
options: [.curveEaseOut,
.transitionCrossDissolve],
animations: {
self.imageView.image = image
})
}
}
}
}

技巧 #1: 绘制到 UIGraphicsImageRenderer

图像渲染最上层 API 在 UIKit 框架中。给定一个 UIImage,你可以绘制到 UIGraphicsImageRenderercontext 中以渲染缩小版本的图像:

1
2
3
4
5
6
7
8
9
10
11
12
import UIKit
// 技巧 #1
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
guard let image = UIImage(contentsOfFile: url.path) else {
return nil
}
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { (context) in
image.draw(in: CGRect(origin: .zero, size: size))
}
}

UIGraphicsImageRenderer 是一项相对来说比较新的一种技术,在 iOS 10 中被引入,以取代旧版本的 UIGraphicsBeginImageContextWithOptions / UIGraphicsEndImageContext API。你通过指定以 point 计量的 size 创建了一个 UIGraphicsImageRendererimage 方法带有一个闭包参数,返回的是一个经过闭包处理后的位图。如此之后,原始图像便会在缩小到指定的范围内绘制。

在不改变图像原始比例的情况下,缩小图像原始的尺寸来显示通常很有用。AVMakeRect(aspectRatio:insideRect:) 是在 AVFoundation 框架中很方便的一个函数,负责帮你做如下的计算:

1
2
3
4
> import func AVFoundation.AVMakeRect
> let rect = AVMakeRect(aspectRatio: image.size, insideRect:
> imageView.bounds)
>

技巧 #2:绘制到 Core Graphics Context 中

Core Graphics / Quartz 2D 提供了一系列底层的 API 配置以让我们可以进行更多高级的设置。

给定一个 CGImage 作为暂时的位图 context ,使用 draw(_:in:) 方法来绘制缩放后的图像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import UIKit
import CoreGraphics
// 技巧 #2
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
else {
return nil
}
let context = CGContext(data: nil,
width: Int(size.width),
height: Int(size.height),
bitsPerComponent: image.bitsPerComponent,
bytesPerRow: image.bytesPerRow,
space: image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: image.bitmapInfo.rawValue)
context?.interpolationQuality = .high
context?.draw(image, in: CGRect(origin: .zero, size: size))
guard let scaledImage = context?.makeImage() else { return nil }
return UIImage(cgImage: scaledImage)
}

这个 CGContext 初始化方法接收了几个参数来构造一个上下文,包括了必要的宽高参数,还有在给出的色域范围内每个颜色通道所需要的内存大小。在这个例子中,这些参数都是通过 CGImage 对象获取的。下一步,设置 interpolationQuality 属性为 .high 指示 context 在👌(还不错)的精度上填充像素。draw(_:in:) 方法则是在给定的宽高和位置绘制图像,可以让图片在特定的边距下裁剪,也可以适用于一些图像特性,比如人脸。最后 makeImage()context 获取信息并且渲染到一个 CGImage 值上(这个之后会被用来构造 UIImage 对象)。

技巧 #3:使用 Image I/O 创建缩略图像

Image I/O 是一个强大但却鲜为人知的一个图像处理框架。除了 Core Graphics 以外,它可以读写许多不同图像格式,访问图像的元数据,还提供常用的图像处理方式。这个框架通过先进的缓存机制,提供了平台上最快的图片编码器和解码器 — 甚至可以增量加载图片。

这个重要的 CGImageSourceCreateThumbnailAtIndex 提供了一个带有许多不同配置选项的 API,比起在 Core Graphics 中进行等价的处理,前者的操作要简洁得多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import ImageIO
// 技巧 #3
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height)
]
guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary)
else {
return nil
}
return UIImage(cgImage: image)
}

给定一个 CGImageSource 和一组配置选项, CGImageSourceCreateThumbnailAtIndex(_:_:_:) 函数创建了一个图像的缩略图。优化尺寸大小的操作是通过 kCGImageSourceThumbnailMaxPixelSize 完成的,它根据图像原始宽高比指定的最大尺寸来缩放图像。通过设定 kCGImageSourceCreateThumbnailFromImageIfAbsentkCGImageSourceCreateThumbnailFromImageAlways 选项,Image I/O 可以自动缓存优化后的结果以便后续的调用。

技巧 #4:使用 Core Image 进行 Lanczos 重采样

Core Image 通过同名的 CILanczosScaleTransform 过滤器提供了内置的 Lanczos 重采样。尽管可以说它是在 UIKit 级别之上的 API,但在 Core Image 自中无处不在的 key-value 编写方式导致它使用起来很不方便。

即便如此,至少它们的处理模式还是一致的。

创建转换过滤器,对其进行配置以及渲染输出图像的过程与任何其他 Core Image 工作流程没有什么不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import UIKit
import CoreImage
let sharedContext = CIContext(options: [.useSoftwareRenderer : false])
// 技巧 #4
func resizedImage(at url: URL, scale: CGFloat, aspectRatio: CGFloat) -> UIImage? {
guard let image = CIImage(contentsOf: url) else {
return nil
}
let filter = CIFilter(name: "CILanczosScaleTransform")
filter?.setValue(image, forKey: kCIInputImageKey)
filter?.setValue(scale, forKey: kCIInputScaleKey)
filter?.setValue(aspectRatio, forKey: kCIInputAspectRatioKey)
guard let outputCIImage = filter?.outputImage,
let outputCGImage = sharedContext.createCGImage(outputCIImage,
from: outputCIImage.extent)
else {
return nil
}
return UIImage(cgImage: outputCGImage)
}

这个名为 CILanczosScaleTransform 的 Core Image 过滤器分别接收 inputImageinputScaleinputAspectRatio 三个参数,每一个参数的意思懂的都懂。

更有趣的是,此处使用 CIContext 来创建一个 UIImage(通过 CGImageRef 间接表示),因为 UIImage(CIImage:) 经常无法按照预期工作。创建 CIContext 是一个代价极为昂贵的操作,所以使用 context 缓存以便重复的渲染工作。

可以使用 GPU 或者 CPU(会慢很多)创建 CIContext 进行渲染 。通过在初始化程序中指定 .useSoftwareRenderer 选项来选择使用哪个硬件。(提示:用更快的那个,对吧?)

技巧 #5: 使用 vImage 优化图片渲染

最后一个了,它是古老的 Accelerate 框架 —— 更具体点来说,它是 vImage 的图像处理子框架。

vImage 附带有 一些不同的功能,可以用来裁剪图像缓冲区大小。这些底层 API 保证了高性能、低能耗,但是缺以你自己管理缓冲区为代价的(更不用说要编写更多的代码了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import UIKit
import Accelerate.vImage
// 技巧 #5
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
// 解码原图像
guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let imageWidth = properties[kCGImagePropertyPixelWidth] as? vImagePixelCount,
let imageHeight = properties[kCGImagePropertyPixelHeight] as? vImagePixelCount
else {
return nil
}
// 定义图像格式
var format = vImage_CGImageFormat(bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: nil,
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.first.rawValue),
version: 0,
decode: nil,
renderingIntent: .defaultIntent)
var error: vImage_Error
// 创建并初始化源缓冲区
var sourceBuffer = vImage_Buffer()
defer { sourceBuffer.data.deallocate() }
error = vImageBuffer_InitWithCGImage(&sourceBuffer,
&format,
nil,
image,
vImage_Flags(kvImageNoFlags))
guard error == kvImageNoError else { return nil }
// 创建并初始化目标缓冲区
var destinationBuffer = vImage_Buffer()
error = vImageBuffer_Init(&destinationBuffer,
vImagePixelCount(size.height),
vImagePixelCount(size.width),
format.bitsPerPixel,
vImage_Flags(kvImageNoFlags))
guard error == kvImageNoError else { return nil }
// 优化缩放图像
error = vImageScale_ARGB8888(&sourceBuffer,
&destinationBuffer,
nil,
vImage_Flags(kvImageHighQualityResampling))
guard error == kvImageNoError else { return nil }
// 从目标缓冲区创建一个 CGImage 对象
guard let resizedImage =
vImageCreateCGImageFromBuffer(&destinationBuffer,
&format,
nil,
nil,
vImage_Flags(kvImageNoAllocate),
&error)?.takeRetainedValue(),
error == kvImageNoError
else {
return nil
}
return UIImage(cgImage: resizedImage)

显然,此处使用的 Accelerate API 比到目前为止讨论的任何其他大小调整方法都要底层得多。但放下这些不友好的类型申明和函数名称不谈的话,你会发现这个方法相当直接了当。

  • 首先,根据输入图像创建一个源缓冲区,
  • 接着,创建目标缓冲区来接收优化后的图像,
  • 然后,在源缓冲区裁剪图像数据,然后传给目标缓冲区,
  • 最后,从目标缓冲区中根据已处理的图像创建 UIImage 对象。

性能基准测试

那么这些不同的方法如何相互对比又如何呢?

以下是这个项目中在运行 iOS 12.2 的 iPhone 7 上执行的一些性能基准测试的结果。

image-resizing-app-screenshot-02998a420a75691f6b5c8de44ba24d6119853776bd78bb9e1bfa3a36cdd7d48d.png

下面的这些数字是多次迭代加载、优化、渲染之前那张 超大地球图片 的平均时间:

方法 耗时 (seconds)
技巧 #1: UIKit 0.1420
技巧 #2: Core Graphics 1 0.1722
技巧 #3: Image I/O 0.1616
技巧 #4: Core Image 2 2.4983
技巧 #5: vImage 2.3126
  1. 设置不同的 CGInterpolationQuality 值出来的结果是一致的,在性能基准方面的差异可以忽略不计。

  2. 若在创建 CIContext 时将 kCIContextUseSoftwareRenderer 的值设置为 true,会导致耗时相比基础结果慢一个数量级。

结论

  • UIKit, Core Graphics, 和 Image I/O 在大多数图像上优化的表现都很不错。如果必须要在(至少在 iOS 平台)选择一个的话,UIGraphicsImageRenderer 是你最佳的选择。
  • Core Image 在图像优化渲染操作方面性能表现卓越。实际上,根据《Core Image 编程指南》中 Apple 的 “性能最佳实践” 部分,你应该使用 Core Graphics 或 Image I/O 对图像进行裁剪和向下采样,而不是使用 Core Image。
  • 除非你已经在使用 vImage,否则在大多数情况下用到底层的 Accelerate API 所需的额外工作可能是不合理的。

Title: 图像渲染的一些方法

Author: Tuski

Published: 12/26/2019 - 09:08:59

Updated: 12/26/2019 - 16:27:18

Link: http://www.perphet.com/2019/12/图像渲染的一些方法/

Protocol: Attribution-NonCommercial-NoDerivatives 4.0 International (CC BY-NC-ND 4.0) Reprinted please keep the original link and author

Thx F Sup