SwiftUI 的基本概念中,我们已经多次强调在声明式UI 中,用户界面只是状态的函数,即:View = f(State)。既然 UI 是 “被动地” 响应状态的变化,那么我们是不是可以将状态变化也作为 “事件” 来看待呢?答案是肯定的,我们可以将 “状态变化” 看作是被发布出来的异步操作的事件,订阅这个事件,并对订阅了事件的 View 根据更新后的状态进行绘制,这就是 SwiftUI 的核心逻辑。
在响应式异步编程中,一个事件及其对应的数据被发布出来,最后被订阅者消化和使用。期间这些事件和数据需要通过一系列操作变形,成为我们最终需要的事件和数据。Combine 中最重要的角色有三种,恰好对应了这三种操作:负责发布事件的 Publisher
,负责订阅事件Subscriber
,以及负责转换事件和数据的 Operator
。
Publisher 最主要的工作其实有两个:发布新的事件及其数据,以及准备好被Subscriber 订阅。
Publisher 可以发布三种事件:
-
1. 类型为 Output 的新值:这代表事件流中出现了新的值。
-
2. 类型为 Failure 的错误:这代表事件流中发生了问题,事件流到此终止。
-
3. 完成事件:表示事件流中所有的元素都已经发布结束,事件流到此终止。
有限事件流和无限事件流
虽然 Publisher 可以发布三种事件,但是它们并不是必须的。一个 Publisher 可能发出一个或多个 output 值,也可能一个值都不发出;Publisher 有可能永远不会停止终结,也有可能通过 failure 或者 finished 事件来表明不再会发出新的事件。我们将最终会终结的事件流称为有限事件流,而将不会发出 failure 或者 finished 的事件流称为无限事件流.
Subject
如果我们说 sink 提供了由函数响应式向指令式编程转变的路径的话,Subject 则补全了这通路的另一侧:它让你可以将传统的指令式异步 API 里的事件和信号转换到响应式的世界中去。
1.PassthroughSubject
let publisher2 = PassthroughSubject<Int, Never>()
publisher2.send(1)
publisher2.sink { complete in
print(complete)
} receiveValue: { value in
print(value)
}
publisher2.send(2)
publisher2.send(completion: .finished)
发出 2, complete
2.CurrentValueSubject
let publisher1 = CurrentValueSubject<Int, Never>(0)
publisher1.send(1)
let sinkValue = publisher1.sink { complete in
print("complete")
} receiveValue: { value in
print(value)
}
// 取消订阅, 只会发出当前值
// sinkValue.cancel()
publisher1.send(2)
publisher1.send(completion: .finished)
发出 1 , 2, complete
Scheduler
如果说 Publisher 决定了发布怎样的 (what) 事件流的话,Scheduler 所要解决的就是两个问题:在什么地方 (where),以及在什么时候 (when) 来发布事件和执行代码。
关于 where
后台线程的网络请求返回,可以通过这样的方式在 main runloop 中进行处理:
URLSession.shared.dataTaskPublisher(for: URL(string: "https://www.example.com")!)
.compactMap { String(data: $0.data, encoding: .utf8) }
.receive(on: RunLoop.main)
.sink { complete in
print("complete")
} receiveValue: { value in
// 更新UI
}
关于 when
延迟1秒
.delay(for: .seconds(1), scheduler: RunLoop.main)
防抖
.debounce(for: .seconds(1), scheduler: RunLoop.main)
节流
.throttle(for: .seconds(1), scheduler: RunLoop.main, latest: true)
常用的 Publisher
用下面的代码验证上面的关系图
public func check<P: Publisher>(_ title: String, publisher: () -> P) -> AnyCancellable {
print("----- \(title) -----")
defer { print("") }
return publisher()
.print()
.sink(
receiveCompletion: { _ in},
receiveValue: { _ in }
)
}
check("Sequence") {
[1, 2, 3].publisher
}
Empty<Int, Never>()
Just("Hello SwiftUI")
Publishers.Sequence<[Int], Never>(sequence: [1, 2, 3])
同上面等效
[1, 2, 3].publisher
进行 map 操作
[1, 2, 3]
.publisher
.map { $0 * 2 }
Just(1)
.map { $0 * 2 }
既然 Publisher 实际上是事件的序列,那么那些适用于普通序列 (Sequence,或者说 Array) 的各类函数式方法,比如 filter,contains 等,是不是都适于 Publisher 呢?答案是肯定的
Reduce
[1, 2, 3]
.publisher
.reduce(1, *)
[1, 2, 3]
.publisher
.reduce(0, +)
Scan 有些情况下,除了最终的结果,我们也有可能会想要把中途每一步的过程保存下来在 Array 中,这种操作一般叫做 scan。
extension Sequence {
public func scan<ResultElement>(
_ initial: ResultElement,
_ nextPartialResult: (ResultElement, Element) -> ResultElement
) -> [ResultElement] {
var result: [ResultElement] = []
for x in self {
result.append(nextPartialResult(result.last ?? initial, x))
}
return result
}
}
[1, 2, 3]
.publisher
.scan(0, +)
实践中,scan 一个最常见的使用场景是在某个下载任务执行期间,接受 URLSession的数据回调,将已接收到的数据量做累加来提供一个下载进度条的界面。
compactMap 和 flatMap
compactMap 比较简单,它的作用是将 map 结果中那些 nil 的元素去除掉.
// 把 cat 过滤掉了
["1", "2", "3", "cat", "5"]
.publisher
.compactMap {
Int($0)
}
flatMap map 及 compactMap 的闭包返回值是单个的 Output 值。而与它们不同,flatMap 的变形闭包里需要返回一个 Publisher。也就是说,flatMap 将会涉及两个 Publisher:一个是 flatMap 操作本身所作用的外层 Publisher,一个是 flatMap 所接受的变形闭包中返回的内层Publisher。flatMap 将外层 Publisher 发出的事件中的值传递给层 Publisher,然后汇总内层 Publisher给出的事件输出,作为最终变形后的结果。
["A", "B", "C"]
.publisher
.flatMap { letter in
[1, 2, 3]
.publisher
.map { "\(letter)\($0)" }
}
将多个 Publisher 进行合并,形成一个新的Publisher 的操作,其核心目的在于 “降维”。Publisher 的核心是事件流,Publisher 都维护了一个独立的事件流。在真实世界里的情况,往往会有多个Publisher 协同工作
removeDuplicates
过滤掉连续重复的事件
["S", "Sw", "Sw", "Sw", "Swi", "Swif", "Swift", "Swift", "Swif"]
.publisher
.removeDuplicates()
Fail
Fail<Int, SampleError>(error: .sampleError)
mapError
当 Subscriber的 Failure 和 Publisher 的 Failure 不一致
Fail<Int, SampleError>(error: .sampleError)
.mapError { _ in
MyError.myError
}
map 对 Output 进行转换,mapError 对 Failure 进行转换,就是这么简单。
tryMap
如果我们不想让 String 转换为 Int 的时候, 通过 compactMap 过滤掉 nil 值的情况
["1", "2", "cat", "4"]
.publisher
.tryMap { s -> Int in
guard let value = Int(s) else {
throw MyError.myError
}
return value
}
replaceError
它会把错误替换成一个给定的值,并且立即发送 finished 事件
["1", "2", "cat", "4"]
.publisher
.tryMap { s -> Int in
guard let value = Int(s) else {
throw MyError.myError
}
return value
}
.replaceError(with: -1)
catch
当上游 Publisher 发生错误时,catch 操作会使用新的 Publisher来把原来的 Publisher 替换掉
["1", "2", "cat", "4"]
.publisher
.tryMap { s -> Int in
guard let value = Int(s) else {
throw MyError.myError
}
return value
}
.catch { _ in
Just(-1)
}
实际上,任何满足Output == Int 和 Failure == Never 的 Publisher 都可以作为 catch 的闭包被返回,并替代原来的 Publisher:
["1", "2", "cat", "4"]
.publisher
.tryMap { s -> Int in
guard let value = Int(s) else {
throw MyError.myError
}
return value
}
.catch { _ in
[-1, -2, -3].publisher
}
一旦用户输入了不能转为 Int 的非法值 (如 “Swift”),整个结果将永远停在我们给定的默认恢复值上,接下来的任意用户输入都将被完全忽略。这往往不是我们想要的结果
["1", "2", "Swift", "4"]
.publisher
.flatMap { s in
Just(s)
.tryMap { s in
guard let value = Int(s) else {
throw MyError.myError
}
return value
}
.catch { _ in
Just(-1)
}
}
merge 所有的值都会发出
zip 等待结合到第二个序列事件发出
let subject1 = PassthroughSubject<String, Never>()
let subject2 = PassthroughSubject<String, Never>()
check("merge") {
subject1.zip(subject2)
}
subject1.send("A")
subject2.send("1")
subject1.send("B")
subject1.send("C")
subject2.send("2")
subject2.send("3")
// ("A", "1")
// ("B", "2")
// ("C", "3")
当 Publisher1 发布值,且 Publisher2 发布值时,将两个值合并,作为新的事件发布出去。在实践中,zip 经常被用在合并多个异步事件的结果,比如同时发出了多个网络请求,希望在它们全部完成的时候把结果合并在一起。
combineLatest
不论是哪个输入 Publisher,只要发生了新的事件,combineLatest 就把新发生的事件值和另一个 Publisher 中当前的最新值合并
在实践中,combineLatest 被用来处理多个可变状态,在其中某一个状态发生变化时,获取这些全部状态的最新值。比如你的 UI 上有多个 TextField,你可能想要在其中某一个值变动时获取到所有 TextField 中的值并对它们进行检查 (没错,我说的就是用户注册)。
Future
如果我们希望订阅操作和值的发布是异步行为,不在同一时间发生的话,可以使用Future
func loadPage(url: URL, handler: @escaping (Data?, URLResponse?, Error?) -> Void) {
URLSession.shared.dataTask(with: URLRequest(url: url)) { data, response, error in
handler(data, response, error)
}
.resume()
}
check("Future") {
Future<(Data, URLResponse), Error> { promise in
loadPage(url: URL(string: "https://example.com")!) { data, response, error in
if let data = data, let response = response {
promise(.success((data, response)))
} else {
promise(.failure(error!))
}
}
}
}
Future 只适用于那些必然会产生事件结果,且至多只会产生一个结果的场景。比如刚才看到的网络请求:它要么成功并返回数据及响应,要么直接失败并给出URLError。
connect 和 autoconnect
对于普通的Publisher, 当Failure是Never时,就可以使用makeConnectable() 将它包装为一个 ConnectablePublisher。这会使得该Publisher 在等到连接 (用 connect()) 后才开始执行和发布事件。在某些情况下,如果我们希望延迟及控制 Publisher 的开始时间,可以使用这个方法。对 ConnectablePublisher 的对象施加 autoconnect()的话,可以让这个ConnectablePublisher “恢复” 为被订阅时自动连接.
assign
只有 class 上用var 声明的属性可以通过 assign 来直接赋值。assign 的另一个 “限制” 是,上游 Publisher 的 Failure 的类型必须是 Never.
share 设想我们有一个涉及到网络请求的界面,它需要同时显示网络请求是否成功,以及请求所得到的结果
class LoadingUI {
var isSuccess: Bool = false
var text: String = ""
}
class Response: Decodable {
struct Args: Decodable {
let foo: String
}
let args: Args?
}
let dataPublisher = URLSession.shared.dataTaskPublisher(for: URL(string: "https://httpbin.org/get?foo=bar")!)
let isSuccess = dataPublisher.map { _, response in
guard let urlResponse = response as? HTTPURLResponse else {
return false
}
return urlResponse.statusCode == 200
}
.replaceError(with: false)
let latestText = dataPublisher.map { data, _ in
return data
}
.decode(type: Response.self, decoder: JSONDecoder())
.compactMap {
$0.args?.foo
}
.replaceError(with: "")
let ui = LoadingUI()
var token1 = isSuccess.assign(to: \.isSuccess, on: ui)
var token2 = latestText.assign(to: \.text, on: ui)
上面的写法, 会触发两次网络请求, 这样不合适
修改如下:
对于多个 Subscriber 对应一个 Publisher 的情况,如果我们不想让订阅行为反复发生 (比如上例中订阅时会发生网络请求),而是想要共享这个 Publisher 的话,使用share() 将它转变为引用类型的 class。
内存管理
针对上面 Combine 中常见的内存资源相关的操作,可以总结几条常见的规则和实践:
-
1. 对于需要 connect 的 Publisher,在 connect 后需要保存返回的 Cancellable, 并在合适的时候调用 cancel() 以结束事件的持续发布。
-
2. 对于 sink 或 assign 的返回值,一般将其存储在实例的变量中,等待属性持有者被释放时一同自动取消。不过,你也完全可以在不需要时提前释放这个变量或者明确地调用 cancel() 以取消绑定。
-
3. 对于 1 的情况,也完全可以将 Cancellable 作为参数传递给 AnyCancellable的初始化方法,将它包装成为一个可以自动取消的对象。这样一来,1 将被转换为 2 的情况。
隐式动画
通过 View 上的 animtion 修饰,就可以在 View 中支持动画的属性发生变化时自动为整个 View 添加上动画支持了。
隐式动画的作用范围很大:只要这个 View 甚至是它的子 View 上的可动画属性发生变化,这个动画就将适用。
.animation(.default)
显式动画
显式动画通过明确的 withAnimation 调用触发,我们可以将改变 app 状态的操作放 在 withAnimation 的闭包中,这时由闭包中状态变化所触发的 View 变化,将以动画形式呈现