学计算机的那个

不是我觉到、悟到,你给不了我,给了也拿不住;只有我觉到、悟到,才有可能做到,能做到的才是我的.

0%

SwiftUI + Combine 【译】

Publisher

发布者向一个或多个订阅者发送值。它们遵循Publisher协议,并声明输出的类型和它们产生的任何错误:

1
2
3
4
5
public protocol Publisher {
associatedtype Output
associatedtype Failure : Error
func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}

发布者可以随时间发送任意数量的值,也可以发送错误而失败。关联类型Output定义发布者可以发送哪些类型的值,而关联类型Failure定义它可能失败的错误类型。发布者可以通过指定never关联类型来声明它永远不会失败。

Subscribers

另一方面,订阅者订阅一个特定的发布者实例,并接收一个值流,直到取消订阅。

它们符合Subscriber 协议。为了订阅发布者,订阅者的关联Input Failure 类型必须符合发布者的关联Output Failure 类型。

1
2
3
4
5
6
7
8
public protocol Subscriber : CustomCombineIdentifierConvertible {
associatedtype Input
associatedtype Failure : Error

func receive(subscription: Subscription)
func receive(_ input: Self.Input) -> Subscribers.Demand
func receive(completion: Subscribers.Completion<Self.Failure>)
}

Operators

发布者和订阅者是SwiftUI在UI和底层模型之间双向同步的支柱。我想你会同意,用SwiftUI保持你的UI和模型同步从来没有比这更容易,这都要归功于Combine框架的这一部分。

然而,Operators才是Combine 的超级力量。它们是对Publisher 进行操作、执行一些计算并生成另一个发布者(Publisher)的方法

  • 例如,可以使用filter 操作符根据某些条件忽略值。
  • 或者,如果需要执行开销较大的任务(例如通过网络获取信息),可以使用debounce操作符等待,直到用户停止输入
  • map操作符允许您将某种类型的输入值转换为不同类型的输出值

Validating the Username - simple validation

考虑到这一点,让我们实现一个简单的验证,以确保用户输入的名称至少包含三个字符。

视图模型上的所有属性都使用@Published属性包装器进行包装。这意味着每个属性都有自己的发布者,我们可以订阅它。

要指示用户名是否有效,可使用map操作符将用户的输入从String转换为Bool:

1
2
3
4
5
6
7
8
$username
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.map { input in
return input.count >= 3
}
.assign(to: \.valid, on: self)
.store(in: &cancellableSet)

然后,assign 订阅者使用此转换的结果,它(顾名思义)将接收到的值分配给视图模型的输出属性valid

debounce

debounce操作符允许我们指定在事件交付中等待暂停,例如当用户停止输入时。

The debounce operator lets us specify that we want to wait for a pause in the delivery of events, for example when the user stops typing.

例如,在输入用户名时,不需要检查用户名是否有效,或者用户输入的每个字母是否可用。仅在他们停止输入(或暂停片刻)时执行此检查就足够了。

removeDuplicates

removeduplicate操作符仅在事件不同于之前的任何事件时才发布事件。例如,如果用户先输入john,然后是joe,然后又是john,我们将只接收到john一次。这有助于提高UI的工作效率。

the removeDuplicates operator will publish events only if they are different from any previous events. For example, if the user first types john, then joe, and then john again, we will only receive john once. This helps make our UI work more efficiently.

Cancellable

这个调用链的结果是一个Cancellable,如果需要,我们可以使用它来取消处理(对于长时间运行的链很有用)。我们将把它(以及我们稍后将创建的所有其他对象)存储到Set<AnyCancellable>中,这样我们就可以在deinit 时进行清理。

Validating the Password(s) multi-staged validation

现在让我们换个角度,看看如何执行多阶段(multi-staged)验证逻辑。这是必需的,因为表单上的密码字段需要满足多个要求:它们不能为空,它们必须匹配,而且(最重要的是)所选择的密码必须足够强。除了将输入值转换为Bool值以指明密码是否满足我们的要求外,我们还希望通过返回适当的警告消息为用户提供一些指导。

让我们一步一步来,首先实现验证用户输入的密码的管道。

1
2
3
4
5
6
7
8
9
10
private var isPasswordEmptyPublisher: AnyPublisher<Bool, Never> {
// (1)
$password
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.map { password in
return password == ""
}
.eraseToAnyPublisher()
}

我们没有将转换结果直接赋值给isValid输出属性,而是返回一个AnyPublisher<Bool, Never>。这样我们就可以在订阅最终结果(valid or not)之前,将多个发布者组合成一个多阶段链(multi-stage chain)。

为了验证两个单独的属性是否包含相等的字符串,我们使用了combinellatest操作符。请记住,每次用户输入字符时,绑定到相应SecureField的属性都会触发,我们希望比较每个字段的最新值。combinellatest让我们做到了这一点。

1
2
3
4
5
6
7
8
private var arePasswordsEqualPublisher: AnyPublisher<Bool, Never> {
Publishers.CombineLatest($password, $passwordAgain)
.debounce(for: 0.2, scheduler: RunLoop.main)
.map { password, passwordAgain in
return password == passwordAgain
}
.eraseToAnyPublisher()
}
  • eraseToAnyPublisher()

如果你想知道为什么我们需要在每个链的末尾调用eraseToAnyPublisher():,这将执行一些类型擦除,以确保我们不会得到一些疯狂的嵌套返回类型。

this performs some type erasure that makes sure we don’t end up with some crazy nested return types.

很好,现在我们对用户输入的密码有了很多了解,让我们把它归结为我们真正想知道的一件事:这是一个有效的密码吗?

正如您可能已经猜到的,我们将需要使用CombineLatest操作符,但由于这次我们有三个参数,我们将使用CombineLatest3,它接受三个输入参数。

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
enum PasswordCheck {
case valid
case empty
case noMatch
case notStrongEnough
}

private var isPasswordValidPublisher: AnyPublisher<PasswordCheck, Never> {
Publishers.CombineLatest3(isPasswordEmptyPublisher, arePasswordsEqualPublisher, isPasswordStrongEnoughPublisher)
.map { passwordIsEmpty, passwordsAreEqual, passwordIsStrongEnough in
if (passwordIsEmpty) {
return .empty
}
else if (!passwordsAreEqual) {
return .noMatch
}
else if (!passwordIsStrongEnough) {
return .notStrongEnough
}
else {
return .valid
}
}
.eraseToAnyPublisher()
}

将三个布尔值映射到单个enum 的主要原因是,我们希望能够根据验证结果生成合适的警告消息。告诉用户他们的密码不好,并不是很有帮助,不是吗?如果我们告诉他们为什么不成立会更好。

Putting it All Together

为了计算验证的最终结果,我们需要将用户名验证的结果与密码验证的结果结合起来。但是,在此之前,我们需要重构用户名验证,以便它也返回一个包含在验证链中的发布者。

1
2
3
4
5
6
7
8
9
private var isUsernameValidPublisher: AnyPublisher<Bool, Never> {
$username
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.map { input in
return input.count >= 3
}
.eraseToAnyPublisher()
}

完成这些后,我们可以实现表单验证的最后阶段:

1
2
3
4
5
6
7
private var isFormValidPublisher: AnyPublisher<Bool, Never> {
Publishers.CombineLatest(isUsernameValidPublisher, isPasswordValidPublisher)
.map { usernameIsValid, passwordIsValid in
return usernameIsValid && (passwordIsValid == .valid)
}
.eraseToAnyPublisher()
}

Updating the UI

如果不将其连接到UI,这些都不会很有用。为了驱动Sign up按钮的状态,我们需要更新视图模型上的isValid输出属性。

为此,我们只需订阅isFormValidPublisher并将其发布的值赋给isValid属性:

1
2
3
4
5
6
init() {
isFormValidPublisher
.receive(on: RunLoop.main)
.assign(to: \.isValid, on: self)
.store(in: &cancellableSet)
}

由于此代码与UI交互,因此需要在UI线程上运行。我们可以通过调用receive(on: RunLoop.main)告诉SwiftUI在UI线程上执行这段代码。

最后,我们将警告消息输出属性绑定到UI,以帮助指导用户填写注册表单。

首先,我们订阅各自的发布者,以了解何时用户名和密码属性无效。同样,我们需要确保这发生在UI线程上,所以我们将调用receive(on:)并传递主运行循环。

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
init() {
// ...
isUsernameValidPublisher
.receive(on: RunLoop.main)
.map { valid in
valid ? "" : "username must at leat have 3 characters"
}
.assign(to: \.usernameMessage, on: self)
.store(in: &cancellableSet)

isPasswordValidPublisher
.receive(on: RunLoop.main)
.map { passwordCheck in
switch passwordCheck {
case .empty:
return "Password must not be empty"
case .noMatch:
return "Passwords don't match"
case .notStrongEnough:
return "Password not strong enough"
default:
return ""
}
}
.assign(to: \.passwordMessage, on: self)
.store(in: &cancellableSet)
}

最后,我们需要将输出属性usernameMessagepasswordMessage绑定到UI。section页脚(footers)是显示错误信息的一个方便的地方,我们可以通过将它们涂成红色来使它们更加突出:

1
2
3
4
5
6
7
8
9
10
11
var body: some View {
Form {
Section(footer: Text(userModel.usernameMessage).foregroundColor(.red)) {
// ...
}
Section(footer: Text(userModel.passwordMessage).foregroundColor(.red)) {
// ...
}
// ...
}
}

参考

SwiftUI + Combine