RxSwift by Examples #3 – Networking

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)
}

分步講解:

  1. 我們有個 provider,我們可以給一個 enum 值它讓它執行 request。
  2. 于是傳遞 GitHub.repo 或者 GitHub.issues,request 完成。
  3. 使用 debug() 操作器,可以打印 request 的相關信息,在開發/debug時相當有用。
  4. 然后試著手動解析和 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([])
}

分步講解:

  1. 我們想確認它在 MainScheduler 中觀察,因為這個 model 的目標是綁定至 UI,在我們的示例中是 table view。
  2. 我們轉換 text(repo 名)到 observable repo 序列,它可以是 nil,以防它不能正確地 map 對象。
  3. 檢查 map 出的結果是否 nil。如果是 nil,下一個 flatMapLatest() 確保返回空數組。 Observable.just(nil) 意味著我們將發送一個元素作為 observable(在示例中這個元素是 nil)。如果不是 nil,我們想把它轉換成 issue 數組(如果 repo 有 issue),它可以返回 nil 或者數組,所以仍然需要 observable 的 optional 數組。
  4. .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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容