欢迎关注微信公众号「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 (); // 收工,准备发朋友圈

总结

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

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

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

译自 MVVM in SwiftUI

让我们用 MVVM (model-view-view model) 来构建一个应用,其中的每个 SwiftUI 视图都有自己的 model 。这会是一个拥有两个视图的 app : 一个电影列表以及一个用于添加电影的表单。新增的电影存在在 MovieStore ,它由两个 view models 共享。我们将通过 environment 来共享 MovieStore ,也就说,当我们需要时,会从 environment 中读取。

用 Movie 和 MovieStore 来表示数据

Movie 是一个很小的结构体,只存储了标题和评分。标题和评分都是可变的,因为我们需要在 AddMovieView 里更新它们。这个结构体也遵循 Identifiable 协议,因为我们将用 List 视图来展示所有的电影。List 需要能够标识内容中的每一项,而遵循这个协议是最简单的方式。

1
2
3
4
5
6
7
8
9
10
11
struct Movie: Equatable, Identifiable {
let id = UUID()
var fullTitle: String
var givenRating: Rating = .notSeen
}

extension Movie {
enum Rating: Int, CaseIterable {
case notSeen, terrible, poor, decent, good, excellent
}
}

MovieStore 也很简单,不过实际的 app 会包含更多的逻辑:持久化,删除等等。我们用 Published 属性包装器来为订阅者自动提供发布。

1
2
3
4
5
6
7
final class MovieStore {
@Published private(set) var allMovies = [Movie]()

func add(_ movie: Movie) {
allMovies.append (movie)
}
}

为了将共享的 MovieStore 插入环境,我们需要使用自定义的 EnvironmentKey 。自定义 key 仅仅只是一个遵循 EnvironmentKey 协议的自定义 key 。我们需要提供类型和默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct MovieStoreKey: EnvironmentKey {
typealias Value = MovieStore
static var defaultValue = MovieStore()
}

extension EnvironmentValues {
var movieStore: MovieStore {
get {
return self[MovieStoreKey]
}
set {
self[MovieStoreKey] = newValue
}
}
}

如果我们不插入自己的 MovieStore 实例到 environment ,那就会使用 defaultValue 默认值。典型情况下,我们会在视图体系之外初始化这个特定实例。

SceneDelegate 和 MovieScene 呈现

MovieStore 作为依赖项,在构造函数被传给 view model 。我们将使用存储在 SceneDelegate 的实例。再次申明,在实际的 app 中,这种依赖项很可能是处于一个独立的容器或者别的类似的东西。 MovieListView 是我们要呈现的第一个视图,因此我们会初始化 view model , view ,并且插入 MovieStore 实例到 environment ,以便后续使用。 (movieStore keypath 是通过 EnvironmentValues 的 extension 来定义的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let movieStore = MovieStore()

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let viewModel = MovieListView.ViewModel(movieStore: movieStore)
let contentView = MovieListView(viewModel: viewModel).environment (\.movieStore, movieStore)

guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible ()
}
}

MovieListView 和对应的 ViewModel

在 SwiftUI 中,view model 遵循 ObservableObject 协议,使用 @Published 属性包装器。 ObservableObject 的默认实现提供了 objectWillChange publisher 。 @Published 属性包装器能在属性将要改变时自动发射这个 publisher 。在 MovieListView 中,我们用 @ObservedObject 属性包装器声明 view model 属性。这会使得该视图订阅 objectWillChange publisher ,并且在 objectWillChange 发动时自动刷新视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension MovieListView {
final class ViewModel: ObservableObject {
private let movieStore: MovieStore
private var cancellables = [AnyCancellable]()

init(movieStore: MovieStore) {
self.movieStore = movieStore
cancellables.append (movieStore.$allMovies.assign (to: \.movies, on: self))
}

@Published private(set) var movies = [Movie]()
@Published var isPresentingAddMovie = false
}
}
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
struct MovieListView: View {
@Environment(\.self) var environment
@ObservedObject var viewModel: ViewModel

var body: some View {
NavigationView {
List(self.viewModel.movies) { movie in
Text(movie.fullTitle)
}.navigationBarTitle ("Movies")
.navigationBarItems (trailing: navigationBarTrailingItem)
}
}

private var navigationBarTrailingItem: some View {
Button(action: {
self.viewModel.isPresentingAddMovie = true
}, label: {
Image(systemName: "plus").frame (minWidth: 32, minHeight: 32)
}).sheet (isPresented: self.$viewModel.isPresentingAddMovie) {
self.makeAddMovieView ()
}
}

private func makeAddMovieView() -> AddMovieView {
let movieStore = environment [MovieStoreKey]
let viewModel = AddMovieView.ViewModel(movieStore: movieStore)
return AddMovieView(viewModel: viewModel)
}
}

你会注意到,MovieStore 时用了两份,一份在 view model 中,一份放在环境中。

AddMovieView 和它的 view model 是在用户点击导航栏上的加号按钮时被创建的。环境属性包装器可以被用于获取整个环境或者借助特定键获取某个值。在这个案例中我们访问了整个环境对象,然后在需要的时候借助 MovieStoreKey 访问 MovieStore 。或者你也可以使用 @Environment (.movieStore) var movieStore 来代替。

AddMovieView 和对应的 ViewModel

AddMovieView 的 view model 是随着 MovieStore 一同被初始化的,它内部呈现了一个 Movie 实例。 Published 属性包装器和 MovieListView 的 view model 里的用法相似。 内部的 movie 对象是一个私有的属性, TextField 和 Picker 都采用双向 Binding 。 Binding 是一种 view 和 model 间的双向连接方式。另外,还有一个 canSave 属性,它是用来控制导航栏上的保存按钮是否启用。保持按钮只有在标题有填充的时才启用。

简单复习一下视图更新的流程:TextField 或者 Picker 会利用 Binding 来更新私有属性 newMovie 。 因为 newMovie 属性使用了 @Published 属性包装器,它会发射 ObservableObject 的 objectWillChange publisher 。 SwiftUI 自动订阅 objectWillChange ,因为 view model 的属性用了 @ObservedObject 。

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
extension AddMovieView {
class ViewModel: ObservableObject {
private let movieStore: MovieStore

init(movieStore: MovieStore) {
self.movieStore = movieStore
}

@Published private var newMovie = Movie(fullTitle: "")

lazy var title = Binding<String>(get: {
self.newMovie.fullTitle
}, set: {
self.newMovie.fullTitle = $0
})

lazy var rating = Binding<Movie.Rating>(get: {
self.newMovie.givenRating
}, set: {
self.newMovie.givenRating = $0
})

var canSave: Bool {
return !newMovie.fullTitle.isEmpty
}

func save() {
movieStore.add (newMovie)
}
}
}

struct AddMovieView: View {
@Environment(\.presentationMode) private var presentationMode
@ObservedObject var viewModel: ViewModel

var body: some View {
NavigationView {
Form {
titleSection
ratingSection
}.navigationBarTitle ("Add Movie", displayMode: .inline)
.navigationBarItems (leading: leadingBarItem, trailing: trailingBarItem)
.navigationViewStyle (StackNavigationViewStyle())

}
}

private var titleSection: some View {
Section() {
TextField("Title", text: viewModel.title)
}
}

private var ratingSection: some View {
Section() {
Picker(LocalizedStringKey("Rating"), selection: viewModel.rating) {
ForEach(Movie.Rating.allCases, id: \.rawValue) {
Text($0.localizedName).tag ($0)
}
}
}
}

private var leadingBarItem: some View {
Button(action: { self.presentationMode.wrappedValue.dismiss () }, label: {
Text("Cancel")
})
}

private var trailingBarItem: some View {
Button(action: {
self.viewModel.save ()
self.presentationMode.wrappedValue.dismiss ()
}, label: {
Text("Save").disabled (!self.viewModel.canSave)
})
}
}

总结

我们创建了一个只有两个视图的简单 app 。两个视图都有各自的 view model ,并且都依赖 MovieStore 。一个 view model 中触发了 MovieStore 的改变,这些改变会被另一个 view model 观察到。另外,我们还了解了 SwiftUI 的 environment 以及如何从 view model 中触发 view 更新。


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

用 SwiftUI 在 Apple Watch 上构建动态通知

译自 Dynamic user notification on Apple Watch with SwiftUI
源码地址:WaterMyPlants

集成了推送或者本地通知的 app 可以定制 apple watch 上的通知。本文是关于如何在 apple watch 上实现动态通知的笔记。样例工程实现一个提醒给植物浇水的功能。我们会聚焦在添加通知视图,省略从 iOS app 发送通知的步骤。

为 Apple Watch 添加富文本通知添加构建目标

如果工程里没有 App Watch app ,你需要添加它。在 Xcode 中,我们新增一个构建目标,并配置成包含通知场景。打开 New -> Target:

确保 User Interface 选择,并且 “Include Notification Scene” 选中。我们将会把它嵌入当前的 iOS app ,所以 “Embed in Companion App” 要选择当前 app 。值得一提的是,从 iOS 13 和 WatchOS 6 开始,Apple Watch app 已经可以独立存在了。

点击完成,Xcode 会询问激活新的 scheme ,点击激活,它会自动选择新建的目标,所以我们可以直接开始写代码了。先检查工程,会发现 Xcode 加了两个目标:watch app 和 extension。App 包含了 storyboard ,而 extension 包含了所有的代码。 storyboard 是提供基于 WKHostingController 的子类的 HostingController 演示用的场景。这个类负责承载你的 Apple Watch app 的 SwiftUI 视图。另外,还有两个场景,分别是静态和动态通知。我们对动态通知感兴趣,在 storyboard 里可以看见动态视图是由 NotificationController 提供的,它是 WKUserNotificationHostingController 的子类,承载通知的 SwiftUI 视图。这里就是我们给通知提供自定义界面的地方。如果通知的分类和 storyboard 里预先定义的匹配,就会选择动态通知视图。

解析通知的 payload 并设置动态通知视图

NotificationController 的职责是消费用户通知的 payload ,并生成 SwiftUI 视图来展示它们。用户通知是从 didReceive 函数接收的,我们需要释放信息,用于视图。在本地测试的时候,我们可以把测试数据写在 PushNotificationPayload.apns 文件里。因为我们要展示的是关于植物的信息,所有我们添加一个植物对象到文件中。同时,我们还需要把通知分类修改成某个有含义的字符串。确保你设置新的分类时正确更新 storyboard 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"aps": {
"alert": {
"body": "Test message",
"title": "Optional title",
"subtitle": "Optional subtitle"
},
"category": "WATERING_REMINDER",
"thread-id": "plantid123"
},

"plant": {
"id": "plantid123",
"name": "Aloe",
"lastDate": 1579937802,
"nextDate": 1580515200
}
}

当我们访问 UNNotification.request.content.userInfo 拿到植物的信息时,我们可以用 DecodableJSONDecoder 将代表植物的字典转换成值类型。 JSONDecoder 接收 JSON 数据,所以我们先用 JSONSerialization 包装数据,然后把包装的结果传给 JSONDecoder 。 或者我们也可以手动从 userInfo 字典里读取所有的值,然后创建出植物类型。留意,我们需要用 view model 来提供数据给 SwiftUI ,而不是直接使用 Plant 类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Plant: Decodable {
let id: String
let name: String
let lastDate: Date
let nextDate: Date
}

do {
let plantInfo = notification.request.content.userInfo ["plant"] as! [String: Any]
let data = try JSONSerialization.data (withJSONObject: plantInfo, options: [])
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let plant = try decoder.decode (Plant.self, from: data)
viewModel = NotificationViewModel(plant: plant)
}
catch let nsError as NSError {
print(nsError.localizedDescription)
}

另外,我们想要添加三个用户可以执行的动作:标记植物已经浇水,推后提醒,或者安排明天再提醒。这些动作是用 UNNotificationAction 实例表示。当用户点击任意其中一个时,UNUserNotificationCenter 的委托方法会被调用,并且带有该动作的 identifier

1
2
3
4
5
6
7
8
let doneTitle = NSLocalizedString("NotificationAction_Done", comment: "Done button title in notification.")
let laterTitle = NSLocalizedString("NotificationAction_Later", comment: "Later button title in notification.")
let tomorrowTitle = NSLocalizedString("NotificationAction_Tomorrow", comment: "Tomorrow button title in notification.")
notificationActions = [
UNNotificationAction(identifier: "water_done", title: doneTitle, options: []),
UNNotificationAction(identifier: "water_later", title: laterTitle, options: []),
UNNotificationAction(identifier: "water_tomorrow", title: tomorrowTitle, options: [])
]

NotificationController 的完整实现如下:

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
final class NotificationController: WKUserNotificationHostingController<NotificationView> {
private var viewModel: NotificationViewModel?

override var body: NotificationView {
return NotificationView(viewModel: viewModel!)
}

override func didReceive(_ notification: UNNotification) {
do {
let plantInfo = notification.request.content.userInfo ["plant"] as! [String: Any]
let data = try JSONSerialization.data (withJSONObject: plantInfo, options: [])
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let plant = try decoder.decode (Plant.self, from: data)
viewModel = NotificationViewModel(plant: plant)
}
catch let nsError as NSError {
print(nsError.localizedDescription)
}

let doneTitle = NSLocalizedString("NotificationAction_Done", comment: "Done button title in notification.")
let laterTitle = NSLocalizedString("NotificationAction_Later", comment: "Later button title in notification.")
let tomorrowTitle = NSLocalizedString("NotificationAction_Tomorrow", comment: "Tomorrow button title in notification.")
notificationActions = [
UNNotificationAction(identifier: "water_done", title: doneTitle, options: []),
UNNotificationAction(identifier: "water_later", title: laterTitle, options: []),
UNNotificationAction(identifier: "water_tomorrow", title: tomorrowTitle, options: [])
]
}
}

呈现通知的 NotificationView

上面提到 view model NotificationViewModel 为 NotificationView 提供文本,它主要处理日期的格式化字符。

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
struct NotificationViewModel {
private let plant: Plant

init(plant: Plant) {
self.plant = plant
}

var title: String {
return plant.name
}

var subtitle: String {
return NSLocalizedString("NotificationView_Subtitle", comment: "Notification suggestion text")
}

private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = DateFormatter.dateFormat (fromTemplate: "dMMMM", options: 0, locale: .current)
return formatter
}()

var lastWatering: String {
let format = NSLocalizedString("NotificationView_LastWatering", comment: "Last watering date.")
return String(format: format, dateFormatter.string (from: plant.lastDate))
}

var nextWatering: String {
let format = NSLocalizedString("NotificationView_NextWatering", comment: "Next watering date.")
return String(format: format, dateFormatter.string (from: plant.nextDate))
}
}

SwiftUI 视图很简单,4 个文本。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct NotificationView: View {
let viewModel: NotificationViewModel

var body: some View {
VStack {
Text(viewModel.title).font (.title)
Text(viewModel.subtitle).font (.subheadline)
Divider()
Text(viewModel.lastWatering).font (.body).multilineTextAlignment (.center)
Text(viewModel.nextWatering).font (.body).multilineTextAlignment (.center)
}
}
}

小结

我们往一个 iOS app 中添加了 watch app ,实现一个通知分类的动态通知视图。我们学习了如何解析通知数据,添加动作按钮。下一步是在 companion iOS app 里基于按钮的 identifier 处理对应通知动作。


拉取和显示数据

这一节的主题是从 compasion iOS app 的 CoreData 存储中获取数据,需要借助 WatchConnectivity framework 。

iOS 和 WatchOS app 之间的 session

iOS app 用 CoreData 来存储植物列表,记录了每株植物上一次和下一次浇水的日期。在这里,没有 web 服务,所有的东西都存在设备上。那么如何把持久化存储中的数据拿给 WatchOS app 使用呢?

我们会用到 WatchConnectivity framework 来做 iOS 和 WatchOS app 之间的交互。连接是在 iOS 和 WatchOS app 上都激活 WCSession 来实现的。因此,第一步是添加一个管理 WCSession 的类到 iOS 工程,我们不妨称它为 WatchConnectivityProvider (稍后也会添加一个相似的类到 WatchOS app)。它的主要职能是建立 WCSession ,处理 WCSessionDelegate ,其中包含从 CoreData 存储拉取数据。因此,有一个叫 NSPersistentContainer 的参数会提供对 CoreData 栈的访问 (借由访问 performBackgroundTask 函数)。

1
2
3
4
5
6
7
8
9
10
final class WatchConnectivityProvider: NSObject, WCSessionDelegate {
private let persistentContainer: NSPersistentContainer
private let session: WCSession

init(session: WCSession = .default, persistentContainer: NSPersistentContainer) {
self.persistentContainer = persistentContainer
self.session = session
super.init()
session.delegate = self
}

WCSession 是通过调用 activate () 来激活,激活过程是异步的。激活的响应通过 session (_:activationDidCompleteWith:error:) 委托访问返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func connect() {
guard WCSession.isSupported () else {
os_log (.debug, log: .watch, "watch session is not supported")
return
}
os_log (.debug, log: .watch, "activating watch session")
session.activate ()
}
func session(_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?) {
os_log (.debug,
log: .watch,
"did finish activating session % lu (error: % s)",
activationState == .activated,
error?.localizedDescription ?? "none")
}

在 watchOS extension target 那边,我们会添加相似的代码,不过名字不一样,叫 “PhoneConnectivityProvider” 。当两个类都创建完成后,我们需要初始化并调用 connect ,分别在 SceneDelegate (iOS) 和 ExtensionDelegate (watchOS) 中完成。注意,在 iOS app 这边,我们需要实现两个委托方面,不过目前我们简单打印就可以了。

1
2
3
4
5
6
7
func sessionDidBecomeInactive(_ session: WCSession) {
os_log (.debug, log: .watch, "session became inactive")
}

func sessionDidDeactivate(_ session: WCSession) {
os_log (.debug, log: .watch, "session deactivated")
}

为了测试 session ,我们需要先编译并运行,然后在编译运行 watchOS app 。如果一切工作正常, Xcode 调试窗口会打印出消息: “did finish activating session 1 (error: none)”. 这表明 session 已经建立并且正在运行,我们可以两个 app 间发送消息了。

Fetching plants from iOS app

因为 iOS 和 watchOS app 之间的通信依赖字典,所以第一步是定义一组两个 app 共享使用的 key 。这样可以减少误拼写的风险,所以我们可以添加新文件,并且同时包含到 iOS app target 和 watchOS extension target 中去。

1
2
3
4
5
6
7
8
struct WatchCommunication {
static let requestKey = "request"
static let responseKey = "response"

enum Content: String {
case allPlants
}
}

第二步是在 PhoneConnectivityProvider (watchOS app extension target) 中实现一个 refreshAllPlants (completionHandler) 函数,用来发送消息给 iOS app ,并且等待植物数据的数组返回。 WCSession 有一个叫 sendMessage (_:replyHandler:errorHandler:) 的函数,我们可以用它来发送一个字典给 iOS app ,然后等待 reply handler 。我们会用 WatchCommunication.requestKey 和 WatchCommunication.Content.allPlants 枚举的 rawValue 来构建消息。这种模式便于后续扩展,你只要添加更到 case 到枚举就可以了。在 reply handler 中,我们期望得到一个字典的数组,描述所有的植物。让我们先看一眼完整的实现,然后再讨论字典是如何被转换成 Plant 值类型的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func refreshAllPlants(withCompletionHandler completionHandler: @escaping ([Plant]?) -> Void) {
guard session.activationState == .activated else {
os_log (.debug, log: .phone, "session is not active")
completionHandler (nil)
return
}
let message = [WatchCommunication.requestKey: WatchCommunication.Content.allPlants.rawValue]
session.sendMessage (message, replyHandler: { (payload) in
let plantDictionaries = payload [WatchCommunication.requestKey] as? [[String: Any]]
os_log (.debug, log: .phone, "received % lu plants", plantDictionaries?.count ?? 0)

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let plants = plantDictionaries?.compactMap({ Plant(dictionary: $0, decoder: decoder) })
DispatchQueue.main.async {
completionHandler (plants)
}
}, errorHandler: { error in
os_log (.debug, log: .phone, "sending message failed: % s", error.localizedDescription)
})
}

iOS app 上处理 CoreData 和 Plant 类型的是一个 NSManagedObject 子类的对象。watchOS app extension 定义了它自己的 Plant 值类型,因为它并没有 CoreData 栈。为了将字典转换成值类型,我们可以使用 “Storing struct in UserDefault” 中描述的方法,只需要额外配置 JSONDecoder 使用的 dateDecodingStrategysecondsSince1970 。理由是我们希望以自 1970 年之后的秒数来存储日期。转换字典到值类型的过程用到了 JSONSerialization ,它只支持 NSStringNSNumberNSArrayNSDictionary , 或者 NSNull

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Plant value type in WatchOS app extension
struct Plant: Identifiable, Decodable, DictionaryDecodable {
let id: String
let name: String
let lastWateringDate: Date
let nextWateringDate: Date
}
// Plant class in iOS app
final class Plant: NSManagedObject, Identifiable {
@NSManaged var id: String
@NSManaged var name: String

@NSManaged var lastWateringDate: Date
@NSManaged var nextWateringDate: Date
}

第三步是在 iOS app 端处理消息,并且提供数据给 watchOS app 。我们需要做的是实现 session 的委托,从 CoreData 栈中获取字典数据。 先看下完整实现,然后逐一拆解。

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
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
os_log (.debug, log: .watch, "did receive message: % s", message [WatchCommunication.requestKey] as? String ?? "unknown")
guard let contentString = message [WatchCommunication.requestKey] as? String , let content = WatchCommunication.Content(rawValue: contentString) else {
replyHandler ([:])
return
}
switch content {
case .allPlants:
persistentContainer.performBackgroundTask { (managedObjectContext) in
let all = Plant.allPlantsDictionaryRepresentation () as! [[String: Any]]
// Replace Date with Double
let converted = all.map { (plantDictionary) -> [String: Any] in
plantDictionary.mapValues { (value) -> Any in
if let date = value as? Date {
return date.timeIntervalSince1970
}
else {
return value
}
}
}
let response = [WatchCommunication.responseKey: converted]
replyHandler (response)
}
}
}

第一步是查看接收到的字典,看看 watchOS app 请求的是哪些内容。然后我们访问持久化存储,获取表示 Plant 的字典,把其他的日期转换成 1970 年后秒数的形式 (以便 watchOS app 能够在字典上使用 JSONSerialization),然后把数据发送回 watchOS app 。注意,从 CoreData 中获取字典形式的 Plant 很容易:我们首先是请求 NSDictionary 类型的数据,并且将结果类型属性设置为 .dictionaryResultType 。对于各庞大的模型,我们可能还会用到属性集合 (propertiesToFetch) 。不过目前,所有的属性都被添加到字典中了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
extension Plant {
static let entityName = "Plant"

static func makeDictionaryRequest() -> NSFetchRequest<NSDictionary> {
return NSFetchRequest<NSDictionary>(entityName: entityName)
}
static func allPlantsDictionaryRepresentation() -> [NSDictionary] {
let request = makeDictionaryRequest ()
request.resultType = .dictionaryResultType
do {
return try request.execute ()
}
catch let nsError as NSError {
os_log (.debug, log: .plants, "failed fetching all plants with error % s % s", nsError, nsError.userInfo)
return []
}
}
}

用 SwiftUI 在 watchOS app 中构建 UI

Xcode 中 watchOS app 的模板是借助 storyboard 初始化 HostingController, 这个控制器负责提供初始的 SwiftUI 视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
class HostingController: WKHostingController<PlantListView> {
lazy private(set) var connectivityProvider: PhoneConnectivityProvider = {
let provider = PhoneConnectivityProvider()
provider.connect ()
return provider
}()

private lazy var listViewModel = PlantListViewModel(connectivityProvider: connectivityProvider)

override var body: PlantListView {
return PlantListView(viewModel: listViewModel)
}
}

PlantListView 是一个显示植物列表的简单视图,它用 PhoneConnectivityProviderrefreshAllPlants (withCompletionHandler:) 来处理刷新植物的逻辑。 SwiftUI 视图会在 view model 改变时自动更新。这是因为 view model 的 plants 属性使用了 @Published 属性包装器,而 view model 本身是 ObservableObject ,这是 SwiftUI 视图中为 view model 采用的属性包装器 (更多信息可以阅读 refreshing SwiftUI view in MVVM in SwiftUI) 。注意,这里的 view model 是 SwiftUI 视图显现时刷新内容的。

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
final class PlantListViewModel: ObservableObject {
private let connectivityProvider: PhoneConnectivityProvider

init(plants: [Plant] = [], connectivityProvider: PhoneConnectivityProvider) {
self.plants = plants
self.connectivityProvider = connectivityProvider
refresh ()
}
@Published private(set) var plants: [Plant]

func refresh() {
connectivityProvider.refreshAllPlants { [weak self] (plants) in
guard let plants = plants else { return }
self?.plants = plants
}
}
}
struct PlantListView: View {
@ObservedObject var viewModel: PlantListViewModel

var body: some View {
VStack {
List(self.viewModel.plants) { plant in
PlantCell(viewModel: PlantCellViewModel(plant: plant))
}
}.onAppear {
self.viewModel.refresh ()
}
}
}

PlantListViewPlantCell 来显示独立的视图。

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
struct PlantCell: View {
let viewModel: PlantCellViewModel

var body: some View {
VStack(spacing: 4) {
Text(viewModel.title).font (.headline).multilineTextAlignment (.center)
Text(viewModel.subtitle).font (.footnote).multilineTextAlignment (.center)
}.padding (8)
.frame (minWidth: 0, maxWidth: .greatestFiniteMagnitude)
}
}
struct PlantCellViewModel {
let plant: Plant

var title: String {
return plant.name
}

private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = DateFormatter.dateFormat (fromTemplate: "dMMMM", options: 0, locale: .current)
return formatter
}()

var subtitle: String {
let format = NSLocalizedString("PlantCellView_NextWatering", comment: "Next watering date.")
return String(format: format, Self.dateFormatter.string (from: plant.nextWateringDate))
}
}

小结

我们在 iOS 和 watchOS app 上都添加 WCSessions ,实现相关的委托方法以处理 session 和接收到的消息。然后,我们定义一个简单的通信模式,并在 watchOS app 端实现刷新植物的方法,在 iOS 端实现 CoreData 集成。当数据访问创建完成后,我们在 watchOS app 上用 SwiftUI 视图显示植物的列表。


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

第一章 基础

创建和绑定视图

1 创建项目和探索 Canvas

要点:

  1. 创建项目时 User Interface 选择 “SwiftUI”
  2. 基础扩展:演示 Xcode 界面布局,菜单
  3. SwiftUI 文件的两部分:描述视图内容和布局的主结构体,预览
  4. 显示隐藏 Canvas 面板快捷键
  5. 演示实时预览 (改变 body 里的代码) SwiftUI 开发体验的核心卖点之一
    进阶资源:声明式 UI 、Flutter 和 SwiftUI 的比较,拖尾闭包语法

2 自定义文本视图

要点:

  1. 演示从预览中的检视 UI 元素,顺便提一下 modifier ,演示 Font modifier
  2. 代码和视图的关系:source of truth (真理之源)
  3. 演示预览、检视器和代码的自动同步

3 通过堆叠组合视图

要点

  1. body 属性只返回一个视图
    进阶资源:关于 body 属性的文章
  2. 介绍三类 stacks ,HStack, VStack,ZStack
  3. 介绍 Xcode 的结构化编辑:嵌套、检视、提取等。通用的 ” 右键菜单 “ ,SwiftUI 专有的 Cmd + 点击
    进阶资源:Flutter
  4. 演示 embed in vstack
  5. 演示添加控件: + 按钮,快捷键
  6. 演示两层 Stack 嵌套,演示 Spacer 的使用。
    进阶资源:Spacer 的特性
  7. 演示 padding
    进阶资源:padding 的各种版本

4 创建自定义图像视图

要点

  1. 简介 image view ,常见图片相关概念:mask,边缘 (border),投影 (drop shadow)
  2. 基础扩展: 添加资源到 asset catalog
  3. 演示创建新的 SwiftUI 文件,CircleImage
  4. 演示 Image () 构造器,演示 clipShaper modifier, Circle mask
    进阶:mask 遮蔽
  5. 演示 overlay, shadow

5 让 UIKit 和 SwiftUI 一起工作

  1. 如何在 SwiftUI 中使用 UIKit 里的视图?关键词:wrap, UIViewRepresentable 协议
    进阶资源:wrap,协议
  2. [学习曲线] 跳过,后续再来说明。
    MapKit 相关知识点

6 构成视图 (Compose Views) —— (需要练习)

要点:

  1. 选择容器 —— stacks,文字、图像、Spacer、自定义视图
  2. 调整视图 —— modifier,属性
  3. 打开源链接,演示样例 app 界面变化过程。

检查理解的测验。


构建 List 和导航

1 了解样本数据

要点:

  1. 理解 Model ,粗浅理解为数据,相对于视图
    进阶资源:MVC 设计模式,结构体和类的区别,数据格式,json
  2. 带一些新的关键字,协议
  3. 进阶扩展:为什么要用扩展 Landmark 的 image 属性
  4. json 数据格式

2 创建 “行” 视图

要点

  1. 创建新 SwiftUI 文件,起名 LandmarkRow.swift
  2. 进阶扩展:存储属性和计算属性
  3. 介绍 Image resizable modifier

3 自定义预览

要点

  1. previewLayout
    进阶资源:previewDevice

4 创建列表

要点

  1. List 的元素可以是动态的、静态的或者混合的
    进阶资源:SwiftUI 的 List
  2. 使用 List
    进阶资源:

5 创建动态列表

要点

  1. List 接收 identifiable 的数据
    进阶资源:Swift keypath 语法,SwiftUI 的 List、ForEach
    进阶资源:闭包、拖尾闭包语法
  2. Identifiable 协议:id 属性的约定

6 在 List 之间建立导航

要点

  1. NavigationView
    进阶资源:SwiftUI 给视图 “赋能” 的方式
  2. 标题、导航按钮
    进阶资源:标准化 UI
  3. NavigationLink

7 数据传递

要点

  1. 演示改动

8 生成动态预览

要点

  1. 演示实现方式
    进阶:对移动开发的意义

处理用户输入

1. 标记最爱的地标

要点

  1. 添加一个图标:系统图标、缩放、着色
    进阶:SF Symbols,着色

2 过滤列表视图

要点

  1. @State 属性

3 添加控件以触发 State

要点

  1. 绑定:$ 语法

4 用 Observable Object 存储

要点

  1. ObservableObject 协议
  2. @Published 属性

5 在视图中接收模型对象

要点:

  1. 环境变量: EnvironmentOjbect 属性声明,environmentObject 传入
  2. where 语句

6 为每个地标创建按钮

要点:演示


第二章 绘制和动画


第三章 App 设计和布局

构成复杂界面

1 Home 视图

要点

Home 界面
进阶:心智模式:干扰和专注,简单和复杂 (通知)

2 分类列表

要点

  1. 分类 => 建立层级 (“抽屉”、容器)=> 寻找
  2. 用字典将地标分组
  3. 重温 List、ForEach、keypath 的用法

3 增加地标分类的内容

要点

  1. 重温 Stack
  2. ScrollView (.horizontal), ForEach

4 构成 Home 视图

要点

  1. 改造 CategoryRow
  2. listRowInsets、EdgeInsets

5 添加导航

要点

  1. 重温 NavigationLink
  2. Image .renderingMode , Text .foregroundColor
  3. 重温 @State, 重点: sheet
    进阶:alert, 内建的环境变量 .presentationMode
  4. 导航栏按钮 navigationBarItems

和 UI 控件合作

1 显示用户资料

2 编辑模式

要点

  1. editMode
  2. 条件视图,wrappedValue

3 定义资料编辑器

要点

  1. Divider
  2. Toggle
  3. Picker
  4. DatePicker

4 延后编辑的生效

要点

  1. 编辑草稿
  2. 可取消的编辑
  3. onAppear, onDisappear

第四章 Framework 集成

与 UIKit 对接

1 创建视图来表示 UIPageViewController

要点

  1. UIViewControllerRepresentable, #makeUIViewController, #updateUIViewController
    进阶资源:回调
  2. 用 SwiftUI 的 view 来构建 UIPageViewController 中的 controller
  3. map

2 创建 ViewController 的数据源

要点

  1. 使用 Coordinator, UIPageViewControllerDataSource

3 用 SwiftUI 中的视图状态跟踪 Page

要点

  1. @State, @Binding 的传递
  2. UIPageViewControllerDelegate

4 添加一个自定义页面控制

要点

  1. UIViewRepresentable
  2. UIPageControl, UIControl
    进阶资源:“控件”
  3. target-action pattern , *delegate#selector
  4. @objc 关键字
  5. 可选进阶:起名字,一个名字在多个层次中使用
    PageControl, UIPageControl
  6. 可选进阶:PageViewController 和 PageControl 之间如何联动

创建 watchOS App

1 添加 watchOS 目标

要点

  1. 激活 watch app 的 scheme
  2. Supports Running Without iOS App Installation
    进阶内容:watchOS 6.0, watch app 和 iOS app 的关系

2 在目标间共享文件

要点

  1. 文件 inspector, Target membership 段
  2. watchkit app 和 watchkit extension 的区别
    进阶内容:watch app 的结构

3 创建细节视图

要点

  1. 适配不同尺寸的屏幕
  2. where 语句
  3. scaleToFill, scaleToFit

4 添加 watchOS 的地图视图

要点

  1. WKInterfaceObjectRepresentable
    进阶:为什么不能像复用 CircleImage 那样直接复用 iOS 里写好的地图视图?SwiftUI 的定位(learn once, apply everywhere)
  2. 添加到 detail 界面

5 创建跨平台的列表视图

要点

  1. 泛型 类型推断
  2. 对应修改 iOS scheme 的 Home 视图,LandmarkList 的初始化,重温拖尾闭包。
  3. LandmarkList 预览怎么解决? #if, #else, #endif, typealias

6 (在 watch app 中)添加 Landmarks List

要点

  1. 切换 scheme
  2. 思考题:watch app 为什么没有 Home ?

7 创建自定义的通知接口

要点

  1. 如何正确地使用通知?
  2. 构建通知视图:swift 的 init 模式
  3. 通知控制:WKUserNotificationHostingController, #didReceive,
  4. UNNotification
  5. 通知配置:Notification Category, apns 文件模拟远程通知
  6. 切换到 Notification scheme

创建 macOS app

1 创建 macOS 目标

要点

  1. Deployment Target
    进阶资源: 软件兼容(向前兼容) 版本号

2 共享数据和 Assets

要点

  1. Membership

3 Row 视图

4 组装视图

要点

  1. List (selection: Binding)

5 过滤器视图

要点

  1. 相同控件在不同平台上的外观
  2. preview 中样例的 .constant 用法
  3. 数组相加
  4. 通用规范:复杂条件,注意使用括号

6 组合列表和过滤器视图

7 复用 CircleImage

要点

  1. 属性默认值

8 macOS 上的地图视图

要点

  1. 善用 extension 分治代码

9 构建细节视图

10 Master Detail 视图

要点

  1. mac (iPad) 上的应用布局,NavigationView

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

认识论 (Epistemology)

在把假说 - 演绎法作为获取世界知识的最佳途径之前。第一个问题是:现实的本性?什么是现实?什么存在?因此,我们首先要问,获得的知识到底是关于什么的知识。解决这类问题的哲学领域叫做 本体论 (ontology) —— 对存在的研究。第二个问题涉及获取知识的方法。假设的确有一个现实:原则上是可知的,那么我们能获取哪些现实的知识?怎么获取?哲学领域中考虑这类问题的叫 认识论 (epistemology) —— 认识的研究或理论。

我们从后一个问题开始讲。假设有一个可知的现实,我们如何来获取知识。有很多不同的认识论观点,我们这里讨论两个最重要的观点。

第一个是 理性主义 (rationalism) 。理性主义者认为知识通过理性获得,用我们大脑的逻辑和理性思维能力,可以推断世界的真理,而无需借助经验。

哲学家柏拉图和笛卡尔结合了理性主义和其他观点,认为至少某些自然结构的抽象概念是天生的,是我们与生俱来的。就是说,我们的大脑能轻易理解这些概念,因为我们已经知道了,只要用推理回忆或辨识即可。

经验主义 (Empiricism) 反对这一观点,经验主义者认为感官经验才是最主要方式。一些绝对经验主义者甚至认为,这是获取世界知识的唯一方式。

亚里士多德被认为是第一个经验主义者,他认为关于自然的基本真理来自感官经验。我们可以通过演绎推理获取更多知识,但观察是所有知识的基础。亚里士多德不相信天赋观念 (innate ideas) 。其实,他创造了 “白板” 这个词,指出每个人生下来就是一块白板。我们的知识并不是预先确定的,大脑可以接受任何概念。

当然,亚里士多德不是激进的经验主义者。他不反对理性思维加入进来,他也不反对用一些抽象的、不能直接观察的概念。

我觉得伽利略算是温和的经验主义者。他很注重观察法和实验法,但他也极度依赖逻辑推理。伽利略有句名言 “自然之书以数学语言写就”。他完全不排斥运用思想实验,也在他的假说中引入不可观测的性质。

后来的经验主义者如培根,尤其是休谟和逻辑实证主义者,都是绝对经验主义者,坚信只有感官经验才能获得世界的真知。他们认为建立在无法被直接观测的共相上的论断没有意义。

现代的经验主义就是范弗拉森的建构经验主义。它强调感官经验在归纳法和演绎法中都有作用,但它也允许理论术语存在,对应不能直接观测的实体。建构经验主义的目标是提出实证上恰当的解释,只要它表述的世界与观测到的一致,即可被接受。建构经验主义者会说因为存在不可观测的性质,真假无法判断。这承认了知识是暂时的,因为未来总存在发现新的反例的可能。


本体论 (Ontology)

我们来说说本体论 (ontology) 即现实的本质是什么。

有很多对立的观点。在深入各种哲学观点之前,我先来解释这些观点里两个主要区别。第一点,现实是否独立存在于人类思想之外。当我们感知世间万物,它们真的脱离我们而真实存在吗?或者只是我们思想构建的心理表征,只能说存在于我们的思想。第二点是关于本体状态的殊相 (particular) 和共相 (universal)。殊相是指具体实例或能观察到属性的事件;共相或者说不可观察的性质,就是指那些无法直接观察到的普遍性质。

我们来举个例子,爱是我们无法直接观察到的普遍性质,但能通过行为表达或具现。因此当我家猫爬上我的膝盖打个小盹,这就是爱这个共相的具体实例。重力是另一个不可观察的共相,重力可用具体实例来表现。例如,当我打翻猫食盆,它就会掉在地上。

我们来看一下不同的本体论观点,看看他们对殊相和共相问题,以及对现实是外部存在还是仅存于思想内的看法。

唯心主义 (Idealism) 哲学观认为我们感知的现实完全存在于脑内。在我们精神世界感知到之前,外部物质世界的存在与我们无关。现实其实是精神世界的映射,重力和爱是存在的,但仅在我们的思想里,与之相关的具体实例也是如此。唯心主义者会说:猫睡在我膝上,以及碗掉在地上,这都是你想出来的。

对唯心主义而言,共相或不可观察的性质,它们是否真的在外部独立存在无关紧要。因为他们认为殊相和共相都是存在的,但都是精神世界的映射。

和唯心主义相对的是唯物主义 (Materialism) 。唯物主义认为思想世界外有独立存在的世界,唯物主义还认为一切由独立的物质世界构成。这就是说一切都是实物相互作用的结果,包括我们的意识、情绪和思想,这些是我们大脑和物质世界相互作用的副产品。

和唯心主义刚好相反,这是物质对精神。唯物主义只关注世界的物质组成,和唯心主义类似,它并不关心如何区分殊相和共相。

现实主义 (Realism) 又有所不同,就像唯物主义者,现实主义者坚持外部现实世界独立于人类思想存在,但现实主义者还坚持,像爱和重力这种共相是真实的,其存在形式取决于现实主义的类型。柏拉图式的现实主义认为像重力、爱这种共相,真的存在但我们观察不到,其位于一个独立的抽象位面。科学现实主义则更温和,它认为把共相用于现象观察能得到坚实可靠的主张。

在科学现实主义中,像爱和重力这种共相,和可观察的殊相位于同样的本体状态。之所以能假设不可测性的存在,是因为其对构造成功的科学主张有用且经常很必需。

最后是唯名论 (名义主义,Nominalism) 。这个观点在共相方面与现实主义完全对立,它承认现实独立于人类思想存在,但否认共相的存在。唯名论认为没有重力或爱这回事儿,只有掉落的物品以及经常在你膝盖打呼噜的猫。根据唯名论,我们用重力和爱这些术语,只因为能帮助理解世界,但共相不是真实存在的。


方法 (Approaches)

至今为止我讲到的科学方法的发展,主要在自然科学领域。物理学 physics、天文学 astronomy、生物学 biology,但在 19 世纪下半叶, 社会科学 (social sciences) 开始登上历史舞台。

这段时期,人们又重拾了现实主义的本体论观点,即假设物质世界是真实的,我们感知的是外部世界,是独立于我们思想存在的。

认识论的观点越来越实证主义 (positivistic) ,这意味着科学家认为我们可以通过观察和实验来获取有关世界本质的知识。这种现实实证观点大多应用于自然现象方面,但随着社会科学发展并成为独特的科学领域,问题来了:现实主义观点是否适用于社会学和心理学现象呢?

根据 客观主义 (objectivism) 的观点,现实主义本体论立场确实适用于心理学和社会学现象,比如智力和社会凝聚力是外部的、独立的性质,是独立于我们的心理表征的。

客观主义可以建构主义 (constructivism) 做对比。

根据建构主义,社会现象的本质取决于所涉及的社会角色。这意味着现实不是独立和外在的,而被认为是基于观察者和情境的心理建构。比如,快乐或女性气质这些属性不是外在的,不是永恒的,也不能被客观定义。要如何看待这些属性以及它们的意义,取决于观察者的文化背景、社会族群及特定的历史时期。那么,如果心理现实和社会现实是建构的、主观的、难以捉摸的,我们如何了解它呢?怎样的认识论立场适合建构主义的本体论立场?

事实上,有一组互相联系的观念,统称为 解释主义 (interpretivism) 。解释主义的观点都假设研究者关于社会现象的经历或观察,可能与这些社会现象亲历者的经历大相径庭。所以重点应该放在参与者的角度来解读现象。

我想讲的三个解释主义观点是 解释学 (hermeneutics)现象学 (phenomenology)诠释社会学 (verstehen) ,它们在如何获得心理学和社会现实的理解上有些微差别。

先来看看解释学。这个术语来自神学,是关于解读经文。解释学旨在通过解读人们在社会情境下的行为 来解释社会现象。研究者需要将情境纳入考量,并试着理解人们如何看待这世界,以此来理解他们的行为。

现象学与解释学密切相关。它的首要前提是人不是无生命的对象,他们会思考和感知周遭的世界,而这会影响他们的行为。为了理解他们的行为,就需要调查他们给自己所经历的现象赋予的意义。这意味着调查人们如何从自身的角度探究世界。要切身了解他人对自己经历的理解,研究者需要尽可能地消除自己先入为主的观念。

诠释社会学是第三种解释主义观点,它与解释学和现象学有紧密联系。诠释社会学主要与社会学家马克斯・韦伯 (Max Weber) 相关。诠释社会学是指对社会现象的移情理解。研究者需要站在研究对象的立场,来解读他们如何看待世界,只有这样研究者才能解释他们的行为。比如,如果欧洲研究者想在一个与世隔绝的亚马逊部落中探究快乐。他们需要站在部落的角度,考虑到部落的社会情境。对部落来说,或许集体比个人更重要,这可能意味着快乐被认为是一种集体属性,甚至根本不适用于个人。现在,为了理解这种完全不同的世界观,研究者需要将自己沉浸在他们研究的人或族群的文化中。

当然,建构解释主义的观点存在一些问题。首先,有分层解读的问题 —— 研究者的解读;研究对象的解读;而将发现放进一个框架或关联一个理论时,又进行了解读。每多一层解读,就增大了误解的机会。第二个更严重的问题是结果缺乏可比性。在我们的例子中,快乐是主观的,在不同的文化中意义不同。我们不能就这么进行比较。这意味着我们永远无法提出普适解释或理论,而仅仅适用于特定人群或特定时段。第三个问题是参考系的不同。如果参考系与研究者相去甚远,研究者就很难站在研究对象的立场上,从而甚至难以发现社会情境中的相关方面。

建构 - 解释主义的观点常与科学的定性方法有关。换言之,观察是通过非结构化访谈或参与性观察进行的,而研究者是他们中的一份子。数据来源于一个或少数几个研究对象,通过解读文本或录制的素材对数据进行定性分析。反之,客观 - 实证观点于定量研究方法相关。得到的观察结果可以被计数或测量,所以多个研究对象的数据可以整合在一起,选取的研究对象代表更大的人群,或许可以支持一个普适解释。而且数据用量化统计手段来分析。

尽管定性方法通常与建构主义的科学观点相关,而定量方法与客观主义观点相关,这并不是限制我们仅使用定性或定量方法的理由。两种方法都各有优劣。对有些研究问题来说,定性方法更好;其他情况下 定量方法可能更合适。事实上,将两种方法互补结合在一起的方法,越来越受到欢迎。


目标

当然最后,科学的总体目标是获得知识,但可以分为更多具体的目标,区分目标的方式有获取知识的类型以及获取知识的目的。

普遍性研究 (universalistic research) 试图提供能广泛使用的解释。

例如,假设玩暴力电脑游戏会导致攻击行为。这与具体游戏或特定玩家没有关系,因为我们假定的是玩暴力游戏和攻击性间的相关性,这适用于任何暴力游戏,如 GTA 、使命召唤等等;我们还假设相关性适用于男性和女性,任何年龄、任何文化背景的人。

普遍性研究致力描述或解释的现象,能用于所有人、所有群体或社会。

科学方法也能用于特殊性研究。特殊性研究致力描述或解释发生在特定环境下的现象,或者涉及特定群体。

例如,在荷兰将法定饮酒年龄从 16 岁升至 18 岁后,我们能调查荷兰青年酒精中毒住院人数的变化。关键是在特定的时间、地点、群体内调查影响的大小。不要指望在不同国家或十年里再次改变饮酒年龄会有同样结果。所以研究目的既可以是普遍性的,也可以是特殊性的。说得简短些就是:可获得普遍性或特定的知识。

基础研究 (fundamental research)应用研究 (applied research) 间关系很近,重叠度很高。

应用研究为了直接解决问题,其开发和应用知识是为了提高人类福祉。假设我们想帮助抑郁人群,我们认为抑郁是孤独造成的。我们就可以建立一个项目,目的是减少孤独感以降低抑郁程度。我们让孤独抑郁的人去养只猫,来观察是否真的由于不再孤独降低了抑郁程度。

基础研究相较于应用研究旨在获取知识,就是为了增进了解。基础研究的唯一目的是加深了解身边的世界,不需要能立即应用和接解决问题。例如,调查孤独和抑郁间的相关性,用大规模调查来看是否越感觉孤独的人越抑郁,反之亦然。这里是为了揭示孤独和抑郁间的相关性。也许我们想看看是否男性女性都有这种相关性,不同文化和年龄也有这种相关性。但注意,我们不关心如何治疗抑郁,这里的目的更多的是了解相关性,不是帮助抑郁人群。

大多数基础研究是普遍性研究,但有时候基础研究也会是特殊性研究。例如,在非常特定的情形下的研究。好比我们调查玩暴力游戏和攻击行为的相关性,就在阿姆斯特丹特定的初犯少年犯群体中,他们都来自权贵阶级。在玩暴力游戏和攻击行为相关性方面,这个非常特定的问题群体能提供有趣的新见解。注意,我们不观察该群体如何改造或不再犯罪。

应用研究常是特殊性研究,旨在特定环境、特定群体中解决问题,但它也可以是普遍性的研究。以养猫来减少抑郁的研究为例,我们可以扩展这项应用研究,比较照顾友善易交流的猫和拒绝接触的猫的人群。这会更有针对性地帮助找到何种治疗有效,但这也加入了普遍性元素,我们还可以调查它对孤独的意义。仅仅有个活物存在就够了吗?还是需要有互动?很多时候,应用研究的结果会产生新的见解,这些见解会和介入或治疗相关,但它们也会提供基础的知识。

因此,两种研究类型会互相增强。