以RxDart为例:用switchMap和debounce查询GitHub搜索API

465 阅读3分钟

这个简短的教程展示了如何在Flutter中用RxDart实现搜索。

我们将使用GitHub搜索API,但同样的概念也适用于任何其他搜索REST APIs。

我们的目标是拥有良好的搜索用户体验,同时不给服务器带来过多的负载,也不影响客户端的带宽和电池寿命。

GitHub搜索示例应用程序

让我们开始看看一个简单的工作应用。

这使用了 SearchDelegate类来显示符合输入搜索查询的用户列表。

为了建立这个应用,我们需要一些组件。

  • 一个GitHubSearchAPIWrapper 类,从 GitHub REST API 中提取数据
  • 一个GitHubSearchResult 模型类,包含 API 响应数据
  • 一个GitHubSearchDelegate 类,显示带有结果网格的搜索用户界面
  • 一个GitHubSearchService 类,包含所有用于连接 API 封装器和用户界面的逻辑。

下面是所有东西是如何连接的。

这是一个简短的教程,所以我们将只关注GitHubSearchService 类。

但你可以在GitHub上查看完整的源代码,了解其余所有细节。

GitHubSearchService

让我们从代表这个类的起点的一些代码开始。

class GitHubSearchService {
  GitHubSearchService({@required this.apiWrapper});
  final GitHubSearchAPIWrapper apiWrapper;

  // Input stream (search terms)
  final _searchTerms = BehaviorSubject<String>();
  void searchUser(String query) => _searchTerms.add(query);

  // Output stream (search results)
  final _results = BehaviorSubject<GitHubSearchResult>();
  Stream<GitHubSearchResult> get results => _results.stream;

  void dispose() {
    _results.close();
    _searchTerms.close();
  }
}

我们有一个搜索词的输入流(当我们在搜索框上输入时产生),和一个结果数据的输出流(在用户界面上显示为项目的网格)。

这个类需要一个GitHubSearchAPIWrapper 作为构造函数参数。

我们需要研究如何在输入发生变化时向输出流中添加数值。

将输入映射到输出:第一次尝试

作为第一次尝试,让我们给_searchTerms 流添加一个监听器。

GitHubSearchService({@required this.apiWrapper}) {
  _searchTerms.listen((query) async {
    print('searching: $query');
    // get new result from the api
    final result = await apiWrapper.searchUser(query);
    print('received result for: $query');
    // add to the output stream
    _results.add(result);
  });
}

每次搜索查询发生变化时,这段代码就会调用API并将结果添加到输出流中。

虽然这种方法看起来合乎逻辑,但它在实践中并没有很好地发挥作用。

为什么呢?因为不能保证结果是按照搜索词的顺序回来的。

当我们有一个不可靠的连接时,这是一个问题,会导致不按顺序的结果和糟糕的用户体验。

// example log
searching: b
searching: bi
searching: biz
searching: bizz
searching: bizz8
searching: bizz84
received result for: b
received result for: bi
received result for: bizz
received result for: bizz8
received result for: bizz84
received result for: biz

asyncMap

让我们尝试使用asyncMap 操作符来代替。

GitHubSearchService({@required this.apiWrapper}) {
  _results = _searchTerms.asyncMap((query) async {
    print('searching: $query');
    return await apiWrapper.searchUser(query);
  }); // discard previous events
}
// To make this work, `_results` is now decleared as a `Stream` 
Stream<GitHubSearchResult> _results;

asyncMap 保证输出的事件与输入的顺序相同

这个解决方案解决了失序的问题,但它有一个主要的缺点。

如果其中一个输出延迟到达,那么所有后续的输出也会延迟。

相反,我们应该在搜索查询发生变化时立即丢弃任何飞行中的请求。

这可以通过switchMap 操作符来实现。

switchMap

switchMap 为我们做了正确的事情,可以像这样使用。

GitHubSearchService({@required this.apiWrapper}) {
  _results = _searchTerms.switchMap((query) async* {
    print('searching: $query');
    yield await apiWrapper.searchUser(query);
  }); // discard previous events
}

注意,在这种情况下,我们使用的是带有async* 语法的流生成器

有了这个设置,一旦有新的搜索词进来,我们就可以丢弃任何飞行中的请求。

但在GitHub Search API中,仍有一个问题特别明显。

如果我们过快地提交了太多的查询,就会出现 "rate limit exceeded "的错误。

{
  "message": "API rate limit exceeded for 82.37.171.3. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)",
  "documentation_url": "https://developer.github.com/v3/#rate-limiting"
}

去抖动

Debounce通过将所有输入事件 "搁置 "一定时间来减轻服务器的压力。

我们可以通过添加一行来轻松地对输入流进行去抖。

_results = _searchTerms
    .debounce((_) => TimerStream(true, Duration(milliseconds: 500)))
    .switchMap((query) async* {
  print('searching: $query');
  yield await apiWrapper.searchUser(query);
}); // discard previous events

我们可以根据自己的喜好控制去抖的时间(500ms是一个很好的默认值)。

就这样,我们拥有了它!一个高效的搜索实现,感觉很敏捷,不会让服务器或客户端负担过重。

荣誉

本教程在很大程度上受到Brian Egan和Filip Hracek在ReactiveConf 2018上的演讲的启发。

完整的源代码可用于GitHub搜索实例--欢迎在你自己的应用程序中使用它作为参考。

编码愉快!