Skip to content

第80天 项目 16 第二部分

今天你将学习 Swift 中的 Result 类型这一较难理解的概念,但为了平衡难度,我们还会涵盖两个相对简单的知识点,希望你不会觉得今天的内容太难。

Swift 的 Result 类型旨在解决这样一个问题:当你知道情况 A 可能为真,情况 B 也可能为真,但在任何时候两者中只有一个能为真。如果你把它们想象成布尔属性,那么每个属性都有两种状态(真和假),但两者结合起来会有四种状态:

  1. A 为假且 B 为假
  2. A 为真且 B 为假
  3. A 为假且 B 为真
  4. A 为真且 B 为真

如果你确定第 1 种和第 4 种情况永远不会出现——即要么 A 为真,要么 B 为真,二者不可能同时为真——那么你就能立即将逻辑复杂度降低一半。

美国作家厄休拉·K·勒奎恩曾说过:“唯一让生命成为可能的是永恒且难以忍受的不确定性,是不知道接下来会发生什么。” 而优秀的软件则恰恰相反:我们能施加的确定性越强、约束越多,代码就越安全,Swift 编译器也能更好地为我们服务。

因此,尽管使用 Result 类型需要你考虑将逃逸闭包作为参数传入,但换来的是更智能、更简洁、更安全的代码——这完全是值得的。

今天你需要学习三个主题,分别是 Result 类型、图像插值和上下文菜单。

  • 理解 Swift 的 Result 类型
  • 在 SwiftUI 中控制图像插值
  • 创建上下文菜单

理解 Swift 的 Result 类型

作者:Paul Hudson 2024年2月1日

Swift 提供了一种特殊的 Result 类型,它允许我们将一个成功的值或某种错误类型封装在单一数据中。例如,就像可选类型可能包含一个字符串,也可能什么都不包含一样,Result 类型可能包含一个字符串,也可能包含一个错误。起初它的使用语法可能有些奇怪,但在我们的项目中确实发挥着重要作用。

要实际体验 Result 类型的用法,我们可以先编写一个从服务器下载数据读数数组的方法,代码如下:

swift
struct ContentView: View {
    @State private var output = ""

    var body: some View {
        Text(output)
            .task {
                await fetchReadings()
            }
    }

    func fetchReadings() async {
        do {
            let url = URL(string: "https://hws.dev/readings.json")!
            let (data, _) = try await URLSession.shared.data(from: url)
            let readings = try JSONDecoder().decode([Double].self, from: data)
            output = "找到 \(readings.count) 个读数"
        } catch {
            print("下载错误")
        }
    }
}

这段代码运行良好,但灵活性不足——如果我们想把这个任务暂存起来,同时去处理其他事情该怎么办?如果我们想在未来某个时候读取任务结果,或许在其他地方专门处理可能出现的错误又该如何?或者,如果这个任务不再需要,我们想取消它该怎么做?

其实,借助 Result 类型我们就能实现所有这些需求,而且它可以通过一个你之前接触过的 API 来使用,那就是 Task。我们可以将上面的代码重写为:

swift
func fetchReadings() async {
    let fetchTask = Task {
        let url = URL(string: "https://hws.dev/readings.json")!
        let (data, _) = try await URLSession.shared.data(from: url)
        let readings = try JSONDecoder().decode([Double].self, from: data)
        return "找到 \(readings.count) 个读数"
    }
}

之前我们曾使用 Task 来启动任务,但这次我们给 Task 对象起了个名字 fetchTask——正是这个名字让我们获得了额外的灵活性,可以传递这个任务对象,或者在需要时取消它。另外请注意,现在我们的 Task 闭包会返回一个值,这个值会存储在 Task 实例中,以便我们在准备好之后读取。

更重要的是,如果网络请求失败或者数据解码失败,这个 Task 可能会抛出错误,而这正是 Result 类型发挥作用的地方:任务的结果可能是一个字符串,比如“找到 10000 个读数”,但也可能包含一个错误。要了解具体结果,唯一的方法就是查看 Result 类型内部的内容——这和可选类型非常相似。

要从 Task 中读取结果,可以像这样读取它的 result 属性:

swift
let result = await fetchTask.result

注意,我们在读取 Result 内容时并没有使用 try——这是因为 Result 类型会将结果(包括错误)存储在其内部。可能确实发生了错误,但除非我们主动要处理,否则现在无需担心。

如果你查看 result 的类型,会发现它是 Result<String, Error> 类型——如果任务成功,它会包含一个字符串;如果任务失败,则会包含一个错误。

你可以直接从 Result 类型中读取成功时的值,但需要确保妥善处理错误,代码如下:

swift
do {
    output = try result.get()
} catch {
    output = "错误:\(error.localizedDescription)"
}

另外,你也可以对 Result 类型使用 switch 语句,分别为成功和失败的情况编写处理代码。这两种情况内部都包含对应的值(成功时是字符串,失败时是错误),因此 Swift 允许我们通过专门的 case 匹配来读取这些值:

swift
switch result {
    case .success(let str):
        output = str
    case .failure(let error):
        output = "错误:\(error.localizedDescription)"
}

无论采用哪种处理方式,Result 类型的优势在于,它能让我们将某项任务的成功或失败结果完整地存储在一个值中,根据需要传递这个值,并且只在准备好时才处理错误。

在 SwiftUI 中控制图像插值

作者:Paul Hudson 2024年2月1日

如果在 SwiftUI 中创建一个 Image 视图,并将其内容拉伸到比原始尺寸更大,会发生什么情况呢?默认情况下,会进行“图像插值”处理,即 iOS 会对像素进行平滑混合,以至于你可能根本意识不到图像已经被拉伸了。当然,这种处理会有一定的性能损耗,但大多数情况下无需担心。

然而,在一种特定场景下,图像插值会带来问题,那就是处理精确像素的时候。例如,本项目在 GitHub 上的文件中包含一个名为 example@3x.png 的卡通外星人图像——该图像来自 Kenney Platform Art Deluxe 资源包(网址:https://kenney.nl/assets/platformer-art-deluxe),基于公有领域协议可用。

请将该图像添加到你的资源目录中,然后将 ContentView 结构体修改为以下代码:

swift
Image(.example)
    .resizable()
    .scaledToFit()
    .background(.black)

这段代码会在黑色背景上渲染外星人图像(这样更容易看清图像),而且由于设置了 resizable(),SwiftUI 会将图像拉伸到填满所有可用空间。

仔细观察图像颜色的边缘:它们看起来既参差不齐,又有些模糊。参差不齐是因为原始图像尺寸较小(仅为 66×92 像素),而模糊则是因为 SwiftUI 在拉伸图像时,试图通过混合像素来让拉伸效果不那么明显。

通常情况下,这种混合效果很好,但在当前场景下却不尽如人意——一方面是因为源图像尺寸小(要显示到我们期望的大小,需要大量的像素混合),另一方面是因为图像包含很多纯色,所以混合后的像素会非常显眼。

针对这类场景,SwiftUI 为我们提供了 interpolation() 修饰符,用于控制像素混合的应用方式。它有多个等级,但实际上我们只需要关注其中一个:.none。该选项会完全关闭图像插值,因此像素不会被混合,只是被放大,边缘会保持锐利。

因此,将图像代码修改为:

swift
Image(.example)
    .interpolation(.none)    
    .resizable()
    .scaledToFit()
    .background(.black)

现在你会看到,外星人图像保留了像素风格的外观——这种风格在复古游戏中非常流行,而且对于线条艺术也很重要,因为线条艺术在模糊后会显得不协调。

创建上下文菜单

作者:Paul Hudson 2024年5月1日

当用户点击按钮或导航链接时,SwiftUI 显然应该触发这些视图的默认操作。但如果用户长按某个元素呢?在旧款 iPhone 上,用户可以通过用力按压某个元素来触发 3D Touch,但其核心原理是相同的:用户希望对所交互的元素有更多操作选项。

SwiftUI 允许我们为元素附加上下文菜单,以提供这些额外功能,具体可通过 contextMenu() 修饰符实现。你可以向该修饰符传递一系列按钮,这些按钮会按顺序显示。例如,我们可以创建一个简单的上下文菜单来控制视图的背景颜色,代码如下:

swift
struct ContentView: View {
    @State private var backgroundColor = Color.red

    var body: some View {
        VStack {
            Text("你好,世界!")
                .padding()
                .background(backgroundColor)

            Text("更改颜色")
                .padding()
                .contextMenu {
                    Button("红色") {
                        backgroundColor = .red
                    }

                    Button("绿色") {
                        backgroundColor = .green
                    }

                    Button("蓝色") {
                        backgroundColor = .blue
                    }
                }
        }
    }
}

TabView 类似,上下文菜单中的每个选项都可以通过 Label 视图附加文本和图像。

例如,我们可以使用 Apple 的 SF Symbols 图标,代码如下:

swift
Button("红色", systemImage: "checkmark.circle.fill") {
    backgroundColor = .red
}

Apple 非常希望不同应用中的这些菜单项在外观上保持一定的统一性,因此如果你尝试在上述代码中添加 foregroundStyle() 修饰符,该修饰符会被忽略——随意给菜单项设置颜色是无法生效的。

如果你确实希望某个菜单项显示为红色(如你所知,红色通常表示“破坏性操作”),则应使用按钮角色(button role),代码如下:

swift
Button("红色", systemImage: "checkmark.circle.fill", role: .destructive) {
    backgroundColor = .red
}

关于使用上下文菜单,我有几个技巧可以分享,帮助你为用户提供更好的体验:

  1. 如果你打算使用上下文菜单,就应在多个地方统一使用——用户长按某个元素却发现没有任何反应,这种体验会很糟糕。
  2. 尽量缩短选项列表的长度——目标是不超过三个选项。
  3. 不要重复用户在 UI 其他位置已能看到的选项。

请记住,上下文菜单本质上是隐藏的,因此在将重要操作隐藏到上下文菜单之前,请务必三思。

本站使用 VitePress 制作