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)
}
分步讲解:
- 我们有个 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 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()
// 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