Combine

26 阅读4分钟

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

image.png

用下面的代码验证上面的关系图

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 中当前的最新值合并

image.png

在实践中,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 变化,将以动画形式呈现