学计算机的那个

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

0%

Networking with Combine and SwiftUI [译]

Getting Started

SwiftUI’s reactive state management makes this a lot easier by introducing the notion of a source of truth that can be shared across your app using SwiftUI’s property wrappers such as @EnvironmentObject, @ObservedObject, and @StateObject

the notion of a source of truth 事实源
This source of truth usually is your in-memory data model

In this series, we will look at how to use Combine in the context of SwiftUI to

  • access the network,
  • map data,
  • handle errors

Demo

1
2
3
4
5
6
7
8
9
GET localhost:8080/isUserNameAvailable?userName=sjobs HTTP/1.1

HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 39
connection: close
date: Thu, 06 Jan 2022 16:09:08 GMT

{"isAvailable":false, "userName":"sjobs"}

network using URLSession

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
func checkUserNameAvailableOldSchool(userName: String, completion: @escaping (Result<Bool, NetworkError>) -> Void) {
guard let url = URL(string: "http://127.0.0.1:8080/isUserNameAvailable?userName=\(userName)") else { 2
completion(.failure(.invalidRequestError("URL invalid")))
return
}

let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error { 3
completion(.failure(.transportError(error)))
return
}
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { 4
completion(.failure(.serverError(statusCode: response.statusCode)))
return
}

guard let data = data else { 5
completion(.failure(.noData))
return
}

do {
let decoder = JSONDecoder()
let userAvailableMessage = try decoder.decode(UserNameAvailableMessage.self, from: data)
completion(.success(userAvailableMessage.isAvailable)) 1
}
catch {
completion(.failure(.decodingError(error)))
}
}

task.resume() 6
}
  • issues:
  1. It’s not immediately clear what the happy path is - the only location that returns a successful result is pretty hidden (1), and developers who are new to using completion handlers might be confused by the fact that the happy path doesn’t even use a return statement to deliver the result of the network call to the caller.
  2. Error handling is scattered all over the place (2, 3, 4, 5).
  3. There are several exit points, and it’s easy to forget one of the return statements in the if let conditions.
  4. Overall, it is hard to read and maintain, even if you’re an experienced Swift developer.
  5. It’s easy to forget you have to call resume() to actually perform the request (6). I am pretty sure most of us have been frantically looking for bugs, only to find out we forgot to actually kick off the request using resume. And yes, I think resume is not a great name for an API that is inteded to send the request.

fetch data using Combine

When they introduced Combine, Apple added publishers for many of their own asynchronous APIs. This is great, as this makes it easier for us to use them in our own Combine pipelines.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func checkUserNameAvailable(userName: String) -> AnyPublisher<Bool, Never> {
guard let url = URL(string: "http://127.0.0.1:8080/isUserNameAvailable?userName=\(userName)") else {
return Just(false).eraseToAnyPublisher()
}

return URLSession.shared.dataTaskPublisher(for: url) 1
.map { data, response in 2
do {
let decoder = JSONDecoder()
let userAvailableMessage = try decoder.decode(UserNameAvailableMessage.self, from: data)
return userAvailableMessage.isAvailable 3
}
catch {
return false 4
}
}
.replaceError(with: false) 5
.eraseToAnyPublisher()
}

This is a lot easier to read already, and (except for the guard statement that makes sure we’ve got a valid URL) there is just one exit point.
Let’s walk through the code step by step:

  1. We use dataTaskPublisher to perform the request. This publisher is a one-shot publisher(一次性发布者) and will emit an event once the requested data has arrived. It’s worth keeping in mind that Combine publishers don’t perform any work if there is no subscriber. This means that this publisher will not perform any call to the given URL unless there is at least one subscriber. I will later show you how to connect this pipeline to the UI and make sure it gets called every time the user enters their preferred username.
  2. Once the request returns, the publisher emits a value that contains both the data and the response . In this line, we use the map operator to transform this result. As you can see, we can reuse most of the data mapping code from the previous version of the code, except for a couple of small changes:
  3. Instead of calling the completion closure, we can return a Boolean value to indicate whether the username is still available or not. This value will be passed down the pipeline.
  4. In case the data mapping fails, we catch the error and just return false, which seems to be a good compromise.
  5. We do the same for any errors that might occur when accessing the network. This is a simplification that we might need to revisit in the future.

do better

Here are three changes that will make the code more linear and easier to reason about:

Destructuring tuples using key paths(使用关键路径解构元组)

我们经常会遇到需要从变量中提取特定属性的情况。在我们的示例中,我们接收到一个元组,其中包含我们发送的URL请求的数据和响应。下面是URLSession中各自的声明:

1
2
3
4
5
6
public struct DataTaskPublisher : Publisher {

/// The kind of values published by this publisher.
public typealias Output = (data: Data, response: URLResponse)
...
}

Combine提供了map操作符的重载版本,允许我们使用键路径解构元组,并只访问我们关心的属性:

1
2
return URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)

Mapping Data more easily

由于映射数据是一项如此常见的任务,Combine附带了专门的操作符:decode (type:decoder:)来简化此任务。

1
2
3
return URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())

这将返回来自上游发布者(upstream publisher)的解码数据值,并将其解码为UserNameAvailableMessage实例。

最后,我们可以再次使用map操作符来解构UserNameAvailableMessage并访问它的isAvailable属性:

1
2
3
4
return URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())
.map(\.isAvailable)

Fetching data using Combine, simplified

有了这些变化,我们现在有了一个易于阅读的管道版本,并且有一个线性流:

1
2
3
4
5
6
7
8
9
10
11
12
func checkUserNameAvailable(userName: String) -> AnyPublisher<Bool, Never> {
guard let url = URL(string: "http://127.0.0.1:8080/isUserNameAvailable?userName=\(userName)") else {
return Just(false).eraseToAnyPublisher()
}

return URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())
.map(\.isAvailable)
.replaceError(with: false)
.eraseToAnyPublisher()
}

How to connect to SwiftUI

最后,让我们看看如何在我们假设的注册表单(hypothetical sign up form)中集成这个新的Combine管道。

下面是一个简化版的注册表单,它只包含一个用户名字段、一个显示消息的文本标签和一个注册按钮。在实际应用程序中,我们还需要一些UI元素来提供密码和密码确认。

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
struct SignUpScreen: View {
@StateObject private var viewModel = SignUpScreenViewModel()

var body: some View {
Form {
// Username
Section {
TextField("Username", text: $viewModel.username)
.autocapitalization(.none)
.disableAutocorrection(true)
} footer: {
Text(viewModel.usernameMessage)
.foregroundColor(.red)
}

// Submit button
Section {
Button("Sign up") {
print("Signing up as \(viewModel.username)")
}
.disabled(!viewModel.isValid)
}
}
}
}

所有UI元素都连接到一个视图模型,以分离关注点,并保持视图的整洁和易读:

1
2
3
4
5
6
7
8
9
class SignUpScreenViewModel: ObservableObject {
// MARK: Input
@Published var username: String = ""

// MARK: Output
@Published var usernameMessage: String = ""
@Published var isValid: Bool = false
...
}

由于@Published属性是Combine 发布者,我们可以订阅它们,以便在它们的值发生变化时接收更新。这允许我们调用上面创建的checkUserNameAvailable管道。

让我们创建一个可重用的发布者,我们可以使用它来驱动UI的各个部分,这些部分需要根据用户名是否可用来显示信息。一种方法是创建一个惰性计算属性。这确保了管道只在需要时才会被设置,并且管道只有一个实例。

1
2
3
4
5
6
7
private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = {
$username
.flatMap { username in
self.authenticationService.checkUserNameAvailable(userName: username)
}
.eraseToAnyPublisher()
}()

要调用另一个管道并使用其结果,可以使用flatMap操作符。这将接受来自上游发布者的所有输入事件(即,$username published属性发出的值),并将它们转换为新的发布者(在我们的示例中,发布者checkUserNameAvailable in)。

在下一步也是最后一步中,我们将把isUsernameAvailablePublisher的结果连接到UI。如果查看一下视图模型,您会注意到在视图模型的输出部分有两个属性:一个用于与用户名相关的任何消息,另一个用于保存表单的总体验证状态(请记住,在实际的注册表单中,我们可能还需要验证密码字段)。

Combine发布者可以连接到多个订阅者,所以我们可以将isValidusernameMessage都连接到isUsernameAvailablePublisher:

1
2
3
4
5
6
7
8
9
10
11
class SignUpScreenViewModel: ObservableObject {
...
init() {
isUsernameAvailablePublisher
.assign(to: &$isValid)

isUsernameAvailablePublisher
.map { $0 ? "" : "Username not available. Try a different one."}
.assign(to: &$usernameMessage)
}
}

使用这种方法,我们可以重用isUsernameAvailablePublisher,并使用它来驱动表单的整体isValid状态(它将启用/禁用Submit按钮)和错误消息标签(通知用户他们选择的用户名是否仍然可用)。

How to handle Publishing changes from background threads is not allowed

当你运行这段代码时,你会注意到几个问题:

  1. 对于您键入的每个字符,API端点将被调用几次
  2. Xcode告诉你不应该从后台线程更新UI
    我们将在下一集中深入探讨这些问题的原因,但现在,让我们解决这个错误消息:

[SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

出现此错误消息的原因是Combine将在后台线程上执行网络请求。当请求得到满足时,我们将结果分配给视图模型上已发布的属性之一。反过来,这将提示SwiftUI更新UI——这将发生在前台线程上。

为了防止这种情况发生,我们需要指示Combine在收到网络请求的结果后切换到前台线程,使用receive(on:)操作符:

1
2
3
4
5
6
7
8
private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = {
$username
.flatMap { username -> AnyPublisher<Bool, Never> in
self.authenticationService.checkUserNameAvailableNaive(userName: username)
}
||> .receive(on: DispatchQueue.main)<||
.eraseToAnyPublisher()
}()

当我们在下一集中讨论组合调度程序时,我们将更深入地研究线程。

Closure

在这篇文章中,我向您展示了如何使用Combine访问网络,以及它如何使您能够编写比相应的回调驱动的对应代码更容易阅读和维护的直线代码。

我们还学习了如何使用视图模型连接一个向SwiftUI发出网络请求的Combine管道,并将该管道附加到@Published属性。

现在,您可能想知道为什么isUsernameAvailablePublisher使用Never作为其错误类型—毕竟,网络错误是我们非常需要处理的事情。

我们将在下一集中讨论错误处理(和自定义数据映射)。我们还将探讨优化基于Combine的网络层的方法,敬请期待!

感谢阅读🔥

#参考
Networking with Combine and SwiftUI