使用Riverpod的Flutter和Firebase应用程序的初始架构

502 阅读16分钟

在本教程中,我详细介绍了一个生产就绪的架构,这是我在过去两年中微调的。你可以使用包含的启动项目作为你的Flutter和Firebase应用程序的基础。

2021年8月更新:启动项目最初是用Provider编写的,后来更新为使用Riverpod。本教程现在是最新的,也包括Riverpod。

动机

Flutter和Firebase是一个很好的组合,可以在创纪录的时间内将应用程序推向市场。

如果没有一个完善的架构,代码库会很快变得难以测试、维护和推理。这严重影响了开发速度,导致了产品的错误,并导致了用户的不满意。

我已经在不同的客户项目中亲眼目睹了这一点,在这些项目中,由于缺乏一个正式的架构,导致了几天、几周甚至几个月的额外工作。

架构 "很难吗?在不断变化的前端开发环境中,如何才能找到 "正确 "或 "正确 "的架构?

每个应用程序都有不同的要求,那么 "正确的 "架构是否首先就存在?

虽然我并不声称自己有银弹,但我已经完善并微调了一个生产就绪的架构,我已经在多个Flutter和Firebase应用中使用了这个架构。

我们将探索这一点,并看看它是如何在启动项目中包含的时间跟踪器应用程序中实际使用的。

时间跟踪器应用程序的截图

所以,拿起饮料,舒适地坐下。让我们开始吧!

概述

我们将从一个概述开始。

  • 什么是架构,为什么我们需要它。
  • 好的架构中的组成的重要性。
  • 当你一个好的架构时,会有好的事情发生。
  • 当你没有一个好的架构时,会发生一些坏事。

然后,我们将专注于使用Riverpod的Flutter和Firebase应用程序的良好架构,并谈论。

  • 应用层
  • 单向的数据流
  • 可变和不可变的状态
  • 基于流的架构

我将解释一些重要的原则,以及我们在代码中想要的理想属性。

我们将通过一些实际的例子来看看一切是如何结合在一起的。

你在这里读到的是我两年多的工作成果,学习概念,编写代码,并在多个个人和客户项目中完善它。

准备好了吗?让我们开始吧!🚀

什么是应用架构?

我喜欢把应用架构看作是支撑一切的基础,并在你的代码库成长过程中提供支持。

如果你有一个好的基础,就会更容易做出改变和增加新的东西。

架构使用设计模式来有效地解决问题。

而你必须选择 最适合你要解决的问题的设计模式。

例如,一个电子商务应用和一个聊天应用会有非常不同的要求。

构成

不管你想建立什么,你很可能会有一组问题,你需要把它们分解成更小的、更容易管理的问题。

你可以为每个问题创建基本的积木,你可以通过积木合成在一起来构建你的应用程序。事实上。

组合是一个基本原则,在Flutter中被广泛使用,在软件开发中也被更广泛地使用。

既然我们是来构建Flutter应用程序的,那么我们需要什么样的积木呢?

例子 签到页

比方说,您要建立一个用于电子邮件和密码登录的页面。

您将需要一些输入字段和一个按钮,您需要将这些输入字段组合在一起,形成一个表单。

但这个表单本身并没有什么作用。

你还需要与一个认证服务对话。这方面的代码与你的用户界面代码非常不同

为了建立这个功能,你需要为UI、输入验证和认证编写代码。

UI、登录和API组件

良好的架构

如果上面的登录页面是由定义良好的构件(或组件)组成的,那么它就有一个好的架构,我们可以将其组合在一起。

我们可以采取同样的方法,并将其扩展到整个应用程序中。这有一些非常明显的好处。

  • 增加新的功能变得更容易,因为你可以在你已经拥有的基础上建立。
  • 代码库变得更容易理解,当你阅读代码时,你可能会发现一些重复出现的模式和惯例。
  • 组件有明确的职责不会做太多的事情。如果你的架构是高度可组合的,这就是设计的结果。
  • 整个类别的问题都会消失(后面会有更多的介绍)。
  • 你可以通过为不同的关注领域(用户界面、逻辑、服务)定义独立的应用层来拥有不同种类的组件。

不太好的架构 😅

如果我们不能定义一个好的架构,我们就没有明确的约定如何构造我们的应用程序。

缺乏可组合的组件会导致代码有很多的依赖性。

这种代码是很难理解的。增加新的功能就成了问题,甚至不清楚新的代码应该放在哪里。

其他一些潜在的问题也很常见。

  • 应用程序有很多易变的状态,使人很难知道哪些小工具在什么时候重建。
  • 不清楚某些变量什么时候可以或不可以null ,因为它们是在多个小部件之间传递的。

所有这些问题都会大大降低开发速度,并否定Flutter中常见的生产力优势。

一句话:好的架构很重要。

Flutter应用层

下面是一张图,显示了我对Flutter & Firebase应用程序的架构。

应用层

虚线定义了一些清晰的应用层

我认为经常思考这些问题是个好主意。当你写新的代码时,你应该问自己:这属于哪里


例如:如果你在为一个新功能写一些UI代码,你很可能会在一个widget类里面。也许你需要在一个按钮被按下时调用一些外部网络服务API。在这种情况下,你需要停下来思考:我的API代码在哪里

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text('New Job'),
      actions: [
        FlatButton(
          child: Text('Save'),
          onPressed: () {
            // web API call here. Where should this code go?
          },
        ),
      ],
    ),
    body: _buildContents(),
  );
}

从应用层的角度来思考,在这里是非常有帮助的。

这可以归结为单一责任原则:你的应用程序中的每个组件都应该一件事

UI和网络代码是两件完全不同的事情。它们不应该在一起,而且它们生活在非常不同的地方。

单向的数据流

在上图中,数据从外部世界流向服务、视图模型,并一直流向小部件。

调用流则进入相反的方向。小部件可以调用视图模型和服务中的方法。反过来,这些方法可以调用外部Dart包内的API。

非常重要的是:生活在某一应用层的组件不知道 下面各层组件的存在。

视图模型引用任何widgets对象(或导入任何UI代码的问题)。相反。

小部件作为监听者订阅自己,而视图模型在有变化时发布更新。

发布-订阅模式

这被称为发布/订阅模式,它在Flutter中有各种实现。如果你在你的应用程序中使用ChangeNotifiersBLoCs,你已经遇到了这个问题。

河豚

为了将所有东西连接在一起,该应用最初是使用Provider包构建的。Provider的工作方式与InheritedWidget类似,使其更容易在widget树中插入依赖关系,并按类型访问它们。

但是Provider有一些弱点,在某些情况下会导致不想要的模板代码运行时错误。出于这个原因,我将该项目迁移到Riverpod包中。就像Provider一样,Riverpod可以用来强制执行单向的数据流与不可变的模型类,但不具有相同的弱点。

使用Riverpod,我们创建了全局提供者,可以通过引用来访问。在这个意义上,Riverpod依赖于widget树,它更像一个服务定位器

如果你是这个包的新手,你可以查看官方文档或我的Riverpod基本指南

用Riverpod创建供应者

例如,这里有一些用Riverpod创建的提供者。

// 1
final firebaseAuthProvider =
    Provider<FirebaseAuth>((ref) => FirebaseAuth.instance);

// 2
final authStateChangesProvider = StreamProvider<User>(
    (ref) => ref.watch(firebaseAuthProvider).authStateChanges());

// 3
final databaseProvider = Provider<FirestoreDatabase?>((ref) {
  final auth = ref.watch(authStateChangesProvider);

  // we only have a valid DB if the user is signed in
  if (auth.data?.value?.uid != null) {
    return FirestoreDatabase(uid: auth.data!.value!.uid);
  }
  // else we return null
  return null;
});

我们可以看到,authStateChangesProvider 依赖于 firebaseAuthProvider ,并且可以通过ref.watch() 来访问它。

同样地,databaseProvider 依赖于 authStateChangesProvider

Riverpod的一个强大的功能是,我们可以观察一个提供者的值,并在值改变时重建所有依赖的提供者和部件。

这方面的一个例子是上面的databaseProvider 。每次authStateChangesProvider"的值发生变化时,这个提供者的值就会被重新构建。这被用来返回一个FirestoreDatabase 对象或null ,取决于认证状态。

在widget内使用Riverpod

widgets可以用WidgetRef ,通过ConsumerConsumerWidget 来访问这些提供者。

例如,这里有一些示例代码,演示如何使用StreamProvider ,从一个流中读取一些数据。

final jobStreamProvider =
    StreamProvider.autoDispose.family<Job, String>((ref, jobId) {
  final database = ref.watch(databaseProvider)!;
  return database.jobStream(jobId: jobId);
});

这里有很多东西需要解读。

  • 当所有的听众都退订时,StreamProvider 可以自动处置自己。
  • 我们使用.family 来读取一个只有在运行时才知道的jobId 参数。
  • 我们通过ref.watch() 来访问数据库,并使用断言操作符(!),只要我们只在数据库不在null 的情况下读取这个流。

这里有一个观察这个StreamProvider ,并根据流的最新状态(数据可用/加载/错误)使用它来显示一些UI。

class JobEntriesAppBarTitle extends ConsumerWidget {
  const JobEntriesAppBarTitle({required this.job});
  final Job job;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1: watch changes in the stream
    final jobAsyncValue = ref.watch(jobStreamProvider(job.id));
    // 2: return the correct widget depending on the value
    return jobAsyncValue.when(
      data: (job) => Text(job.name),
      loading: () => Container(),
      error: (_, __) => Container(),
    );
  }
}

这个widget类是最简单的,因为它只需要观察流中的变化(步骤1),并根据值返回正确的widget(步骤2)。

这很好,因为所有设置StreamProvider 的逻辑都在提供者本身里面,并且与UI代码完全分开。

可变和不可变的状态

这种架构的一个重要方面在于服务和视图模型之间的差异。特别是。

  • 视图模型可以保持和修改状态。
  • 服务则不能。

换句话说,我们可以把服务看成是纯粹的、功能性的组件。

服务可以转换它们从外部世界收到的数据,并通过特定领域的API将其提供给应用程序的其他部分。

例如,在使用Firestore时,我们可以使用一个封装服务来进行序列化。

  • 数据输入(read)。这将Firestore文档中的键值对流转换为强类型的不可变的数据模型。
  • 数据输出(写)。它将数据模型转换回键值对,并写入Firestore。

另一方面,视图模型包含你的应用程序的业务逻辑,并可能持有可变的状态。

这是可以的,因为根据上面描述的发布/订阅模式,小部件可以被通知到状态变化并重建自己。

通过将单向数据流与发布/订阅模式相结合,我们可以最大限度地减少易变的应用程序状态,以及通常伴随着它的问题。

基于流的架构

与传统的REST APIs不同,使用Firebase我们可以建立实时的应用程序。

这是因为Firebase可以在事情发生变化时直接向订阅的客户端推送更新。

例如,当某些Firestore文档集合被更新时,widget可以重新构建自己。

许多Firebase的API本质上是基于流的。因此,使我们的小部件具有反应性的最简单方法是使用Riverpod的 StreamProvider类。

StreamProvider 类会接收一个输入并将其快照转换为 AsyncValue对象,这样使用起来就更安全了。

再一次,这里是观察变化并在流变化时重建用户界面的代码。

class JobEntriesAppBarTitle extends ConsumerWidget {
  const JobEntriesAppBarTitle({required this.job});
  final Job job;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1: watch changes in the stream
    final jobAsyncValue = ref.watch(jobStreamProvider(job.id));
    // 2: return the correct widget depending on the value
    return jobAsyncValue.when(
      data: (job) => Text(job.name),
      loading: () => Container(),
      error: (_, __) => Container(),
    );
  }
}

注意在第2步中,我们如何使用.when ,将jobAsyncValue 转换为一个Text 小工具,一个CircularProgressIndicator ,或一个错误小工具。这要比使用 AsyncSnapshot值的工作要容易得多,也安全得多,因为Flutter的 StreamBuilder小组件提供的值相比,要简单和安全得多。

注意:流不仅是Firebase推送变化的默认方式,而且也是许多其他服务的默认方式。例如,你可以通过位置包的onLocationChanged() 流来获得位置更新。无论你是使用Firestore,还是想从设备的输入传感器获取数据,流都是随着时间的推移传递异步数据的最方便方式。


总之,这种架构定义了独立的应用层,具有单向的数据流。数据通过流从Firebase读取,然后转换为AsyncValue 对象,并根据使用Riverpod的发布/订阅模式重建小部件。

理想的代码属性

如果使用得当,这种架构导致的代码是。

  • 清晰
  • 可重复使用
  • 可扩展
  • 可测试的
  • 性能良好
  • 可维护

让我们通过一些例子来看看每一点。

清晰

假设我们想建立一个显示工作列表的页面。

下面是我在我的项目中的实现方法(解释如下)。

// 1
final jobsStreamProvider = StreamProvider.autoDispose<List<Job>>((ref) {
  final database = ref.watch(databaseProvider)!;
  return database.jobsStream();
});

// 2
class JobsPage extends ConsumerWidget {

  // 3
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: const Text(Strings.jobs),
        actions: <Widget>[
          IconButton(
            icon: const Icon(Icons.add, color: Colors.white),
            onPressed: () => EditJobPage.show(context),
          ),
        ],
      ),
      body: _buildContents(context, ref),
    );
  }

  // 4
  Widget _buildContents(BuildContext context, WidgetRef ref) {
    final jobsAsyncValue = ref.watch(jobsStreamProvider);
    return ListItemsBuilder<Job>(
      data: jobsAsyncValue,
      itemBuilder: (context, job) => Dismissible(
        key: Key('job-${job.id}'),
        background: Container(color: Colors.red),
        direction: DismissDirection.endToStart,
        onDismissed: (direction) => _delete(context, ref, job),
        child: JobListTile(
          job: job,
          onTap: () => JobEntriesPage.show(context, job),
        ),
      ),
    );
  }

  Future<void> _delete(BuildContext context, WidgetRef ref, Job job) async {
    try {
      final database = ref.read<FirestoreDatabase?>(databaseProvider)!;
      await database.deleteJob(job);
    } catch (e) {
      unawaited(showExceptionAlertDialog(
        context: context,
        title: 'Operation failed',
        exception: e,
      ));
    }
  }
}
  • 第1步:我们创建一个jobsStreamProvider
  • 第2步:我们通过扩展ConsumerWidget ,创建JobsPage 。这使得我们很容易观察到流中的变化,因为它在build() 方法中给了我们一个额外的WidgetRef 参数。
  • 3步:build() 方法返回一个Scaffold 和一个AppBar
  • 第4步_buildContents() 方法观察 jobsStreamProvider ,并将产生的异步值传递给一个ListItemsBuilder widget(这是我为显示一个项目列表而创建的通用widget)。

在短短的50行中,这个widget显示了一个项目列表,并处理了三个不同的回调,用于。

  • 创建一个新工作
  • 删除一个现有的工作
  • 转移到一个工作的详细信息页面

这些操作中的每一个都只需要一行代码,因为它实际工作委托外部类。

因此,这段代码是清晰和可读的。

如果我们把数据库代码、序列化、路由和用户界面都放在一个类中,就很难理解所有的东西了。而且,我们的代码也会因此而减少可重用性。

可重用性

下面是另一个页面的代码,它显示了所有工作的每日明细和报酬。

final entriesTileModelStreamProvider =
    StreamProvider.autoDispose<List<EntriesListTileModel>>(
  (ref) {
    final database = ref.watch(databaseProvider)!;
    final vm = EntriesViewModel(database: database);
    return vm.entriesTileModelStream;
  },
);

class EntriesPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final entriesTileModelStream = ref.watch(entriesTileModelStreamProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text(Strings.entries),
        elevation: 2.0,
      ),
      body: ListItemsBuilder<EntriesListTileModel>(
        data: entriesTileModelStream,
        itemBuilder: (context, model) => EntriesListTile(model: model),
      ),
    );
  }
}

请注意这段代码是如何重复使用我们在前一个页面中的相同的ListItemsBuilder widget的--这次是用不同的模型类型(EntriesListTileModel )。

但数据流向用户界面的方式与之前一样。

一句话:建立可以在多个地方使用的可重复使用的组件是值得的。

可扩展性

让我们来谈谈可扩展的代码。如果你以前实现过Firestore的CRUD操作,你可能对这种语法很熟悉。

Widget build(BuildContext context) {
  final user = FirebaseAuth.instance.currentUser;
  final ref = FirebaseFirestore.instance.collection('users')
                .doc(user.uid).collection('jobs').doc(job.id);
  return StreamBuilder<DocumentSnapshot>(
    stream: ref.snapshots(),
    builder: (_, snapshot) {
      // TODO: check for connectionState, hasData, errors etc
      final data = snapshot.data.data();
      return Text(data['name']);
    },
  );
}

这种代码有两个主要问题。

  • 它与FirebaseAuthFirebaseFirestore 单一变量高度耦合,使其无法测试
  • 它直接从DocumentSnapshot 对象中访问键值对,这很容易出错,而且不是类型安全。

这可能会变得很不方便,特别是当你的文档有很多键值对时。

你不希望在你的小部件里有这样的代码。

相反,你可以使用一些服务类来定义一个特定领域的Firestore API,并保持事情的整洁。

下面是我创建的一个FirestorePath 类,用来列出我的Firestore数据库中所有可能的读/写位置。

class FirestorePath {
  static String job(String uid, String jobId) => 'users/$uid/jobs/$jobId';
  static String jobs(String uid) => 'users/$uid/jobs';
  static String entry(String uid, String entryId) =>
      'users/$uid/entries/$entryId';
  static String entries(String uid) => 'users/$uid/entries';
}

除了这个,我还有一个FirestoreDatabase 类,用来提供对各种文档和集合的访问。

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

  // CRUD operations - implementations omitted for simplicity
  Future<void> setJob(Job job) { ... }
  Future<void> deleteJob(Job job) { ... }
  Stream<Job> jobStream({@required String jobId}) { ... }
  Stream<List<Job>> jobsStream() { ... }
  Future<void> setEntry(Entry entry) { ... }
  Future<void> deleteEntry(Entry entry) { ... }
  Stream<List<Entry>> entriesStream({Job job}) { ... }
}

这个类将所有各种CRUD操作暴露给应用程序的其他部分,在一个漂亮的API后面,使用类型安全的模型类。

有了这个设置,在Firestore中添加一个新类型的文档或集合就成为一个可重复的过程。

  • 添加一些额外的路径到FirestorePath
  • 将相应的FutureStream 的API添加到FirestoreDatabase ,以支持各种操作。
  • 根据需要创建类型安全的模型类。这些包括我们需要使用的新种类文档的序列化代码。

所有这些代码都被限制在项目中的一个services 文件夹内。

小工具可以通过创建一个相应的StreamProvider ,从数据库API访问特定的流,并观察它来重建用户界面。

// 1: create StreamProvider
final jobsStreamProvider = StreamProvider.autoDispose<List<Job>>((ref) {
  final database = ref.watch(databaseProvider)!;
  return database.jobsStream();
});

class JobsPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 2: watch the stream data and rebuild the UI
    final jobsAsyncValue = ref.watch(jobsStreamProvider);
    return jobsAsyncValue.when(
      data: (value) => /* data widget */,
      loading: () => /* loading widget */,
      error: (_, __) => /* error widget */,
    );
  }
}

上面的代码是很容易扩展的。我们可以通过遵循可重复的步骤添加新的功能,并确保代码的一致性。如果你在一个团队中工作,这一点是非常有价值的。

可测试

这种架构导致了可测试的代码。

对于我们的单元测试来说,这是真的,只要我们的类是小的,并且有很少的依赖性。

但它也适用于widget测试,因为我们可以使用Riverpod的依赖覆盖

例如,考虑这个项目中使用的全局firebaseAuthProvider

final firebaseAuthProvider =
    Provider<FirebaseAuth>((ref) => FirebaseAuth.instance);

在我们的widget测试中,我们可以创建一个模拟对象来替代FirebaseAuth ,并根据需要进行配置。

import 'package:mocktail/mocktail.dart';

class MockFirebaseAuth extends Mock implements FirebaseAuth {}

void main() {
  late MockFirebaseAuth mockFirebaseAuth;

  setUp(() {
    mockFirebaseAuth = MockFirebaseAuth();
    // stub methods here, or in the tests 
    when(() => mockFirebaseAuth.authStateChanges())
      .thenAnswer((_) => /* some stream */);
  });
  /// tests here
}

然后我们可以像这样在泵送widget的时候覆盖firebaseAuthProvider

await tester.pumpWidget(
  ProviderScope(
    overrides: [
      firebaseAuthProvider
          .overrideWithProvider(Provider((ref) => mockFirebaseAuth)),
    ],
    child: MaterialApp(...),
  ),
);

因此,widget每次读取firebaseAuthProvider 的值时都会访问mockFirebaseAuth

这导致了widget测试的快速和可预测性,因为它们不会调用任何网络代码。

依赖重写是非常强大的。我们可以使用它们来覆盖任何我们想要的提供者,这很容易做到,因为Riverpod的提供者是全局的,可以通过名字来访问。

这在运行集成测试时特别有用,可以用来测试应用程序中的整个用户流

关于使用Riverpod测试的更多信息,请阅读官方文档

执行性

这种架构的一个好处是,只要我们正确使用Riverpod,它可以最大限度地减少小部件的重建

这里的关键是要知道何时使用ref.read() ,何时使用ref.watch() 。Riverpod文档有一个详细的页面,解释了如何正确消费一个提供者。

这里有一个学习曲线,因为Riverpod给了我们各种类型的提供者消费者,以及使用它们的多种方法。

我计划在未来创建一些关于Riverpod的教程,更详细地解释事情。

现在,我建议查看我的Riverpod基本指南,花时间阅读文档,并在实践中尝试所有这些概念。

可维护性

这种架构导致了可维护的代码,上面的例子应该作为证据。

可维护的代码将为你(和你的团队)节省几天、几周和几个月的额外努力。

除此以外,你的代码会更加好用,你在晚上也会睡得更好。😴

总结

我希望这个概述能激励你投资于好的架构。

如果你正在开始一个新的项目,请考虑根据你的需求,预先规划好你的架构。

如果你正在为一个不遵循良好的软件设计原则的代码库而苦苦挣扎,那就开始小规模迭代重构。你不必一次解决所有的问题,但这有助于慢慢地走向你所期望的架构。

如果你正在用Flutter和Firebase或任何其他类型的流媒体API构建一个项目,请查看我在GitHub上的启动项目。这是一个完整的时间跟踪应用程序。

时间跟踪应用程序的截图

README是一个很好的地方,可以让你更熟悉我们所涉及的所有概念。

之后,你可以看看源代码,运行项目(注意:需要Firebase配置),并充分了解所有东西是如何结合在一起的。

编码愉快