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 | GET localhost:8080/isUserNameAvailable?userName=sjobs HTTP/1.1 |
network using URLSession
1 | func checkUserNameAvailableOldSchool(userName: String, completion: @escaping (Result<Bool, NetworkError>) -> Void) { |
- issues:
- 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. - Error handling is scattered all over the place (2, 3, 4, 5).
- There are several exit points, and it’s easy to forget one of the
return
statements in theif let
conditions. - Overall, it is hard to read and maintain, even if you’re an experienced Swift developer.
- 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 thinkresume
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 | func checkUserNameAvailable(userName: String) -> AnyPublisher<Bool, Never> { |
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:
- 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 thatCombine publishers
don’t perform any work if there is nosubscriber
. This means that this publisher will not perform any call to the givenURL
unless there is at least one subscriber. I will later show you how to connect this pipeline to theUI
and make sure it gets called every time the user enters their preferred username. - 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:
- 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 thepipeline
. - In case the data mapping fails, we catch the
error
and just return false, which seems to be a good compromise. - 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 | public struct DataTaskPublisher : Publisher { |
Combine
提供了map
操作符的重载版本,允许我们使用键路径解构元组,并只访问我们关心的属性:
1 | return URLSession.shared.dataTaskPublisher(for: url) |
Mapping Data more easily
由于映射数据是一项如此常见的任务,Combine
附带了专门的操作符:decode (type:decoder:)
来简化此任务。
1 | return URLSession.shared.dataTaskPublisher(for: url) |
这将返回来自上游发布者(upstream publisher)的解码数据值,并将其解码为UserNameAvailableMessage
实例。
最后,我们可以再次使用map
操作符来解构UserNameAvailableMessage
并访问它的isAvailable
属性:
1 | return URLSession.shared.dataTaskPublisher(for: url) |
Fetching data using Combine, simplified
有了这些变化,我们现在有了一个易于阅读的管道版本,并且有一个线性流:
1 | func checkUserNameAvailable(userName: String) -> AnyPublisher<Bool, Never> { |
How to connect to SwiftUI
最后,让我们看看如何在我们假设的注册表单(hypothetical sign up form)中集成这个新的Combine
管道。
下面是一个简化版的注册表单,它只包含一个用户名字段、一个显示消息的文本标签和一个注册按钮。在实际应用程序中,我们还需要一些UI元素来提供密码和密码确认。
1 | struct SignUpScreen: View { |
所有UI
元素都连接到一个视图模型,以分离关注点,并保持视图的整洁和易读:
1 | class SignUpScreenViewModel: ObservableObject { |
由于@Published
属性是Combine 发布者,我们可以订阅它们,以便在它们的值发生变化时接收更新。这允许我们调用上面创建的checkUserNameAvailable
管道。
让我们创建一个可重用的发布者,我们可以使用它来驱动UI
的各个部分,这些部分需要根据用户名是否可用来显示信息。一种方法是创建一个惰性计算属性。这确保了管道只在需要时才会被设置,并且管道只有一个实例。
1 | private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = { |
要调用另一个管道并使用其结果,可以使用flatMap
操作符。这将接受来自上游发布者的所有输入事件(即,$username
published属性发出的值),并将它们转换为新的发布者(在我们的示例中,发布者checkUserNameAvailable
in)。
在下一步也是最后一步中,我们将把isUsernameAvailablePublisher
的结果连接到UI。如果查看一下视图模型,您会注意到在视图模型的输出部分有两个属性:一个用于与用户名相关的任何消息,另一个用于保存表单的总体验证状态(请记住,在实际的注册表单中,我们可能还需要验证密码字段)。
Combine发布者可以连接到多个订阅者,所以我们可以将isValid
和usernameMessage
都连接到isUsernameAvailablePublisher
:
1 | class SignUpScreenViewModel: ObservableObject { |
使用这种方法,我们可以重用isUsernameAvailablePublisher
,并使用它来驱动表单的整体isValid
状态(它将启用/禁用Submit
按钮)和错误消息标签(通知用户他们选择的用户名是否仍然可用)。
How to handle Publishing changes from background threads is not allowed
当你运行这段代码时,你会注意到几个问题:
- 对于您键入的每个字符,API端点将被调用几次
- 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 | private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = { |
当我们在下一集中讨论组合调度程序时,我们将更深入地研究线程。
Closure
在这篇文章中,我向您展示了如何使用Combine
访问网络,以及它如何使您能够编写比相应的回调驱动的对应代码更容易阅读和维护的直线代码。
我们还学习了如何使用视图模型连接一个向SwiftUI
发出网络请求的Combine
管道,并将该管道附加到@Published
属性。
现在,您可能想知道为什么isUsernameAvailablePublisher
使用Never
作为其错误类型—毕竟,网络错误是我们非常需要处理的事情。
我们将在下一集中讨论错误处理(和自定义数据映射)。我们还将探讨优化基于Combine
的网络层的方法,敬请期待!
感谢阅读🔥