基于RxSwift的网络编程 - I

项目准备工作

我们的App会在Github上搜索特定名称的项目,在UITextField里输入项目名称,我们就自动在Github上搜索项目的名字,并在下面的UITableView中显示一些项目信息显示出来。

image

然后,在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获取用户输入。很简单,直接订阅UITextFieldrx_text就可以了,在viewDidLoad方法里,添加下面的代码:

self.repositoryName.rx_text
    .subscribeNext {
        print("Search item: \($0)")
    }.addDisposableTo(self.bag)

执行后,会发现,控制台里的结果是这样的:

image

第一次的空白字符串是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,表示是否要向订阅者发送事件。

重新运行,就会发现我们过滤掉了一些明显无效的输入:

image

尽管如此,我们还是订阅到了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的第一个参数表示希望忽略的时间间隔,第二个参数表示在主线程中运行计时器。

重新运行,这次,控制台里的结果基本就可用了:

image

接下来的思路就很简单了,我们直接在订阅到的事件里,调用Github API查询项目,并把查询结果更新到TableView里就好了。

思路虽然简单,却关联到了不少的实现细节,我们先来完成网络请求的部分。

包装Alamofire成Observable

我们先给ViewController添加一个extension,所有和网络相关的代码,都放到这个extension里:

extension ViewController {
}

我们希望最终订阅到的事件值,是个包含我们需要内容的Key-Value集合,简单起见,我们添加一个类型的别名:

typealias RepositoryInfo = Dictionary<String, AnyObject>

在这个Dictionary中,String用于索引结果中的内容,而值有可能是整数、有可能是字符串,因此我们定义成了AnyObject

接下来,我们在ViewController extension中,添加一个方法:

private func searchForGithub(repositoryName: String) 
    -> Observable<RepositoryInfo>

searchForGithub接受一个表示,表示要查询的repository的名字,返回一个事件值类型是RepositoryInfo的事件序列。

怎么实现呢?

之前的视频里我们也提到过,RxSwift提供了一个叫做create的方法,可以让我们自定义事件序列。对于封装一个网络请求来说,它简直再合适不过了。

searchForGithub里,添加下面的代码:

private func searchForGithub(repositoryName: String) 
    -> Observable<RepositoryInfo> {

    return Observable.create {
        (observer: AnyObserver<RepositoryInfo>) -> 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

image

在返回的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))
        }
    } 

当请求失败的时候,我们的处理逻辑很简单:

  1. 直接把返回的NSError对象封装在Event.Error里;
  2. 通过on方法把事件发送给订阅者;

那成功的时候呢?发送事件的部分,当然也是如法炮制,用on方法就好了,我们发送些什么呢?

  1. 我们首先要把返回的结果做一些筛选,只找出我们需要使用的数据;
  2. 当请求成功时,我们要先发送.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": []
    ];
}

在上面的代码里:

  1. JSON(response)用于初始化SwiftyJSON,我们可以得到一个Swity.JSON对象json
  2. 然后,就可以像访问普通Dictionary一样去访问JSON串中的内容了,例如:json["total_count"]。如果我们确信它是个整数,就直接访问它的int属性,读取optional的值就可以了;
  3. 我们构建了一个最基本的返回值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<RepositoryInfo>,因此,我们订阅到的实际上是一个事件序列。我们还需要在.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)

这样,我们就实现实时响应查询的功能了,编译执行,我们就可以在控制台看到结果了:

image

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

推荐阅读更多精彩内容