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

/// <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. 😬

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

建议横屏阅读代码

  • 本文的主要价值:提供一种抽象复杂逻辑,达成功能复用的思路
  • 关键词:语义提炼、动态具名
  • 本文约 4000 字,建议阅读时间 12 分钟。

引子

在软件开发中,我们常常会遇到一种场景:随着产品功能的扩展,出现了多个具备高度相似性的功能单元。作为功能单元,它们可能有着相似的交互逻辑,提供同类的输入数据和输出数据。并且,对于用户来说,它们都在处理同一个的东西。

举个例子,比如一款修图 app ,它包含了一组编辑功能,每个功能都作用于一张图片,处理之后的图片,还可以作为其他功能的输入。作为编辑工具,在每个功能内部,可能还需要支持撤销和重做这样的用户操作。我们容易想到的是,这些功能间存在着许多可以进行复用设计的代码。

本文基于一次回顾发起,出于记录和分享的目的:一次代码重构,一款之前我参与开发的图像处理应用。


重构的具体背景

请看下面这幅图:

某个图像功能模块的结构图

元素不多,让我解释一下。图中的 “__内存图像管理 + 效果处理__” 是一个 “__黑盒子__”。“逻辑黑盒” 有的时候是好事,有的时候是坏事。那这里的黑盒子算好事还是坏事呢? 既然对这种设计做了重构,多半是有痛点了。这里,我们重点探讨一下它的负面效应。

在具体业务场景下,这个黑盒子有两个问题:

  • 图像处理接口粒度太大,难以复用代码;
  • 图像 管理 和图像 效果处理 被绑定在一起。使得外部难以灵活的接触和使用图像。强调一下,管理和效果处理是两件事。前者是站在用户的角度,后者是站在服务提供者的角度。更高层应用逻辑的开发者,对于更底层支撑 API 的开发者来说,也是用户。

“管道” 概念的提炼

黑盒子的两个问题在重构时都得到了解决,但第一个问题与本文要分享的设计思想关联不大,不做展开。

为了说明我们是如何解决第二个问题的,这里先引入两个概念:“__流水线__” 和 “__例程__”。相信对于从事编程类工作的读者来说,这两个词不会陌生。

流水线 pipeline,[计] 又称管道,管线。
例程 routine,[计] 程序;日常工作;例行公事

在我们的案例中,Pipeline 相当于内存中的图像状态机,提供了基本的图像管理功能,例如加入图像,删除图像,复制图像,移动图像,等等。Routine 相当于各个图像功能单元中的通用事务,比如说,对于每个图像功能单元,都需要在其开始运作时从某处获得一份初始的图像,并在其结束运作时输出一份 最终的 图像到另一处。我们还约定,Routine 中的事务会基于 Pipeline 来完成。可以具体解释成这样:每个 Routine 都会包含一组典型的图像处理动作,这些处理动作借助一个或者多个 Pipeline 的通用操作,以及每个 Pipeline 的差异化操作来完成(后面会具体说明这个 ___差异化的图像处理步骤___)。

从这里开始,我们不妨把 “__流水线__” 的叫法直接替换成 “__管道__”,因为后面会用到一些比喻性的描述,我个人它们觉得基于 “管道” 一词衍生出来,会比用 “流水线” 来得更自然。接下来,我们对 “管道” 这个意象再做进一步的挖掘,可以设计出下面这些对应关系(表格中左侧的概念只是一种比喻,读者可自行体会,这里不会详细解读)

比喻 原对象
“管道” 图像状态机
“流体” 图像
“节点” 图像状态
“流动” 图像状态流转
“锋面”(流体的最前端) 当前正在处理的图像状态
“连通性” 状态机内的图像以及图像状态机之间都是可串联的

“流体” 是一个名词,它对应的是图像,涉及到存储模型。根据 “流体” 的特性,我们可以想象,或者说推断,管道里的图像存储模型应该会被设计为平行结构。

请读者联想一个类比, <__化妆 / 整容 VS 软件上美化照片上的人脸__> ,再思考一下,两者在存储模型和工序这两个方面有什么异同?

回到正题,我配了五幅图来描述管道在具体实现中的五个特性:

  1. “流体” 由一系列 “节点” 组成。“节点”,即图像的状态,它的含义构成了我们对某一个图像的本征性认知。通俗地讲,图像状态能够帮助我们在特定的场景下把不同的图像区分开来。举个例子,有协同开发的两位程序员,对于 “美颜” 和 “滤镜” 这两个步骤的认知达成了共识。于是,我们就可以建立两个节点:“美颜”、“滤镜”,然后在开发过程中使用这两个节点来 协作 。注意,图像状态不是图像本身。对于图像状态的代码实现,我们可以使用一个极轻量的数据结构 —— 字符串。它体现的是 __占位符思想__,而我想要强调占位符的三个重要好处:它们是 __可预见的__(基于认知共识)、__可预置的__(很轻量)、__可固化的__(可复用代码的一个内在要求)。

节点及同位节点

  1. “管道” 通过衔接 “节点” 构成 “连通”。在 “节点” 中,有必要特别介绍的是 “同位节点”。它指的是:几个步骤在 同一个图像 上先后发生。在时间上有先后,但在空间上始终操作同一份存储。我后面会再用到这个描述。

连通性

  1. “流动” 的 “流体” 会有一个 最前部 ,就好像水流的最前端,又称为 “锋面” (Waterfront),对应着这样一个事实:“管道” 中所有的图像,在同一时间里,只会有 唯一的 图像处于 可操作 的状态,这个状态代表着 图像的变化趋势 。具体到代码实现,可能会是一组带有 * 同步关键字 * 的方法,加上一个唯一的指向当前状态的指针。我们通过 引导操刀 这个趋势,把图像 “引向” 最终要呈现出来的样子。在图示中,我有意使用了绿色代表原始的、最初的,使用红色代表成熟的、完全体的。Pipeline 专注于做一件事:把图像从一种状态转化为另外一种状态。这期间,可能要经历多个 “节点”,而 “锋面” 的意义就在于,它保证了一件事。那就是 Pipeline 的操刀者可以确信,这一刻只有他自己在引导图像的 “流向”,没有人会干扰到他。

    锋面

  2. “流动” 可以是双向的(相比生产车间的 “流水线”,“管道” 之所以更贴切,在于后者可以实现双向的流动,对应到图像,相当于实现反向编辑,或者说撤销到一个处理步骤之前的状态)

  3. “流体” 如果 “分流”,则可以出现多个 “锋面”,对应着图像的 并行处理

    分流

“管道” 的具体实现

如前所述,“流体” 其实就是图像,简单封装即可。我们主要实现的是 “__节点__”、“__锋面__”、 “__流动__” 和 “__连通__”。

节点的实现方案和意义

我们先来看一种典型的图像处理过程中可能会采用的写法,代码为 swift 实现:

1
2
3
4
5
6
// 图像 xyz
var xyz: MyImage
// 图像 ijk
var ijk: MyImage
// 图像 abc
var abc: MyImage

当然,现代编程语言的语法特性,可以让你省去写各种 getter/setter 的样板代码,从而节省代码量。但这不是重点,重点在于 —— 上述这种代码无法复用。因为每一个图像的引用都被赋予了 具体 的含义:同样的写法不太可能完全地适用于另外一个图像处理场景。比如说,另外那个图像处理场景很可能不会用到描述为 ijk 的图像,可能会用到描述是 uvw 的图像。因此,采取这种写法会遇到的一个典型问题是:每新增一个图像处理场景,我们都需要新增若干个特定描述的图像声明。在编码层面,这无疑是一项繁冗的工作。

上面说的图像引用,其实正是我们的图像 “管道” 里的某个 “节点”。思考一个问题,如果要对 “节点” 实现代码复用,你会怎么做?稍微提示一下,关键在于 “__具体__” 这两个字。

是的,如果我们能想到,上面的写法中代码之所以不能复用,根源在于图像引用的用途已经被 __具体定义__(同时也是被具体 __约束__),那么我们就更有可能往这样一个方向思考问题的解决方案:能不能把图像引用 “去具体化” ,让它的含义在具体场景到来时才被赋予呢?

讲到这一层,有些读者可能已经想到一种数据结构 —— 字典。是的,没有什么奇淫巧技,只用字典,就能实现 “去具体化”,解决这个代码复用问题中的最大障碍 —— 既然无法预知我们可能需要处理什么样的图像,可能需要处理多少份图像,并且这些未知数总是易变的,那为什么不让具体场景的使用者来 动态添加 这些图像引用,并且为它们具名呢?图像部分被复用的代码,这里只声明了一样东西,就是从图像状态表述到图像引用的映射表。它提供了一个之前的写法不具备的特点,而这个特点是达成复用的必要前提:图像存取的方式是 __统一的__,__有限的__,从而是 __可固化的__。

1
var stateTagToImageMap = [String:MyImage]()

我们用一个 字符串标签 来表示图像的状态。对于图像 “管道” 的使用者来说,他只需要理解每个标签的含义,通过标签来存取图像并进行处理。在这些标签中,我们再提炼出几个具有通用含义的代表性标签:比如,original 代表 “最初的__”,processed 代表 “__加工完成的__”,这正是前文提到的 __占位符 。容易理解,在一份可复用的代码库中,你可以声明并且预置许多 占位符 。但你不会在这个代码库里声明同样数量的图像引用 —— 这样很奇怪对吧?哪怕从程序实现的角度来说,没有分配实际空间的引用并不一定会占据更多的内存。在后面列举的代码范例中,我们将会经常地用到 originalprocessed 这样的标签。

不妨阅读下面这段代码,这就是一种使用标签来操作对应图像的写法。

1
2
3
4
5
6
7
// 显示两个处理步骤之后的图像 
pipeline.from (.original) // 从原始的图像开始
.copyTo (.processed) // 拷贝出一份图像用于处理,对应标签 processed
.doProcess (tag: .processed, specificProcess1) // 在 processed 上执行处理 1
.doProcess (tag: .processed, specificProcess2) // 在 processed 上执行处理 2

showImage (pipeline.fetch (.processed)) // 取得 processed 标签代表的图像并且展示

锋面的实现方案和意义

解决了 “节点” 的设计,我们再来看基于 “节点” 提炼出来的 “锋面” 要怎么设计。容易理解,“__锋面__” 是最前面的那个 “节点” ,具有 __唯一性__,对应具体的图像处理代码中就是 “当前正在被处理的那个图像”。在设计图像管道对外提供的处理 API 时,我们约定处理动作一定只能发生在这个 “__当前的__” 图像上,这样就能够保证我们的 “图像流” 总是按照我们想要的方向流动,并且在这个过程中,“图像流” 是不会被篡改的。这也是我们的图像编辑功能要实现撤销和重做功能的基本前提。

还是上面那段代码,现在可以去掉实际处理步骤的标签参数。因为我们约束了处理只能发生在 唯一的__、__当前的 图像上。

1
2
3
4
5
6
7
// 显示一个处理步骤之后的图像 
pipeline.from (.original) // 从原始的图像开始
.copyTo (.processed) // 拷贝出一份图像用于处理,对应标签 processed
.doProcess (specificProcess1) // 隐含了在 processed 上执行处理 1
.doProcess (specificProcess2) // 隐含了在 processed 上执行处理 2

showImage (pipeline.fetch (.processed)) // 取得 processed 标签代表的图像并且展示

如果要求能够回撤到第一个处理步骤之后的状态,再做第二个处理步骤,并且第二个处理步骤的参数是可以改变的。可以这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 显示一个处理步骤之后的图像,但我们在过程中保留了第一个步骤的状态 
pipeline.from (.original)
.copyTo ("specificProcess1") // 相比一步到位,这里多存储了第一个步骤的状态
.doProcess (specificProcess1)
.copyTo (.processed)
.doProcess (specificProcess2.setParams (params1))

showImage (pipeline.fetch (.processed))

// 调整第二个步骤的某些参数,重新显示图像
pipeline.from ("specificProcess1") // 之前存储了第一个步骤的状态,直接从这个步骤开始
.copyTo (.processed)
.doProcess (specificProcess2.setParams (params2))

showImage (pipeline.fetch (.processed))

流动和连通性的实现方案

有了 “节点” 和 “锋面”,“流动” 和 “连通” 就有了作用的主体。对应到图像编辑功能,“__流动__” 其实就是图像从一个状态变成另外一个状态的过程。“__连通__” 则更好理解,一个管道出来的图像可以被另外一个管道接纳,由此构成管道之间的连接。连接在一起的每一节 “__小管道__” 各司其职,灵活组合,再构成更长跨度的 “__大管道__” 或者 “__管道网络__”,从而协同完成复杂的业务流程。

回归到代码,我们来看一组步骤稍多的图片处理工序,看它是如何体现出管道的 “流动性” 和 “连通性”。刨去内部的实现细节,整合或者忽略一些与管道设计思想关联不大的逻辑,以下代码在流程上算是比较接近实际生产环境了。虽然采用的是伪代码,相信读者可以看懂。

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
/** 主功能区,不妨将它的例程称为 Main
* 基本功能:
* 1. 展示图像
* 2. 可以从这里进入各子功能处理图片再回到这里展示新的图片
* 3. 撤销到经过某个步骤处理之前的图像或者重做出之前做过但是被撤销掉的某个步骤的图像
*/
RoutineMain.startFrom (imageFile) {
RoutineMain.pipeline.loadFrom (imageFile, .original) // 从图片中加载初始的图像
}
RoutineMain.showCurrent () {
showImage (RoutineMain.pipeline.front () // 显示 “锋面”
}

/** 进入到一个叫 “美型” 的功能区,对应的例程称为 FaceLift
* 基本功能:
* 1. 展示图像
* 2. 针对图像中的人脸轮廓,五官进行形状调整
* 3. 输出处理后的图像到主功能区
*/
RoutineFaceLift.startFrom (RoutineMain.pipeline.front ().copy ())
RoutineFaceLift.process () {
RoutineFaceLift.pipeline
// 这个过程用户无法干预,不会有 “重做”,因此我们可以直接在原稿上操作
.from (.original)
.doProcess (faceLift_step_1_process)
.doProcess (faceLift_step_2_process)
.doProcess (faceLift_step_3_process)
...
}
// 把子功能 “美型” 处理好的图像提交给主功能
RoutineMain.accept (RoutineFaceLift.commit ())

/** 进入到一个叫 “滤镜” 的功能区,对应的例程称为 Filter
* 基本功能:
* 1. 展示图像
* 2. 滤镜化处理图像
* 3. 输出处理后的图像到主功能区
*/
RoutineFilter.startFrom (RoutineMain.pipeline.front ().copy ());
RoutineFilter.process () {
RoutineFilter.pipeline
// 这个过程中用户决定要选用哪个具体的滤镜,因此每次都需要基于原稿复制一份再滤镜化
.from (.original).copyTo (.processed)
.doProcess (filterProcess (pickFilter ("awful")))
... // 皱眉,这个不好,换一个!
.from (.original).copyTo (.processed)
.doProcess (filterProcess (pickFilter ("notbad")))
... // 托腮,这个还行,再换个试试~
.from (.original).copyTo (.processed)
.doProcess (filterProcess (pickFilter ("perfect")))
... // 完美~
...
}
// 把子功能 “滤镜” 处理好的图像提交给主功能
RoutineMain.accept (RoutineFilter.commit ())

/** 进入到一个叫 “美颜 ” 的功能区,对应的例程称为 SkinBeauty
* 基本功能:
* 1. 展示图像
* 2. 针对图像中的人脸皮肤进行色相调整
* 3. 输出处理后的图像给 Main 功能
*/
RoutineSkinBeauty.startFrom (RoutineMain.pipeline.front ().copy ());
RoutineSkinBeauty.process () {
RoutineSkinBeauty.pipeline
// 这个过程用户可以调节一个滑竿来控制色相参数,每次都基于原稿复制一份再调色相
.from (.original).copyTo (.processed)
.doProcess (skinBeautyProcess (level_too_weak))
... // 托腮,效果好像不明显,加强一点
.from (.original).copyTo (.processed)
.doProcess (skinBeautyProcess (level_too_much)))
... // 皱眉,好像有点过头了,往回调一点
.from (.original).copyTo (.processed)
.doProcess (skinBeautyProcess (level_just_right)
... // 完美~
...
}
// 把子功能 “美颜” 处理好的图像提交给主功能
RoutineMain.accept (RoutineSkinBeauty.commit ())

// 纠结一下。。
// 犹豫,要不还是不美颜了吧?
RoutineMain.undo ();
// 迟疑,滤镜也不要了?
RoutineMain.undo ();
// 思考中。。。
//... 不行,还是都加回来吧
RoutineMain.redo ().redo ();
// 端详 5 分钟。。。完美~
save (); // 收工,准备发朋友圈

总结

关于 “管道” 的设计思路和实现方案介绍到此。我们可以回顾一下,本文开始所提到 “黑盒子” 设计的第二个问题:“图像 管理 和图像 效果处理 被绑定在一起。”,在管道方案中是不是已经解决了呢?

“管道” 设计的基石是 无差别地管理图像 ,被管理的每一个图像,由最初将其投入管道的创建者为其定义标签。最初的创建者和后来的协同者,只需要对这个标签的含义达成 共识 便可以进行协作。“管道” 的思想是模拟 “流体__” 的运行方式来实现图像处理过程,通过 “__节点__” 的设定来 __分解 处理步骤,通过 “锋面__” 的操控来 __聚焦 每个单步的操作,通过 连通性 来将 分治 的逻辑重新 串联 起来完成复杂的功能。