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

Ficow發表於2020-10-09

 

本文首發於 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

官網文件:https://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

官網文件:https://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

官網文件:https://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

官網文件:https://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

官網文件:https://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

官網文件:https://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

官網文件:https://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

官網文件:https://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

官網文件:https://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

官網文件:https://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

官網文件:https://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 中還有非常多的預置操作符,如果您感興趣,可以去官網一探究竟:https://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()

 

相關文章