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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容