以RxDart为例: combineLatest和Firestore的数据建模

311 阅读12分钟

本教程是关于RxDart的,这是一个非常有用的软件包,用于处理随时间变化的可观察数据/数据流。

RxDart可以使我们的生活更轻松,特别是当我们使用Firestore作为远程数据库时。

因此,我们不仅要讨论RxDart,还要学习Firestore的数据建模。


RxDart有一个非常广泛的API。如果我想广泛地介绍它,我可以创建一个关于它的整个课程。

相反,在这里我将专注于一个特定的使用案例,在这个案例中,RxDart真正发挥了作用。

前提条件

要学习本教程,你需要熟悉流、StreamBuilder以及Firestore集合和文件。

CombineLatest

combineLatest 是我需要将多个流合并成一个流时的首选API方法。这是Firestore的一个非常常见的用例,我们通过流实时读取数据。

为了将Firestore的实时数据连接到用户界面,我们可以使用 StreamBuilder并在有新值的时候重建我们的小部件。

所以本教程分为两部分。

  • 我们将从一个显示来自Firestore上一个集合的项目列表的例子应用开始。
  • 然后,我们将引入一个新的需求,看看如何结合来自多个集合的数据。

我们将探索两种方法,并在最后讨论其利弊。

本教程将包括最相关的代码片段,但你可以在GitHub上找到完整的源代码,看看一切是如何结合在一起的。

应用实例 电影收藏夹

让我们假设我们想显示一个电影列表。

这在Firestore中被表示为一个顶级的集合。每个文件都是一部电影,它包含标题、描述,可能还有其他一些元数据。

Firestore中的电影集

为了在Flutter应用程序中显示这些数据,可以将所有这些文件解析为一个Movie 对象的列表,并通过Stream<List<Movie>>

数据流图(单流)

为了显示这些数据,我们可以用一个StreamBuilder 来获取数据流,并在每次数据变化时重建一个ListView

class MoviesList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final database = Provider.of<FirestoreDatabase>(context, listen: false);
    return StreamBuilder<List<Movie>>(
      stream: database.moviesStream(),
      builder: (_, snapshot) {
        // NOTE: snapshot.connectionState and null checks omitted for simplicity
        final movies = snapshot.data;
        return ListView.builder(
          itemCount: movies.length,
          itemBuilder: (_, index) => MovieListItem(
            movie: movies[index],
          ),
        );
      },
    );
  }
}

而我们可以把每个Movie 对象传递给一个MovieListItem widget,在屏幕上绘制一个ListTile

class MovieListItem extends StatelessWidget {
  const MovieListItem({Key key, this.movie}) : super(key: key);
  final Movie movie;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: ListTile(
        title: Text(movie.title),
        subtitle: Text(movie.description),
      ),
    );
  }
}

最终的结果看起来像这样。

单一的电影列表

这个设置非常简单,因为每个Firestore文档和屏幕上的每个MovieListItem 之间有一个1比1的映射。

添加用户收藏夹

让我们引入一个新的要求:我们希望用户能够保存他们最喜欢的电影。

不同的用户会有不同的收藏夹。

所以我们不能在全局的 movies 集合里面为每个用户存储favourite 元数据。


相反,我们可以添加一个users 集合来识别我们应用程序中的所有用户,使用FirebaseUser.uid 作为文档ID。

对于每个用户,我们可以添加一个名为userFavourites 的子集合。它里面的每个文档都包含一个isFavourite 布尔值,并由movieId 来识别,我们可以用它来引用movies 集合中的一个电影。

Firestore中的用户喜爱的集合

显示用户界面

为了在用户界面上布线,我们可以更新我们的MovieListItem widget类中的build 方法。

@override
Widget build(BuildContext context) {
  // temporary variable to be set when reading the data from Firestore
  final isFavourite = false;
  return Padding(
    padding: const EdgeInsets.all(8.0),
    child: ListTile(
        title: Text(movie.title),
        subtitle: Text(movie.description),
        trailing: IconButton(
          icon: Icon(
            Icons.favorite,
            // switch icon color based on isFavourite
            color: isFavourite ? Colors.red : Colors.grey[300],
          ),
          onPressed: () => _toggleFavourite(context, isFavourite),
        )),
  );
}

上面我们添加了一个trailing widget,有一个IconButton ,如果电影是最喜欢的,就显示一个红色的心,如果不是,就显示灰色。

我们可以按下这个按钮来收藏一部电影。当这种情况发生时,我们调用_toggleFavourite 方法。

Future<void> _toggleFavourite(BuildContext context, bool isFavourite) async {
  try {
    final database = Provider.of<FirestoreDatabase>(context, listen: false);
    await database.setUserFavourite(UserFavourite(
      isFavourite: !isFavourite,
      movieId: movie.id,
    ));
  } catch (e) {
    // TODO: handle exceptions
    print(e);
  }
}

这段代码使用电影ID将一个isFavourite 的布尔值写入Firestore的users/$uid/userFavourites/$movieId 。我们用一个处理序列化的中间UserFavourite 模型类来做这件事。

class UserFavourite {
  final String movieId;
  final bool isFavourite;

  Map<String, dynamic> toMap() {
    return {
      'isFavourite': isFavourite,
    };
  }
}

但是写这些数据只是解决方案的一半。我们还需要能够读回isFavourite 数据。

记住:当我们显示电影列表时,我们需要知道哪些电影是当前用户最喜欢的。

这是一个问题,因为电影数据收藏夹数据是在不同的Firestore集合中。

Firestore中的用户最爱集合

那么,我们怎样才能将这些数据结合起来并显示我们的用户界面呢?

解决方案1:在MovieListItem里面添加一个StreamBuilder

我们可以在MovieListItem 里面添加一个新的StreamBuilder ,这样我们就可以读取每个特定电影的isFavourite 标志。

由此产生的build 方法看起来像这样。

// MovieListItem widget class
@override
Widget build(BuildContext context) {
  final database = Provider.of<FirestoreDatabase>(context, listen: false);
  return StreamBuilder<UserFavourite>(
      // reads from `users/$uid/userFavourites/$movieId`
      stream: database.userFavouriteStream(movie.id),
      builder: (context, snapshot) {
        // read isFavourite from snapshot
        final userFavourite = snapshot.data;
        final isFavourite = userFavourite?.isFavourite ?? false;
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
              title: Text(movie.title),
              subtitle: Text(movie.description),
              trailing: IconButton(
                icon: Icon(
                  Icons.favorite,
                  color: isFavourite ? Colors.red : Colors.grey[300],
                ),
                onPressed: () => _toggleFavourite(context, isFavourite),
              )),
        );
      });
}

通过这些改变,电影列表现在可以工作了,我们可以在用户界面中切换最喜欢的图标。

已完成的电影应用程序

这个解决方案是可行的,只要我们乐意在我们的MovieListItem 类里面有一个StreamBuilder

然而,产生的widget层次结构有两个嵌套的StreamBuilder:一个在MoviesList ,一个在MovieListItem

一般来说(但并不总是如此!),避免嵌套的StreamBuilders是一个好主意,以尽量减少widget的重建。

所以在本教程的其余部分,我们将尝试一个不同的解决方案。我们将结合两个独立的集合的数据,只产生一个输出流,其中有我们的用户界面需要的所有数据。

最后,我们将评估每个解决方案的优点和缺点。

解决方案2:使用 combineLatest

首先,我们要把最新版本的 rxdart到我们的pubspec.yaml 文件。

然后我们可以写一个FirestoreDatabase 类,通过一些方便的方法来获取所需的Firestore集合(更多细节请看我的启动器架构教程)。

class FirestoreDatabase {
  FirestoreDatabase({@required this.uid}) : assert(uid != null);
  final String uid;

  // see project on GitHub for how FirestoreService is implemented
  final _service = FirestoreService.instance;

  Future<void> setUserFavourite(UserFavourite userFavourite) =>
      _service.setData(
        path: FirestorePath.userFavourite(uid, userFavourite.movieId),
        data: userFavourite.toMap(),
      );

  // global collection of movies
  Stream<List<Movie>> moviesStream() => _service.collectionStream<Movie>(
        path: FirestorePath.movies(),
        builder: (data, documentId) => Movie.fromMap(data, documentId),
      );

  // user-specific favourites collection
  Stream<List<UserFavourite>> userFavouritesStream() =>
      _service.collectionStream<UserFavourite>(
        path: FirestorePath.userFavourites(uid),
        builder: (data, documentId) => UserFavourite.fromMap(data, documentId),
      );

  Stream<UserFavourite> userFavouriteStream(String movieId) =>
      _service.documentStream<UserFavourite>(
        path: FirestorePath.userFavourite(uid, movieId),
        builder: (data, documentId) => UserFavourite.fromMap(data, documentId),
      );
}

提醒:UserFavourite 类包含isFavourite 标志和一个String movieId ,这样我们就可以引用movies 集合里面的电影。

接下来,我们要找到一种方法来结合moviesStream()userFavouritesStream() ,并生成一个Stream<List<MovieUserFavourite>> ,其中MovieUserFavourite 是这样定义的。

class MovieUserFavourite {
  final Movie movie;
  final bool isFavourite;
}

为了澄清我们要做的事情,这里有一个整个数据流的图示。

我们的目标是使用combineLatest ,将这两个流的数据结合起来,并有一个输出流,我们可以将其输入我们的用户界面。


与其将新的数据流添加到FirestoreDatabase ,不如将其添加到一个新的MoviesListViewModel 类中,该类将被MoviesList 小部件使用。

class MoviesListViewModel {
  MoviesListViewModel({@required this.database});
  final FirestoreDatabase database;

  /// returns the entire movies list with user-favourite information
  Stream<List<MovieUserFavourite>> moviesUserFavouritesStream() {
    // TODO: combine streams here
  }
}

所以我们的挑战是添加逻辑来合并两个输入流,并返回一个新流。

准备好了吗?我们开始吧💪

第一步是调用Rx.combineLatest2 方法(别忘了import 'package:rxdart/rxdart.dart'; )。

/// returns the entire movies list with user-favourite information
Stream<List<MovieUserFavourite>> moviesUserFavouritesStream() {
  return Rx.combineLatest2(
      database.moviesStream(), database.userFavouritesStream(),
      (List<Movie> movies, List<UserFavourite> userFavourites) {
    // TODO: combine logic here
  });
}

这个方法需要三个参数。

  • 前两个是我们想要合并的输入流。
  • 第三个是一个闭包,给我们提供两个数据流的最新数据。

在这个闭包中,我们需要对电影列表进行映射。

return movies.map((movie) {
  final userFavourite = userFavourites?.firstWhere(
      (userFavourite) => userFavourite.movieId == movie.id,
      orElse: () => null);
  return MovieUserFavourite(
    movie: movie,
    isFavourite: userFavourite?.isFavourite ?? false,
  );
}).toList();

让我们把它分解一下。

  • 首先,我们对电影列表进行映射。
  • 对于每部电影,我们找到第一个具有匹配的电影ID的UserFavourite 对象,如果没有找到匹配的对象,则使用orElse 闭包来返回null
  • 然后我们返回一个MovieUserFavourite 对象,如果我们找不到匹配的对象,就把isFavourite 设为false

这里是完整的实现。

class MoviesListViewModel {
  MoviesListViewModel({@required this.database});
  final FirestoreDatabase database;

  /// returns the entire movies list with user-favourite information
  Stream<List<MovieUserFavourite>> moviesUserFavouritesStream() {
    return Rx.combineLatest2(
        database.moviesStream(), database.userFavouritesStream(),
        (List<Movie> movies, List<UserFavourite> userFavourites) {
      return movies.map((movie) {
        final userFavourite = userFavourites?.firstWhere(
            (userFavourite) => userFavourite.movieId == movie.id,
            orElse: () => null);
        return MovieUserFavourite(
          movie: movie,
          isFavourite: userFavourite?.isFavourite ?? false,
        );
      }).toList();
    });
  }
}

关于 combineLatestX 的说明

在我们继续之前,请注意我们已经根据我们的具体要求编写了一些自定义的组合逻辑。

如果你在自己的应用程序中使用combineLatest ,你将不得不根据你的用例编写不同的逻辑。

但同样的原理是成立的,因为通过使用combineLatest ,我们可以从两个或多个输入流中得到一个输出流。

事实上,RxDart支持combineLatest2combineLatest3 ,甚至更多,这样你就可以把多达9个输入流合并成一个输出流。

完成用户界面

困难的部分已经完成。

现在我们有了我们的MoviesListViewModel ,更新UI代码变得很容易。

MovieList widget里面,我们可以添加一个新的静态方法并使用Provider 来连接新的视图模型。

static Widget create(BuildContext context) {
  final database = Provider.of<FirestoreDatabase>(context, listen: false);
  return Provider<MoviesListViewModel>(
    create: (_) => MoviesListViewModel(database: database),
    child: MoviesList(),
  );
}

然后我们可以在build 方法中更新StreamBuilder ,像这样改变输入流。

final viewModel = Provider.of<MoviesListViewModel>(context, listen: false);
return StreamBuilder<List<MovieUserFavourite>>(
  stream: viewModel.moviesUserFavouritesStream(),
  builder: (_, snapshot) {
    // NOTE: snapshot.connectionState and null checks omitted for simplicity
    final movies = snapshot.data;
    return ListView.builder(
      itemCount: movies.length,
      itemBuilder: (_, index) => MovieListItem(
        movieUserFavourite: movies[index],
      ),
    );
  },
);

而最终的MovieListItem 类现在更简单了,不再需要一个StreamBuilder

class MovieListItem extends StatelessWidget {
  const MovieListItem({Key key, this.movieUserFavourite}) : super(key: key);
  final MovieUserFavourite movieUserFavourite;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: ListTile(
        title: Text(movieUserFavourite.movie.title),
        subtitle: Text(movieUserFavourite.movie.description),
        trailing: IconButton(
          icon: Icon(
            Icons.heart,
            color: movieUserFavourite.isFavourite
                ? Colors.red : Colors.grey[300],
          ),
          onPressed: () => _toggleFavourite(context),
        ),
      ),
    );
  }

  Future<void> _toggleFavourite(BuildContext context) async {
    try {
      final database = Provider.of<FirestoreDatabase>(context, listen: false);
      await database.setUserFavourite(UserFavourite(
        isFavourite: !movieUserFavourite.isFavourite,
        movieId: movieUserFavourite.movie.id,
      ));
    } catch (e) {
      // TODO: handle exceptions
      print(e);
    }
  }
}

这样,我们就绕了一圈。

作为提醒,这里是我们简单应用程序的完整系统图。

我们可以看到,在我们的数据流中有一个读写循环,当用户切换电影上的最爱标志时,它导致我们的用户界面重建。

当喜爱的数据被写入Firestore。

  • users/$uid/userFavourites 集合被更新
  • 这反过来又将一个新的流值推送到我们的数据流中
  • combineLatest2 将其与电影数据相结合,产生一个新的输出。
  • 最后,StreamBuilder 得到一个新的快照并重建用户界面。

比较 嵌套的StreamBuilders vs combineLatest

使用combineLatest 的解决方案有一些好处,因为我们可以很容易地结合数据并将一些逻辑从UI层中剥离出来。

这意味着我们的小部件变得更简单。

但是,魔鬼就在细节中。事实上,与使用嵌套的StreamBuilders相比,combineLatest 方法导致了更多的widget重建。

原因是,为了结合两个输入流,我们需要读取整个 userFavourites 集合。

Firestore最高提示:当我们写下里面的任何一个文件时,集合监听器就会被更新。

因此,当我们最喜欢一部电影时,整个电影列表会被重新建立

相反,如果我们为每个UserFavourite 文档使用一个StreamBuilder ,当我们设置isFavourite 标志时,只有一个 MovieListItem 被重建。

一句话:在这个非常特殊的情况下,结合两个集合是最没有效率的解决方案,而使用嵌套的StreamBuilders ,可以最大限度地减少小部件的重建。

在任何情况下,本教程的目的是向你展示如何在实践中使用combineLatest 。有些情况下,你可以用它来获得更好**、**更高性能的代码。

在你评估不同的解决方案时,请确保始终了解你的应用程序的运行时行为。

combineLatest是如何工作的?

我还没有谈及这个问题。

为此,我向你推荐RxMarbles网站,以交互式的方式说明 combineLatest 。你可以拖放输入值,并看到何时以及如何产生输出值。

RxDart支持其他的组合运算符,如merge,concatzip ,所以一定要检查一下它们。但combineLatest 是我似乎最需要的一个。

RxDart的其他方法

作为本教程的总结,我想谈谈其他的流组合方法。

没有什么可以阻止你为你的输入流创建多个StreamSubscription,并推出你的自定义组合逻辑。但这需要手动跟踪流事件,并将输出数据输送到StreamController 或其他一些反应式结构。

这导致了更多的(容易出错的)代码。使用RxDart,你可以得到一个完全功能性的方法,以及许多有用的流合并操作。

如果你愿意,你也可以实现一种不同的视图模型,操作来自Firestore的输入流,但输出流(例如,支持ChangeNotifier )。

但我发现,在Firestore项目中,一直使用流会导致最少的额外模板代码。

因此,如果你只需要执行流操作,而不需要复杂的逻辑,combineLatest 可能对你来说已经足够好了。明智地使用它。 😉

combineLatest还能解决什么问题?

combineLatest 是关于组合流的。如果你需要结合来自不同的Firestore集合或文件的数据,它是一个不错的选择。

在教程中,我们用它来合并两个集合(N对N的关系)。你也可以用它来组合一个集合和一个文档(1对N),或者两个文档(1对1)。

下面是一些现实世界的例子,你可以使用combineLatest

  • 带有产品列表+购物车的电子商务应用
  • 类似于Twitter的feed + 最喜欢的tweets
  • 带有FirebaseAuth基本数据的用户档案页(displayName,photoUrl )和来自Firestore的自定义数据(年龄、简历和其他自定义字段)。

我们已经走了很远的路。我想用这句话来总结一下。

你的想象力是极限。你可以用正确的工具使你的想法变成现实。 😀🚀

编码愉快!