原文:Thinking in RxSwift

距离我 上一篇文章 现已很久了。我想写一篇文章,介绍 Observable 背面的理论,并展现在实在而非虚拟示例中的用法。事实证明,这并不是我想象中的小事。

文章分为两部分。一开端,我试图解说什么是 Observable 以及怎么处理它们。

第二部分是怎么运用 RxSwift 完成 Spotify 曲目查找的教程。理论归理论,实例却能让人更简单了解。

长话短说,期望大家喜爱阅览

Observable – 序列

在运用 Rx 时,阻止人们深入其中的原因是指令式的考虑方法。你有必要打破旧习惯,开端用 Rx 的方法考虑。

我主张你像考虑数组相同考虑 Observable。这是因为,你能够像运用数组相同对 Observable 进行 mapfilter 或者 reduce。经过 map,你能够将 Observable<String> 转化为 Observable<Bool>,就像将 Array<String> 转化为 Array<Bool> 相同。

不过,Observable 和数组之间有一个巨大的差异。

你知道正方形和立方体是什么吧?它们是怎么彼此参照的?立方体便是在正方形的基础上增加了一个维度——深度。我喜爱这样描绘 Observable:它是一个数组,多了一个维度——时刻

当你有一个包括 5 个元素的 Array<String> 时,你能够访问任何你想要的元素。一切元素在时刻 t0 时都可用。

另一方面,当你有一个 Observable<String> 时,第一个元素只在 t1 时刻可用,第二个在 t2 时刻可用,第三个在 t3 时刻可用,依此类推。更重要的是,在 tn 时刻内,你无法获取任何从前或未来的元素。

迭代器和调查者形式

在计算机科学中,迭代器形式十分常用。在 Swift 中,迭代器形式由 IteratorProtocol 完成(在 Swift 3.0 之前是 GeneratorType)。

迭代器只有一个函数 next()。经过迭代器,能够遍历序列中的一切元素。每个 Sequence 和数组相同,都完成了 IteratorProtocol

另一方面,Observable 混合了两种设计形式迭代器形式调查者形式。混合这两种形式的成果是,当你处理 Observable 时,你能够监听 “next” 事情。

你不得不供认,这两者有异曲同工之妙,不是吗?

然而,国际是由小细节构建而成的。我想在这儿指出的是,在数组中,接收者担任何时获取下一个元素。

而在 Observable 中,生产者担任何时发送下一个元素。接收者只能监听它们。

map 示例

一开端我说过,你能够像映射数组相同映射 Observable

let names = ["adam", "daniel", "christian"]
let nameLengths = names.map { $0.characters.count }
print(nameLengths) // "4, 6, 9"

需求留意的是,nameLengths 是一个独自的数组,称号不会改变。同样的状况也会产生在 Observable 上:

let disposeBag = DisposeBag()
let backgroundScheduler = SerialDispatchQueueScheduler(globalConcurrentQueueQOS: .userInitiated)
//(1) This Observable emits names in 1 second interval.
let asyncNames: Observable<String> = self.createObservable(from: ["adam", "daniel", "christian"], withTimeInterval: 1)
//(2) map creates new independant Observable<Int>
let asyncNameLenghts: Observable<Int> = asyncNames
    .map { name in
        return name.characters.count
}
asyncNameLenghts.subscribe(onNext: { count in
    print("[(NSDate())]: (count)")
}).disposed(by: disposeBag)
/*
#################################################
[2016-10-31 08:15:27 +0000]: 4
[2016-10-31 08:15:28 +0000]: 6
[2016-10-31 08:15:29 +0000]: 9
*/

在数组中运用 map 会立即转化一切元素,而在 Observable 中运用 map 也会转化序列中的一切项目,但转化会在新事情产生时独自应用。

在上面的示例中,你看到 subscribe 办法了吗?subscribe 是运用 Observable 时的一个十分重要的办法。subscribe 告诉 Observable “嘿,我准备好监听你的元素了”。假如忘掉调用 subscribe,就不会有任何事情产生。

Observable – 依据事情的序列

到现在中止,我一直在运用 “next” 这个词来调用在 Observable 序列中发送的事情。事实上,Observable 会宣布事情(events)。Next 仅仅枚举中的一种特别类型。Observable 能够带着三种类型的事情,分别是:nexterrorcompleted

public enum Event<Element> {
    case next(Element)
    case error(Swift.Error)
    case completed
}

我以为 next 不言自明。假如你有一个 Observable<Person>,那么每个 next 事情都会带着一个 Person 实例。

因为 Observable 是一个具有时刻维度的序列,因而调查者不知道序列的巨细,这意味着他不知道何时中止监听事情。这便是 errorcompleted 存在的目的。

出错时会发送 error 事情,当 Observable 想告诉它的调查者不再发送事情时会发送 completed 事情。

请记住 Observable 的另一个重要特性:errorcompleted 总是中止 Observable 序列,因而它不会再发送任何新事情。

RxMarbles

map 仅仅现有操作符中一个常用的比如。除此之外,Observable 还有更多的操作符:

  • map

  • flatMap

  • flatMapLatest

  • withLatestFrom

  • filter

  • debounce

  • retry

  • skip

  • catchError

  • take

  • concat

  • merge

上面列出的是我在日常开发中最常用的一组 Rx 操作符。你或许会说它很大,但我想说,与可用的操作符集比较,它依然很小:D。

为了使操作符易于了解,ReactiveX 为你提供了名为 RxMarbles 的图表。这些图表示 Observable 序列以及操作符怎么影响它。我向你展现的 map 示例的 RxMarble 能够如下所示:

弹珠图例

因为 Observable 能够宣布 3 种类型的事情,因而 RxMarbales 中也有 3 种类型的符号,分别代表 nexterrorcompleted

另一种制作弹珠图的办法是 ASCII 格式。这种方法在 stackoverflow、论坛或 slack 上很常见:

----A--D---C---|->
# map(get string size) #
----4--6---9---|->
---1------X------>
A, B, D, 1, 6, 9 - are next events
| - is the completed event
X - is the error event
--------> - is a timeline

此外,你还能够在 rxmarbles.com 网站上以可调查序列操作弹珠,并查看运算成果。它还能够作为 iPhone 应用程序在 App Store 上下载。肯定值得一试;)。

Spotify 查找示例

没有什么能比一个好比如更清楚地说明问题了。我的示例包括了反应式编程的基本概念。使命很简单。你有必要完成与 Spotify Search API 交互的 SearchViewController

要下载初始项目设置,请点击此处

需求

为了让本教程不那么琐碎,我期望你能包括那些我以为在 iOS 开发人员的实际生活中或许会呈现的需求:

  • 不要在用户每次输入时都向 Spotify 发送恳求。等候 0.3 秒,直到他输完中止;
  • 在开端时预载 “Let it go – frozen” 作为特征查询
  • 当用户更改查询时,铲除之前的查找成果
  • 用户能够经过 “下拉改写” 功用改写当时查找成果;
  • 查询有必要至少包括 3 个字母才干进行查找;

RxMantra – 一切都能够是序列

在编写任何代码之前,我期望你首要考虑序列和图表。之后,你就能够开端考虑代码了。记住,一切都能够是序列。这是 ReactiveX 的口号。

要求 #0 – 下载和显现曲目

首要要完成的是为给定的查询下载曲目。你需求将来自查找栏的查询转化为一组曲目。记住,首要要考虑序列!

在 “Observable 语言” 中,你有必要将一个 Observable<String> 转化为 Observable<[Track]>。正如你在示例代码中看到的,TrackCellTrackRenderable 作为输入来烘托自己。这意味着你有必要转化 Observable<String> -> Observable<[Track]> -> Observable<[TrackRenderable]>

正如你所看到的,[Track] 事情会依据查询事情在时刻上移动。这是因为 [Track] 来自 Spotify API,它需求一些时刻来树立连接,所以呼应会晚一点。

你是否问过自己,怎么才干将 query 映射到 [Track]?到现在中止,咱们现已运用了同步回来值的 map,但现在有必要异步转化它。

flatMap

我想介绍flatMap操作符。flatMap 回来一个 Observable,并将其一切事情转移到原始序列中。

现在咱们来看看这些图的代码是怎样的:

searchBar.rx.text.orEmpty
    .flatMap { [spotifyClient] query in
        return spotifyClient.rx.search(query: query)
    }.map { tracks in
        return tracks.map(TrackRenderable.init)
    }

要在 UITableView 中显现 [TrackRenderable],需求将调查对象与 tableView 绑定:

searchBar.rx.text.orEmpty
    .flatMap { [spotifyClient] query in
        return spotifyClient.rx.search(query: query)
    }.map { tracks in
        return tracks.map(TrackRenderable.init)
    }.bindTo(tableView.rx.items(cellIdentifier: "TrackCell", cellType: TrackCell.self)) { index, track, cell in
        cell.render(trackRenderable: track)
    }.disposed(by: disposeBag)

要求 #1 – 延迟恳求

当时的 Observable 序列会在用户输入新内容时转化查询。能够经过将查询事情延迟到用户中止编写整个查询时进行改善。这种需求的弹珠图如下所示:

searchBarText:     -A-B-C---D-E-F-G-H---I-J----->
delayedQuery:      -----C-----------H-----J----->

幸运的是,RxSwift 有一个 debounce 操作符,它的效果与上图所示完全相同。它从 Observable 中获取最终一个事情,假如在经过的时刻间隔内没有发送新事情,就会发送该事情。

searchBar.rx.text.orEmpty
    .debounce(.milliseconds(300), scheduler: MainScheduler.instance)
    .flatMap { [spotifyClient] query in
        return spotifyClient.rx.search(query: query)
    }.map { tracks in
        return tracks.map(TrackRenderable.init)
    }.bindTo(tableView.rx.items(cellIdentifier: "TrackCell", cellType: TrackCell.self)) { index, track, cell in
        cell.render(trackRenderable: track)
    }.disposed(by: disposeBag)

在上一篇文章中,我介绍了本教程中要用到的几个操作符。我还展现了怎么在 Observable 中封装 API 调用。假如你不知道 spotifyClient 是怎么工作的,我主张你阅览这篇文章 。

中止之前的恳求

此刻,你应该具有一个 SearchViewController,当用户输入任何内容时,它将显现专辑。恭喜,你现已迈出了运用 RxSwift 的第一步。

当然,现在的完成还不足以在应用程序商店发布。不幸的是,其中存在一个 bug。

假设用户刚输入 “let” 时,应用程序就开端下载曲目。在恳求结束前,用户完成了他的意图,并输入了 “let it go”。对 “let it go” 恳求的呼应比对 “let” 的呼应更早抵达。

因而,应用程序将首要显现 “let it go” 的成果。当 “let it go” 的呼应抵达时,应用程序将显现错误的旧查询成果。或许图表更简单了解:

因为网络恳求存在延迟,当“晚查询的内容”先回来,“从前恳求的内容”延迟回来时怎么处理?

searchRequest:      --a-b---------c-->
                    ## flatMap {...}
searchResponse:     -----B-A-------C->
a,b,c - requests
A,B,C - responses for corresponding requests (A is response for a)

运用 flatMapLatest

flatMapLatest 是另一个很酷的操作符。它用于替代 flatMap。它们之间的差异在于,flatMapLatest 会取消对闭包内部运用的任何曾经的 Observable 的订阅。有了它,新数据将永远不会被旧呼应所替代:

searchRequest:      --a--b--------c-->
                    ## flatMapLatest {...}
searchResponse:     ------B--------C->
a,b,c - requests
A,B,C - responses for corresponding requests (A is response for a)

在代码中,只需更改 flatMapflatMapLatest 即可:

searchBar.rx.text.orEmpty
    .debounce(.milliseconds(300), scheduler: MainScheduler.instance)
    .flatMapLatest { [spotifyClient] query in
        return spotifyClient.rx.search(query: query)
    }.map { tracks in
        return tracks.map(TrackRenderable.init)
    }.bindTo(tableView.rx.items(cellIdentifier: "TrackCell", cellType: TrackCell.self)) { index, track, cell in
        cell.render(trackRenderable: track)
    }.disposed(by: disposeBag)

需求 2 – 特征查询

下一个要求是,应用程序应在视图加载时加载 “Let it go – frozen” 成果。

图片转存失利,主张将图片保存下来直接上传转存失利,主张直接上传图片文件

总是先考虑弹珠图;):

searchBar.rx.text   ----a---b-c-----d->
                        # ???????????
query               F---a---b-c-----d->
a, b, c, d - string events representing searchBar text
F - featured query

你或许猜到了,Rx 为此提供了另一个操作符,那便是 startWith()startWith() 的效果正是你想要做的。它接收一个参数,并将其作为 Observable 的初始值发送:

searchBar.rx.text   ----a---b-c-----d->
                        # startWith(F)
query               F---a---b-c-----d->
a, b, c, d - string events representing searchBar text
F - featured query string

o.O 屏幕依然是空的!

UISearchBarUITextFieldUITextView 会向其 text Observable 上的新订阅者发送初始值。在咱们的比如中,searchBar 发送的是空字符串。应用程序内部产生的状况如下图所示:

searchBar.rx.text     ""--a---b-c-----d->
                       # startWith(F)
query               F-""--a---b-c-----d->
a, b, c, d - string events representing searchBar text
"" - empty string
F - featured query

序列以特征查询开端,但在特征查询之后会呈现空字符串,从而加载新的空呼应。要防止这种状况产生,能够运用 skip(1) 操作符。它仅仅省掉给定数量的事情:

searchBar.rx.text   ""--a---b-c-----d->
                        # skip(1) 
                    ----a---b-c-----d->
                        # startWith(F)
query               F---a---b-c-----d->
a, b, c, d - string events representing searchBar text
"" - empty string from
F - featured query

现在,当你知道 skip 的效果后,就能够将其增加到代码中了:

searchBar.rx.text.orEmpty
    .skip(1) // 越过前1个元素
    .debounce(.milliseconds(300), scheduler: MainScheduler.instance)
    .startWith("Let it go - frozen")
    .flatMapLatest { [spotifyClient] query in
        return spotifyClient.rx.search(query: query)
    }.map { tracks in
        return tracks.map(TrackRenderable.init)
    }.bindTo(tableView.rx.items(cellIdentifier: "TrackCell", cellType: TrackCell.self)) { index, track, cell in
        cell.render(trackRenderable: track)
    }.disposed(by: disposeBag)

这儿也能够运用 .filter { !$0.isEmpty } // 过滤空值 操作符替代,或者运用 RxSwift 5 新增的 compactMap() 操作符。

要求 3 – 铲除曾经的查找成果

下一个需求改善的当地是在查询产生改变时铲除查找成果。

关于与 tableView 绑定的 Observable 序列来说,这意味着什么?这意味着当用户在 searchBar 中写入新内容时,Observable<[TrackRenderable]> 需求发送空数组:

RxSwift 编程思想

请看上图。因为运用了 debounce 操作符,运转 Spotify API 的 Observable 会在用户中止输入时发送事情。但是,咱们期望强制终究 Observable<[TrackRenderable]> 在用户输入任何内容时发送空数组。

图片转存失利,主张将图片保存下来直接上传转存失利,主张直接上传图片文件

上图中有一个关键点值得留意。trackRenderables 似乎是 emptyTracksOnTextChangedspotifyResponse 的总和。

在 RxSwift 中,你能够将同一类型的多个调查对象合并为一个调查对象。这一切都要归功于 merge 操作符。

让咱们创立一个 Observable,当用户在查找栏中写入任何内容时,它将发送 TrackRenderable 类型的空数组:

let clearTracksOnQueryChanged = searchBar.rx.text.orEmpty
    .skip(1).map { _ in return [TrackRenderable]() }

要运用 merge 运算符,有必要首要重构当时代码

let tracksFromSpotify: Observable<[TrackRenderable]> = searchBar.rx.text.orEmpty
    .skip(1)
    .debounce(.milliseconds(300), scheduler: MainScheduler.instance)
    .startWith("Let it go - frozen")
    .flatMapLatest { [spotifyClient] query in
        return spotifyClient.rx.search(query: query)
    }.map { tracks in
        return tracks.map(TrackRenderable.init)
    }
tracksFromSpotify.bindTo(tableView.rx.items(cellIdentifier: "TrackCell", cellType: TrackCell.self)) { index, track, cell in
        cell.render(trackRenderable: track)
}.disposed(by: disposeBag)

有什么改变?我仅仅把 Observable 移到了变量中。现在你能够运用 merge 了:

Observable.of(tracksFromSpotify, clearTracksOnQueryChanged).merge()
.bindTo(tableView.rx.items(cellIdentifier: "TrackCell", cellType: TrackCell.self)) { index, track, cell in
        cell.render(trackRenderable: track)
}.disposed(by: disposeBag)

它应该能够见效!当用户输入新的查询内容时,tableView 应该是空白的。

不过,我想进步可读性,并在将 ObservabletableView 绑定之前将其提取合并到一个独自的变量中。此外,我还发现有一处重复,我想去掉它:

let searchTextChanged = searchBar.rx.text.orEmpty.asObservable().skip(1)
let tracksFromSpotify = searchTextChanged
    .debounce(.milliseconds(300), scheduler: MainScheduler.instance)
    .startWith("Let it go - frozen")
    .flatMapLatest { [spotifyClient] query in
        return spotifyClient.rx.search(query: query)
    }.map { tracks in
        return tracks.map(TrackRenderable.init)
    }
let clearTracksOnQueryChanged = searchTextChanged
    .map { _ in return [TrackRenderable]() }
let tracks = Observable.of(tracksFromSpotify, clearTracksOnQueryChanged).merge()
tracks.bindTo(tableView.rx.items(cellIdentifier: "TrackCell", cellType: TrackCell.self)) { index, track, cell in
    cell.render(trackRenderable: track)
}.disposed(by: disposeBag)

要求 #4 – 下拉改写

猜猜你一开端要做什么?是的,你有必要首要考虑弹珠图,并记住一切都能够是一个序列!我会反复强调这一点;)。

现在是第二个问题。你通常是怎么经过下拉改写完成查找的?我猜当用户滑动 tableView 时,你会从查找栏中获取当时文本并执行查找。

我说错了吗?当用户滑动 tableView 时,你需求重复之前的恳求。让咱们画一些图:

searchBar.rx.text       --a---b----c-----d-----e--->
pullToRefreshObservable --------X-----X------X----->
queryOnPullToRefresh    --------b-----c------d----->
requestObservable       --a---b-b--c--c--d---d-e--->
responseObservable      ----A---B-B--C--C--D--D-E-->
a,b,c,d,e - query from searchBar or request with the query
X - event when a user pulls the tableView
A,B,C,D,E - response for corresponding request with the query

现在,当你知道要找什么时,请访问 rxmarbles.com,找到你需求的操作符;)。

你回来了!这意味着你现已找到了 withLatestFrom 操作符,它的功用如上图所示。

一开端,你有必要在 tableView 中增加 UIRefreshControl。办法是在 viewDidLoad 的最初增加这几行:

let refreshControl = UIRefreshControl()
tableView.addSubview(refreshControl)

好了,现在是创立 Observable 的时分了,它会在用户滑动 tableView 时发送事情:

let didPullToRefresh: Observable<Void> =  refreshControl.rx.controlEvent(.valueChanged)
    .map { [refreshControl] in
        return refreshControl.isRefreshing
    }.filter { $0 == true } // 过滤一切 false 值
    .map { _ in return () }

在上面的代码中,我运用了 filter 运算符。期望从名字上就能了解。它会忽略一切假布尔值。

isRefreshing       ---T--F-T---F----T->
            # filter { $0 == T }
didPullToRefresh   ---T----T---T----T->

下一个需求的 Observable 是当用户拉取 tableView 时重复前次查询的 Observable

let refreshWithLastQuery = didPullToRefresh
    .withLatestFrom(searchTextChanged)

这很简单,不是吗?现在,你有必要将它与当时的 Observable 堆栈结合起来:

let didPullToRefresh: Observable<Void> =  refreshControl.rx.controlEvent(.valueChanged)
    .map { [refreshControl] in
        return refreshControl.isRefreshing
    }.distinctUntilChanged()
    .filter { $0 == true }
    .map { _ in return () }
let searchTextChanged = searchBar.rx.text.orEmpty.asObservable().skip(1)
let theQuery = searchTextChanged
    .debounce(.milliseconds(300), scheduler: MainScheduler.instance)
    .startWith("Let it go - frozen")
let refreshLastQuery = didPullToRefresh
    .withLatestFrom(searchTextChanged)
let tracksFromSpotify = Observable.of(theQuery, **refreshLastQuery**).merge()
    .flatMapLatest { [spotifyClient] query in
        return spotifyClient.rx.search(query: query)
    }.map { tracks in
        return tracks.map(TrackRenderable.init)
    }
let clearTracksOnQueryChanged = searchTextChanged
    .map { _ in return [TrackRenderable]() }
let tracks = Observable.of(tracksFromSpotify, clearTracksOnQueryChanged).merge()
tracks.bindTo(tableView.rx.items(cellIdentifier: "TrackCell", cellType: TrackCell.self)) { index, track, cell in
    cell.render(trackRenderable: track)
}.disposed(by: disposeBag)

躲藏 UIRefreshControl

还有一种状况需求考虑。当应用程序结束下载曲目时,你需求躲藏 UIRefreshControl

Observable 有一个特别的操作符,用于处理躲藏 UIRefreshControl 这样的副效果。我说的是 do(next:) 操作符。

do(next:) 不会从闭包回来任何内容。它仅仅确保在 next 事情产生时调用传递的闭包:

...
let tracksFromSpotify = Observable.of(theQuery, refreshLastQuery).merge()
    .flatMapLatest { [spotifyClient] query in
        return spotifyClient.rx.search(query: query)
    }.map { tracks in
        return tracks.map(TrackRenderable.init)
    }.do(onNext: { [refreshControl] _ in
        refreshControl.endRefreshing()
    })
    ...

运转应用程序,看看有什么发现。你应该能看到 Spotify 查找和下拉改写功用。你还保护了自己,防止向 API 发送过多恳求。做得好

最终的话和作业

到此中止。教程到此结束。读完这篇文章后,我期望你记住什么是 Observable。正如你所读到的,我喜爱把 Observable 描绘为一个数组,并增加了一个维度 — 时刻。重要的是,Observable 能够宣布 nexterrorcompleted 事情。

反应式编程是关于序列和事情的,它迫使咱们选用一种新的思想方法。遇到问题时,先画几个图,然后去 rxmarbles.com 查找所需的运算符。

还有一个要求咱们还没有完成。查询有必要至少包括 3 个字母才干开端查找成果。我期望你能增加这一功用;)。最终,你能够点击此处查看我的解决方案。

private lazy var query: Observable<String> = {
    return self.searchText
        .debounce(.milliseconds(300), scheduler: MainScheduler.instance)
        .filter(self.filterQuery(containsLessCharactersThan: Constatnts.minimumSearchLength))
}()
// 这是对 filter 操作符 block 块的封装
private func filterQuery(containsLessCharactersThan minimumCharacters: Int) -> (String) -> Bool {
    return { query in
        return query.count >= minimumCharacters
    }
}

敬请期待!

PS 假如你喜爱这篇文章,请共享给你的朋友们!

PS2 假如你有任何疑问或想给我反馈,请在下方留言。