项目准备工作
我们的App会在Github上搜索特定名称的项目,在UITextField
里输入项目名称,我们就自动在Github上搜索项目的名字,并在下面的UITableView
中显示一些项目信息显示出来。
然后,在ViewController
里,添加对应的IBOutlet:
@IBOutlet weak var repositoryName: UITextField!
@IBOutlet weak var searchResult: UITableView!
以及DisposeBag
:
var bag: DisposeBag! = DisposeBag()
最后,通过CocoaPods安装项目需要的组件:
# Uncomment this line to define a global platform for your project
platform :ios, '9.0'
# Uncomment this line if you're using Swift
use_frameworks!
target 'RxNetworkDemo' do
pod 'Alamofire', '~> 3.4'
pod 'RxSwift', '~> 2.0'
pod 'RxCocoa', '~> 2.0'
pod 'SwiftyJSON', :git => 'https://github.com/SwiftyJSON/SwiftyJSON.git'
end
并且,在ViewController
里,引入对应的组件:
import UIKit
import RxSwift
import RxCocoa
import Alamofire
import SwiftyJSON
这样,我们就做好所有的准备工作了。
控制网络请求频度
发送请求之前,我们要先通过UITextField
获取用户输入。很简单,直接订阅UITextField
的rx_text
就可以了,在viewDidLoad
方法里,添加下面的代码:
self.repositoryName.rx_text
.subscribeNext {
print("Search item: \($0)")
}.addDisposableTo(self.bag)
执行后,会发现,控制台里的结果是这样的:
第一次的空白字符串是UI加载的时候,监听到的事件值;第二次空白是UITextField
获取输入事件的时候间听到的事件值;而后,我们每输入一个字符,就会监听到一个不同的事件。
如果我们用这样的结果来作为在Github上搜索的内容,会有一些问题:
- 我们用空的字符串进行了搜索,明显是错误的;
- 当输入只有1,2个字符时,发起的搜索明显是不精准的;
- 当输入的名称较长时,输入过程会发起大量无效的搜索(例如:仅仅是输入RxSwift,就发起了9次);
首先,我们来解决前两个问题。
使用filter过滤事件内容
我们要先过滤掉过短的输入,例如,当用户输入2个以上的字符时才进行查询。很简单,在订阅前,使用filter(n)
对事件值进行过滤就可以了:
self.repositoryName.rx_text
.filter {
return $0.characters.count > 2
}
.subscribeNext {
print("search item: \($0)")
}.addDisposableTo(self.bag)
.filter
的参数是事件值的类型,在我们的例子里,也就是String
,返回一个Bool
,表示是否要向订阅者发送事件。
重新运行,就会发现我们过滤掉了一些明显无效的输入:
尽管如此,我们还是订阅到了5次事件,如果每次订阅到都发起请求,还是太频繁了,我们希望进一步控制请求的频率。
使用throttle控制请求频度
我们可以使用throttle
在指定的时间间隔里,忽略掉发生的事件。这样,就不会每次输入都订阅到事件了。继续修改订阅代码:
self.repositoryName.rx_text
.filter {
return $0.characters.count > 2
}
.throttle(0.5, scheduler: MainScheduler.instance)
.subscribeNext {
print("search item: \($0)")
}.addDisposableTo(self.bag)
throttle
的第一个参数表示希望忽略的时间间隔,第二个参数表示在主线程中运行计时器。
重新运行,这次,控制台里的结果基本就可用了:
接下来的思路就很简单了,我们直接在订阅到的事件里,调用Github API查询项目,并把查询结果更新到TableView
里就好了。
思路虽然简单,却关联到了不少的实现细节,我们先来完成网络请求的部分。
包装Alamofire成Observable
我们先给ViewController
添加一个extension
,所有和网络相关的代码,都放到这个extension
里:
extension ViewController {
}
我们希望最终订阅到的事件值,是个包含我们需要内容的Key-Value集合,简单起见,我们添加一个类型的别名:
typealias RepositoryInfo = Dictionary
在这个Dictionary
中,String
用于索引结果中的内容,而值有可能是整数、有可能是字符串,因此我们定义成了AnyObject
。
接下来,我们在ViewController extension
中,添加一个方法:
private func searchForGithub(repositoryName: String)
-> Observable
searchForGithub
接受一个表示,表示要查询的repository的名字,返回一个事件值类型是RepositoryInfo
的事件序列。
怎么实现呢?
之前的视频里我们也提到过,RxSwift提供了一个叫做create
的方法,可以让我们自定义事件序列。对于封装一个网络请求来说,它简直再合适不过了。
在searchForGithub
里,添加下面的代码:
private func searchForGithub(repositoryName: String)
-> Observable {
return Observable.create {
(observer: AnyObserver) -> Disposable in
let url = "https://api.github.com/search/repositories"
let parameters = [
"q": repositoryName + " stars:>=2000"
]
let request = Alamofire.request(.GET, url,
parameters: parameters,
encoding: .URLEncodedInURL)
}
}
create
接受一个closure参数,这个closure参数本质上和我们之前用过的subscribeNext
方法是类似的。它接受一个AnyObserver
,并返回一个Disposable
对象。
在这里,AnyObserver
表示要创建的事件序列的订阅者。稍候,我们要根据请求的不同结果,向这个订阅者发送事件。由于我们要返回的Observable的事件值类型是RepositoryInfo
,因此,这里AnyObserver
可以订阅到的事件值的类型,也是RepositoryInfo
。
然后,在这个Clousre的实现里,我们先分别添加了请求的URL,以及附带的参数。其中:
- 参数q表示要查询的项目名;
- “ start:>=2000”是Github的项目查询语言,表示查询大于2000星的项目;
最后,直接用Alamofire.request
请求了Github API。为了先了解下这个API的返回值,我们不妨先在浏览器里看一下调用结果:
https://api.github.com/search/repositories?q=RxSwift%20stars:>=2000
在返回的JSON里,大致分成几大部分:
- total_count:表示查询到的repository个数;
- incomplete_results:表示是否返回的是部分结果;
- items是一个JSON对象数组,包含了每一个查询到的repository的详细信息;
稍候,我们就会接收这个结果集,把他筛选成下面这样:
{
"total_count": 1,
"items": [
{
"full_name": "RxSwift",
"description": "ReactiveX/RxSwift"
"html_url": "Reactive Programming in Swift",
"avatar_url": "https://avatars.githubusercontent.com/u/6407041?v=3"
}
]
}
然后,返回给事件的订阅者。至此,这一切都还不太难理解。接下来,重头戏就来了。
自定义向订阅者发送的事件
接下来我们要进行的工作,是使用create
方法自定义Observable的重点,我们需要根据Github的返回值,来定义向订阅者返回的内容。
把Alamofire.request
部分的代码,添加上结果处理:
let request = Alamofire.request(.GET, url,
parameters: parameters,
encoding: .URLEncodedInURL)
.responseJSON { response in
switch response.result {
case .Success(let json):
// How can we handle success event?
case .Failure(let error):
observer.on(.Error(error))
}
}
当请求失败的时候,我们的处理逻辑很简单:
- 直接把返回的
NSError
对象封装在Event.Error
里; - 通过
on
方法把事件发送给订阅者;
那成功的时候呢?发送事件的部分,当然也是如法炮制,用on
方法就好了,我们发送些什么呢?
- 我们首先要把返回的结果做一些筛选,只找出我们需要使用的数据;
- 当请求成功时,我们要先发送.Next事件,传递事件值,然后发送.Completed事件,表示结束;
使用SwiftyJSON过滤返回结果
首先来实现第一步,对返回结果进行筛选,在ViewController extension
中,添加一个新的方法:
private func parseGithubResponse(
response: AnyObject) -> RepositoryInfo
它接受一个AnyObject
作为参数,我们会传递请求成功时.Success
的associated value,然后返回要发送给订阅者的RepositoryInfo
。
在parseGithubResponse
的实现里,我们使用SwiftyJSON来简化JSON串的处理:
private func parseGithubResponse(
response: AnyObject) -> RepositoryInfo {
let json = JSON(response);
let totalCount = json["total_count"].int!
var ret: RepositoryInfo = [
"total_count": totalCount,
"items": []
];
}
在上面的代码里:
-
JSON(response)
用于初始化SwiftyJSON,我们可以得到一个Swity.JSON对象json
; - 然后,就可以像访问普通
Dictionary
一样去访问JSON串中的内容了,例如:json["total_count"]
。如果我们确信它是个整数,就直接访问它的int
属性,读取optional的值就可以了; - 我们构建了一个最基本的返回值
ret
,初始化了total_count
;
查询到了repository的个数之后,我们来处理返回结果中的“items”部分,它是一个JSON数组,数组中的每一个对象,都表示一个repository。同样,SwiftyJSON也有方便我们处理数组的方法。在ret
的定义后面,继续添加下面的代码:
if totalCount != 0 {
let items = json["items"]
var info: [RepositoryInfo] = []
for (_, subJson):(String, JSON) in items {
let fullName = subJson["full_name"].string!
let description = subJson["description"].string!
let htmlUrl = subJson["html_url"].string!
let avatarUrl = subJson["owner"]["avatar_url"].string!
info.append([
"full_name": fullName,
"description": description,
"html_url": htmlUrl,
"avatar_url": avatarUrl
])
}
ret["items"] = info
}
在上面的代码里,当查询到的repository不为0时:
首先,我们使用json["items"]
读取到了JSON数组,它仍旧是一个Swity.JSON
对象;
其次,我们定义了一个存储items信息的RepositoryInfo
数组,用于保存筛选过的内容;
第三,尽管items
是一个Swifty.JSON
对象,我们仍旧可以使用for...in
循环来遍历它。对于items
中的每一个key-value,我们可以把它理解为是一个(String, JSON)
类型的Tuple,于是,我们用这样的代码:
let fullName = subJson["full_name"].string!
let description = subJson["description"].string!
let htmlUrl = subJson["html_url"].string!
let avatarUrl = subJson["owner"]["avatar_url"].string!
分别读取了每一个项目的名称、描述、网址以及创始人头像。值得说明的是,当读取创始人头像时,由于owner索引的内容又是一个JSON对象,因此,我们可以使用串联索引的方式把嵌套的JSON串中的内容读取出来,很方便。
筛选出了所有需要的信息之后,我们就把内容添加到用于保存筛选结果的数组里。最后全部筛选结束之后,我们就把info
更新到返回值的“items”字段里。
最后,别忘记让parseGithubResponse
返回ret
:
return ret
这样我们就完成对结果的筛选了,最终我们得到了一个只包含我们感兴趣的RepositoryInfo
对象。这时,我们回到主战场,处理Alamofire请求成功时的事件处理。
封装.Next()事件
在之前.Success的case里,添加下面的代码:
let request = Alamofire.request(.GET, url,
parameters: parameters,
encoding: .URLEncodedInURL)
.responseJSON { response in
switch response.result {
case .Success(let json):
// How can we handle success event?
let info = self.parseGithubResponse(json)
observer.on(.Next(info))
observer.on(.Completed)
case .Failure(let error):
observer.on(.Error(error))
}
}
其实很简单,我们只要把parseGithubResponse
的返回值,直接作为.Next的associated value就可以了。这里,再次提醒大家,不要忘记在.Next
之后发送.Completed
。
处理Observable.create参数的返回值
至此,我们已经完成了90%的工作,但是,现在还不是休息的时候。如果你记不清了,可以翻回头看看Observable.create
的参数定义,它接受的closure参数还要返回一个Disposable
对象呢。这个对象,用于对create
返回的Observable进行“善后工作”。
在处理网络请求的时候,无论因为任何原因,create
创建的事件序列被销毁了,那么我们最好取消掉正在执行的网络请求。因此,我们要添加一个AnonymousDisposable
对象,他唯一的工作,就是取消网络请求。在create
的Closure方法最后,添加下面的代码:
return AnonymousDisposable {
request.cancel()
}
如果request
已经完成了,调用cancel()
也不会带来任何问题。
如果我们创建的事件序列在被销毁时无需执行任何额外操作,我们也可以直接使用return NopDisposable
返回一个“什么也不需要做的Disposable
对象”。
这样,使用create
封装网络请求的功能就全部完成了。我们把每一次网络请求,都封装成了一个可以被订阅的事件序列。
接下来,我们实现在UITextField
中输入后,自动查询的功能。别急,看似简单的事情,仍旧有新要点要注意。
使用.flatMap转化Observable
基本思路是很简单的,把要发送给订阅者的每一次UITextField
输入事件,在map()
里调用searchForGithub
方法,变成Github的查询结果就好了。按照想象的在订阅前添加下面的代码:
self.repositoryName.rx_text
.filter {
return $0.characters.count > 2
}
.throttle(0.5, scheduler: MainScheduler.instance)
.map {
self.searchForGithub($0)
}
它可以正常工作,但是,执行的方式一定和我们想象中有点儿差别。我们希望subscribeNext
可以订阅到一个事件值是RepositoryInfo
的事件。
但是,由于self.searchForGithub($0)
返回的是一个Observable
,因此,我们订阅到的实际上是一个事件序列。我们还需要在.subscribeNext
里继续订阅它,这显然不是我们想要的。
为了解决这样的问题,RxSwift提供了另外一个映射事件序列的方法.flatMap
,在它的实现里,我们可以找到这样的注释:
Projects each element of an observable sequence to an observable sequence and merges the resulting observable sequences into one observable sequence.
简单来说,就是如果经过映射后的结果是一个新的事件序列,那么flatMap
把映射前的事件(在我们的例子里是UITextField
的输入)和映射后的事件(在我们的例子里是一个网络请求)合并成一个事件发送给订阅者。
这样,我们就可以直接在subscribeNext
中订阅到RepositoryInfo
了。我们可以把各种订阅到的值,输出到控制台上。
self.repositoryName.rx_text
.filter {
return $0.characters.count > 2
}
.throttle(0.5, scheduler: MainScheduler.instance)
.flatMap {
self.searchForGithub($0)
}
.subscribeNext {
let repoCount = $0["total_count"] as! Int;
let repoItems = $0["items"] as! [RepositoryInfo];
if repoCount != 0 {
print("item count: \(repoCount)")
for item in repoItems {
print("---------------------------------")
let name = item["full_name"]
let description = item["description"]
let avatarUrl = item["avatar_url"]
print("full name: \(name)")
print("description: \(description)")
print("avatar_url: \(avatarUrl)")
}
}
}.addDisposableTo(self.bag)
这样,我们就实现实时响应查询的功能了,编译执行,我们就可以在控制台看到结果了: