Flutter 和 Firebase 应用的入门架构
在本教程中,我将详细介绍我过去两年不断优化完善的、可用于生产环境的架构。您可以将随附的入门项目用作Flutter 和 Firebase 应用的基础。
动机
Flutter 和 Firebase 是快速将应用程序推向市场的绝佳组合。
如果没有合理的架构,代码库很快就会变得难以测试、维护和理解。这会严重影响开发速度,导致产品缺陷百出,开发人员沮丧,用户不满。
我已经在各种客户项目中亲眼目睹了这一点,由于缺乏正式的架构,导致额外工作数天、数周甚至数月。
“架构”很难吗?在瞬息万变的前端开发领域,如何找到“正确”或“合适的”架构?
每个应用程序都有不同的需求,那么是否存在“正确”的架构呢?
虽然我不敢说自己拥有万全之策,但我已经改进并完善了一个可用于生产的架构,并在多个 Flutter 和 Firebase 应用程序中使用过。
我们将对此进行探讨,并了解它在入门项目中包含的时间跟踪应用程序中的实际应用:
所以,拿杯饮料,舒舒服服地坐下。让我们开始吧!
概述
我们将从概述开始:
- 什么是建筑?为什么我们需要建筑?
- 构图在优秀建筑中的重要性。
- 拥有良好的架构会带来诸多好处。
- 没有好的架构会导致哪些糟糕的事情?
接下来,我们将重点讨论 Flutter 和 Firebase 应用的优秀架构,并探讨以下内容:
- 应用层
- 单向数据流
- 可变状态和不可变状态
- 基于流的架构
我将解释一些重要的原则,以及我们希望代码具备的理想特性。
我们将通过一些实际例子来了解这一切是如何联系起来的。
你在这里读到的内容是我两年多来学习概念、编写代码并在多个个人和客户项目中不断改进的成果。
准备好了吗?出发!🚀
什么是建筑?
我喜欢把架构想象成支撑一切的基础,它能随着代码库的增长而提供支持。
如果基础打得好,就更容易进行修改和添加新内容。
建筑设计运用设计模式来高效地解决问题。
你必须选择最适合你试图解决的问题的设计模式。
例如,电子商务应用程序和聊天应用程序的需求就截然不同。
作品
无论你想构建什么,你都可能会遇到一系列问题,你需要将它们分解成更小、更容易管理的问题。
你可以为每个问题创建基本构建模块,然后通过组合这些模块来构建你的应用程序。实际上:
组合是 Flutter 中广泛使用的基本原则,也是软件开发中更广泛使用的基本原则。
既然我们要构建 Flutter 应用,那么我们需要哪些类型的构建模块呢?
示例:登录页面
假设您正在构建一个用于电子邮件和密码登录的页面。
你需要一些输入字段和一个按钮,并且需要将这些输入内容组合在一起以创建一个表单。
但这种形式本身并没有什么实际作用。
您还需要与身份验证服务进行通信。这部分代码与您的用户界面代码截然不同。
要实现此功能,您需要编写用户界面、输入验证和身份验证的代码:
优秀的建筑
如果上面的登录页面是由定义明确的构建模块(或组件)组成,我们可以将这些模块(或组件)组合在一起,那么它就具有良好的架构。
我们可以采用同样的方法,并将其扩展到整个应用程序。这样做有一些非常明显的优势:
- 添加新功能变得更加容易,因为你可以在已有的基础上进行构建。
- 代码库会变得更容易理解,而且在阅读代码的过程中,你很可能会发现一些反复出现的模式和约定。
- 组件职责明确,功能适中。如果你的架构具有高度可组合性,这种情况自然而然就会发生。
- 整类问题都消失了(稍后会详细介绍)。
- 你可以拥有不同类型的组件,通过为不同的关注领域(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(),
);
}
从应用层的角度思考问题真的很有帮助。
归根结底,这就是单一职责原则:应用程序中的每个组件都应该只做一件事。
UI代码和网络代码是两码事。它们不应该放在一起,而且它们位于完全不同的位置。
单向数据流
在上图中,数据从外部世界流入服务、视图模型,一直流向小部件。
调用流程则相反。组件可以调用视图模型和服务内部的方法。反过来,这些方法又可以调用外部 Dart 包中的 API。
非常重要:位于特定应用层的组件并不知道其下层组件的存在。
视图模型不引用任何控件对象(也不导入任何 UI 代码)。相反:
小部件会订阅自身作为监听器,而视图模型会在某些内容发生变化时发布更新。
这被称为发布/订阅模式,Flutter 中有多种实现方式。如果你的应用中使用了ChangeNotifier或BLoC,那么你已经使用过这种模式了。
为了将所有内容连接起来,我们可以使用这个Provider包。
公平地说,熟悉所有不同类型的提供程序可能需要一些时间。但我们可以使用它们来强制执行带有不可变模型类的单向数据流。这有诸多好处,下文将对此进行讨论。
要了解有关 Provider 的更多信息以及如何在实践中使用它,您可以观看我在 YouTube 上的系列视频。
作为参考,以下是入门项目中包含的应用程序的简化版组件树图:
这应该能让你对 Provider 在此项目中的使用方式有一个大致的了解。
它用于MultiProvider将多个服务和值组合在一起。对于更高级的用例,您可以使用ProxyProvider。
可变状态和不可变状态
这种架构的一个重要方面在于服务和视图模型之间的差异。具体来说:
- 视图模型可以保存和修改状态。
- 服务做不到。
换句话说,我们可以把服务看作是纯粹的功能性组件。
服务可以转换从外部 Dart 包接收的数据,并通过特定领域的API 将其提供给应用程序的其余部分。
例如,在使用 Firestore 时,我们可以使用包装服务来进行序列化:
- 数据输入(读取):此功能将 Firestore 文档中的键值对流转换为强类型不可变数据模型。
- 数据输出(写入):此操作将数据模型转换回键值对,以便写入 Firestore。
另一方面,视图模型包含应用程序的业务逻辑,并且很可能保存可变状态。
这没问题,因为小部件可以收到状态更改的通知,并根据上面描述的发布/订阅模式自行重建。
通过将单向数据流与发布/订阅模式相结合,我们可以最大限度地减少可变应用程序状态,以及随之而来的问题。
基于流的架构
与传统的 REST API 不同,使用 Firebase 我们可以构建实时应用程序。
这是因为当某些内容发生变化时,Firebase 可以直接将更新推送给已订阅的客户端。
例如,当某些 Firestore文档或集合更新时,小部件可以自行重建。
许多 Firebase API本质上都是基于流的。因此,使我们的组件具有响应式的最简单方法是使用StreamBuilder(或StreamProvider)。
是的,您可以使用ChangeNotifier或其他实现可观察对象/监听器的状态管理技术。
但是,如果您想将输入流“转换”为基于响应式模型的模型,则需要额外的“粘合”代码ChangeNotifier。
注意:流不仅是 Firebase 的默认变更推送方式,也是许多其他服务的默认方式。例如,您可以使用location
onLocationChanged()包的流来获取位置更新。无论您是使用 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),
),
),
);
},
);
}
}
该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),
);
},
);
}
}
请注意,我重复使用了上一页中的一些相同组件。
我再次使用我的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
这可能会变得非常难以管理,特别是当你的文档有很多键值对时。
你不希望在你的组件中,甚至在你的视图模型中出现这样的代码。
相反,您可以使用一些服务类定义特定领域的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}) { ... }
}
此类通过一个使用强类型模型类的友好 API,向应用程序的其他部分公开了各种 CRUD 操作。
通过这种设置,在 Firestore 中添加新的文档或集合类型就变成了一个可重复的过程:
- 添加一些额外的路径
FirestorePath - 添加相应的
Future基于StreamAPI 的功能,以FirestoreDatabase支持各种操作 - 根据需要创建强类型模型类。这些类包括我需要使用的新型文档的序列化代码。
所有这些代码都保留在services我的项目中的一个文件夹内。
小部件可以使用新的数据库 API Provider:
final database = Provider.of<FirestoreDatabase>(context, listen: false);
// TODO: call database APIs as needed
上面的代码易于扩展。我可以按照可重复的步骤添加新功能,并确保代码的一致性。这对于团队合作来说非常有价值。
可测试的
这种架构可以生成可测试的代码。
我的单元测试确实如此,因为我的类很小,依赖项也很少。
但这同样适用于组件测试,因为所有组件都具有对其所需依赖项的限定范围访问权限。
多亏了这个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
}
这使得小部件测试快速且可预测,因为它们不调用任何网络代码。
只要设置得当,甚至可以使整个应用程序可测试,只要我们能够将小部件树根部的任何服务替换为模拟对象即可。
这在运行集成测试时尤其有用,集成测试可以用来测试应用程序中的整个用户流程。
表演者
这种架构的一大优点是最大限度地减少了组件重建。
这是通过使用/来实现的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();
},
);
}
User一旦从快照中提取出一个非空对象,就可以将其同步提供给所有后代对象:
Widget build(BuildContext context) {
final user = Provider.of<User>(context);
// show UI
}
上面的代码比这个好得多:
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();
},
);
}
这里我们创建了一个组件,只是为了异步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





