发布于 2026-01-06 2 阅读
0

Flutter 和 Firebase 应用的入门架构

Flutter 和 Firebase 应用的入门架构

本文最初发表于我的网站。

观看YouTube上的视频教程。

在本教程中,我将详细介绍我过去两年不断优化完善的、可用于生产环境的架构。您可以将随附的入门项目用作Flutter 和 Firebase 应用的基础。

动机

Flutter 和 Firebase 是快速将应用程序推向市场的绝佳组合。

如果没有合理的架构,代码库很快就会变得难以测试、维护和理解。这会严重影响开发速度,导致产品缺陷百出,开发人员沮丧,用户不满。

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

“架构”很难吗?在瞬息万变的前端开发领域,如何找到“正确”或“合适的”架构?

每个应用程序都有不同的需求,那么是否存在“正确”的架构呢?

虽然我不敢说自己拥有万全之策,但我已经改进并完善了一个可用于生产的架构,并在多个 Flutter 和 Firebase 应用程序中使用过。

我们将对此进行探讨,并了解它在入门项目中包含的时间跟踪应用程序中的实际应用

时间跟踪应用程序的屏幕截图

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

概述

我们将从概述开始:

  • 什么是建筑?为什么我们需要建筑?
  • 构图在优秀建筑中的重要性。
  • 拥有良好的架构会带来诸多好处。
  • 没有好的架构会导致哪些糟糕的事情?

接下来,我们将重点讨论 Flutter 和 Firebase 应用的优秀架构,并探讨以下内容:

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

我将解释一些重要的原则,以及我们希望代码具备的理想特性。

我们将通过一些实际例子来了解这一切是如何联系起来的。

你在这里读到的内容是我两年多来学习概念、编写代码并在多个个人和客户项目中不断改进的成果。

准备好了吗?出发!🚀

什么是建筑?

我喜欢把架构想象成支撑一切的基础,它能随着代码库的增长而提供支持。

如果基础打得好,就更容易进行修改和添加新内容。

建筑设计运用设计模式来高效地解决问题。

必须选择最适合你试图解决的问题的设计模式

例如,电子商务应用程序和聊天应用程序的需求就截然不同。

作品

无论你想构建什么,你都可能会遇到一系列问题,你需要将它们分解成更小、更容易管理的问题。

你可以为每个问题创建基本构建模块,然后通过组合这些模块来构建你的应用程序。实际上:

组合是 Flutter 中广泛使用的基本原则,也是软件开发中更广泛使用的基本原则。

既然我们要构建 Flutter 应用,那么我们需要哪些类型的构建模块呢?

示例:登录页面

假设您正在构建一个用于电子邮件和密码登录的页面。

你需要一些输入字段和一个按钮,并且需要将这些输入内容组合在一起以创建一个表单。

但这种形式本身并没有什么实际作用。

您还需要与身份验证服务进行通信。这部分代码与您的用户界面代码截然不同

要实现此功能,您需要编写用户界面、输入验证和身份验证的代码:

UI、登录和 API 组件

优秀的建筑

如果上面的登录页面是由定义明确的构建模块(或组件)组成,我们可以将这些模块(或组件)组合在一起,那么它就具有良好的架构。

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

  • 添加新功能变得更加容易,因为你可以在已有的基础上进行构建。
  • 代码库会变得更容易理解,而且在阅读代码的过程中,你很可能会发现一些反复出现的模式和约定。
  • 组件职责明确功能适中。如果你的架构具有高度可组合性,这种情况自然而然就会发生。
  • 整类问题都消失了(稍后会详细介绍)。
  • 你可以拥有不同类型的组件,通过为不同的关注领域(UI、逻辑、服务)定义单独的应用程序层来实现。

建筑设计不太好😅

如果我们未能定义一个好的架构,我们就没有清晰的规范来构建我们的应用程序。

缺乏可组合组件会导致代码存在大量依赖关系。

这种代码很难理解。添加新功能会变得很麻烦,甚至不清楚新代码应该放在哪里。

其他一些潜在问题也比较常见:

  • 该应用程序有很多可变状态,因此很难知道哪些小部件会重建以及何时重建。
  • null由于某些变量会在多个小部件之间传递,因此尚不清楚何时可以或不可以传递这些变量。

所有这些问题都会显著减慢开发速度,并抵消 Flutter 中常见的生产力优势。

总之:好的建筑很重要。

应用层

下图展示了我的 Flutter 和 Firebase 应用架构:

应用层

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

我认为时刻思考这些问题是个好主意。当你编写新代码时,应该问问自己:这段代码应该放在哪里


例如:如果你正在为新功能编写一些 UI 代码,你很可能是在某个控件类中。也许你需要在按钮被按下时调用一些外部 Web 服务 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(),
  );
}
Enter fullscreen mode Exit fullscreen mode

从应用层的角度思考问题真的很有帮助。

归根结底,这就是单一职责原则:应用程序中的每个组件都应该只做一件事

UI代码和网络代码是两码事。它们不应该放在一起,而且它们位于完全不同的位置。

单向数据流

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

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

非常重要:位于特定应用层的组件并不知道其下层组件的存在

视图模型引用任何控件对象(也不导入任何 UI 代码)。相反:

小部件会订阅自身作为监听器,而视图模型会在某些内容发生变化时发布更新。

发布-订阅模式

这被称为发布/订阅模式,Flutter 中有多种实现方式。如果你的应用中使用了ChangeNotifierBLoC,那么你已经使用过这种模式了。


为了将所有内容连接起来,我们可以使用这个Provider包。
公平地说,熟悉所有不同类型的提供程序可能需要一些时间。但我们可以使用它们来强制执行带有不可变模型类的单向数据流。这有诸多好处,下文将对此进行讨论。

要了解有关 Provider 的更多信息以及如何在实践中使用它,您可以观看我在 YouTube 上的系列视频

作为参考,以下是入门项目中包含的应用程序的简化版组件树图

时间跟踪器小部件树

这应该能让你对 Provider 在此项目中的使用方式有一个大致的了解。

它用于MultiProvider将多个服务和值组合在一起。对于更高级的用例,您可以使用ProxyProvider

可变状态和不可变状态

这种架构的一个重要方面在于服务和视图模型之间的差异。具体来说:

  • 视图模型可以保存和修改状态。
  • 服务做不到。

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

服务可以转换从外部 Dart 包接收的数据,并通过特定领域的API 将其提供给应用程序的其余部分。

例如,在使用 Firestore 时,我们可以使用包装服务来进行序列化:

  • 数据输入(读取):此功能将 Firestore 文档中的键值对流转换为强类型不可变数据模型。
  • 数据输出(写入):此操作将数据模型转换回键值对,以便写入 Firestore。

另一方面,视图模型包含应用程序的业务逻辑,并且很可能保存可变状态。

这没问题,因为小部件可以收到状态更改的通知,并根据上面描述的发布/订阅模式自行重建。

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

基于流的架构

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

这是因为当某些内容发生变化时,Firebase 可以直接将更新推送给已订阅的客户端。

例如,当某些 Firestore文档集合更新时,小部件可以自行重建。

许多 Firebase API本质上都是基于流的。因此,使我们的组件具有响应式的最简单方法是使用StreamBuilder(或StreamProvider)。

是的,您可以使用ChangeNotifier或其他实现可观察对象/监听器的状态管理技术。

但是,如果您想将输入流“转换”为基于响应式模型的模型,则需要额外的“粘合”代码ChangeNotifier

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


总而言之,这种架构定义了具有单向数据流的独立应用层。数据通过从 Firebase 读取,并根据发布/订阅模式重建小部件。

理想的代码属性

正确使用这种架构可以生成以下类型的代码:

  • 清除
  • 可重复使用的
  • 可扩展
  • 可测试的
  • 表现者
  • 可维护的

让我们通过一些例子来逐一来看:

清除

假设我们要创建一个页面,用来显示职位列表。

以下是我在项目中实现此功能的方法(解释如下):

class JobsPage extends StatelessWidget {
  Future<void> _delete(BuildContext context, Job job) async {
    try {
      final database = Provider.of<FirestoreDatabase>(context, listen: false);
      // database call
      await database.deleteJob(job);
    } on PlatformException catch (e) {
      PlatformExceptionAlertDialog(
        title: 'Operation failed',
        exception: e,
      ).show(context);
    }
  }

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

  Widget _buildContents(BuildContext context) {
    final database = Provider.of<FirestoreDatabase>(context, listen: false);
    // Read jobsStream from Firestore & build UI when updated
    return StreamBuilder<List<Job>>(
      stream: database.jobsStream(),
      builder: (context, snapshot) {
        // Generic widget for showing a list of items
        return ListItemsBuilder<Job>(
          snapshot: snapshot,
          itemBuilder: (context, job) => Dismissible(
            key: Key('job-${job.id}'),
            background: Container(color: Colors.red),
            direction: DismissDirection.endToStart,
            // database call
            onDismissed: (direction) => _delete(context, job),
            child: JobListTile(
              job: job,
              // navigation call
              onTap: () => JobEntriesPage.show(context, job),
            ),
          ),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

build()方法返回一个带有AppBar.

_buildContents()方法返回一个StreamBuilder,用于从 Firestore 中以流的形式读取一些数据。

在它内部,我们可以将快照传递给一个ListItemsBuilder通用小部件(我创建的),用于显示项目列表。

这个小部件仅用 50 行代码就显示了一个项目列表,并处理三个不同的回调:

  • 创造一份新工作
  • 删除现有职位
  • 跳转至职位详情页面

这些操作都只需要一行代码,因为它将实际工作委托给了外部类。

因此,这段代码清晰易读。

如果把数据库代码、序列化、路由和用户界面都放在一个类里,那么理解所有内容就会困难得多。
而且,我们的代码也因此更难重用。

可重复使用的

以下是另一个页面的代码,该页面显示了每日所有工作及其薪资的详细情况:

class EntriesPage extends StatelessWidget {
  static Widget create(BuildContext context) {
    final database = Provider.of<FirestoreDatabase>(context, listen: false);
    return Provider<EntriesViewModel>(
      create: (_) => EntriesViewModel(database: database),
      child: EntriesPage(),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(Strings.entries),
        elevation: 2.0,
      ),
      body: _buildContents(context),
    );
  }

  Widget _buildContents(BuildContext context) {
    final vm = Provider.of<EntriesViewModel>(context);
    return StreamBuilder<List<EntriesListTileModel>>(
      stream: vm.entriesTileModelStream,
      builder: (context, snapshot) {
        return ListItemsBuilder<EntriesListTileModel>(
          snapshot: snapshot,
          itemBuilder: (context, model) => EntriesListTile(model: model),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

请注意,我重复使用了上一页中的一些相同组件。

我再次使用我的ListItemsBuilder小部件,这次使用不同的模型类型(EntriesListTileModel)。

这一次,StreamBuilder的输入流来自一个EntriesViewModel

但是数据流入用户界面的方式与以前相同。

总之,构建可在多个地方使用的可重用组件是值得的。

可扩展

我们来谈谈可扩展的代码。如果您之前实现过 Firestore 的 CRUD 操作,那么您可能熟悉这种语法:

final ref = Firestore.instance.collection('users').document(uid).collection('jobs');
final snapshots = ref.snapshots();
// TODO: Manipulate snapshots stream and read documents' data
Enter fullscreen mode Exit fullscreen mode

这可能会变得非常难以管理,特别是当你的文档有很多键值对时。

你不希望在你的组件中,甚至在你的视图模型中出现这样的代码。

相反,您可以使用一些服务类定义特定领域的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';
}
Enter fullscreen mode Exit fullscreen mode

除此之外,我还有一个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}) { ... }
}
Enter fullscreen mode Exit fullscreen mode

此类通过一个使用强类型模型类的友好 API,向应用程序的其他部分公开了各种 CRUD 操作。

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

  • 添加一些额外的路径FirestorePath
  • 添加相应的Future基于StreamAPI 的功能,以FirestoreDatabase支持各种操作
  • 根据需要创建强类型模型类。这些类包括我需要使用的新型文档的序列化代码。

所有这些代码都保留在services我的项目中的一个文件夹内。

小部件可以使用新的数据库 API Provider

final database = Provider.of<FirestoreDatabase>(context, listen: false);
// TODO: call database APIs as needed
Enter fullscreen mode Exit fullscreen mode

上面的代码易于扩展。我可以按照可重复的步骤添加新功能,并确保代码的一致性。这对于团队合作来说非常有价值。

可测试的

这种架构可以生成可测试的代码。

我的单元测试确实如此,因为我的类很小,依赖项也很少。

但这同样适用于组件测试,因为所有组件都具有对其所需依赖项的限定范围访问权限。

多亏了这个Provider软件包,可以轻松地将服务类替换为模拟对象,并针对它们运行测试:

void main() {
  MockAuthService mockAuthService;
  MockDatabase mockDatabase;

  setUp(() {
    mockAuthService = MockAuthService();
    mockDatabase = MockDatabase();
  });

  Future<void> pumpAuthWidget(
      WidgetTester tester,
      {@required
          Widget Function(BuildContext, AsyncSnapshot<User>) builder}) async {
    await tester.pumpWidget(
      Provider<FirebaseAuthService>(
        create: (_) => mockAuthService,
        child: AuthWidgetBuilder(
          databaseBuilder: (_, uid) => mockDatabase,
          builder: builder,
        ),
      ),
    );
    await tester.pump(Duration.zero);
  }

  // TODO: Widget tests here
}
Enter fullscreen mode Exit fullscreen mode

这使得小部件测试快速且可预测,因为它们不调用任何网络代码。

只要设置得当,甚至可以使整个应用程序可测试,只要我们能够将小部件树根部的任何服务替换为模拟对象即可。

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

表演者

这种架构的一大优点是最大限度地减少了组件重建

这是通过使用/来实现的Provider视情况而定。StreamBuilderFutureBuilder

如何?

一旦我们从外部服务异步读取了一些状态或数据,我们就可以将其同步提供给所有组件。

例如,此应用要求用户使用 Firebase 登录。此代码根据用户的身份验证状态返回 `<username> HomePage` 或 `<username>` :SignInPage

@override
Widget build(BuildContext context) {
  final authService =
      Provider.of<FirebaseAuthService>(context, listen: false);
  return StreamBuilder<User>(
    // asynchronous data in
    stream: authService.onAuthStateChanged,
    builder: (BuildContext context, AsyncSnapshot<User> snapshot) {
      final User user = snapshot.data;
      if (user != null) {
        // make data available synchronously to all descendants
        return MultiProvider(
          providers: [
            Provider<User>.value(value: user),
            Provider<FirestoreDatabase>(
              create: (_) => FirestoreDatabase(user.uid),
            ),
          ],
          // HomePage and all descendant widgets can get the current user with `Provider.of<User>(context)`,
          // rather than `await FirebaseAuth.instance.currentUser()`
          child: HomePage(),
        );
      }
      return SignInPage();
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

User一旦从快照中提取出一个非空对象,就可以将其同步提供给所有后代对象:

Widget build(BuildContext context) {
  final user = Provider.of<User>(context);
  // show UI
}
Enter fullscreen mode Exit fullscreen mode

上面的代码比这个好得多:

Widget build(BuildContext context) {
  // so much boilerplate code 😅
  return FutureBuilder<FirebaseUser>(
    future: FirebaseAuth.instance.currentUser(),
    builder: (context, snapshot) {
      if (snapshot.data != null) {
        // show UI
      }
      return CircularProgressIndicator();
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

这里我们创建了一个组件,只是为了异步FutureBuilder获取当前用户。但这其实没有必要,因为我们已经在某个组件中获取到了。而且我们还需要在构建器中编写更多样板代码,并显示加载指示器直到获取到结果。Future

相反,Provider它可以为我们解决这个问题,我们应该利用这一点。

结论:我们可以利用它Provider来最大限度地减少组件重建,避免对 Firebase 进行任何不必要的 API 调用,并减少样板代码。

可维护的

这种架构可以编写出易于维护的代码,以上示例可以作为证明。

可维护的代码将为您(和您的团队)节省数天、数周甚至数月的额外精力。

除此之外,你的代码会更容易编写,晚上也能睡得更安稳。😴

结论

我希望这篇概述能启发您投资优秀的建筑。

如果你要启动一个新项目,请考虑根据你的需求提前规划好架构

如果你的代码库不符合良好的软件设计原则,不妨尝试小规模迭代地进行重构。不必一次性解决所有问题,但循序渐进地朝着理想的架构迈进会很有帮助。

如果您正在使用 Flutter 和 Firebase 或任何其他类型的流式 API 构建项目,请务必查看我在 GitHub 上的入门项目。这是一个完整的计时应用程序:

时间跟踪应用程序的屏幕截图

README 文件是熟悉我们所涵盖的所有概念的好地方。

之后,您可以查看源代码,运行项目(注意:需要 Firebase 配置),并深入了解各个部分是如何协同工作的。

如果你想更深入地学习所有这些原理,并从头开始构建时间跟踪应用程序,那么我的Flutter & Firebase 课程就是最好的选择。

课程内容超过 20 小时,涵盖了您需要了解的所有内容,从 Dart 语言的基础知识到更高级的主题。


非常感谢您阅读本教程。如果您最终采用了这种架构,我非常希望听到您的反馈。

祝您编程愉快!

文章来源:https://dev.to/biz84/starter-architecture-for-flutter-firebase-apps-50bc