0202 年了,是時候學習 Combine 了

0202 年了,是時候學習 Combine 了

學習如何使用 Combine 框架中的 Publisher(發佈者)和 Subscriber(訂閱者)來處理隨時間變化的事件流,合併多個 publisher。前端

在 2019 年的 WWDC 大會上,Combine 框架登場,它是蘋果公司新推出的「響應式」框架,用來處理隨時間變化的事件。你能夠用 Combine 來統一和簡化像代理、通知、定時器、完成回調這樣的代碼。在 iOS 平臺上,以前也有可用的第三方響應式框架,但如今蘋果開發了本身的框架。android

在本教程中,你將學到:ios

  • 使用 PublisherSubscriber
  • 處理事件流。
  • 用 Combine 框架中的方式使用 Timer
  • 肯定在項目中使用 Combine 的時機。

咱們經過優化 FindOrLose 來學習這些核心概念。FindOrLose 是一個遊戲,它的玩法是:在四張圖中,有一張圖與其餘三張圖不一樣,你須要快速辨別出這張圖。git

準備好探索 iOS 中 Combine 的奇妙世界嗎?是時候開始了!github

入門

你能夠在這裏下載本教程的項目資源編程

打開 starter 項目,查看一下項目文件。swift

在玩遊戲以前,你必須先在 Unsplash Developers Portal 上註冊並獲取一個 API key。註冊完以後,在他們的開發者門戶網站上建立一個 App。建立完成後,在屏幕上看到下面的內容:後端

Creating Unsplash app to get the API key

註釋: Unsplash APIs 每小時有 50 次的調用上限。咱們的遊戲頗有趣,但不要玩太多喲 :]數組

打開 UnsplashAPI.swift,而後在 UnsplashAPI.accessToken 中添加你的 Unsplash API key,以下:bash

enum UnsplashAPI {
  static let accessToken = "<your key>"
  ...
}
複製代碼

編譯運行。主屏幕上會顯示四個灰色正方形,還有一個用於開始或者中止遊戲的按鈕。

First screen of FindOrLose with four gray squares

點擊 Play 開始遊戲:

First run of FindOrLose with four images

如今,遊戲運行徹底正常,可是請看看 GameViewController.swift 文件中的 playGame(),這個方法的結尾是這樣的:

}
          }
        }
      }
    }
  }
複製代碼

有太多內嵌的閉包了。你能理清裏面的邏輯和順序嗎?若是你想改變調用順序或者增長新功能,要怎麼辦?Combine 幫你的時候到了。

Combine 介紹

Combine 框架提供了一套聲明式的 API,用來計算隨時間變化的值。它有三個要素:

  1. Publishers:產生值
  2. Operators:對值進行運算
  3. Subscribers:接收值

下面咱們依次來看每個要素:

Publishers

遵循 Publisher 協議的對象能發送隨時間變化的值序列。協議中有兩個關聯類型:Output 是產生值的類型;Failure 是異常類型。

每個 publisher 能夠發送多種事件:

  • Output 類型的值輸出
  • 完成回調
  • Failure 類型的異常輸出

爲了支持 Publishers,在 Foundation 框架中已經優化了一些類型的函數式特性,好比 TimerURLSession。在本教程咱們也會用到它們。

Operators

Operators 是特殊的方法,它能被 Publishers 調用而且返回相同的或者不一樣的 Publisher。Operator 描述了對一個值進行修改、增長、刪除或者其餘操做的行爲。你能夠經過鏈式調用將這些操做組合在一塊兒,進行復雜的運算。

想象一下,值從原始的 Publisher 開始流動,而後通過一系列 Operator 的處理,造成新的 Publisher。這個過程就像一條河,值從上游的 Publisher 流向下游的 Publisher。

Subscribers

若是沒有監聽這些發佈的事件,Publishers 和 Operators 就沒有意義。因此咱們須要 Subscriber 來監聽。

Subscriber 是另外一個協議。跟 Publisher 協議相似,它也有兩個關聯類型:InputFailure。這兩個類型必須和 Publisher 中的 OutputFailure 類型相對應。

Subscriber 接收 Publisher 的值序列以及正常或者異常的事件。

組合

在調用 publisher 的 subscribe(_:) 方法時,它就準備給 subscriber 傳值。這個時候,publisher 會給 subscriber 發送一個 subscription。subscriber 就能夠用這個 subscription 向 publisher 請求數據。

這些完成以後,publisher 就能夠自由地向 subscriber 傳送數據了。在這個過程當中,publisher 有可能會傳送請求的全部數據,有可能只會傳送部分數據。若是 publisher 是有限事件流,它最終會以完成事件或者錯誤事件結束。下面的圖表總結了這個過程:

Publisher-Subscriber pattern

在網絡層使用 Combine

上文是對 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 的特性。下面一步一步來說解:

  1. URL session 返回了 URL 請求的 publisher。這個 publisher 是 URLSession.DataTaskPublisher 類型,它的 output 類型是 (data: Data, response: URLResponse)。這不是正確的輸出類型,因此你要用一系列 operator 進行轉換來達到目的。
  2. 使用 tryMap。這個 operator 會接收上游的值,並嘗試將它映射成其它的類型,映射過程當中可能會拋出錯誤。還有一個叫 map 的 operator 能夠執行映射操做,但它不會拋出錯誤。
  3. 檢查 HTTP 狀態是否爲 200 OK
  4. 若是 HTTP 狀態碼不是 200 OK,拋出自定義的 GameError.statusCode 錯誤。
  5. 若是一切都 OK,返回 response.data。這意味着如今鏈式調用的輸出類型是 Data
  6. 使用 decode,它將嘗試用 JSONDecoder 把上游的值解析爲 RandomImageResponse類型。到這一步,輸出類型纔是正確的。
  7. 錯誤類型沒有徹底正確。若是在 decode 的過程當中產生了錯誤,錯誤的類型不會是 GameError。在 mapError 這個 operator 中,咱們使用 GameError 中定義的方法,把任意的錯誤類型映射成你想要的錯誤類型。
  8. 若是查看一下 mapError 的返回類型,你可能會被嚇到。.eraseToAnyPublisher 操做者會幫你把一切都收拾好,讓返回值會更有可讀性。

上面的絕大部分邏輯,你也能夠在一個 operator 中實現,但這明顯不是 Combine 的思想。你能夠思考一下 UNIX 中的一些工具,它們每一步只作一件事情,而後把每一步中的結果向下一步傳遞。

用 Combine 框架下載圖片

重構好了網絡層的邏輯,咱們來下載圖片

打開 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()
}
複製代碼

這裏的代碼與以前例子中的很是相似。下面一步一步來說解:

  1. 跟以前同樣,修改方法簽名。讓它返回一個 publisher,而不是接收閉包參數。
  2. 得到圖片 URL 的 dataTaskPublisher
  3. 使用 tryMap 檢查響應碼,若是沒有錯誤,就提取數據。
  4. 用另外一個 tryMap 操做者把上游的 Data 轉換成 UIImage,若是失敗,就拋出錯誤。
  5. 將錯誤映射成 GameError 類型。
  6. .eraseToAnyPublisher 返回一個優雅的類型

使用 Zip

咱們已經用 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)
  }
複製代碼

在上面的代碼中:

  1. 得到一個隨機圖片的 publisher。
  2. 使用 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)
複製代碼

下面的步驟分解:

  1. zip 經過組合現有的 pulisher 的 output,來建立一個新的 publisher。它會等全部的 publisher 都發送 output 以後,纔會把組合值發送給下游。
  2. receive(on:) 能夠指定上游的事件在哪裏處理。若是要在 UI 上操做,就必須使用主隊列。
  3. 這是咱們的第一個 subscriber。sink(receiveCompletion:receiveValue:) 建立了一個 subscriber,它有兩個閉包參數。當收到完成事件或者正常值時,閉包就會調用。
  4. Publisher 有兩種方式結束調用 — 完成或者異常。若是產生了異常,遊戲就會終止。
  5. 將兩張隨機圖片的數據加入到數組中進行隨機化,而後更新 UI。
  6. 把訂閱信息存儲到 subscriptions 中,用於消除引用。沒有引用以後,訂閱信息就會取消,publisher 也會當即中止發送。

最後,編譯運行吧。

Playing the FindOrLose game made with Combine

恭喜,如今你的 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()
  }
}
複製代碼

在上面的代碼中,咱們打算讓 gameTimer0.1 秒觸發一次,同時讓分數減少 10。當分數達到 0 的時候,終止定時器。

如今編譯運行,肯定遊戲分數是否隨着時間流逝在減少。

Game score decreases as time elapses

在 Combine 中使用定時器

定時器是另一種支持 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()
    }
  }
複製代碼

下面是分解步驟:

  1. 用這個新 API 能夠經過 Timer 建立 publisher。這個 publisher 會在給定的時間間隔下和給定的 runloop 上重複發送當前的時刻。
  2. 這個 publisher 是一個特殊的 Publisher 類型,它須要明確指定開始和結束的 時機。當訂閱開始或取消時,.autoconnect 經過鏈接或者斷開鏈接來進行管理。
  3. 這個 publisher 不可能異常,因此不用處理異常回調。在這個例子中,sink 建立的 subscriber,只須要處理正常值。

編譯運行,玩一下你的 Combine App 吧。

FindOrLose game made with Combine

改進 App

這裏還有幾個待優化的地方,咱們用 .store(in: &subscriptions) 連續添加了多個 subscriber,但沒有移除它們。下面咱們來改進。

resetImages() 的頂部添加下面這行代碼:

subscriptions = []
複製代碼

這裏,你聲明瞭一個空數組,用來移除全部無用訂閱信息的引用。

下一步,在 stopGame() 方法的頂部添加下面這行代碼:

subscriptions.forEach { $0.cancel() }
複製代碼

這裏,你遍歷了全部的 subscriptions,而後取消了它們。

最後一次編譯運行了。

FindOrLose game made with Combine

用 Combine 作全部的事情!

使用 Combine 框架是一個很好的選擇。它既流行又新穎,並且仍是官方的,爲何不如今就用呢?不過在你打算全面使用以前,你得考慮一些事情:

iOS 低版本

首先,你得爲用戶考慮。若是你打算繼續支持 iOS 12,你就不能使用 Combine。(Combine 須要 iOS 13 及以上的版本才支持)

團隊

響應式編程在思惟上的轉變很大,會有學習曲線,可是你的團隊要趕進度。在你的團隊中是否每一個人都像你同樣熱衷於改變固有的工做方式?

其餘的 SDK

在採用 Combine 以前,思考一下你的 app 中已經用到的技術。若是你有其餘基於回調的 SDK,好比 Core Bluetooth,你必須用 Combine 對它們進行封裝。

逐漸整合

當你逐漸掌握 Combine 時,就沒有那麼多顧慮了。你能夠先從網絡層調用開始重構,而後切換到 app 的其餘模塊。你也能夠在使用閉包的地方使用 Combine。

接下來怎麼學?

你能夠在原文頁面下載本工程的完整版本。

本教程中,你學習了 Combine 的基礎知識:PublisherSubscriber。你也學會了 operator 和定時器的使用。恭喜,你已經入門了!

想學習更多 Combine 的用法,請看咱們的書籍 Combine: Asynchronous Programming with Swift

若是你對本教程有問題或者評價,歡迎在下討論區討論!

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索