Skip to content

第63天 项目 13 第二部分

今天,我们继续探讨项目所使用的技术,并且开始涉足一些 SwiftUI 用起来不那么顺手的领域。今天你将看到 Core Image 如何与 SwiftUI 集成,答案是“集成得并不好”。我们还将开始研究 UIKit 如何与 SwiftUI 集成,同样,答案也不尽如人意——我们需要付出相当多的努力,才能把 UIKit 这个“圆榫头”塞进 SwiftUI 这个“方榫眼”里。

我是否希望在这方面看到更好的解决方案?当然希望——或许在未来 SwiftUI 的更新中会实现。不过有句不知名的谚语我觉得很适合这里:“别让你渴望的东西,让你忘记你已拥有的东西。”

诚然,目前 SwiftUI 与其他框架的集成还不太稳定,但这并不意味着它会掩盖 SwiftUI 在其他方面为我们所做的出色工作。

今天你只需学习两个主题,通过这些主题,你将学会如何使用 Core Image 处理图像,以及如何处理应用中的缺失内容。

  • Core Image 与 SwiftUI 的集成
  • 使用 ContentUnavailableView 显示空状态

如果有空闲时间,可以尝试用 Core Image 做些实验,看看能做出什么效果——截图分享总是很有趣的!

Core Image 与 SwiftUI 的集成

保罗·哈德森 2023年12月8日

Core Image 是苹果用于处理图像的框架。这并非“绘图”(至少大部分情况下不是),而是对现有图像进行修改:比如应用锐化、模糊、晕影、像素化等效果。如果你用过苹果 Photo Booth 应用中的各种照片特效,就能很好地理解 Core Image 的用途了!

然而,Core Image 与 SwiftUI 的集成效果并不好。事实上,我甚至认为它与苹果较旧的 UIKit 框架集成得也不算好——尽管苹果做了一些工作提供辅助工具,但仍需要花费不少心思去琢磨。不过,请坚持下去:一旦理解了其中的原理,你会发现结果非常出色,而且未来这将为你的应用开启一系列全新的功能。

首先,我们要编写一些代码来创建一个基础图像。我会用一种略显特别的结构来编写,不过当我们融入 Core Image 后,你就会明白这样做的原因:我们将把 Image 视图创建为一个可选的 @State 属性,让它调整大小以适应屏幕尺寸,然后添加 onAppear() 修饰符来实际加载图像。

在资源目录中添加一个示例图像,然后将 ContentView 结构体修改为以下代码:

swift
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 处理可选视图的方式非常流畅——它就是能正常工作!不过要注意,我将 onAppear() 修饰符附加到了图像周围的 VStack 上,因为如果可选图像为 nil,它就不会触发 onAppear() 函数。

无论如何,运行这段代码后,应该会显示你添加的示例图像,并且图像会巧妙地缩放以适应屏幕。

现在来看复杂的部分:Image 究竟是什么?正如你所知,它是一个“视图”,这意味着我们可以在 SwiftUI 视图层级结构中对其进行定位和调整大小。它还能处理从资源目录和 SF Symbols 加载图像的操作,也能从其他一些来源加载图像。然而,归根结底,它只是一个用于显示的元素——我们无法将其内容写入磁盘,除了应用一些简单的 SwiftUI 滤镜外,也无法对其进行其他转换。

如果我们想使用 Core Image,SwiftUI 的 Image 视图是一个很好的最终展示载体,但在其他环节中并不实用。也就是说,如果我们想动态创建图像、应用 Core Image 滤镜等,SwiftUI 的图像类型是无法胜任的。

苹果为我们提供了另外三种图像类型,巧妙的是,如果要使用 Core Image,这三种类型我们都需要用到。它们听起来可能有些相似,但彼此之间存在细微差别,要想从 Core Image 中获得有意义的结果,正确使用它们至关重要。

除了 SwiftUI 的 Image 视图外,另外三种图像类型分别是:

  • UIImage:来自 UIKit。这是一种功能极其强大的图像类型,能够处理多种图像格式,包括位图(如 PNG)、矢量图(如 SVG),甚至是构成动画的序列帧。UIImage 是 UIKit 的标准图像类型,在这三种类型中,它与 SwiftUI 的 Image 类型最为接近。
  • CGImage:来自 Core Graphics。这是一种相对简单的图像类型,本质上就是一个二维像素数组。
  • CIImage:来自 Core Image。它存储生成图像所需的所有信息,但除非明确要求,否则不会将这些信息转换为像素。苹果将 CIImage 称为“图像配方”,而不是实际的图像。

这些不同的图像类型之间存在一定的互操作性:

  • 我们可以从 CGImage 创建 UIImage,也可以从 UIImage 创建 CGImage
  • 我们可以从 UIImageCGImage 创建 CIImage,也可以从 CIImage 创建 CGImage
  • 我们可以从 UIImageCGImage 创建 SwiftUI 的 Image

我知道这很令人困惑,但希望看到代码后你会感觉好一些。重要的是,这些图像类型都是纯粹的“数据”——我们不能将它们直接放入 SwiftUI 视图层级结构中,但可以自由地对其进行处理,然后将处理结果用 SwiftUI 的 Image 展示出来。

我们要修改 loadImage() 函数,使其从示例图像创建 UIImage,然后使用 Core Image 对其进行处理。更具体地说,我们首先要完成两项任务:

  1. 将示例图像加载到 UIImage 中,UIImage 有一个名为 UIImage(resource:) 的初始化器,可以从资源目录加载图像。
  2. UIImage 转换为 CIImage,因为 Core Image 需要使用这种类型进行操作。

因此,首先用以下代码替换当前的 loadImage() 实现:

swift
func loadImage() {
    let inputImage = UIImage(resource: .example)
    let beginImage = CIImage(image: inputImage)

    // 后续代码待添加
}

下一步是创建 Core Image 上下文和 Core Image 滤镜。滤镜负责执行图像数据的实际转换工作,比如模糊、锐化、调整颜色等;而上下文则负责将处理后的数据转换为我们可以使用的 CGImage

这两种数据类型都来自 Core Image,因此你需要添加两个导入语句,以便在代码中使用它们。请在 ContentView.swift 文件的顶部附近添加以下代码:

swift
import CoreImage
import CoreImage.CIFilterBuiltins

接下来,我们创建上下文和滤镜。在这个示例中,我们将使用棕褐色调滤镜(sepia tone filter),该滤镜会给图像添加棕色色调,让照片看起来像是很久以前拍摄的。

因此,用以下代码替换注释“// 后续代码待添加”:

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

现在,我们可以自定义滤镜以改变其效果。棕褐色调滤镜比较简单,它只有两个关键属性:inputImage 是我们要处理的图像,intensity 是棕褐色效果的应用强度,取值范围在 0(原始图像)到 1(完全棕褐色)之间。

因此,在之前两行代码的下方添加以下两行:

swift
currentFilter.inputImage = beginImage
currentFilter.intensity = 1

到目前为止,这些都不算太难,但接下来情况会有所变化:我们需要将滤镜的输出转换为可以在视图中显示的 SwiftUI Image。这时候,我们就需要同时用到这四种图像类型了,因为最简单的流程是:

  • 从滤镜中读取输出图像,其类型为 CIImage。这个操作可能会失败,因此返回的是可选类型。
  • 请求上下文从该输出图像创建 CGImage。这个操作也可能失败,因此返回的也是可选类型。
  • CGImage 转换为 UIImage
  • UIImage 转换为 SwiftUI 的 Image

你也可以直接从 CGImage 转换为 SwiftUI 的 Image,但这需要额外的参数,只会增加更多复杂性!

以下是 loadImage() 函数的完整代码:

swift
// 从滤镜中获取 CIImage,若失败则退出
guard let outputImage = currentFilter.outputImage else { return }

// 尝试从 CIImage 创建 CGImage
guard let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { return }

// 将 CGImage 转换为 UIImage
let uiImage = UIImage(cgImage: cgImage)

// 将 UIImage 转换为 SwiftUI 的 Image
image = Image(uiImage: uiImage)

再次运行应用,你应该会看到示例图像现在应用了棕褐色效果,这一切都要归功于 Core Image。

现在,你可能会觉得,仅仅为了得到一个相对简单的效果,竟然需要做这么多工作。但一旦掌握了 Core Image 的这些基础知识,切换到其他滤镜就会相对容易了。

话虽如此,Core Image 多少有些……嗯,不妨说它“独具特色”。它早在 iOS 5.0 时就已推出,那时苹果内部已经在开发 Swift 了,但你完全看不出来这一点——长期以来,它的 API 是最不符合 Swift 风格的。尽管苹果逐渐对其进行了优化,但有时你还是不得不深入研究它的底层实现。

首先,我们来看现代 API——我们可以用像素化滤镜替换棕褐色调滤镜,代码如下:

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

运行这段代码后,你会看到图像呈现出像素化效果。scale 值设为 100,理论上意味着每个像素的宽度为 100 点,但由于我的图像尺寸较大,所以像素看起来相对较小。

现在,我们尝试使用水晶效果滤镜,代码如下:

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

或者,我们可以添加旋转扭曲滤镜,代码如下:

swift
let currentFilter = CIFilter.twirlDistortion()
currentFilter.inputImage = beginImage
currentFilter.radius = 1000
currentFilter.center = CGPoint(x: inputImage.size.width / 2, y: inputImage.size.height / 2)

由此可见,仅使用现代 API 我们就能实现很多效果。但在本项目中,我们将使用较旧的 API 来设置 radiusscale 等值,因为这种方式能让我们动态设置值——我们可以直接查询当前滤镜支持哪些值,然后将这些值传递进去。

具体代码如下:

swift
let currentFilter = CIFilter.twirlDistortion()
currentFilter.inputImage = beginImage

let amount = 1.0

let inputKeys = currentFilter.inputKeys

if inputKeys.contains(kCIInputIntensityKey) {
    currentFilter.setValue(amount, forKey: kCIInputIntensityKey) }
if inputKeys.contains(kCIInputRadiusKey) { currentFilter.setValue(amount * 200, forKey: kCIInputRadiusKey) }
if inputKeys.contains(kCIInputScaleKey) { currentFilter.setValue(amount * 10, forKey: kCIInputScaleKey) }

这样设置后,你可以将旋转扭曲滤镜替换为任何其他滤镜,代码仍然能正常工作——只有当滤镜支持某个调整值时,该值才会被传递进去。

注意,这种方式依赖于通过键(key)来设置值,这可能会让你联想到 UserDefaults 的工作方式。实际上,所有这些 kCIInput 键在底层都是以字符串形式实现的,所以它们之间的相似性比你想象的还要高!

如果你要实现精确的 Core Image 调整,那么无疑应该使用较新的 API,因为它使用明确的属性名和类型。但在本项目中,较旧的 API 更为实用,因为无论使用哪种滤镜,它都能让我们传递调整值。

使用 ContentUnavailableView 显示空状态

保罗·哈德森 2023年12月8日

当应用没有内容可显示时,SwiftUI 的 ContentUnavailableView 会显示一个标准的用户界面。我知道,这听起来似乎有些多余——毕竟,如果没有内容可显示,那就什么都不显示好了!

但在某些情况下,ContentUnavailableView 非常实用,比如当应用依赖于用户尚未提供的信息时(例如用户尚未创建任何数据,或者用户搜索时没有找到结果)。

举个例子,如果你正在开发一个应用,让用户记录想要记住的 Swift 代码片段,那么默认情况下,应用启动时可能没有任何代码片段。这时,你可以像下面这样使用 ContentUnavailableView

swift
ContentUnavailableView("没有代码片段", systemImage: "swift")

这样会显示一个来自 SF Symbols 的大型 Swift 图标,图标下方是标题文本“没有代码片段”。

你还可以在下方添加一行描述文本,该文本以 Text 视图的形式指定,因此你可以添加额外的样式(如自定义字体或自定义颜色):

swift
ContentUnavailableView("没有代码片段", systemImage: "swift", description: Text("你目前还没有任何已保存的代码片段。"))

如果你想获得“完全”的控制权,可以为标题和描述分别提供单独的视图,同时添加一些按钮来帮助用户开始使用应用:

swift
ContentUnavailableView {
    Label("没有代码片段", systemImage: "swift")
} description: {
    Text("你目前还没有任何已保存的代码片段。")
} actions: {
    Button("创建代码片段") {
        // 创建代码片段的逻辑
    }
    .buttonStyle(.borderedProminent)
}

这是一个使用起来非常简单的视图,但它比用户首次打开应用时只显示空白屏幕要好得多!

本站使用 VitePress 制作