RxSwift by Examples 分成 4 部分。以下是 PART 3 的學習筆記和翻譯整理。原文在這里。
隨著我們越來越深入函數式響應式編程,我們將談一談網絡,并連接數據與 UI。
對于 Rx 有許多網絡 extension,包括 RxAlamofire 和 Moya。在這個教程中我們使用 Moya。
Moya
Moya 是對你需要處理的所有網絡事件的一個抽象層。使用這個類庫我們將很容易連接 API,這個 extension 集成了 RxSwift 和 ModelMapper。
設置
為了設置 Moya,我們需要一個 Provider,它集成了 setup for stubbing, endpoint closure 等等(當我們做測試的時候會更多地涉及)。對于我們簡單的示例不需要這些,所以當前我們只初始化 Provider 和 RxSwift。
我們要做的第二件事是設置 Endpoint - 一個包含可能的終端目標的 enum。我們創建一個 enum 遵循 TargetType。什么是 TargetType?這是一個協議,包含了 url,方法,任務(比如 request/upload/download),參數和參數encoding(url 的基礎)。
還有一件事。最后要指定的參數叫做 sampleData。Moya 重度依賴測試。它將測試視為一等公民。
示例
我們將使用 github api 去獲取指定的 repo 的 issues。為了復雜化一點,得到 repo 對象之后我們將檢查它是否存在,然后進行鏈式請求,獲取這個 repo 的 issues。然后把 json map 成對象。我們還需要小心error,重復的請求,濫用api等等。
別擔心,大部分內容我們已經在這個系列的第一部分中覆蓋了。在這里我們需要理解鏈式和錯誤處理,并且知道如何連接操作至 table view。
最終 Issue Tracker 將是這樣:輸入完整的 repo 名字(包含 repo 所有者和斜杠),比如 apple/swift, apple/cups, moya/moya 諸如此類。當 repo 找到(一個 url 請求),接著搜索這個 repo 的 issues(第二個 url 請求)。這就是主要目標。
首先創建一個項目并用 cocoapods 安裝它。這次需要更多的 pods。我們將使用 RxSwfit, Moya, RxCocoa, RxOptional 和 Moya 為 RxSwift 做的拓展以及用來 map 對象的 ModelMapper。
platform :ios, '8.0'
use_frameworks!
target 'RxMoyaExample' do
pod 'RxCocoa', '~> 3.0.0'
pod 'Moya-ModelMapper/RxSwift', '~> 4.1.0'
pod 'RxOptional'
end
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['ENABLE_TESTABILITY'] = 'YES'
config.build_settings['SWIFT_VERSION'] = '3.0'
end
end
end
第1步 - Controller 和 Moya 設置
從 UI 開始,一個 UITableView 和 UISearchBar。非常簡單。
我們需要一個 Controller 來管理所有東西。在創建架構之前我們嘗試描述一下這個 controller。
controller 要做什么呢?它將獲取 search bar 的數據,傳遞給 model,從 model 獲取 issues 并傳遞給 table view。
創建 IssueListViewController.swift,引入 modules 并做基礎設置:
import Moya
import Moya_ModelMapper
import UIKit
import RxCocoa
import RxSwift
class IssueListViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var searchBar: UISearchBar!
override func viewDidLoad() {
super.viewDidLoad()
setupRx()
}
func setupRx() {
}
}
已經準備好了 setupRx() 方法,我們將設置 binding。在此之前,先設置 Moya 的 Endpoint?;貞浺幌?,前面說過需要兩步:第一步是 Provider,第二步是 Endpoint。
創建 GithubEndpoint.swift,創建 enums,放入一些可能的 targets:
import Foundation
import Moya
enum GitHub {
case userProfile(username: String)
case repos(username: String)
case repo(fullName: String)
case issues(repositoryFullName: String)
}
但是之前說過要遵循 TargetType,然而這個只是 enum。沒錯,我們將制作一個 GitHub enum 的 extension,它將包含所有需要的屬性。我們需要 7 個。除了 baseURL,path 和 task,我們還需要 method,它是.get, .post 等請求。還有 parameters 和 parametersEncoding,以及 sampleData。
ENUM
下面,創建 GitHub 的 extension,遵循 TargetType:
import Foundation
import Moya
private extension String {
var URLEscapedString: String {
return self.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlHostAllowed)!
}
}
enum GitHub {
case userProfile(username: String)
case repos(username: String)
case repo(fullName: String)
case issues(repositoryFullName: String)
}
extension GitHub: TargetType {
var baseURL: URL { return URL(string: "https://api.github.com")! }
var path: String {
switch self {
case .repos(let name):
return "/users/\(name.URLEscapedString)/repos"
case .userProfile(let name):
return "/users/\(name.URLEscapedString)"
case .repo(let name):
return "/repos/\(name)"
case .issues(let repositoryName):
return "/repos/\(repositoryName)/issues"
}
}
var method: Moya.Method {
return .get
}
var parameters: [String: Any]? {
return nil
}
var sampleData: Data {
switch self {
case .repos(_):
return "{{\"id\": \"1\", \"language\": \"Swift\", \"url\": \"https://api.github.com/repos/mjacko/Router\", \"name\": \"Router\"}}}".data(using: .utf8)!
case .userProfile(let name):
return "{\"login\": \"\(name)\", \"id\": 100}".data(using: .utf8)!
case .repo(_):
return "{\"id\": \"1\", \"language\": \"Swift\", \"url\": \"https://api.github.com/repos/mjacko/Router\", \"name\": \"Router\"}".data(using: .utf8)!
case .issues(_):
return "{\"id\": 132942471, \"number\": 405, \"title\": \"Updates example with fix to String extension by changing to Optional\", \"body\": \"Fix it pls.\"}".data(using: .utf8)!
}
}
var task: Task {
return .request
}
var parameterEncoding: ParameterEncoding {
return JSONEncoding.default
}
}
整個 GithubEndpooint.swift 都完成了??雌饋硭坪鹾芸膳拢绻屑氶喿x它其實并非如此。在這里我們不需要發送任何參數,所以返回 nil。在這個例子中 method 總是 .get。 baseURL 也是一樣。只有 sampleData 和 path 需要放到 switch 中。
如果你需要添加其他目標,你可能需要看看它的請求是需要 .get 還是 .post 方法,可能還需要參數,那么你需要給它添加 switch。
我們還添加了 URLEscapedString 函數,當需要 encoding URL 中的字符時很有幫助。
Controller
回到 controller?,F在要實現 Moya 的 Provider。還需要實現當點擊 cell 時隱藏鍵盤,這些 RxSwift 都已經做好了。為此我們還需要 DisposeBag。此外我們將創建新的 Observable,它會是 search bar 中的 text,不過是過濾后的(移除重復,等待改變,與 part 1 一樣)
總之,我們需要添加 3 個屬性,實現 setupRx() 方法。
class IssueListViewController: UIViewController {
...
let disposeBag = DisposeBag()
var provider: RxMoyaProvider<GitHub>!
var latestRepositoryName: Observable<String> {
return searchBar
.rx.text
.orEmpty
.debounce(0.5, scheduler: MainScheduler.instance)
.distinctUntilChanged()
}
...
func setupRx() {
// First part of the puzzle, create our Provider
provider = RxMoyaProvider<GitHub>()
// Here we tell table view that if user clicks on a cell,
// and the keyboard is still visible, hide it
tableView
.rx.itemSelected
.subscribe(onNext: { indexPath in
if self.searchBar.isFirstResponder == true {
self.view.endEditing(true)
}
})
.addDisposableTo(disposeBag)
}
...
}
希望你覺得 latestRepositoryName 看起來很熟悉,因為在 part 1 深入討論過了。接著看看更多有意思的東西。
首先我們設置了之前提到過的神秘的 Provider。如你所見,沒有什么特別,只是 initializer。因為我們使用 Moya 和 RxSwift,所以必須使用 RxMoyaProvider。如果你想使用 Moya + ReactiveCocoa,或者只使用 Moya 來寫 API,provider 會有些不同(純 Moya 用MoyaProvider,ReactiveCocoa + Moya 用 ReactiveCocoaMoyaProvider)。
我們需要隱藏鍵盤。感謝 RxCocoa,我們可以訪問 tableView.rx.itemSelected,每次當用戶點擊 table view cell 的時候它就會發出信號。當然我們可以訂閱它,做我們要做的事(因此鍵盤)。我們檢查了 search bar 是否是 first responder(如果鍵盤顯示),于是隱藏它。
第2步 - Network model and mapping objects
現在我們需要 model 基于 text 提供數據給我們。不過首先,在發送任何信息之前需要先解析對象。感謝我們的朋友 ModelMapper 做了這個工作。我們需要兩個 entity,一個給 repo,一個給 issue。這很容易創建,我們需要遵循 Mappable 協議,并用 try 解析對象。
RepositoryEntity.swift
import Mapper
struct Repository: Mappable {
let identifier: Int
let language: String
let name: String
let fullName: String
init(map: Mapper) throws {
try identifier = map.from("id")
try language = map.from("language")
try name = map.from("name")
try fullName = map.from("full_name")
}
}
IssueEntity.swift
import Mapper
struct Issue: Mappable {
let identifier: Int
let number: Int
let title: String
let body: String
init(map: Mapper) throws {
try identifier = map.from("id")
try number = map.from("number")
try title = map.from("title")
try body = map.from("body")
}
}
我們不需要更多屬性,你可以根據 GitHub API 文檔添加更多。
Networking Model
現在進入這個教程最有意思的部分。IssueTrackerModel,網絡層的核心。
首先,我們的 model 將有 Provider 屬性,我們通過 init 傳遞它。然后我們將有一個屬性來觀察 text,這是一個 Observable 類型,這是我們的資源的 repositoryNames,我們的 view controller 將會傳遞。我們需要一個方法返回 observable 序列,issue 數組,Observable<[Issue]>,view controller 將用來綁定到 table view。我們不需要實現 init,因為 swift 原生支持 memberwise initializer。
創建 IssueTrackerModel.swift
import Foundation
import Moya
import Mapper
import Moya_ModelMapper
import RxOptional
import RxSwift
struct IssueTrackerModel {
let provider: RxMoyaProvider<GitHub>
let repositoryName: Observable<String>
func trackIssues() -> Observable<[Issue]> {
}
internal func findIssues(repository: Repository) -> Observable<[Issue]?> {
}
internal func findRepository(name: String) -> Observable<Repository?> {
}
}
你注意到我添加了兩個函數。findRepository(_:)
返回 optional repo(如果返回的對象不能map則返回nil, 如果可以則返回 Repository 對象)。findIssue(_:)
(一樣的邏輯),基于得到的 repository 對象搜索 repo。
首先實現這兩個方法,你認為很麻煩,但實際上超級簡單。
internal func findIssues(repository: Repository) -> Observable<[Issue]?> {
return self.provider
.request(GitHub.issues(repositoryFullName: repository.fullName))
.debug()
.mapArrayOptional(type: Issue.self)
}
internal func findRepository(name: String) -> Observable<Repository?> {
return self.provider
.request(GitHub.repo(fullName: name))
.debug()
.mapObjectOptional(type: Repository.self)
}
分步講解:
- 我們有個 provider,我們可以給一個 enum 值它讓它執行 request。
- 于是傳遞 GitHub.repo 或者 GitHub.issues,request 完成。
- 使用 debug() 操作器,可以打印 request 的相關信息,在開發/debug時相當有用。
- 然后試著手動解析和 map 響應的數據,由于有 extension,我們可以訪問方法 mapObject(), mapArray(), mapObjectOptional() 或者 mapArrayOptional()。區別是什么呢?當對象無法解析的時候用 optional 方法,函數返回 nil。通常的方法會拋出異常,我們需要用 catch() 或者 retry() 捕獲它們。在我們的案例中 optional 非常適合。我們可以清空 table view 如果 request 失敗。
我們有了兩個方法,基于某物得到某物,然而如何連接它們呢?為了這個任務我們需要學習新的操作器, flatMap() 和尤其特別的 flatMapLatest()。這些操作器所做的是,從一個序列創建另一個序列。為什么要這樣座?比如說有一個 string 序列,你希望轉換成 repo 序列,或者一個 repo 的序列需要轉換成 issue 序列。正如我們的情況。我們將在一個鏈式操作中轉換它。當得到 nil 的時候(獲取 repo 或者 issue 時),我們將返回空數組,用以清空 table view。
flatMap() 和 flatMapLatest() 的區別是什么?flatMap() 得到一個值,當執行一個長時間的任務,然后它得到下一個值時,之前的任務將仍然執行到完成后才結束,即使當前任務返回的新值已經執行到一半。這不是我們想要的,因為當我們得到下一個 text 的時候,我們希望取消之前的 request 并啟動新的 request。這就是 flatMapLatest() 所做的。
trackIssues 方法如下:
func trackIssues() -> Observable<[Issue]> {
return repositoryName
.observeOn(MainScheduler.instance)
.flatMapLatest { name -> Observable<Repository?> in
print("Name: \(name)")
return self
.findRepository(name: name)
}
.flatMapLatest { repository -> Observable<[Issue]?> in
guard let repository = repository else { return Observable.just(nil) }
print("Repository: \(repository.fullName)")
return self.findIssues(repository: repository)
}
.replaceNilWith([])
}
分步講解:
- 我們想確認它在 MainScheduler 中觀察,因為這個 model 的目標是綁定至 UI,在我們的示例中是 table view。
- 我們轉換 text(repo 名)到 observable repo 序列,它可以是 nil,以防它不能正確地 map 對象。
- 檢查 map 出的結果是否 nil。如果是 nil,下一個 flatMapLatest() 確保返回空數組。 Observable.just(nil) 意味著我們將發送一個元素作為 observable(在示例中這個元素是 nil)。如果不是 nil,我們想把它轉換成 issue 數組(如果 repo 有 issue),它可以返回 nil 或者數組,所以仍然需要 observable 的 optional 數組。
- .replaceNilWith([]) 是 RxOptional extension,幫助我們處理 nil,在示例中我們把 nil 轉換成空數組,清空 table view。
這就是我們的 model。
第3步 - 綁定 issue 到 table view
最后一步要連接 model 中的數據到 table view。這意味著我們需要綁定 observable 到 table view。
通常你要讓 view controller 遵循 UITableViewDataSource,實現一些方法,比如 number of rows, cell for row 等等,然后指派 dataSource 給 view controller。
用 RxSwift,我們可以在一個閉包中設置 UITableViewDataSource。RxCocoa 提供另一個很棒的工具,叫做 rx.itemWithCellFactory,它在一個閉包中處理要顯示的 cell。這同步做了所有的事情,基于 observable 和我們提供的 closure。
回到 IssueListViewController,實現完整的 setupRx() 方法:
class IssueListViewController: UIViewController {
...
var issueTrackerModel: IssueTrackerModel!
...
func setupRx() {
// First part of the puzzle, create our Provider
provider = RxMoyaProvider<GitHub>()
// Now we will setup our model
issueTrackerModel = IssueTrackerModel(provider: provider, repositoryName: latestRepositoryName)
// And bind issues to table view
// Here is where the magic happens, with only one binding
// we have filled up about 3 table view data source methods
issueTrackerModel
.trackIssues()
.bindTo(tableView.rx.items) { tableView, row, item in
let cell = tableView.dequeueReusableCell(withIdentifier: "issueCell", for: IndexPath(row: row, section: 0))
cell.textLabel?.text = item.title
return cell
}
.addDisposableTo(disposeBag)
// Here we tell table view that if user clicks on a cell,
// and the keyboard is still visible, hide it
tableView
.rx.itemSelected
.subscribe(onNext: { indexPath in
if self.searchBar.isFirstResponder == true {
self.view.endEditing(true)
}
})
.addDisposableTo(disposeBag)
}
...
}
這里新增是,新的屬性給 IssueTrackerModel(也在 setupRx() 中初始化)。新的綁定:從 model 的 trackIssues() 方法,到 rx.itemsWithCellFactory 屬性。別忘了修改 dequeueReusableCell() 方法中的 cellIndentifier。
至此,所有要實現的都已經實現了。run