欢迎关注微信公众号「Swift 花园」

封面来自 Pauline Loroy on Unsplash

译自 https://www.hackingwithswift.com/articles/249/whats-new-in-swift-5-7

Swift 5.7 变化巨大,新特性中包括正则表达式, if let 速记语法,以及围绕 anysome 关键字的一致性改动。

在本文中,我会通过一些示例来介绍这些新特性。

解包可选型的 if let 速记

SE-0345 引入了新的速记语法,可以将可选型展开为 ** 同名 ** 的阴影变量。以后我们可以像下面这样解包了:

1
2
3
4
5
var name: String? = "Linda"

if let name {
print("Hello, \(name)!"
}

对比之前的写法:

1
2
3
4
5
6
7
if let name = name {
print("Hello, \(name)!"
}

if let unwrappedName = name {
print("Hello, \(unwrappedName)!"
}

注意:这个变化并不适用于对象内的属性,所以像下面这样的代码无法通过编译:

1
2
3
4
5
6
7
8
9
struct User {
var name: String
}

let user: User? = User(name: "Linda")

if let user.name {
print("Welcome, \(user.name)!")
}

多语句闭包类型推断

SE-0326 极大地提高了 Swift 对闭包使用参数和类型推断的能力,这意味着我们现在可以删除许多必须明确指定输入和输出类型的写法。

之前 Swift 处理闭包的书写难免琐碎,但从 Swift 5.7 开始,我们可以编写如下简化的代码:

1
2
3
4
5
6
7
8
9
let scores = [100, 80, 85]

let results = scores.map { score in
if score >= 85 {
return "\(score)%: Pass"
} else {
return "\(score)%: Fail"
}
}

在 Swift 5.7 之前则必须像下面这样书写:

1
2
3
4
5
6
7
let oldResults = scores.map { score -> String in
if score >= 85 {
return "\(score)%: Pass"
} else {
return "\(score)%: Fail"
}
}

Clock,Instant 和 Duration

SE-0329 为 Swift 引入了一种新的标准化方式来引用时间和持续时间。它可以拆解为三个主要部分:

  • Clock 代表了一种测量时间流逝的方式。有两个内置时钟:连续时钟在系统处于睡眠状态时也会保持时间递增,而挂起时钟则不会。
  • Instant 代表一个精确的瞬间。
  • Durations 表示两个 Instant 之间经过了多少时间。

这个新特性对许多人来说来联想到的最直接的应用就是新升级的 Task API:它现在可以用比纳秒更合理的术语来指定休眠时长:

1
try await Task.sleep (until: .now +  .seconds (1), clock: .continuous)

这个新 API 还有一个好处是能够指定容差,使得系统在睡眠截止日期之后能够稍等片刻,以便最大限度地提高电源效率。所以,假如我们想 sleep 至少 1 秒,并且能接受它总共持续 1.5 秒,我们可以这样写:

1
try await Task.sleep (until: .now + .seconds (1), tolerance: .seconds (0.5), clock: .continuous)

时钟对于测量某些特定的工作也很有用。比如,我们想向用户展示文件导出过程花费了多长时间,可以使用时钟的

measure 闭包:

1
2
3
4
5
6
7
let clock = ContinuousClock()

let time = clock.measure {
//complex work here
}

print("Took \(time.components.seconds) seconds")

正则表达式

Swift 5.7 引入了大量与正则表达式相关的改进,这是一整套相互关联的提案,包括:

  • SE-0350 引入了新的 Regex 类型
  • SE-0351 引入了一个用于创建正则表达式的 result builder 驱动的 DSL。
  • SE-0354 引入了使用 /.../ 而不单是 Regex 来共同创建正则表达式的方式。
  • SE-0357 添加了许多新的基于正则表达式的字符串处理算法。

与其他语言和平台相比,正则表达式一直是 Swift 语言一个相当大的痛点。

现在,让我们从简单的例子开始:

1
2
3
4
let message = "the cat sat on the mat"
print(message.ranges (of: "at"))
print(message.replacing ("cat", with: "dog"))
print(message.trimmingPrefix ("the "))

它们的真正威力在于也都接受正则表达式:

1
2
3
print(message.ranges (of: /[a-z] at/))
print(message.replacing (/[a-m] at/, with: "dog"))
print(message.trimmingPrefix (/The/.ignoresCase ()))

如果您不熟悉正则表达式,下面是几条快速入门:

  • 在第一个正则表达式中,我们要求所有匹配任何小写字母后跟 “at” 的子字符串的范围,以便找到 “cat”、“sat” 和 “mat” 的位置。
  • 在第二个正则表达式中,我们只匹配从 “a” 到 “m” 的范围,所以 sat 不会被替换,它会打印 “the dog sat on the dog”。
  • 在第三个正则表达式中,我们寻找 “The”,但将正则表达式修改为不区分大小写,以便匹配 “the”、“THE” 等。

注意这些正则表达式是如何使用正则表达式字面量来生成的 —— 以 / 开始和结束。

除了正则表达式字面量,Swift 还提供了专门的 Regex 类型:

1
2
3
4
5
6
do {
let atSearch = try Regex("[a-z] at")
print(message.ranges (of: atSearch))
} catch {
print("Failed to create regex")
}

这里两种方式有一个关键区别:当我们使用 Regex 从字符串创建正则表达式时,Swift 必须在运行时解析字符串以找出它应该使用的实际表达式。相比之下,使用正则表达式字面量允许 Swift 在编译时 检查你的正则表达式:它可以验证正则表达式不包含错误,并且还可以准确了解它将包含什么匹配项。

在编译时解析你的正则表达式,确保它们是有效的 —— 牛 🍺!

想知道这个差异有多强大,咱们来看下面的代码:

1
2
3
4
5
6
7
let search1 = /My name is (.+?) and I'm (\d+) years old./
let greeting1 = "My name is Taylor and I'm 26 years old."

if let result = try search1.wholeMatch (in: greeting1) {
print("Name: \(result.1)")
print("Age: \(result.2)")
}

这会创建一个正则表达式来查找某些文本中的两个特定值,如果找到它们都会打印它们。但请注意 result 元组如何将其匹配项引用为 .1.2,因为 Swift 知道将发生哪些匹配项。 (.0 将返回整个匹配的字符串。)

事实上,正则表达式还允许我们命名匹配项,这些匹配项会流向生成的匹配元组:

1
2
3
4
5
6
7
let search2 = /My name is (?<name>.+?) and I'm (?<age>\d+) years old./
let greeting2 = "My name is Taylor and I'm 26 years old."

if let result = try search2.wholeMatch (in: greeting2) {
print("Name: \(result.name)")
print("Age: \(result.age)")
}

这种安全性对于从字符串创建的正则表达式是不可能的。

但 Swift 更进一步,你还可以从类似于 SwiftUI 代码的 DSL 语言创建正则表达式。

例如,如果我们想匹配 “我的名字是 Taylor,我 26 岁” 的文本,我们可以写一个这样的正则表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let search3 = Regex {
"My name is "

Capture {
OneOrMore(.word)
}

" and I'm "

Capture {
OneOrMore(.digit)
}

" years old."
}

更棒的是,这种 DSL 方法能够对其找到的匹配项应用转换,如果我们使用 TryCapture 而不是 Capture,在捕获失败或有错误抛出时,Swift 将自动认为整个正则表达式不匹配。因此,在我们的年龄匹配的例子中,我们可以编写以下代码来将年龄字符串转换为整数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let search4 = Regex {
"My name is "

Capture {
OneOrMore(.word)
}

" and I'm "

TryCapture {
OneOrMore(.digit)
} transform: { match in
Int(match)
}

Capture(.digit)

" years old."
}

你甚至可以使用具有特定类型的变量将命名匹配组合在一起,如下所示:

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
let nameRef = Reference(Substring.self)
let ageRef = Reference(Int.self)

let search5 = Regex {
"My name is "

Capture(as: nameRef) {
OneOrMore(.word)
}

" and I'm "

TryCapture(as: ageRef) {
OneOrMore(.digit)
} transform: { match in
Int(match)
}

Capture(.digit)

" years old."
}

if let result = greeting.firstMatch (of: search5) {
print("Name: \(result [nameRef])")
print("Age: \(result [ageRef])")
}

在这三个选项中,我怀疑正则表达式文字会得到最广泛的使用。尽管在 Swift 6 发布之前,默认情况下对它们的支持将被禁用。你可以把 “-Xfrontend -enable-bare-slash-regex” 添加到 Xcode 中的 Swift Flags 设置以启用这个语法特性。

基于默认表达式的类型推断

SE-0347 扩展了 Swift 使用泛型参数类型的默认值的能力。这个特性似乎相当小众,但确实重要:如果你有一个泛型类型或函数,现在可以为默认表达式提供一个具体类型。

例如,我们可能有一个函数,它从任意类型的序列中返回 count 个随机项:

1
2
3
func drawLotto1<T: Sequence>(from options: T, count: Int = 7) -> [T.Element] {
Array(options.shuffled ().prefix(count))
}

这允许我们使用任何类型的序列来运行函数,例如字符串数组或者整数范围:

1
2
print(drawLotto1 (from: 1...49))
print(drawLotto1 (from: ["Jenny", "Trixie", "Cynthia"], count: 2))

SE-0347 允许我们为函数中的 T 参数提供一个具体类型作为默认值,同时允许我们保持使用字符串数组或任何其他序列类型的灵活性:

1
2
3
func drawLotto2<T: Sequence>(from options: T = 1...49, count: Int = 7) -> [T.Element] {
Array(options.shuffled ().prefix(count))
}

这样一来我们既可以使用自定义序列调用函数,也可以让默认值接管:

1
2
print(drawLotto2 (from: ["Jenny", "Trixie", "Cynthia"], count: 2))
print(drawLotto2 ())

顶级代码的并发

SE-0343 升级了 Swift 对顶级代码的支持 —— 想想 macOS 命令行工具项目中的 main.swift —— 以便它支持开箱即用的并发。这个变化看起来微不足道,但为了支持它需要相当多的工作。

实践上,这个变化意味着我们可以将这样的代码直接写入 main.swift 文件:

1
2
3
4
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)
print("Found \(readings.count) temperature readings")

在这个变化以前,我们必须创建一个具有异步 main () 方法的 @main 结构。因此说这个变化是一个不小的改进。

不透明参数声明

SE-0341 解锁了在使用更简单泛型的地方对参数声明使用 some 的能力。

举个例子,如果我们想编写一个检查数组是否排序的函数,Swift 5.7 及更高版本允许我们这样写:

1
2
3
func isSorted(array: [some Comparable]) -> Bool {
array == array.sorted ()
}

[some Comparable] 参数类型意味着此函数适用于包含某种类型的元素的数组,该类型遵循 Comparable 协议,这是等效通用代码的语法糖:

1
2
3
func isSortedOld<T: Comparable>(array: [T]) -> Bool {
array == array.sorted ()
}

当然,我们也可以写更长的约束扩展:

1
2
3
4
5
extension Array where Element: Comparable {
func isSorted() -> Bool {
self == self.sorted ()
}
}

这种简化的泛型语法确实意味着我们不再有能力为我们的类型添加更复杂的约束,因为合成的泛型参数没有特定的名称。

** 重要提示:** 你可以在显式泛型参数和这种新的更简单语法之间切换,而不会破坏 API。

结构化的不透明结果类型

SE-0328 拓宽了不透明结果类型可以使用的范围。

例如,我们现在可以一次返回多个不透明类型:

1
2
3
func showUserDetails() -> (some Equatable, some Equatable) {
(Text("Username"), Text("@twostraws"))
}

我们还可以返回不透明类型数组:

1
2
3
4
func createUser() -> [some View] {
let usernames = ["@frankefoster", "@mikaela__caron", "@museumshuffle"]
return usernames.map(Text.init)
}

甚至返回一个在调用时本身返回不透明类型的函数:

1
2
3
4
5
6
func createDiceRoll() -> () -> some View {
return {
let diceRoll = Int.random (in: 1...6)
return Text(String(diceRoll))
}
}

因此,这是 Swift 进化过程中保持一致性的另一个很好的例子。

zdenek-machacek-pqYGHW0_od0-unsplash

译自 https://www.hackingwithswift.com/articles/224/common-swiftui-mistakes-and-how-to-fix-them

欢迎关注微信公众号「Swift 花园」

SwiftUI 是一个庞大且复杂的框架。使用这个框架编程无疑是享受的,但犯错的机会也不少见。这篇文章我将带大家速览 SwiftUI 初学者常犯的一些错误,并提供修正方案。

其中的一些错误是由于简单的误解导致。由于 SwiftUI 太大,这种情况其实容易出现。而另一些错误则与深入理解 SwiftUI 的工作方式有关,还有一些是原有的思维方式导致 —— 你可能花了很多时间编写 view 和 modifier,但没有想到用 SwiftUI 的方式简化结果。

开门见山,你不需要猜我会给你准备什么菜,这里我直接把八条误用先简明扼要地罗列如下,然后我们逐条深入展开:

  1. 添加不必要的 View 和 Modifier

  2. 在需要用 @StateObject 的地方用了 @ObservedObject

  3. Modifier 顺序错误

  4. 给属性包装器添加属性观察者

  5. 在需要用描边框的地方使用了描形状

  6. Alert 和 Sheet 与可选状态的使用

  7. 尝试改变 SwiftUI 视图后面的东西

  8. 用错误的范围动态创建视图


1 添加不必要的 View 和 Modifier

让我们从最常见的一个误用开始,它会让我们编写更多的 SwiftUI 代码。这种误用的部分原因通常是我们在解决问题时编写了许多代码,但是最后忘记整理代码。还有的时候,则是旧习惯作祟,尤其是当编写者是从 UIKit 或者其他 UI 框架转到 SwiftUI 上。

比如,你可能希望用一个红色矩形填满屏幕?然后你像下面这样编写代码:

1
2
Rectangle()
.fill (Color.red)

的确,上面的代码可以工作 —— 它能准确地得到你想要的效果。但是其中一半的代码是不必要的,因为你只需要像下面这样写也能实现一样的效果:

1
Color.red

这是因为在 SwiftUI 中,所有的颜色和形状都自动遵循了 View 协议,你可以把它们直接当成视图来使用。

你可能也会经常看见形状裁切,因为为了实现特定形状,应用 clipShape () 是件很自然的事情。例如,可以像下面这样让我们的红色矩形拥有圆角:

1
2
Color.red
.clipShape (RoundedRectangle(cornerRadius: 50))

但这也是不要的 —— 借助 cornerRadius () modifier,代码可以简化如下:

1
2
Color.red
.cornerRadius (50)

移除这类的冗余代码需要时间,因为你需要转变思维习惯,这一点对于 SwiftUI 的初学者来说更加困难。 因此,假如你一开始采用了这些更长版本的代码,不必担忧,多加训练。

2 在需要用 @StateObject 的地方用了 @ObservedObject

SwiftUI 提供了众多属性包装器,帮助我们构建数据响应式的用户界面,其中最重要的当属 @State@StateObject@ObservedObject。掌握它们的使用场景非常重要,因为误用它们会给你的代码带来各种问题。

第一个比较直接:@State 用于值类型属性,并且属性由当前视图拥有。因此,整数,字符串,数组等,都是应用 @State 的绝佳场景。

后两者则有点令人困惑,你可能会经常看到下面这样的代码:

1
2
3
4
5
6
7
8
9
10
11
class DataModel: ObservableObject {
@Published var username = "@twostraws"
}

struct ContentView: View {
@ObservedObject var model = DataModel()

var body: some View {
Text(model.username)
}
}

可以明确的说,这么做是错误的,并且极有可能在你的应用中带来问题。

译者注:基于代码片段说这样写一定是错误的,这个表述是不严谨的。作者应该是隐含假设了 ContentView 是应用的顶级视图(通常来说,如果你不改工程模板的默认输出,ContentView 也确实是顶级视图)。对于顶级视图来说,SwiftUI 2.0 应当使用 @StateObject ,它是为了解决 @ObservedObject 或者 @EnvironmentObject 对象的所有权问题。但是对于附属于顶级视图的视图层级,各子视图的数据源可以是 @ObservedObject 或者 @EnvironmentObject,因为它们的生命周期受顶级视图管理,进而可以由顶级视图统一保证数据的可用性。

正如我前面说到的,@State 表示某个值类型属性由当前视图拥有,这里的 “拥有” 很重要。而 @StateObject 则相当于引用类型版本的 @State

因此,上面的代码应该改成这样:

1
@StateObject model = DataModel()

当你使用 @ObservedObject 来创建某个对象实例时,你的视图并不拥有这个对象实例,也就是说,这个实例可以在任何时候被销毁(译者注:视图无法了解也无法干预这个时机)。狡猾的是,对象在视图还需要用它时被销毁的情况只是偶尔发生,所以你可能认为你的代码很完美。

需要记住的重点是 @State@StateObject 表示 “视图拥有数据”,而 @ObservedObject@EnvironmentObject 则没有。

3 Modifier 顺序错误

Modifier 的顺序在 SwiftUI 中至关重要。顺序错误不仅会导致布局在上视觉上的偏差,也会导致其行为的错误。

解释这个问题最经典的例子是 paddingbackground 的使用,如下:

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.background (Color.green)
.padding ()

由于我们在 background 颜色之后应用 padding,颜色只会被直接应用在文本周围,而不是被添加留白之后的文本周围。如果你希望留白和文本背景都是绿色,应该将代码改成下面这样:

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.padding ()
.background (Color.green)

当你尝试调整视图位置时,这个原理会让事情变得更有趣。

例如,offset () modifier 会修改一个视图被渲染的位置,但并不实际改变视图的位置。也就是说,应用在 offset 之后的 modifier 表现得就像 offset 从未发生过。

尝试下面的代码:

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.offset (x: 15, y: 15)
.background (Color.green)

你会发现文本偏移了,但背景颜色没有偏移。现在,尝试交换 offset ()background () 的位置:

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.background (Color.green)
.offset (x: 15, y: 15)

现在你会看到文本和背景都移动了。

另外,position () modifier 会改变一个视图在其父节点中的渲染位置,但这一点是借助它先在视图周围应用一个可伸展尺寸的 frame 来实现的。

尝试下面的代码:

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.background (Color.green)
.position (x: 150, y: 150)

你会发现背景颜色紧贴在文本四周,并且整个视图被放置在左上角。现在,尝试对调 background ()position ()

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.position (x: 150, y: 150)
.background (Color.green)

这一回你会发现整个屏幕都变成绿色了。还是因为 position () 要求 SwiftUI 放置一个可伸缩尺寸的 frame 在文本视图周围,这导致视图自动占满了所有的可用空间。然后我们给视图上了绿色,所以整个屏幕呈现绿色。

你所应有的绝大多数 modifier 都创建了新视图 —— 应用一个 position 或者 background 时,你实际上是在将现有的视图包装起来。这个机制对于我们大有用处,我们可以多次应用 modifier,比如添加多层留白和背景:

1
2
3
4
5
6
Text("Hello, World!")
.font (.largeTitle)
.padding ()
.background (Color.green)
.padding ()
.background (Color.blue)

或者应用多个 shadows 以创建很深的阴影效果:

1
2
3
4
5
6
Text("Hello, World!")
.font (.largeTitle)
.foregroundColor (.white)
.shadow (color: .black, radius: 10)
.shadow (color: .black, radius: 10)
.shadow (color: .black, radius: 10)

4 给属性包装器添加属性观察者

某些情况下你可能会为属性包装器添加诸如 didSet 这样的属性观察者,但它不会如你预期的那样工作。

例如,如果你在使用滑块,希望在滑块值改变时执行某种动作,你可能会下面这样编写代码:

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {
@State private var rating = 0.0 {
didSet {
print("Rating changed to \(rating)")
}
}

var body: some View {
Slider(value: $rating)
}
}

但是,这个 didSet 属性观察者永远都不会被调用,因为属性的值是由绑定直接修改的,而不是每次创建一个新值。

对此,SwiftUI 原生的方式是使用 onChange () modifier,如下:

1
2
3
4
5
6
7
8
9
10
struct ContentView: View {
@State private var rating = 0.0

var body: some View {
Slider(value: $rating)
.onChange (of: rating) { value in
print("Rating changed to \(value)")
}
}
}

不过,我个人更喜欢一种不同的方案:我使用基于 Binding 的扩展来返回新的绑定,其中的 getset 包装的值和之前一样,但是在新值得到时也会调用处理函数:

1
2
3
4
5
6
7
8
9
10
11
extension Binding {
func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { newValue in
self.wrappedValue = newValue
handler (newValue)
}
)
}
}

有了这个扩展,我们就可以把绑定的动作直接附着在滑块视图上:

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {
@State private var rating = 0.0

var body: some View {
Slider(value: $rating.onChange (sliderChanged))
}

func sliderChanged(_ value: Double) {
print("Rating changed to \(value)")
}
}

挑选最适合你的方案。

5 在需要用描边框的地方使用了描形状

不理解 stroke ()strokeBorder 的区别是初学者常犯的错误。尝试下面的代码:

1
2
Circle()
.stroke (Color.red, lineWidth: 20)

注意看,你会发现圆的左边缘和右边缘怎么不见了?(译者:这里预设你是竖屏运行程序,高度大于宽高)这是因为 stroke () modifier 会把描边居中对齐在形状的轮廓线上,所以一个 20 个点的红色描边会绘制 10 个点到形状的边缘线外部,10 个点在边缘线内部 —— 这就导致了你看到圆形的左右超出屏幕的现象。

作为对照,strokeBorder () 则是把整个描边都绘制在形状内部,所以它不会放大形状的边框。

1
2
Circle()
.strokeBorder (Color.red, lineWidth: 20)

相比于使用 strokeBorder (),使用 stroke () 有一个好处是它返回的是一个新形状,而不是一个新视图。这使得你可以创建出某些本来难以实现的效果,比如给一个形状描两次边:

1
2
3
4
Circle()
.stroke (style: StrokeStyle(lineWidth: 20, dash: [10]))
.stroke (style: StrokeStyle(lineWidth: 20, dash: [10]))
.frame (width: 280, height: 280)

6 Alert 和 Sheet 与可选状态的使用

当你在学习使用 sheet 和可选型的时候,很容易想到把 sheet 的展示绑定到像下面这样的 Boolean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct User: Identifiable {
let id: String
}

struct ContentView: View {
@State private var selectedUser: User?
@State private var showingAlert = false

var body: some View {
VStack {
Button("Show Alert") {
selectedUser = User(id: "@twostraws")
showingAlert = true
}
}
.alert (isPresented: $showingAlert) {
Alert(title: Text("Hello, \(selectedUser!.id)"))
}
}
}

当然,这可以正确工作 —— 而且这个方案容易理解。但是一旦你越过了初级阶段,你就应当考虑换成可选型的实现方案。这个方案去掉了 Boolean,也不必强制解包。唯一的要求是你所监听的目标需要遵循 Identifiable

举个例子,我们可以在 selectedUser 发生变化的任何时候展示警告弹窗,就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ContentView: View {
@State private var selectedUser: User?

var body: some View {
VStack {
Button("Show Alert") {
selectedUser = User(id: "@twostraws")
}
}
.alert (item: $selectedUser) { user in
Alert(title: Text("Hello, \(user.id)"))
}
}
}

这会使得你的代码更加易于读写,并且避免因为强制解包可能带来的麻烦。

7 尝试改变 SwiftUI 视图后面的东西

SwiftUI 初学者最常犯的一个错误是他们常常试图去改变 SwiftUI 视图的背景。代码通常长下面这个样子:

1
2
3
4
5
6
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.background (Color.red)
}
}

这会展示一个白色的屏幕,中间是红色背景色只匹配文本区域的文本视图。而大多数人的本意其实是想让整个屏幕的背景呈现出红色。这个时候他们会想,SwiftUI 背后究竟是什么样的 UIKit 视图呢?

当然,背后肯定是有一个 UIKit 视图,它由 UIHostingController 管理,角色类似于一个 UIKit 视图控制器。但是假如你通过 SwiftUI 试图去踏足 UIKit 的领地,你的改动很可能会让 SwiftUI 呈现出奇怪的结果,或者你甚至都没法直接改动 UIKit。

实际上,想到达成大多数人想要的效果,SwiftUI 里的实现方式应该是像下面这样的:

1
2
3
4
Text("Hello, World!")
.frame (maxWidth: .infinity, maxHeight: .infinity)
.background (Color.red)
.ignoresSafeArea ()

8 动态视图的范围参数错误

有多个 SwiftUI 视图的构造器允许我们传入范围,这个事实让许多复杂视图的创建过程变得十分简单。

例如,假设我们想要展示一个拥有 4 个项目的列表,我们只需要这样写:

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {
@State private var rowCount = 4

var body: some View {
VStack {
List(0..<rowCount) { row in
Text("Row \(row)")
}
}
}
}

这样写本身没问题,不过一旦你需要在运行时改变范围时,问题就来了。你看我已经用 @State 属性包装器把想要改变的行数变成可修改的,所以我们可以用一个按钮来修改它的值:

1
2
3
4
Button("Add Row") {
rowCount += 1
}
.padding (.top)

运行代码,点击按钮,Xcode 调试输出会输出警告,而列表视图纹丝不动 —— 这个方案不管用。

问题出在你既没有为列表的参数提供 Identifiable 协议实现,也没有提供指定的 id 参数,以此告诉 SwiftUI 这个范围会动态变化:(译者:实际上并不是 “告诉 SwiftUI 范围会动态变化”,而是明确范围的项怎样才算变化。Identifiable 或者 id 参数明确了两个项之间是如何区别。能够区别开的项目才能侦测变化)。

1
2
3
List(0..<rowCount, id: \.self) { row in
Text("Row \(row)")
}

代码改成这样就没问题了。


封面来自 Zdeněk Macháček on Unsplash

chewy-EV9_vVMZTcg-unsplash

欢迎关注微信公众号「Swift 花园」

译自 https://swiftui-lab.com/nsuseractivity-with-swiftui/

译者注:作者为了扩展他的 SwiftUI 应用去研究了 NSUserActivity,发现关于 NSUserActivity 的大量信息都已经过时了。比如,绝大多数关于 Handoff 的文章都是 Handoff 特性刚有的时候发布的,而那个时候还没有 scene 的概念,所有逻辑都是通过 application 的 delegate 处理的。之后 scene 出现了,许多代码被移到了 scene delegate,原来的 Handoff 示例也就失效了。如果你是刚开始学习 NSUserActivity,一定会感到困惑,而现在 SwiftUI 也支持用户活动,但现在又没有 scene 了,变化更大,所以作者认为需要写一篇新的文档来介绍 SwiftUI 中 NSUserActivity 的使用。

NSUserActivity 令人费解的另一个原因是它是一个可以用来处理多个不相干功能的东西。它的各项属性只在某些情况下相关,多数情况下却是没有关联的。

下面是有关 NSUserActivity 的一些总结:

  • Universal Links: Universal links 是可以在关联应用或者 Safari 中打开的 URL。
  • SiriKit: Siri 可以调起你的应用并且告知你它想要做什么。
  • Spotlight: 定义你的应用可以做的动作,这些动作会被引入 Spotlight 的搜索结果中。
  • Handoff: 即 “接力”,指一个应用可以继续另一个应用的工作,或者一台设备上的相同应用可以继续另一个设备上的应用的工作。

这篇文档会提供一系列示例,逐步介绍 SwiftUI 中提供的用于处理 NSUserActivity 的方法,其中上面提到的每一种情况都会有示例。

重要的笔记

SwiftUI 中跟 NSUserActivity 有关的方法包括:onOpenURL (), userActivity (), onContinueUserActivity ()handlesExternalEvents ()。注意,这些方法只有当你的应用采用的是 SwiftUI 应用生命周期时才能工作。如果你的项目还是使用 scene delegate,引入这几个方法会在控制台输出下面的消息:

1
2
3
Cannot use Scene methods for URL, NSUserActivity, and other External Events 
without using SwiftUI Lifecycle. Without SwiftUI Lifecycle,
advertising and handling External Events wastes resources, and will have unpredictable results.

个人经验,上面的消息中提到的不可预测的结果,实际上完全可以预测:所有这些方法都将被忽略。

User Activity 的两面

根据 Apple 的 官方文档,一个用户活动(user activity)对象代表了某个应用在某个时刻的状态:

An NSUserActivity object provides a lightweight way to capture the state of your app and put it to use later. You create user activity objects and use them to capture information about what the user was doing, such as viewing app content, editing a document, viewing a web page, or watching a video. When the system launches your app and an activity object is available, your app can use the information in that object to restore itself to an appropriate state.

某个 NSUserActivity 对象提供一种捕捉你的应用状态的轻量级方式。你创建 user activity 对象,并用它们来捕获用户正在做的事情的信息,比如查看应用内容,编辑文档,阅览网页,或者观看视频。当系统启动你的应用时,如果活动对象可用,你的应用可以利用对象中的信息把应用还原成合适的状态。

理解了概念,我们就可以区分用户活动中的两个关键时刻:其一,用户活动创建(稍后说明何时、如何创建);其二,系统决定启动或者恢复某个应用,并且为应用提供一个 NSUserActivity,以便应用展示相关的 UI。我们接下来会学习如何在应用中对用户活动做出反应。

注意,尽管一个应用可以有多个 scene,但某个时刻只有一个 scene 会获得用户活动。在本文中我们还将了解到获取用户活动的 scene 是如何被确定的…


介绍 onOpenURL ()

Universal Links 对于把应用集成到网站十分有用。建立 Universal Links 需要几个步骤,Apple 为其提供了详细的文档:Universal Links

在 SwiftUI 中使用 NSUserActivity 的所有用法中,Universal Links 是最容易实现的。尽管 Universal Links 本质上是使用 NSUserActivity 来启动或者恢复你的应用,但假如你的应用是走 SwiftUI 应用生命周期,你却根本看不到 NSUserActivity 的踪影!

在 UIKit 里实现 Universal Links ,一般是在 scene delegate 里这么做:

1
2
3
4
5
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
doSomethingWith (url: userActivity.webpageURL)
}
}

但现在没有了 scene delegate,我们只需要简单地使用 onOpenURL 方法,它会得到 URL 对象,而不是 NSUserActivity 对象:

1
2
3
4
5
6
7
8
9
10
11
12
struct ContentView: View {
var body: some View {
SomeView()
.onOpenURL { url in
doSomethingWith (url: url)
}
}

func doSomethingWith(url: URL?) {
...
}
}

SiriKit

介绍 onContinueUserActivity ()

我们可以给一个应用中的特定部分定义快捷指令。在 iOS 中,这个动作可以借助 “快捷指令” 应用来实现,但我们也可以在应用内通过代码实现。UIKit 有一些专门的 UI 元素来处理这件事,但 SwiftUI 中并没有直接可用的方法,所以这一节中的例子会包含一个 UIViewControllerRepresentable,它的作用是提供一个按钮,点击这个按钮可以打开系统的模态表单,让用户创建或者编辑快捷指令。

一旦快捷指令创建,我们就可以调用 Siri 指令来执行它。它会启动或者恢复我们的应用,并且通过 NSUserActivity 提供用户希望我们执行的快捷指令的细节信息。SwiftUI 对此为我们提供了一个 onContinueUserActivity ()

在下面的例子中,通过指令 “Hey Siri, show random animal” (或者某些其它的预置指令),系统会启动我们的应用并导航到某个随机的动物视图。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import SwiftUI
import Intents

// 要记得把下面的声明添加到 Info.plist 文件的 NSUserActivityTypes 数组中
let aType = "com.example.show-animal"

struct Animal: Identifiable {
let id: Int
let name: String
let image: String
}

let animals = [Animal(id: 1, name: "Lion", image: "lion"),
Animal(id: 2, name: "Fox", image: "fox"),
Animal(id: 3, name: "Panda", image: "panda-bear"),
Animal(id: 4, name: "Elephant", image: "elephant")]

struct ContentView: View {
@State private var selection: Int? = nil

var body: some View {
NavigationView {
List(animals) { animal in
NavigationLink(
destination: AnimalDetail(animal: animal),
tag: animal.id,
selection: $selection,
label: { AnimalRow(animal: animal) })
}
.navigationTitle ("Animal Gallery")
.onContinueUserActivity (aType, perform: { userActivity in
self.selection = Int.random (in: 0...(animals.count - 1))
})

}.navigationViewStyle (StackNavigationViewStyle())
}
}

struct AnimalRow: View {
let animal: Animal

var body: some View {
HStack {
Image(animal.image)
.resizable ()
.frame (width: 60, height: 60)

Text(animal.name)
}
}
}

struct AnimalDetail: View {
@State private var showAddToSiri: Bool = false
let animal: Animal

let shortcut: INShortcut = {
let activity = NSUserActivity(activityType: aType)
activity.title = "Display a random animal"
activity.suggestedInvocationPhrase = "Show Random Animal"

return INShortcut(userActivity: activity)
}()

var body: some View {
VStack(spacing: 20) {
Text(animal.name)
.font (.title)

Image(animal.image)
.resizable ()
.scaledToFit ()

SiriButton(shortcut: shortcut).frame (height: 34)

Spacer()
}
}
}

下面是用于创建快捷指令和编辑模态表单的 UIViewControllerRepresentable

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import SwiftUI
import IntentsUI

struct SiriButton: UIViewControllerRepresentable {
public let shortcut: INShortcut

func makeUIViewController(context: Context) -> SiriUIViewController {
return SiriUIViewController(shortcut: shortcut)
}

func updateUIViewController(_ uiViewController: SiriUIViewController, context: Context) {
}
}

class SiriUIViewController: UIViewController {
let shortcut: INShortcut

init(shortcut: INShortcut) {
self.shortcut = shortcut
super.init(nibName: nil, bundle: nil)
}

required init?(coder: NSCoder) {
fatalError("init (coder:) has not been implemented")
}

override func viewDidLoad() {
super.viewDidLoad ()

let button = INUIAddVoiceShortcutButton(style: .blackOutline)
button.shortcut = shortcut

self.view.addSubview (button)
view.centerXAnchor.constraint (equalTo: button.centerXAnchor).isActive = true
view.centerYAnchor.constraint (equalTo: button.centerYAnchor).isActive = true
button.translatesAutoresizingMaskIntoConstraints = false

button.delegate = self
}
}

extension SiriUIViewController: INUIAddVoiceShortcutButtonDelegate {
func present(_ addVoiceShortcutViewController: INUIAddVoiceShortcutViewController, for addVoiceShortcutButton: INUIAddVoiceShortcutButton) {
addVoiceShortcutViewController.delegate = self
addVoiceShortcutViewController.modalPresentationStyle = .formSheet
present (addVoiceShortcutViewController, animated: true)
}

func present(_ editVoiceShortcutViewController: INUIEditVoiceShortcutViewController, for addVoiceShortcutButton: INUIAddVoiceShortcutButton) {
editVoiceShortcutViewController.delegate = self
editVoiceShortcutViewController.modalPresentationStyle = .formSheet
present (editVoiceShortcutViewController, animated: true)
}
}

extension SiriUIViewController: INUIAddVoiceShortcutViewControllerDelegate {
func addVoiceShortcutViewController(_ controller: INUIAddVoiceShortcutViewController, didFinishWith voiceShortcut: INVoiceShortcut?, error: Error?) {
controller.dismiss (animated: true)
}

func addVoiceShortcutViewControllerDidCancel(_ controller: INUIAddVoiceShortcutViewController) {
controller.dismiss (animated: true)
}
}

extension SiriUIViewController: INUIEditVoiceShortcutViewControllerDelegate {
func editVoiceShortcutViewController(_ controller: INUIEditVoiceShortcutViewController, didUpdate voiceShortcut: INVoiceShortcut?, error: Error?) {
controller.dismiss (animated: true)
}

func editVoiceShortcutViewController(_ controller: INUIEditVoiceShortcutViewController, didDeleteVoiceShortcutWithIdentifier deletedVoiceShortcutIdentifier: UUID) {
controller.dismiss (animated: true)
}

func editVoiceShortcutViewControllerDidCancel(_ controller: INUIEditVoiceShortcutViewController) {
controller.dismiss (animated: true)
}
}

Spotlight

介绍 userActivity ()

Spotlight 的搜索结果可以包含应用中的通用活动。为了让 Spotlight 学会你的活动,你需要在这些活动出现时对外发布,以便 Spotlight 发现它们。要在 SwiftUI 中发布 NSUserActivities,我们需要使用 userActivity () modifier。

在下面的例子中,我们有一个售卖冰淇淋的应用。每当我们选择了某个冰淇淋尺寸,应用将对外发布冰淇淋尺寸;每当有用户搜索冰淇淋时,我们的应用将出现在搜索结果中。如果用户选择了我们的应用对于的搜索结果项,我们的应用将被调起,并且将用户带到最后公布的冰淇淋尺寸。

注意,系统会优化 userActivity () 闭包的调用时机。然而不幸的是,这一点并没有文档说明。系统很聪明,知道该如何保存当前的信息,避免不停更新。在调试的时候,建议你最好在 userActivity 闭包中加入打印语句。

下面的例子中还包含了一个 “Forget” 按钮,这对于调试十分有帮助。它会清除掉已经发布的用户活动,以便将应用从 Spotlight 的搜索结果中移除。注意,NSUserActivity 有一个可选属性:expirationDate,如果将其置为 nil,则活动永远不会过期。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import SwiftUI
import Intents
import CoreSpotlight
import CoreServices

// 要记得把下面的声明添加到 Info.plist 文件的 NSUserActivityTypes 数组中
let aType = "com.example.icecream-selection"

struct IceCreamSize: Identifiable {
let id: Int
let name: String
let price: Float
let image: String
}

let sizes = [
IceCreamSize(id: 1, name: "Small", price: 1.0, image: "small"),
IceCreamSize(id: 2, name: "Medium", price: 1.45, image: "medium"),
IceCreamSize(id: 3, name: "Large", price: 1.9, image: "large")
]

struct ContentView: View {
@State private var selection: Int? = nil

var body: some View {
NavigationView {
List(sizes) { size in
NavigationLink(destination: IceCreamDetail(icecream: size),
tag: size.id,
selection: $selection,
label: { IceCreamRow(icecream: size) })
}
.navigationTitle ("Ice Creams")
.toolbar {
Button("Forget") {
NSUserActivity.deleteAllSavedUserActivities {
print("done!")
}
}
}

}
.onContinueUserActivity (aType, perform: { userActivity in
if let icecreamId = userActivity.userInfo?["sizeId"] as? NSNumber {
selection = icecreamId.intValue

}
})
.navigationViewStyle (StackNavigationViewStyle())
}
}

struct IceCreamRow: View {
let icecream: IceCreamSize

var body: some View {
HStack {
Image(icecream.image)
.resizable ()
.frame (width: 80, height: 80)

VStack(alignment: .leading) {
Text("\(icecream.name)").font (.title).fontWeight (.bold)
Text("$ \(String (format: "%0.2f", icecream.price))").font (.subheadline)
Spacer()
}
}
}
}

struct IceCreamDetail: View {
let icecream: IceCreamSize

var body: some View {
VStack {
Text("\(icecream.name)").font (.title).fontWeight (.bold)
Text("$ \(String (format: "%0.2f", icecream.price))").font (.subheadline)

Image(icecream.image)
.resizable ()
.scaledToFit ()

Spacer()
}
.userActivity (aType) { userActivity in
userActivity.isEligibleForSearch = true
userActivity.title = "\(icecream.name) Ice Cream"
userActivity.userInfo = ["sizeId": icecream.id]

let attributes = CSSearchableItemAttributeSet(itemContentType: kUTTypeItem as String)

attributes.contentDescription = "Get a delicious ice cream now!"
attributes.thumbnailData = UIImage(named: icecream.image)?.pngData ()
userActivity.contentAttributeSet = attributes

print("Advertising: \(icecream.name)")
}
}
}

Handoff - 接力

基于已经介绍的方法,我们可以创建一个接力应用了。这将是一个可以在另外的设备上接力工作的应用。两个设备上的应用可以是同一个应用或者不同的应用。这种情况通常发生在我们分发了应用的两个不同版本的时候:比如一个是 iOS 版本,另一个是 macOS 版本。

为了让接力能够工作,相关的应用都需要注册在同一个开发者团队的标识下面,并且在所有参与的应用的 Info.plist 中配置 NSUserActivityTypes 实体。

有关于接力的更多实现细节,可以参考 Apple 的 网站

下面的示例实现了一个简单的 web 浏览器,通过调用 userActivity () 发布用户正在浏览的页面以及他在页面上滚动的位置。

假如用户切换到了另一个设备,当同一个应用被调起或者恢复时,onContinueUserActivity () 闭包将被调用,应用可以借此打开同一个页面,并且滚动到他在之前设备上浏览到的页面位置。

用户活动可以通过 userInfo 字典的形式提供负载数据,它是我们存放特定于接力活动信息的地方。在这个示例中,即页面滚动位置(百分比)和所打开页面的 URL。此外,还包含了发布此活动的应用的 bundle id,它只是用来调试的信息,便于我们准确地知道发生的事情。

注意,示例的代码在 iOS 和 macOS 上都能工作,这是为了让你能够同时创建两个应用,并且测试 iOS 设备和 Mac 设备之间的接力。

最后,虽然跟 NSUserActivity 无关,这个示例还封装了一个 WKWebView,这是演示 javascript 事件(这个示例中的 onScroll)如何更新你的 SwiftUI 视图绑定的绝佳示例。完整的 WebView 代码可以从下面的 gist 文件找到:WebView.swift

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import SwiftUI

// 要记得把下面的声明添加到 Info.plist 文件的 NSUserActivityTypes 数组中
let activityType = "com.example.openpage"

struct ContentView: View {
@StateObject var data = WebViewData()

@State private var reload: Bool = false

var body: some View {
VStack {
HStack {
TextField("", text: $data.urlBar, onCommit: { self.loadUrl (data.urlBar) })
.textFieldStyle (RoundedBorderTextFieldStyle())
.disableAutocorrection (true)
.modifier (KeyboardModifier())
.frame (maxWidth: .infinity)
.overlay (ProgressView().opacity (self.data.loading ? 1 : 0).scaleEffect (0.5), alignment: .trailing)


Button(action: {
self.data.scrollOnLoad = self.data.scrollPercent
self.reload.toggle ()
}, label: { Image(systemName: "arrow.clockwise") })

Button("Go") {
self.loadUrl (data.urlBar)
}
}
.padding (.horizontal, 4)

Text("\(data.scrollPercent)")

WebView(data: data)
.id (reload)
.onAppear { loadUrl (data.urlBar) }
}
.userActivity (activityType, element: data.url) { url, activity in

let bundleid = Bundle.main.bundleIdentifier ?? ""

activity.addUserInfoEntries (from: ["scrollPercent": data.scrollPercent,
"page": data.url?.absoluteString ?? "",
"setby": bundleid])

logUserActivity (activity, label: "activity")
}
.onContinueUserActivity (activityType, perform: { userActivity in
if let page = userActivity.userInfo?["page"] as? String {
// Load handoff page
if self.data.url?.absoluteString != page {
self.data.url = URL(string: page)
}

// Restore handoff scroll position
if let scrollPercent = userActivity.userInfo?["scrollPercent"] as? Float {
self.data.scrollOnLoad = scrollPercent
}
}

logUserActivity (userActivity, label: "on activity")
})
}

func loadUrl(_ string: String) {
if string.hasPrefix ("http") {
self.data.url = URL(string: string)
} else {
self.data.url = URL(string: "https://" + string)
}

self.data.urlBar = self.data.url?.absoluteString ?? string
}
}

func logUserActivity(_ activity: NSUserActivity, label: String = "") {
print("\(label) TYPE = \(activity.activityType)")
print("\(label) INFO = \(activity.userInfo ?? [:])")
}

struct KeyboardModifier: ViewModifier {
func body(content: Content) -> some View {
#if os (iOS)
return content
.keyboardType (.URL)
.textContentType (.URL)
#else
return content
#endif
}
}

Scene 选择

介绍 handlesExternalEvents ()

当系统启动或者恢复我们的应用的时候,它必须确定哪个 scene 能够接受到用户活动(某一个时刻只有一个能接收到)。为了帮助它做出这个决策,我们的应用可以用上 handlesExternalEvents () 方法。不幸运是,写这篇文档的时候 (Xcode 12, beta 6),这个方法貌似不起作用,而且 macOS 上虽然有支持,但缺少平台定义文件。

所以我这里将通过注释来说明它的工作方式,等将来真正可用时,我会更新这篇文章。

这个方法有两个版本。一个用于 WindowGroup 场景:

1
func handlesExternalEvents(matching conditions: Set<String>) -> some Scene

另一个用于视图:

1
func handlesExternalEvents(preferring: Set<String>, allowing: Set<String>) -> some View

两种版本中我们都会指定一个字符串 Set,系统用它来跟 NSUserActivity 的 targetContentIdentifier 属性做比对。假如找到匹配项,对应的 scene 会被选用。如果不指定这个 Set,或者没有找到匹配项,则实际行为由具体平台决定。例如,在 iPadOS 上会选择一个已经现有的 scene,而在 macOS 上,新的 scene 会被创建。

在只支持一个 scene 的系统上,这个方法将被忽略。

注意,targetContentIdentifierUNNotificationContentUIApplicationShortcutItem 中也有提供,因此 handlesExternalEvents () 大概率也会支持它们。

总结

Apple 关于 NSUserActivity 的文档资料非常多,所以我建议你去查阅这些文档。但目前 缺少 SwiftUI 的示例。这篇文章的目的就是为你提供一些在 SwiftUI 中实践 NSUserActivity 的启动代码。


封面来自 Chewy on Unsplash

swift-evolution-4

欢迎关注微信公众号「Swift 花园」

Swift 5.3 有不少变化,这其中包括多模式 catch 语句,多拖尾闭包,以及 Swift Package Manager 的一些重要改变。

本文会带你浏览一些主要的变化,同时提供参考代码,以便你可以自行尝试。以下是要介绍的新特性的清单:

  • 多模式 catch 语句
  • 多拖尾闭包
  • 为枚举自动生成的 Comparable 实现
  • self. 书写省略
  • 基于类型的程序入口
  • 基于上下文泛型声明的 where 语句
  • 枚举的 cases 可以作为 protocol witnesses
  • 重新提炼的 didSet 语义
  • 新的 Float16 类型
  • Swift Package Manager 支持二进制依赖,资源等更多类型

多模式 catch 语句

SE-0276 引入了一个可以在单个 catch 块中捕获多个错误 case 的特性,这能让我们免除错误处理时的重复代码。

例如,下面的代码用枚举定义了错误的两种情况:

1
2
3
enum TemperatureError: Error {
case tooCold, tooHot
}

当我们读取到温度时,既可以抛出两种错误中的某一个,也可以返回 “OK”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func getReactorTemperature() -> Int {
90
}

func checkReactorOperational() throws -> String {
let temp = getReactorTemperature ()

if temp < 10 {
throw TemperatureError.tooCold
} else if temp > 90 {
throw TemperatureError.tooHot
} else {
return "OK"
}
}

在捕获错误的环节,SE-0276 允许我们用逗号分隔来表示我们要以相同方式处理 tooHottooCold

1
2
3
4
5
6
7
8
do {
let result = try checkReactorOperational ()
print(" 结果: \(result)")
} catch TemperatureError.tooHot, TemperatureError.tooCold {
print(" 关闭反应堆 & quot;)
} catch {
print(" 未知错误 & quot;)
}

处理的 case 可以是任意数量的。

多拖尾闭包

SE-0279 引入了多拖尾闭包,这使得调用包含多个闭包的函数可以更简单地实现。

这个特性在 SwiftUI 中非常受欢迎。原来形如下面这样的代码:

1
2
3
4
5
6
7
8
9
10
11
struct OldContentView: View {
@State private var showOptions = false

var body: some View {
Button(action: {
self.showOptions.toggle ()
}) {
Image(systemName: "gear")
}
}
}

可以被改写成:

1
2
3
4
5
6
7
8
9
10
11
struct NewContentView: View {
@State private var showOptions = false

var body: some View {
Button {
self.showOptions.toggle ()
} label: {
Image(systemName: "gear")
}
}
}

理论上并不要求 label: 要跟在前一个闭包的 } 后面,所以你甚至可以像下面这样书写:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct BadContentView: View {
@State private var showOptions = false

var body: some View {
Button {
self.showOptions.toggle ()
}

label: {
Image(systemName: "gear")
}
}
}

不过,我会建议你当心代码的可读性 —— 像上面的 label 那样的代码块,在 Swift 里看起来更像是标签化的代码块,而不是 Button 构造器的第二个参数。

注: 有关 Swift 的多拖尾闭包特性的讨论非常热烈。我想提醒大家的是,像这种类型的语法变动一开始看起来可能会有点别扭,我们需要耐心,给它时间,在实践中体会它带来的结果。

为枚举自动生成的 Comparable 实现

SE-0266 使得我们可以为枚举生成 Comparable 实现,同时不要求我们声明关联值,或者要求关联值本身必须是 Comparable 的。这个特性让我们可以在同类型的枚举之间用 <> 和类似的比较操作符来进行比较。

例如,假设我们有一个枚举,它描述了衣服的尺寸,我们可以要求 Swift 为它自动生成 Comparable 实现,代码如下:

1
2
3
4
5
6
enum Size: Comparable {
case small
case medium
case large
case extraLarge
}

然后我们就可以创建两个这个枚举的实例,并且用 < 进行比较:

1
2
3
4
5
6
let shirtSize = Size.small
let personSize = Size.large

if shirtSize < personSize {
print("T 恤 太小了!")
}

自动生成的实现,也能很好地适应枚举的 Comparable 关联值。例如,假设我们有一个枚举,描述了某个队伍获取世界杯冠军的次数,代码可以这样实现:

1
2
3
4
enum WorldCupResult: Comparable {
case neverWon
case winner (stars: Int)
}

然后我们用不同的值来创建枚举的不同实例,并且让 Swift 对它们进行排序:

1
2
3
4
5
6
7
8
let americanMen = WorldCupResult.neverWon
let americanWomen = WorldCupResult.winner (stars: 4)
let japaneseMen = WorldCupResult.neverWon
let japaneseWomen = WorldCupResult.winner (stars: 1)

let teams = [americanMen, americanWomen, japaneseMen, japaneseWomen]
let sortedByWins = teams.sorted ()
print(sortedByWins)

排序过程会把未获得世界杯冠军的队伍放在前面,然后是日本女子队,再然后是美国女子队 —— 两组 winner 的队被认为是大于两组 neverWon 的队,而 winner (stars: 4) 被认为是大于 winner (stars: 1)

self. 书写省略

SE-0269 使得我们可以在一些不必要的地方省略 self 。在这个改变之前,我们需要在所有的闭包当中对引用 self 的属性或者方法冠以 self.,以便显式地明确语义。但有的时候由于闭包不可能产生引用循环,self 是多余的。

例如,之前我们需要把代码写成下面这样:

1
2
3
4
5
6
7
8
9
10
11
struct OldContentView: View {
var body: some View {
List(1..<5) { number in
self.cell (for: number)
}
}

func cell(for number: Int) -> some View {
Text("Cell \(number)")
}
}

self.cell (for:) 的调用不会产生引用循环,因为它是在结构体内使用。多亏了 SE-0269,上面的代码现在可以免去 self.

1
2
3
4
5
6
7
8
9
10
11
struct NewContentView: View {
var body: some View {
List(1..<5) { number in
cell (for: number)
}
}

func cell(for number: Int) -> some View {
Text("Cell \(number)")
}
}

这个特性对于大量使用闭包的框架非常有用,包括 SwiftUI 和 Combine。

基于类型的程序入口

SE-0281 引入了一个新的 @main 属性,它可以让我们声明程序的入口。这个特性使得我们可以精确地控制程序启动时要执行的代码,对于命令行程序尤其有帮助。

例如,当我们创建一个终端应用时,我们必须创建一个叫 main.swift 的文件,然后把启动代码放在里面:

1
2
3
4
5
6
7
8
struct OldApp {
func run() {
print("Running!")
}
}

let app = OldApp()
app.run ()

Swift 会自动把 main.swift 看作最顶层的代码,创建 App 实例并且运行。即便在 SE-0281 之后这个做法都一直被延续,但现在你可以干掉 main.swift 了,转而使用 @main 属性来标记某个包含静态 main 方法的结构体或者类,让它充当程序入口:

1
2
3
4
5
6
@main
struct NewApp {
static func main() {
print("Running!")
}
}

上面的代码所在的程序运行时,Swift 会自动调用 NewApp.main () 来启动程序流程。

新的 @main 属性对于 UIKit 和 AppKit 开发者来说可能有点属性,因为我们正是用 @UIApplicationMain@NSApplicationMain 来标记 app 代理的。

不过,使用 @main 的时候有一些注意事项:

  • 已经有 main.swift 文件的 app 不能使用这个属性
  • 不能有一个以上的 @main 属性
  • @main 属性只能用在最顶层的类型上 —— 这个类型不继承自任何其他类

基于上下文泛型声明的 where 语句

SE-0267 引入一个新特性,你可以给泛型类型或者扩展添加带有 where 语句限定的函数。

例如,我们创建了一个简单的 Stack,可以压栈,出栈元素:

1
2
3
4
5
6
7
8
9
10
11
struct Stack<Element> {
private var array = [Element]()

mutating func push(_ obj: Element) {
array.append (obj)
}

mutating func pop() -> Element? {
array.popLast ()
}
}

借助 SE-0267,我们现在可以添加一个 sorted () 方法给这个 Stack,并且要求这个方法只有在 Stack 的泛型参数 Element 遵循 Comparable 协议的时候才能使用:

1
2
3
4
5
extension Stack {
func sorted() -> [Element] where Element: Comparable {
array.sorted ()
}
}

枚举的 cases 可以作为 protocol witnesses

SE-0280 使得枚举可以参与 protocol witness matching,这是一种表述我们可以更容易地匹配协议要求的技术方式。

例如,你可以编写代码处理各种类型的数据,但是假如数据不见了怎么办呢?当然,你可以借助空合运算符,每次都提供一个默认值。不过。你可以借助协议来要求默认值,然后让各种类型遵循这个协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protocol Defaultable {
static var defaultValue: Self { get }
}

// 让整数有默认值 0
extension Int: Defaultable {
static var defaultValue: Int { 0 }
}

// 让数组有默认值空数组
extension Array: Defaultable {
static var defaultValue: Array { [] }
}

// 让字典有默认值空字典
extension Dictionary: Defaultable {
static var defaultValue: Dictionary { [:] }
}

SE-0280 使得我们能对枚举做出一样的控制。比如,你有一个 padding 枚举,它能接收像素值,厘米值,或者是系统的默认值:

1
2
3
4
5
enum Padding: Defaultable {
case pixels (Int)
case cm (Int)
case defaultValue
}

这样的代码在 SE-0280 之前是无法实现的 —— Swift 会抱怨 Padding 不满足协议。但是,如果你仔细琢磨一下,协议其实是满足的:我们需要一个静态的 defaultValue,它返回 Self,换言之,就是某个遵循协议的具体类型,而这正是 Padding.defaultValue 提供的。

重新提炼的 didSet 语义

SE-0268 调整了 didSet 属性观察者的工作方式,以便它们能更高效地工作。对于这个优化你不需要改动任何代码,自动获得一个小小的性能提升。

在内部,Swift 做出的改变是在设置新值时不再查询旧值。如果你不使用旧值,也没有设置 willSet,Swift 会即时修改数值。

假如你需要用到旧值,只需要引用 oldValue 即可,方式如下:

1
2
3
didSet {
_ = oldValue
}

新的 Float16 类型

SE-0277 引入了一个新的半精度浮点类型,Float16。这个精度在图像编程和机器学习中十分常见。

新类型和 Swift 原来的其他类型相似:

1
2
3
4
let first: Float16 = 5
let second: Float32 = 11
let third: Float64 = 7
let fourth: Float80 = 13

Swift Package Manager 支持二进制依赖,资源等更多类型

Swift 5.3 为 Swift Package Manager (SPM) 带来了很多提升,恕我不能在这里一一举例。不过我们可以讨论一下 SPM 有哪些变化以及为什么会有这些变化。

首先,SE-0271 (Package Manager Resources) 使得 SPM 能包含诸如图片,音频,JSON 等类型的资源。这个机制可不只是把文件拷进最终的 app bundle 这么简单 —— 举个例子,我们可以应用一个自定义处理步骤到我们的 assets,比如为 iOS 优化图片。为此,新增的 Bundle.module 属性就是用来在运行时访问这些 assets 的。SE-0278 (Package Manager Localized Resources) 进一步支持了资源的本地化版本,例如提供适用某个国家的图片。

其次,SE-0272 (Package Manager Binary Dependencies) 使得 SPM 可以使用二进制包。这意味着像 Firebase 这样的闭源 SDK 现在也可以通过 SPM 集成了。

再次,SE-0273 (Package Manager Conditional Target Dependencies) 可以让我们指定为特定平台和配置使用依赖。例如,我们可能在为 Linux 平台编译时,额外需要某些特定的框架,或者我们在本地测试的时候需要一些依赖调试用的框架。

值得一提的是,SE-0271 的 “Future Directions” 一节中提到了对资源的安全访问 —— 这意味着像 Image ("avatar") 这样的代码之后会变成 Image (module.avatar)

In our latest game development, I implemented an interesting action: “jumping block”.

The team used this skill on the white wolf guard. The specific skill is called “sacrifice”. 🤨

Whenever the wolf leader around him receives deadly ballistic damage, the heroic white wolf guard will take a deep leap and block this damage for his boss.

Here is the video demonstration on Youtube:

And here is the schematic diagram:

The realization of the jumping block is very interesting 🤪. It is a combination of mathematics and physics.

Let’s skip the unimportant part and look directly at the most critical code:

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
/// <summary>
/// A callback invoked when we find a particle(i.e. our bullet) is flying toward something
/// </summary>
/// <param name="collisionInstance">a manager contains information about the particle</param>
/// <param name="estimatedArrival">estimated time of flight</param>
public void OnParticleHeadingToTarget(ParticleCollisionInstance collisionInstance, float estimatedArrival)
{
// Check if the bullet is flying toward our fellow
if (collisionInstance != null && collisionInstance.HeadingTarget == wolfFellow.gameObject)
{
// Calculate which point should I jump in order to take the bullet for my fellow
jumpSpot = collisionInstance.EvaluateNecessaryArrival(4f, out float time);
// of course, you should look at the bullet when you try to take it.
lookSpot = collisionInstance.gameObject.transform.position;

/// Every creature has its react time when something happens
float reactTime = (_dollAction as WolfDollAction).WardJumpToApexDuration;

if (reactTime <= time)
{
Invoke(nameof(WardFellow), time - reactTime);
}
}
}

/// <summary>
/// Ward fellow, for wolf guard, warding is implemented by jump to take bullet for fellow
/// </summary>
void WardFellow()
{
WolfDollAction wolfAction = _dollAction as WolfDollAction;

wolfAction.TriggerJumpWard(wolfFellow, jumpSpot, lookSpot);
}

the mathematical part, EvaluateNecessaryArrival

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

/// <summary>
/// Evaluate necessary arrival which we must pass through
/// </summary>
/// <param name="distanceToHeadingTarget">distance for the arrival to the target</param>
/// <param name="estimatedTime">out parameter, a estimated time by when we arrive</param>
/// <returns>the vector of the must-pass arrival</returns>
public Vector3 EvaluateNecessaryArrival(float distanceToHeadingTarget, out float estimatedTime)
{
// Target
var heading = HeadingTargetSpot - transform.position;
// Distance
var currentDistance = heading.magnitude;
// Direction
var direction = heading / currentDistance;

Vector3 arrival = HeadingTargetSpot + direction * -distanceToHeadingTarget;
estimatedTime = (currentDistance - distanceToHeadingTarget) / MainFlyingParticleVelocity.magnitude;

return arrival;
}

It’s not that difficult, right? 🙊 Hoping you enjoy this skill. 😬