[SwiftUI 100 天]集成 Core Image 到 SwiftUI

2,114 阅读8分钟

译自 www.hackingwithswift.com/books/ios-s…

更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀

正如 Core Data 是 Apple 内建的操作数据的框架,Core Image 是操作图片的框架。它不是关于绘制,至少其中的大部分不是关于绘制,而是关于修改现有的图片:应用锐化,模糊,暗角,像素化,等等。如果你曾经使用过 Apple 的 Photo Booth 应用里的各种效果,那你对于 Core Image 擅长的东西应该就比较有概念了。

不过,Core Image 并没有很好地集成到 SwiftUI,实际上,即便是 UIKit,也不算集成地很完善 —— Apple 确实提供了一些辅助,但还是挺费心思的。但请你跟随我的步伐:一旦你理解它的工作机制,你会发现从此就打开了一扇新世界的大门。

首先,我们要放一些基础的代码,显示一张图片。我会以一种稍微有点奇怪的方式构筑它,但对于 Core Image 将是合理的:我们要以可选的 @State 属性来创建图像,强制它铺满屏幕宽度,并且在 onAppear() modifier 里实际加载图像。

添加一个示例图片到你的 asset catalog,然后把 ContentView 修改成这样:

struct ContentView: View {
    @State private var image: Image?

    var body: some View {
        VStack {
            image?
                .resizable()
                .scaledToFit()
        }
        .onAppear(perform: loadImage)
    }

    func loadImage() {
        image = Image("Example")
    }
}

首先,留意一下 SwiftUI 是如何处理可选视图的 —— 它竟然能工作!但是你注意到没,我是在 VStackonAppear() modifier 里加载图片,而不是 image,这是因为如果可选图像本身是 nil,就无法触发 onAppear() 函数了。

不管怎么说,当代码运行起来后,应该要显示你放的示例图片,并且整齐地缩放成铺满屏幕宽度的状态。

然后是复杂的部分:一个 Image? 本质上是什么。正如你知道的,它是一个 视图,意味着我们可以在 SwiftUI 视图层级里放置它。它也处理从我们的 asset catalog 和 SF Symbols 加载图片的事情,同时也能够从许多其他资源里加载。但是,最终它是一种用于显示的东西 —— 我们不能把它的内容写进磁盘,或者给它应用滤镜。

假如要使用 Core Image,SwiftUI 的 Image 视图是一个很好的终点,但并不能在各个环节都发挥作用,包括动态地创建图像,应用 Core Image 滤镜,保存到用户相册,等等。SwiftUI 的图像对这些是无能为力的。

Apple 给了我们三种图像类型,在我们使用 Core Image 时,需要巧妙地选择。它们听起来很相似,但有微妙的差异,正确使用它们很重要。

除了 SwiftUI 的 Image 视图,还有三种其他类型的图像:

  • UIImage,来自 UIKit。这是能够处理各种图片的强大的图像类型,包括像位图(比如 PNG),向量(比如 SVG),甚至形成动画的序列帧。UIImage 是 UIKit 的标准图像类型,也是三种之中最接近 SwiftUI 的 Image 类型的图像。
  • CGImage,来自 Core Graphics。这是一种更简单的图像类型,只有二维的像素数组。
  • CIImage,来自 Core Image。它存储了用于产生图像的所有信息,但只有在被请求时才实际转换成像素。Apple 称 CIImage 为“图像菜谱”,而非图像本身。

三种图像之间的互操作性:

  • 我们可以从 CGImage 中创建 UIImage,也可以从 UIImage 中创建 CGImage
  • 我们可以从 UIImage 或者 CGImage 中创建 CIImage,也可以从 CIImage 中创建 CGImage
  • SwiftUI 的 Image 既可以通过 UIImage 创建,也可以通过 CGImage 创建。

是不是有点绕晕了?希望你看到代码的时候感觉好一点。其中的关键在于,这些图像类型都是纯数据 —— 我们无法将它们直接放置到 SwiftUI 的视图层级中,但我们可以自由地维护这些图像,然后以 SwiftUI 的 Image 来呈现。

我们将要修改 loadImage() 以便从示例图片从创建出 UIImage,然后用 Core Image 维护它。具体地,包含两个任务:

  1. 我们需要将示例图片载入 UIImage,它有一个叫 UIImage(named:) 的构造器,可以从我们的 asset catalog 中加载图像。由于图像可能不存在,所以它返回的是 UIImage 可选型。
  2. 我们将转换成 CIImage,以便使用 Core Image 处理。

现在,把 loadImage() 实现替换成下面这样:

func loadImage() {
    guard let inputImage = UIImage(named: "Example") else { return }
    let beginImage = CIImage(image: inputImage)

    // more code to come
}

下一步是创建 Core Image 上下文和 Core Image 滤镜。滤镜负责实际变换图像数据工作的东西,比如模糊图像,锐化图像,调整颜色,等等,而上下文则负责将处理好的数据转成 CGImage,以便我们能使用。

下面两个数据类型都来自 Core Image,因此你需要添加两条 import:

import CoreImage
import CoreImage.CIFilterBuiltins

对于这个例子,我们要使用墨色调滤镜,它会应用一个墨色调到照片上,让照片看起来就像老照片。

// more code to come 注释替换成:

let context = CIContext()
let currentFilter = CIFilter.sepiaTone()

我们可以自定义滤镜的参数,墨色调滤镜相对简单,只有两个属性:inputImage 是我们要改变的图像, 而 intensity 是墨色效果要应用的强度,范围从 0 (原始图像)到 1(完全的墨色调)。

把下面两行代码添加到之前的两行代码之后:

currentFilter.inputImage = beginImage
currentFilter.intensity = 1

所有这些都不难,不过这里就要转折了:我们需要把滤镜的输出转成一个 SwiftUI Image,以用于视图的显示。

  • 从滤镜中读取输出文件,它是一个 CIImage,因为可能失败,所以返回可选型。
  • 让上下文基于输出图像创建一个 CGImage,这同样可能失败,所以也是返回可选型。
  • CGImage 生成 UIImage
  • 再把 UIImage 变成 SwiftUI Image

你可以直接从 CGImage 得到 SwiftUI Image,但需要传入额外的参数,这样只会更复杂。

loadImage() 的最终代码如下:

// get a CIImage from our filter or exit if that fails
guard let outputImage = currentFilter.outputImage else { return }

// attempt to get a CGImage from our CIImage
if let cgimg = context.createCGImage(outputImage, from: outputImage.extent) {
    // convert that to a UIImage
    let uiImage = UIImage(cgImage: cgimg)

    // and convert that to a SwiftUI image
    image = Image(uiImage: uiImage)
}

再次运行应用,你会看到一张看起来像老照片的示例图片,这都要归功于 Core Image。

现在,你不免会开始想,我只要这么简单的一个结果,怎么需要这么多的工作?但想想看,上面的代码基本上了覆盖了 Core Image 的常规操作,相对来说,你要切换不同的滤镜效果是很简单的。

前面提到过,Core Image 有一点… 怎么说呢?… 不如说是 “创造性” 吧。它在 iOS 5.0 就引入了,彼时 Swift 已经在苹果内部开发了,但你不会想知道 —— 长期以来, Core Image 的 API 对 Swift 难以想象地不友好。尽管苹果目前已经逐步擦除其中的遗留 API,但 Core Image 仍有一些地方的行为比较怪异。

让我们举例说明吧,把墨色调滤镜替换成像素风滤镜:

let currentFilter = CIFilter.pixellate()
currentFilter.inputImage = beginImage
currentFilter.scale = 100

运行代码,你会发现图像看起来就像棋盘格一样。100 的 scale 表示跨度是 100 个点,但由于我的图像很大,像素相对来很小。

现在,让我们尝试一下水晶效果:

let currentFilter = CIFilter.crystallize()
currentFilter.inputImage = beginImage
currentFilter.radius = 200

代码运行起来我们本应该看到一个水晶效果,但实际上代码会崩溃。我们的代码是合法的 Swift 和 Core Image 代码,但就是无法工作。

这其实是一个 bug,在你阅读本文时可能已经修复了,假如我们切换到一个更老的 API,像下面这样:

let currentFilter = CIFilter.crystallize()
currentFilter.setValue(beginImage, forKey: kCIInputImageKey)
currentFilter.radius = 200

kCIInputImageKey 是一个指定滤镜要处理的图片的常量,深挖进去你会发现它实际上是一个字符串 —— Core Image 在幕后其实一套基于字符串的 API。

当你发现只有部分 Apple 的 Core Image 滤镜是很好地适配了 Swift API 这个事实时,就会发现我提出的问题愈发明显了。举个例子,假如你想要一个旋转扭曲的滤镜效果,你只能使用老版的 API,这就有点痛苦了:

  1. 用滤镜名创建 CIFilter 实例
  2. 重复多次调用 setValue(),每次用不同的 key。
  3. 因为 CIFilter 不是一个特定的滤镜,Swift 允许我们传入这个滤镜可能并不支持的参数。

举个例子,使用旋转扭曲滤镜的代码如下:

guard let currentFilter = CIFilter(name: "CITwirlDistortion") else { return }
currentFilter.setValue(beginImage, forKey: kCIInputImageKey)
currentFilter.setValue(2000, forKey: kCIInputRadiusKey)
currentFilter.setValue(CIVector(x: inputImage.size.width / 2, y: inputImage.size.height / 2), forKey: kCIInputCenterKey)

提示: CIVector 是 Core Image 中存储点和方向的类型。

运行代码,你会看到结果还是不赖的,希望 Apple 在未来的时间里继续清理 API。

尽管新的 API 更易用,为了能够使用任意种类的滤镜,我们在这个项目中主要会使用老 API。


我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~