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 都完成了。看起来似乎很可怕,但如果仔细阅读它其实并非如此。在这里我们不需要发送任何参数,所以返回 nil。在这个例子中 method 总是 .get。 baseURL 也是一样。只有 sampleData 和 path 需要放到 switch 中。

如果你需要添加其他目标,你可能需要看看它的请求是需要 .get 还是 .post 方法,可能还需要参数,那么你需要给它添加 switch。

我们还添加了 URLEscapedString 函数,当需要 encoding URL 中的字符时很有帮助。

Controller

回到 controller。现在要实现 Moya 的 Provider。还需要实现当点击 cell 时隐藏键盘,这些 RxSwift 都已经做好了。为此我们还需要 DisposeBag。此外我们将创建新的 Observable,它会是 search bar 中的 text,不过是过滤后的(移除重复,等待改变,与 part 1 一样)

总之,我们需要添加 3 个属性,实现 setupRx() 方法。

class IssueListViewController: UIViewController {
    ...
    let disposeBag = DisposeBag()
    var provider: RxMoyaProvider!    
    var latestRepositoryName: Observable {
        return searchBar
            .rx.text
            .orEmpty
            .debounce(0.5, scheduler: MainScheduler.instance)
            .distinctUntilChanged()
    }
    ...
    func setupRx() {
        // First part of the puzzle, create our Provider
        provider = RxMoyaProvider()
   
        // 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
    let repositoryName: Observable
    
    func trackIssues() -> Observable<[Issue]> {
        
    }
    
    internal func findIssues(repository: Repository) -> Observable<[Issue]?> {
 
    }
    
    internal func findRepository(name: String) -> Observable {
 
    }
}

你注意到我添加了两个函数。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 {
    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 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()
        
        // 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

你可能感兴趣的:(RxSwift by Examples #3 – Networking)