RxSwift 编程思想

原文: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

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 可以携带三种类型的事件,分别是:nexterrorcompleted

swift 复制代码
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 上很常见:

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]>。正如你在示例代码中看到的,TrackCellTrackRenderable 作为输入来渲染自己。这意味着你必须转换 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)

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

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 屏幕仍然是空的!

UISearchBarUITextFieldUITextView 会向其 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 似乎是 emptyTracksOnTextChangedspotifyResponse 的总和。

在 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 应该是空白的。

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

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 可以发出 nexterrorcompleted 事件。

反应式编程是关于序列和事件的,它迫使我们采用一种新的思维方式。遇到问题时,先画几个图,然后去 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 如果你有任何疑问或想给我反馈,请在下方留言。

相关推荐
良技漫谈2 天前
Rust移动开发:Rust在iOS端集成使用介绍
后端·程序人生·ios·rust·objective-c·swift
KeithTsui2 天前
ZFC in LEAN 之 前集的等价关系(Equivalence on Pre-set)详解
开发语言·其他·算法·binder·swift
袁代码2 天前
Swift 开发教程系列 - 第4章:函数与闭包
ios·swift·ios开发
安泽13143 天前
高德地图美食
开发语言·swift·美食
袁代码4 天前
Swift 开发教程系列 - 第2章:Swift 基础语法
swift·ios开发·基础教程
袁代码4 天前
Swift 开发教程系列 - 第1章:Swift 简介与开发环境配置
swift·ios开发·基础教程
孚亭4 天前
一些swift问题
swift
莫问alicia4 天前
echarts 实现3D饼状图 加 label标签显示
前端·3d·echarts·swift
uiop_uiop_uiop6 天前
iOS Swift5算法恢复——HMAC
ios·iphone·swift
東三城8 天前
【ios】---SwiftUI开发从入门到放弃
ios·swiftui·swift·1024程序员节