- 原文地址:Combine: Getting Started
- 原文做者:Fabrizio Brancati
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:chaingangway
- 校對者:lsvih
學習如何使用 Combine 框架中的 Publisher(發佈者)和 Subscriber(訂閱者)來處理隨時間變化的事件流,合併多個 publisher。前端
在 2019 年的 WWDC 大會上,Combine 框架登場,它是蘋果公司新推出的「響應式」框架,用來處理隨時間變化的事件。你能夠用 Combine 來統一和簡化像代理、通知、定時器、完成回調這樣的代碼。在 iOS 平臺上,以前也有可用的第三方響應式框架,但如今蘋果開發了本身的框架。android
在本教程中,你將學到:ios
Publisher
和 Subscriber
。Timer
。咱們經過優化 FindOrLose 來學習這些核心概念。FindOrLose 是一個遊戲,它的玩法是:在四張圖中,有一張圖與其餘三張圖不一樣,你須要快速辨別出這張圖。git
準備好探索 iOS 中 Combine 的奇妙世界嗎?是時候開始了!github
你能夠在這裏下載本教程的項目資源。編程
打開 starter 項目,查看一下項目文件。swift
在玩遊戲以前,你必須先在 Unsplash Developers Portal 上註冊並獲取一個 API key。註冊完以後,在他們的開發者門戶網站上建立一個 App。建立完成後,在屏幕上看到下面的內容:後端
註釋: Unsplash APIs 每小時有 50 次的調用上限。咱們的遊戲頗有趣,但不要玩太多喲 :]數組
打開 UnsplashAPI.swift,而後在 UnsplashAPI.accessToken
中添加你的 Unsplash API key,以下:bash
enum UnsplashAPI {
static let accessToken = "<your key>"
...
}
複製代碼
編譯運行。主屏幕上會顯示四個灰色正方形,還有一個用於開始或者中止遊戲的按鈕。
點擊 Play 開始遊戲:
如今,遊戲運行徹底正常,可是請看看 GameViewController.swift 文件中的 playGame()
,這個方法的結尾是這樣的:
}
}
}
}
}
}
複製代碼
有太多內嵌的閉包了。你能理清裏面的邏輯和順序嗎?若是你想改變調用順序或者增長新功能,要怎麼辦?Combine 幫你的時候到了。
Combine 框架提供了一套聲明式的 API,用來計算隨時間變化的值。它有三個要素:
下面咱們依次來看每個要素:
遵循 Publisher
協議的對象能發送隨時間變化的值序列。協議中有兩個關聯類型:Output
是產生值的類型;Failure
是異常類型。
每個 publisher 能夠發送多種事件:
Output
類型的值輸出Failure
類型的異常輸出爲了支持 Publishers,在 Foundation 框架中已經優化了一些類型的函數式特性,好比 Timer
和 URLSession
。在本教程咱們也會用到它們。
Operators 是特殊的方法,它能被 Publishers 調用而且返回相同的或者不一樣的 Publisher。Operator 描述了對一個值進行修改、增長、刪除或者其餘操做的行爲。你能夠經過鏈式調用將這些操做組合在一塊兒,進行復雜的運算。
想象一下,值從原始的 Publisher 開始流動,而後通過一系列 Operator 的處理,造成新的 Publisher。這個過程就像一條河,值從上游的 Publisher 流向下游的 Publisher。
若是沒有監聽這些發佈的事件,Publishers 和 Operators 就沒有意義。因此咱們須要 Subscriber 來監聽。
Subscriber
是另外一個協議。跟 Publisher
協議相似,它也有兩個關聯類型:Input
和 Failure
。這兩個類型必須和 Publisher 中的 Output
和 Failure
類型相對應。
Subscriber 接收 Publisher 的值序列以及正常或者異常的事件。
在調用 publisher 的 subscribe(_:)
方法時,它就準備給 subscriber 傳值。這個時候,publisher 會給 subscriber 發送一個 subscription。subscriber 就能夠用這個 subscription 向 publisher 請求數據。
這些完成以後,publisher 就能夠自由地向 subscriber 傳送數據了。在這個過程當中,publisher 有可能會傳送請求的全部數據,有可能只會傳送部分數據。若是 publisher 是有限事件流,它最終會以完成事件或者錯誤事件結束。下面的圖表總結了這個過程:
上文是對 Combine 的概述。如今咱們在項目中使用它。
首先,建立 GameError
枚舉來處理全部的 Publisher
錯誤。在 Xcode 的主目錄中,進入 File ▸ New ▸ File... 選項卡,而後選擇 template iOS ▸ Source ▸ Swift File。
給這個新文件命名爲 GameError.swift,而後添加到 Game 文件夾中。
下面來完善 GameError
這個枚舉:
enum GameError: Error {
case statusCode
case decoding
case invalidImage
case invalidURL
case other(Error)
static func map(_ error: Error) -> GameError {
return (error as? GameError) ?? .other(error)
}
}
複製代碼
枚舉中定義了在遊戲中全部可能遇到的錯誤,還定義了一個處理任意類型錯誤的方法,用來保證錯誤是 GameError 類型。咱們在處理 publisher 的時候就會用到。
有了這些,咱們就能夠處理 HTTP 狀態碼和 decoding 中的錯誤了。
下一步,導入 Combine 框架。打開 UnsplashAPI.swift,在文件的開頭加入下面這段:
import Combine
複製代碼
而後把 randomImage(completion:)
的簽名改爲以下:
static func randomImage() -> AnyPublisher<RandomImageResponse, GameError> {
複製代碼
如今這個方法沒有把回調閉包做爲參數,而是返回了一個 publisher,它的 output 是 RandomImageResponse 類型,faliure 是 GameError 類型。
AnyPublisher
是一個系統類型,你能夠用它來包裝「任意」的 publisher。這意味着,若是你想使用 operators 或者對調用者隱藏實現細節時,就沒必要修改方法簽名了。
下一步,咱們來修改代碼,讓 URLSession
支持 Combine 的新功能。找到以 session.dataTask(with:
開頭的那一行,從這行開始到方法的末尾,用下面的代碼替換。
// 1
return session.dataTaskPublisher(for: urlRequest)
// 2
.tryMap { response in
guard
// 3
let httpURLResponse = response.response as? HTTPURLResponse,
httpURLResponse.statusCode == 200
else {
// 4
throw GameError.statusCode
}
// 5
return response.data
}
// 6
.decode(type: RandomImageResponse.self, decoder: JSONDecoder())
// 7
.mapError { GameError.map($0) }
// 8
.eraseToAnyPublisher()
複製代碼
這段代碼看起來有不少,可是它用到了不少 Combine 的特性。下面一步一步來說解:
URLSession.DataTaskPublisher
類型,它的 output 類型是 (data: Data, response: URLResponse)。這不是正確的輸出類型,因此你要用一系列 operator 進行轉換來達到目的。tryMap
。這個 operator 會接收上游的值,並嘗試將它映射成其它的類型,映射過程當中可能會拋出錯誤。還有一個叫 map
的 operator 能夠執行映射操做,但它不會拋出錯誤。200 OK
。200 OK
,拋出自定義的 GameError.statusCode
錯誤。response.data
。這意味着如今鏈式調用的輸出類型是 Data
。decode
,它將嘗試用 JSONDecoder
把上游的值解析爲 RandomImageResponse
類型。到這一步,輸出類型纔是正確的。mapError
的返回類型,你可能會被嚇到。.eraseToAnyPublisher
操做者會幫你把一切都收拾好,讓返回值會更有可讀性。上面的絕大部分邏輯,你也能夠在一個 operator 中實現,但這明顯不是 Combine 的思想。你能夠思考一下 UNIX 中的一些工具,它們每一步只作一件事情,而後把每一步中的結果向下一步傳遞。
重構好了網絡層的邏輯,咱們來下載圖片
打開 ImageDownloader.swift 文件,而後在文件的開頭用下面的代碼導入 Combine:
import Combine
複製代碼
和 randomImage
同樣,有了 Combine 你沒必要使用閉包。用下面的代碼替換 download(url:, completion:)
方法:
// 1
static func download(url: String) -> AnyPublisher<UIImage, GameError> {
guard let url = URL(string: url) else {
return Fail(error: GameError.invalidURL)
.eraseToAnyPublisher()
}
//2
return URLSession.shared.dataTaskPublisher(for: url)
//3
.tryMap { response -> Data in
guard
let httpURLResponse = response.response as? HTTPURLResponse,
httpURLResponse.statusCode == 200
else {
throw GameError.statusCode
}
return response.data
}
//4
.tryMap { data in
guard let image = UIImage(data: data) else {
throw GameError.invalidImage
}
return image
}
//5
.mapError { GameError.map($0) }
//6
.eraseToAnyPublisher()
}
複製代碼
這裏的代碼與以前例子中的很是相似。下面一步一步來說解:
dataTaskPublisher
。tryMap
檢查響應碼,若是沒有錯誤,就提取數據。tryMap
操做者把上游的 Data
轉換成 UIImage
,若是失敗,就拋出錯誤。GameError
類型。.eraseToAnyPublisher
返回一個優雅的類型咱們已經用 publisher 來代替回調閉包修改完了全部網絡相關的方法。如今,咱們來調用這些方法。
打開 GameViewController.swift,在文件的開頭導入 Combine:
import Combine
複製代碼
在 GameViewController
類的開頭加入下面的屬性:
var subscriptions: Set<AnyCancellable> = []
複製代碼
這個屬性是用來存儲全部的 subscriptions。目前爲止,咱們使用過 publishers 和 operators,可是沒有訂閱。
刪除 playGame()
中全部的代碼,在 startLoaders()
方法調用的後面,用下面的代碼替換:
// 1
let firstImage = UnsplashAPI.randomImage()
// 2
.flatMap { randomImageResponse in
ImageDownloader.download(url: randomImageResponse.urls.regular)
}
複製代碼
在上面的代碼中:
flatMap
,把上一個 publisher 的值映射爲新的 publisher。在本例中,你首先調用了 randomImage,得到了 output 後,將它映射成下載圖片的 publisher。下一步,咱們用一樣的邏輯來獲取第二張圖片。把下面的代碼添加到 firstImage
後面:
let secondImage = UnsplashAPI.randomImage()
.flatMap { randomImageResponse in
ImageDownloader.download(url: randomImageResponse.urls.regular)
}
複製代碼
如今咱們已經下載了兩張隨機圖片了。用 zip
對這些操做進行組合。在 secondImage
的後面添加下面的代碼:
// 1
firstImage.zip(secondImage)
// 2
.receive(on: DispatchQueue.main)
// 3
.sink(receiveCompletion: { [unowned self] completion in
// 4
switch completion {
case .finished: break
case .failure(let error):
print("Error: \(error)")
self.gameState = .stop
}
}, receiveValue: { [unowned self] first, second in
// 5
self.gameImages = [first, second, second, second].shuffled()
self.gameScoreLabel.text = "Score: \(self.gameScore)"
// TODO: Handling game score
self.stopLoaders()
self.setImages()
})
// 6
.store(in: &subscriptions)
複製代碼
下面的步驟分解:
zip
經過組合現有的 pulisher 的 output,來建立一個新的 publisher。它會等全部的 publisher 都發送 output 以後,纔會把組合值發送給下游。receive(on:)
能夠指定上游的事件在哪裏處理。若是要在 UI 上操做,就必須使用主隊列。sink(receiveCompletion:receiveValue:)
建立了一個 subscriber,它有兩個閉包參數。當收到完成事件或者正常值時,閉包就會調用。subscriptions
中,用於消除引用。沒有引用以後,訂閱信息就會取消,publisher 也會當即中止發送。最後,編譯運行吧。
恭喜,如今你的 App 成功使用了 Combine 來處理事件流。
你也許會注意到,分數邏輯沒有起做用。重構以前,咱們選擇圖片的同時分數也在倒數,可是如今分數是靜止的。如今咱們要用 Combine 重構計時器的功能。
首先,用下面的代碼替換 playGame() 方法中的 // TODO: Handling game score
,用來恢復計時器功能:
self.gameTimer = Timer
.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [unowned self] timer in
self.gameScoreLabel.text = "Score: \(self.gameScore)"
self.gameScore -= 10
if self.gameScore <= 0 {
self.gameScore = 0
timer.invalidate()
}
}
複製代碼
在上面的代碼中,咱們打算讓 gameTimer
每 0.1
秒觸發一次,同時讓分數減少 10
。當分數達到 0
的時候,終止定時器。
如今編譯運行,肯定遊戲分數是否隨着時間流逝在減少。
定時器是另一種支持 Combine 功能的 Foundation 類型。如今咱們把定時器遷移到 Combine 的版原本看看差別。
在 GameViewController
的頂部,修改 gameTimer
的定義。
var gameTimer: AnyCancellable?
複製代碼
如今是在定時器裏存儲一個 subscription,而不是定時器自己。在 Combine 中咱們使用 AnyCancellable
。
用下的代碼替換 playGame()
和 stopGame()
方法的第一行:
gameTimer?.cancel()
複製代碼
如今用下面的代碼在 playGame()
方法中修改 gameTimer
的賦值:
// 1
self.gameTimer = Timer.publish(every: 0.1, on: RunLoop.main, in: .common)
// 2
.autoconnect()
// 3
.sink { [unowned self] _ in
self.gameScoreLabel.text = "Score: \(self.gameScore)"
self.gameScore -= 10
if self.gameScore < 0 {
self.gameScore = 0
self.gameTimer?.cancel()
}
}
複製代碼
下面是分解步驟:
.autoconnect
經過鏈接或者斷開鏈接來進行管理。sink
建立的 subscriber,只須要處理正常值。編譯運行,玩一下你的 Combine App 吧。
這裏還有幾個待優化的地方,咱們用 .store(in: &subscriptions)
連續添加了多個 subscriber,但沒有移除它們。下面咱們來改進。
在 resetImages()
的頂部添加下面這行代碼:
subscriptions = []
複製代碼
這裏,你聲明瞭一個空數組,用來移除全部無用訂閱信息的引用。
下一步,在 stopGame()
方法的頂部添加下面這行代碼:
subscriptions.forEach { $0.cancel() }
複製代碼
這裏,你遍歷了全部的 subscriptions
,而後取消了它們。
最後一次編譯運行了。
使用 Combine 框架是一個很好的選擇。它既流行又新穎,並且仍是官方的,爲何不如今就用呢?不過在你打算全面使用以前,你得考慮一些事情:
首先,你得爲用戶考慮。若是你打算繼續支持 iOS 12,你就不能使用 Combine。(Combine 須要 iOS 13 及以上的版本才支持)
響應式編程在思惟上的轉變很大,會有學習曲線,可是你的團隊要趕進度。在你的團隊中是否每一個人都像你同樣熱衷於改變固有的工做方式?
在採用 Combine 以前,思考一下你的 app 中已經用到的技術。若是你有其餘基於回調的 SDK,好比 Core Bluetooth,你必須用 Combine 對它們進行封裝。
當你逐漸掌握 Combine 時,就沒有那麼多顧慮了。你能夠先從網絡層調用開始重構,而後切換到 app 的其餘模塊。你也能夠在使用閉包的地方使用 Combine。
你能夠在原文頁面下載本工程的完整版本。
本教程中,你學習了 Combine 的基礎知識:Publisher
和 Subscriber
。你也學會了 operator 和定時器的使用。恭喜,你已經入門了!
想學習更多 Combine 的用法,請看咱們的書籍 Combine: Asynchronous Programming with Swift!
若是你對本教程有問題或者評價,歡迎在下討論區討論!
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。