Combine 框架,從0到1 —— 5.Combine 常用操作符

 

本文首發於 Ficow Shen’s Blog,原文地址: Combine 框架,從0到1 —— 5.Combine 常用操作符

 

內容概覽

  • 前言
  • print
  • breakpoint
  • handleEvents
  • map
  • flatMap
  • eraseToAnyPublisher
  • merge
  • combineLatest
  • zip
  • setFailureType
  • switchToLatest
  • 總結

 

前言

 

正所謂,工欲善其事,必先利其器。在開始使用 Combine 進行響應式編程之前,建議您先了解 Combine 為您提供的各種發布者(Publishers)、操作符(Operators)、訂閱者(Subscribers)。

Combine 操作符(Operators) 其實是發布者,這些操作符發布者的值由上游發布者提供。操作符封裝了很多常用的響應式編程演算法,有一些可以幫助我們更輕鬆地進行調試,而另一些可以幫助我們更輕鬆地通過結合多個操作符來實現業務邏輯,本文將主要介紹這兩大類操作符。

後續示例程式碼中出現的 cancellables 均由 CommonOperatorsDemo 實例提供:

final class CommonOperatorsDemo {
    
    private var cancellables = Set<AnyCancellable>()
    
}

 

print

官網文檔://developer.apple.com/documentation/combine/publishers/print

 

print 操作符主要用於列印所有發布的事件,您可以選擇為輸出的內容添加前綴。

print 會在接收到以下事件時列印消息:

  • subscription,訂閱事件
  • value,接收到值元素
  • normal completion,正常的完成事件
  • failure,失敗事件
  • cancellation,取消訂閱事件

 

示例程式碼:

func printDemo() {
        [1, 2].publisher
            .print("_")
            .sink { _ in }
            .store(in: &cancellables)
}

輸出內容:

_: receive subscription: ([1, 2])
_: request unlimited
_: receive value: (1)
_: receive value: (2)
_: receive finished

 

breakpoint

官網文檔://developer.apple.com/documentation/combine/publishers/breakpoint

 

breakpoint 操作符可以發送調試訊號來讓調試器暫停進程的運行,只要在給定的閉包中返回 true 即可。

示例程式碼:

func breakpointDemo() {
        [1, 2].publisher
            .breakpoint(receiveSubscription: { subscription in
                return false // 返回 true 以拋出 SIGTRAP 中斷訊號,調試器會被調起
            }, receiveOutput: { value in
                return false // 返回 true 以拋出 SIGTRAP 中斷訊號,調試器會被調起
            }, receiveCompletion: { completion in
                return false // 返回 true 以拋出 SIGTRAP 中斷訊號,調試器會被調起
            })
            .sink(receiveValue: { _ in
                
            })
            .store(in: &cancellables)
}

您可能會好奇,為什麼需要用這個操作符來實現斷點,為何不直接打斷點呢?
從上面的示例程式碼中,我們可以看出,通過使用 breakpoint 操作符,我們可以很容易地在訂閱操作、輸出、完成發生時啟用斷點。
如果這時候想直接在程式碼上打斷點,我們就要重寫 sink 部分的程式碼,而且無法輕易地為訂閱操作啟用斷點。

 

handleEvents

官網文檔://developer.apple.com/documentation/combine/publishers/handleevents

 

handleEvents 操作符可以在發布事件發生時執行指定的閉包。

示例程式碼:

func handleEventsDemo() {
        [1, 2].publisher
            .handleEvents(receiveSubscription: { subscription in
                // 訂閱事件
            }, receiveOutput: { value in
                // 值事件
            }, receiveCompletion: { completion in
                // 完成事件
            }, receiveCancel: {
                // 取消事件
            }, receiveRequest: { demand in
                // 請求需求的事件
            })
            .sink(receiveValue: { _ in
                
            })
            .store(in: &cancellables)
    }

handleEvents 接受的閉包都是可選類型的,所以我們可以只需要對感興趣的事件進行處理即可,不必為所有參數傳入一個閉包。

 

map

官網文檔://developer.apple.com/documentation/combine/publishers/map

 

寶石圖

map 操作符會執行給定的閉包,將上游發布的內容進行轉換,然後再發送給下游訂閱者。和 Swift 標準庫中的 map 函數類似。

示例程式碼:

func mapDemo() {
        [1, 2].publisher
            .map { $0.description + $0.description }
            .sink(receiveValue: { value in
                print(value)
            })
            .store(in: &cancellables)
}

輸出內容:

11
22

 

flatMap

官網文檔://developer.apple.com/documentation/combine/publishers/flatmap

 

寶石圖

flatMap 操作符會轉換上游發布者發送的所有的元素,然後返回一個新的或者已有的發布者。

flatMap 會將所有返回的發布者的輸出合併到一個輸出流中。我們可以通過 flatMap 操作符的 maxPublishers 參數指定返回的發布者的最大數量。

flatMap 常在錯誤處理中用於返回備用發布者和默認值,示例程式碼:

struct Model: Decodable {
    let id: Int
}
	
func flatMapDemo() {
        guard let data1 = #"{"id": 1}"#.data(using: .utf8),
            let data2 = #"{"i": 2}"#.data(using: .utf8),
            let data3 = #"{"id": 3}"#.data(using: .utf8)
            else { fatalError() }
        
        [data1, data2, data3].publisher
            .flatMap { data -> AnyPublisher<CommonOperatorsDemo.Model?, Never> in
                return Just(data)
                    .decode(type: Model?.self, decoder: JSONDecoder())
                    .catch {_ in
                        // 解析失敗時,返回默認值 nil
                        return Just(nil)
                    }.eraseToAnyPublisher()
            }
            .sink(receiveValue: { value in
                print(value)
            })
            .store(in: &cancellables)
}

輸出內容:

Optional(CombineDemo.CommonOperatorsDemo.Model(id: 1))
nil
Optional(CombineDemo.CommonOperatorsDemo.Model(id: 3))

錯誤處理在響應式編程中是一個重點內容,也是一個常見的坑!一定要小心,一定要注意!!!

如果沒有 catch 操作符,上面的事件流就會因為 data2 解析失敗而終止。

比如,現在將 catch 去掉:

        [data1, data2, data3].publisher
            .setFailureType(to: Error.self)
            .flatMap { data -> AnyPublisher<Model?, Error> in
                return Just(data)
                    .decode(type: Model?.self, decoder: JSONDecoder())
                    .eraseToAnyPublisher()
            }
            .sink(receiveCompletion: { completion in
                print(completion)
            }, receiveValue: { value in
                print(value)
            })
            .store(in: &cancellables)

此時,輸出內容變為了:

Optional(CombineDemo.CommonOperatorsDemo.Model(id: 1))
failure(Swift.DecodingError.keyNotFound(CodingKeys(stringValue: "id", intValue: nil), Swift.DecodingError.Context(codingPath: [], debugDescription: "No value associated with key CodingKeys(stringValue: \"id\", intValue: nil) (\"id\").", underlyingError: nil)))

最終,下游訂閱者因為上游發生了錯誤而終止了訂閱,下游便無法收到 Optional(CombineDemo.CommonOperatorsDemo.Model(id: 3))

 

eraseToAnyPublisher

官網文檔://developer.apple.com/documentation/combine/anypublisher

 

eraseToAnyPublisher 操作符可以將一個發布者轉換為一個類型擦除後的 AnyPublisher 發布者。

這樣做可以避免過長的泛型類型資訊,比如:Publishers.Catch<Publishers.Decode<Just<JSONDecoder.Input>, CommonOperatorsDemo.Model?, JSONDecoder>, Just<CommonOperatorsDemo.Model?>>。使用 eraseToAnyPublisher 操作符將類型擦除後,我們可以得到 AnyPublisher<Model?, Never> 類型。

除此之外,如果需要向調用方暴露內部的發布者,使用 eraseToAnyPublisher 操作符也可以對外部隱藏內部的實現細節。

示例程式碼請參考上文 flatMap 部分的內容。

 

merge

官網文檔://developer.apple.com/documentation/combine/publishers/merge

 

寶石圖

merge 操作符可以將上游發布者發送的元素合併到一個序列中。merge 操作符要求上游發布者的輸出和失敗類型完全相同。

merge 操作符有多個版本,分別對應上游發布者的個數:

  • merge
  • merge3
  • merge4
  • merge5
  • merge6
  • merge7
  • merge8

示例程式碼:

func mergeDemo() {
        let oddPublisher = PassthroughSubject<Int, Never>()
        let evenPublisher = PassthroughSubject<Int, Never>()
        
        oddPublisher
            .merge(with: evenPublisher)
            .sink(receiveCompletion: { completion in
                print(completion)
            }, receiveValue: { value in
                print(value)
            })
            .store(in: &cancellables)
        
        oddPublisher.send(1)
        evenPublisher.send(2)
        oddPublisher.send(3)
        evenPublisher.send(4)
}

輸出內容:

1
2
3
4

 

combineLatest

官網文檔://developer.apple.com/documentation/combine/publishers/combinelatest

 

寶石圖

combineLatest 操作符接收來自上游發布者的最新元素,並將它們結合到一個元組後進行發送。

combineLatest 操作符要求上游發布者的失敗類型完全相同,輸出類型可以不同。

combineLatest 操作符有多個版本,分別對應上游發布者的個數:

  • combineLatest
  • combineLatest3
  • combineLatest4

示例程式碼:

func combineLatestDemo() {
        let oddPublisher = PassthroughSubject<Int, Never>()
        let evenStringPublisher = PassthroughSubject<String, Never>()
        
        oddPublisher
            .combineLatest(evenStringPublisher)
            .sink(receiveCompletion: { completion in
                print(completion)
            }, receiveValue: { value in
                print(value)
            })
            .store(in: &cancellables)
        
        oddPublisher.send(1)
        evenStringPublisher.send("2")
        oddPublisher.send(3)
        evenStringPublisher.send("4")
}

輸出內容:

(1, "2")
(3, "2")
(3, "4")

請注意,這裡的第一次輸出是 (1, "2")combineLatest 操作符的下游訂閱者只有在所有的上游發布者都發布了值之後才會收到結合了的值。

 

zip

官網文檔://developer.apple.com/documentation/combine/publishers/zip

 

寶石圖

zip 操作符會將上游發布者發布的元素結合到一個流中,在每個上游發布者發送的元素配對時才向下游發送一個包含配對元素的元組。

zip 操作符要求上游發布者的失敗類型完全相同,輸出類型可以不同。

zip 操作符有多個版本,分別對應上游發布者的個數:

  • zip
  • zip3
  • zip4

示例程式碼:

func zipDemo() {
        let oddPublisher = PassthroughSubject<Int, Never>()
        let evenStringPublisher = PassthroughSubject<String, Never>()
        
        oddPublisher
            .zip(evenStringPublisher)
            .sink(receiveCompletion: { completion in
                print(completion)
            }, receiveValue: { value in
                print(value)
            })
            .store(in: &cancellables)
        
        oddPublisher.send(1)
        evenStringPublisher.send("2")
        oddPublisher.send(3)
        evenStringPublisher.send("4")
        evenStringPublisher.send("6")
        evenStringPublisher.send("8")
}

輸出內容:

(1, "2")
(3, "4")

請注意,因為 1 和 “2” 可以配對,3 和 “4” 可以配對,所以它們被輸出。而 “6” 和 “8” 無法完成配對,所以沒有被輸出。
combineLatest 操作符一樣,zip 操作符的下游訂閱者只有在所有的上游發布者都發布了值之後才會收到結合了的值。

 

setFailureType

官網文檔://developer.apple.com/documentation/combine/publishers/setfailuretype

 

setFailureType 操作符可以將當前序列的失敗類型設置為指定的類型,主要用於適配具有不同失敗類型的發布者。

示例程式碼:

func setFailureTypeDemo() {
        let publisher = PassthroughSubject<Int, Error>()
        
        Just(2)
            .setFailureType(to: Error.self)
            .merge(with: publisher)
            .sink(receiveCompletion: { completion in
                print(completion)
            }, receiveValue: { value in
                print(value)
            })
            .store(in: &cancellables)
        
        publisher.send(1)
}

輸出內容:

2
1

如果注釋 .setFailureType(to: Error.self) 這一行程式碼,編譯器就會給出錯誤:
Instance method 'merge(with:)' requires the types 'Never' and 'Error' be equivalent

因為,Just(2) 的失敗類型是 Never,而 PassthroughSubject<Int, Error>() 的失敗類型是 Error
通過調用 setFailureType 操作符,可以將 Just(2) 的失敗類型設置為 Error

 

switchToLatest

官網文檔://developer.apple.com/documentation/combine/publishers/switchtolatest

 

switchToLatest 操作符可以將來自多個發布者的事件流展平為單個事件流。

switchToLatest 操作符可以為下游提供一個持續的訂閱流,同時內部可以切換多個發布者。比如,對 Publisher<Publisher<Data, NSError>, Never> 類型調用 switchToLatest() 操作符後,結果會變成 Publisher<Data, NSError> 類型。下游訂閱者只會看到一個持續的事件流,即使這些事件可能來自於多個不同的上游發布者。

下面是一個簡單的示例,可以讓我們更容易理解 switchToLatest 到底做了什麼。示例程式碼:

func switchToLatestDemo() {
        let subjects = PassthroughSubject<PassthroughSubject<String, Never>, Never>()
        
        subjects
            .switchToLatest()
            .sink(receiveValue: { print($0) })
            .store(in: &cancellables)
        
        let stringSubject1 = PassthroughSubject<String, Never>()
        
        subjects.send(stringSubject1)
        stringSubject1.send("A")
        
        let stringSubject2 = PassthroughSubject<String, Never>()
        
        subjects.send(stringSubject2) // 發布者切換為 stringSubject2
        
        stringSubject1.send("B") // 下游不會收到
        stringSubject1.send("C") // 下游不會收到
        
        stringSubject2.send("D")
        stringSubject2.send("E")
        
        stringSubject2.send(completion: .finished)
}

輸出內容:

A
D
E

下面將是一個更複雜但是卻更常見的用法,示例程式碼:

func switchToLatestDemo2() {
        let subject = PassthroughSubject<String, Error>()
        
        subject.map { value in
            // 在這裡發起網路請求,或者其他可能失敗的任務
            return Future<Int, Error> { promise in
                if let intValue = Int(value) {
                    // 根據傳入的值來延遲執行
                    DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(intValue)) {
                        print(#function, intValue)
                        promise(.success(intValue))
                    }
                } else {
                    // 失敗就立刻完成
                    promise(.failure(Errors.notInteger))
                }
            }
            .replaceError(with: 0) // 提供默認值,防止下游的訂閱因為失敗而被終止
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
        }
        .switchToLatest()
        .sink(receiveCompletion: { completion in
            print(completion)
        }, receiveValue: { value in
            print(value)
        })
        .store(in: &cancellables)
        
        subject.send("3") // 下游不會收到 3
        subject.send("") // 立即失敗,下游會收到0,之前的 3 會被丟棄
        subject.send("1") // 延時 1 秒後,下游收到 1
}

輸出內容:

0
switchToLatestDemo2() 1
1
switchToLatestDemo2() 3

請注意,在發送了 "" 之後,之前發送的 "3" 依然會觸發 Future 中的操作,但是這個 Future 里的 promise(.success(intValue)) 中傳入的 3,下游不會收到。

 

總結

 

Combine 中還有非常多的預置操作符,如果您感興趣,可以去官網一探究竟://developer.apple.com/documentation/combine/publishers

雖然學習這些操作符的成本略高,但是當您掌握之後,開發效率必然會大幅提升。尤其是當 CombineSwiftUI 以及 MVVM 結合在一起使用時,這些學習成本就會顯得更加值得!因為,它們可以幫助您寫出更簡潔、更易讀、更優雅,同時也更加容易測試的程式碼!

Ficow 還會繼續更新 Combine 系列的文章,後續的內容會講解如何將 CombineSwiftUI 以及 MVVM 結合在一起使用。

 

推薦繼續閱讀:Combine 框架,從0到1 —— 5.Combine 中的 Scheduler

 

參考內容:

Using Combine
The Operators of ReactiveX
Combine — switchToLatest()