本教程是关于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支持combineLatest2 、combineLatest3 ,甚至更多,这样你就可以把多达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,concat和zip,所以一定要检查一下它们。但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的自定义数据(年龄、简历和其他自定义字段)。
我们已经走了很远的路。我想用这句话来总结一下。
你的想象力是极限。你可以用正确的工具使你的想法变成现实。 😀🚀
编码愉快!