距离我 上一篇文章 已经很久了。我想写一篇文章,介绍 Observable
背后的理论,并展示在真实而非虚拟示例中的用法。事实证明,这并不是我想象中的小事😉。
文章分为两部分。一开始,我试图解释什么是 Observable
以及如何处理它们。
第二部分是如何使用 RxSwift 实现 Spotify 曲目搜索的教程。理论归理论,实例却能让人更容易理解。
长话短说,希望大家喜欢阅读 🙂
Observable - 序列
在使用 Rx 时,阻碍人们深入其中的原因是命令式的思考方式。你必须打破旧习惯,开始用 Rx 的方式思考。
我建议你像思考数组一样 思考 Observable
。这是因为,你可以像使用数组一样对 Observable
进行 map
、filter
或者 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
:
swift
let names = ["adam", "daniel", "christian"]
let nameLengths = names.map { $0.characters.count }
print(nameLengths) // "4, 6, 9"
需要注意的是,nameLengths
是一个单独的数组,名称不会改变。同样的情况也会发生在 Observable
上:
swift
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
可以携带三种类型的事件,分别是:next
、error
和 completed
:
swift
public enum Event<Element> {
case next(Element)
case error(Swift.Error)
case completed
}
我认为 next
不言自明。如果你有一个 Observable<Person>
,那么每个 next
事件都会携带一个 Person
实例。
由于 Observable
是一个具有时间维度的序列,因此观察者不知道序列的大小,这意味着他不知道何时停止监听事件。这就是 error
和 completed
存在的目的。
出错时会发送 error
事件,当 Observable
想告诉它的观察者不再发送事件时会发送 completed
事件。
请记住 Observable
的另一个重要特性:error
和 completed
总是终止 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 种类型的标记,分别代表 next
、error
和 completed
:
另一种绘制弹珠图的方法是 ASCII 格式。这种方式在 stackoverflow、论坛或 slack 上很常见:
swift
----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]>
。正如你在示例代码中看到的,TrackCell
将 TrackRenderable
作为输入来渲染自己。这意味着你必须转换 Observable<String> -> Observable<[Track]> -> Observable<[TrackRenderable]>
:
正如你所看到的,[Track]
事件会根据查询事件在时间上移动。这是因为 [Track]
来自 Spotify API,它需要一些时间来建立连接,所以响应会晚一点。
你是否问过自己,如何才能将 query
映射到 [Track]
?到目前为止,我们已经使用了同步返回值的 map
,但现在必须异步转换它。
flatMap
我想介绍 flatMap
操作符。flatMap
返回一个 Observable
,并将其所有事件转移到原始序列中。
现在我们来看看这些图的代码是怎样的:
swift
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
绑定:
swift
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
序列会在用户输入新内容时转换查询。可以通过将查询事件延迟到用户停止编写整个查询时进行改进。这种需求的弹珠图如下所示:
swift
searchBarText: -A-B-C---D-E-F-G-H---I-J----->
delayedQuery: -----C-----------H-----J----->
幸运的是,RxSwift 有一个 debounce
操作符,它的作用与上图所示完全相同。它从 Observable
中获取最后一个事件,如果在经过的时间间隔内没有发送新事件,就会发送该事件。
swift
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" 的响应到达时,应用程序将显示错误的旧查询结果。也许图表更容易理解:
💡 由于网络请求存在延迟,当"晚查询的内容"先返回,"先前请求的内容"延迟返回时如何处理?
swift
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
的订阅。有了它,新数据将永远不会被旧响应所取代:
swift
searchRequest: --a--b--------c-->
## flatMapLatest {...}
searchResponse: ------B--------C->
a,b,c - requests
A,B,C - responses for corresponding requests (A is response for a)
在代码中,只需更改 flatMap
为 flatMapLatest
即可:
swift
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" 结果。
总是先思考弹珠图;):
swift
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
的初始值发送:
swift
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 屏幕仍然是空的!
UISearchBar
、UITextField
或 UITextView
会向其 text Observable
上的新订阅者发送初始值。在我们的例子中,searchBar
发送的是空字符串。应用程序内部发生的情况如下图所示:
swift
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)
操作符。它只是省略给定数量的事件:
swift
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
的作用后,就可以将其添加到代码中了:
swift
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]>
需要发送空数组:
请看上图。由于使用了 debounce
操作符,运行 Spotify API 的 Observable
会在用户停止输入时发送事件。但是,我们希望强制最终 Observable<[TrackRenderable]>
在用户输入任何内容时发送空数组。
上图中有一个关键点值得注意。trackRenderables
似乎是 emptyTracksOnTextChanged
和 spotifyResponse
的总和。
在 RxSwift 中,你可以将同一类型的多个观察对象合并为一个观察对象。这一切都要归功于 merge
操作符。
让我们创建一个 Observable
,当用户在搜索栏中写入任何内容时,它将发送 TrackRenderable
类型的空数组:
swift
let clearTracksOnQueryChanged = searchBar.rx.text.orEmpty
.skip(1).map { _ in return [TrackRenderable]() }
要使用 merge
运算符,必须首先重构当前代码:
swift
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
了:
swift
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
应该是空白的。
不过,我想提高可读性,并在将 Observable
与 tableView
绑定之前将其提取合并到一个单独的变量中。此外,我还发现有一处重复,我想去掉它:
swift
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
时,你需要重复之前的请求。让我们画一些图:
swift
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
的开头添加这几行:
swift
let refreshControl = UIRefreshControl()
tableView.addSubview(refreshControl)
好了,现在是创建 Observable
的时候了,它会在用户滑动 tableView
时发送事件:
swift
let didPullToRefresh: Observable<Void> = refreshControl.rx.controlEvent(.valueChanged)
.map { [refreshControl] in
return refreshControl.isRefreshing
}.filter { $0 == true } // 过滤所有 false 值
.map { _ in return () }
在上面的代码中,我使用了 filter
运算符。希望从名字上就能理解。它会忽略所有假布尔值。
swift
isRefreshing ---T--F-T---F----T->
# filter { $0 == T }
didPullToRefresh ---T----T---T----T->
下一个需要的 Observable
是当用户拉取 tableView 时重复上次查询的 Observable
:
swift
let refreshWithLastQuery = didPullToRefresh
.withLatestFrom(searchTextChanged)
这很简单,不是吗?现在,你必须将它与当前的 Observable
堆栈结合起来:
swift
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
事件发生时调用传递的闭包:
swift
...
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
可以发出 next
、error
和 completed
事件。
反应式编程是关于序列和事件的,它迫使我们采用一种新的思维方式。遇到问题时,先画几个图,然后去 rxmarbles.com 查找所需的运算符。
还有一个要求我们还没有实现。查询必须至少包含 3 个字母才能开始查找结果。我希望你能添加这一功能;)。最后,你可以点击此处查看我的解决方案。
swift
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 如果你有任何疑问或想给我反馈,请在下方留言。