swift-evolution-4

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("关闭反应堆")
} catch {
print("未知错误")
}

处理的 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. 😬

建议横屏阅读代码

  • 本文的主要价值:提供一种抽象复杂逻辑,达成功能复用的思路
  • 关键词:语义提炼、动态具名
  • 本文约 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 (); // 收工,准备发朋友圈

总结

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

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

译自 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 更新。



title: SwiftUI 应用 | 浇水提醒 app
top: false
date: 2020-03-01 17:08:42
tags:

  • watchOS
  • development
  • swiftui
    categories: development
    description:

用 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 视图显示植物的列表。