小工具-同步-Bloc-服务|一个用于Flutter应用程序的实用架构

349 阅读14分钟

简介

状态管理是目前Flutter的一个热门话题。

在过去的一年中,各种状态管理技术被提出。

Flutter团队和社区还没有确定一个单一的 "通用 "解决方案。

这是有道理的,因为不同的应用程序有不同的要求。而选择最合适的技术取决于我们要建立的东西。

说实话,有些状态管理技术已经被证明是非常流行的。

  • 范围模型(Scoped Model)以其简单性而闻名。
  • BLoCs也被广泛使用,它们与Streams和RxDart一起很好地适用于更复杂的应用程序。
  • 最近在谷歌I/O大会上,Flutter团队向我们展示了如何使用 Provider包和 ChangeNotifier来传播小部件之间的状态变化。

有多种选择可以是一件好事。

但它也可能让人困惑。选择一种技术,随着我们的应用程序的增长,它可以很好地工作和扩展是很重要的。

更重要的是,在早期做出正确的选择可以为我们节省大量的时间和精力

我对状态管理和应用程序架构的看法

在过去的一年里,我已经建立了很多大大小小的Flutter应用程序。

在这段时间里,我遇到并解决了很多问题。

而且我了解到,在状态管理方面没有银弹。

然而,经过一次又一次的构建和拆解,我已经微调了一种技术,在我所有的项目中都能很好地工作。

所以在这篇文章中,我介绍了一种新的架构模式,它是由我定义的。

  • 从现有的模式中借鉴了很多想法
  • 调整它们以适应现实世界中Flutter应用程序的需要

在我们看到这个模式是什么样子的之前,让我定义一些目标。

这个模式应该。

  • 一旦基本构件清楚了,就应该容易理解
  • 在添加新功能时易于复制
  • 建立在干净的架构原则之上
  • 在编写反应式Flutter应用程序时能很好地工作
  • 只需要很少或没有模板代码
  • 导致可测试的代码
  • 导致可移植的代码
  • 倾向于小型、可组合的部件和类
  • 易于与异步 API(Futures 和 Streams)集成
  • 随着应用程序的规模和复杂性的增加,可以很好地扩展

在Flutter现有的状态管理技术中,这种模式在BLoCs上建立得最多,而且与RxVMS架构相当相似。

不多说了,我很高兴地介绍。

小工具-同步-BLoC-服务模式

简称。WABS(这很酷,因为它包含了我名字的首字母 :D)

这种架构模式有四种变体。

小工具-Bloc-服务

小工具-服务

小工具-Bloc

只有小工具

注意:除了Widget项目外,BLoC和Service项目可选的

换句话说:你可以根据具体情况来使用或省略它们

现在,让我们通过一个更详细的图来探索完整的实现。

首先,这张图定义了三个应用层

  • UI层:这始终是必要的,因为它是我们的小部件所在的地方。
  • 数据层(可选):这是我们添加逻辑和修改状态的地方。
  • 服务层(可选):这是我们用来与外部服务通信的地方。

接下来,让我们为每个层能(和不能)做什么定义一些规则。

游戏规则

UI层

这就是我们放置小部件的地方。

小部件可以是无状态的,也可以是有状态的,但它们不应该包括任何显式的状态管理逻辑。

显式状态管理的一个例子是Flutter计数器的例子,当按下增量按钮时,我们用setState() 来增加计数器。

隐式状态管理的例子是一个StatefulWidget ,它包含一个由TextEditingController 管理的TextField 。在这种情况下,我们需要一个StatefulWidget ,因为TextEditingController 会带来副作用(我是 通过艰难的方式发现的),但我们没有显式地管理任何状态。


UI层中的小部件可以自由地调用由块或服务定义的同步异步方法,并且可以通过StreamBuilder 来订阅流。

注意上图是如何将一个小部件连接到BLoC的输入和输出的。但我们可以使用这种模式将一个部件连接到输入端,而将另一个部件连接到输出端。

换句话说,我们可以实现一个生产者→消费者的数据流。


WABS模式鼓励我们将任何状态管理逻辑转移到数据层。所以我们来看看。

数据层

在这一层,我们可以定义本地全局应用状态,以及修改状态的代码。

这是用业务逻辑组件(BLoCs)完成的,这是DartConf 2018期间首次引入的模式。

BLoC的构思是为了将业务逻辑与UI层分开,并增加跨平台的代码重用。

当使用BLoC模式时,小部件可以。

  • 事件派发到一个汇中
  • 通过一个来通知状态的更新

根据最初的定义,我们只能通过汇和流与BLoCs通信。


虽然我喜欢这个定义,但我发现它在一些用例中限制性太强。所以在WABS中,我使用了一种BLoC的变种,叫做Async BLoC

就像BLoCs一样,我们有可以被订阅的输出流。

然而,BLoC的输入可以包括一个同步水槽、一个异步方法或两者。

换句话说,我们从这个。

到这个。

非同步方法可以。

  • 将零、一个或多个值添加到输入汇中。
  • 返回一个带有结果的Future<T> 。调用代码可以await ,以获取结果并做相应的事情。
  • 抛出一个异常。调用代码可以通过try/catch ,并在需要时显示一个警告。

稍后,我们将看到一个完整的例子,说明这在实践中是多么有用。


关于BLoCs的更多信息

一个异步BLoC可以定义一个StreamController/Stream 对,如果使用RxDart,则可以定义等同的BehaviorSubject/Observable

如果我们愿意,我们甚至可以进行高级流操作,比如用combineLatest 来组合流。为了明确起见。

  • 如果需要以某种方式组合,我建议在一个BLoC中拥有多个流。

  • 我不鼓励在一个BLoC中使用多个StreamController。相反,我更喜欢将代码分成两个或更多的BLoC类,以便更好地分离关注点。

我们在数据层/BLoCs中应该/不应该做的事情

  • BLoCs应该只包含纯Dart代码。没有UI代码,没有导入Flutter文件,也没有在BLoCs中使用 BuildContext在BLoCs中使用。
  • BLoCs不应该直接调用第三方代码。这是服务类的工作。
  • Widgets和BLoCs之间的接口与BLoCs和服务之间的接口相同。也就是说,BloCs可以通过sync/async方法直接与服务类通信,并通过流通知更新。

服务层

服务类具有与BLoCs相同的输入/输出接口。

然而,服务和BLoCs之间有一个基本区别。

  • BLoCs可以持有和修改状态。
  • 服务则不能。

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

它们可以修改和转换从第三方库接收的数据。

例子。Firestore服务

  • 我们可以实现一个FirestoreDatabase 服务,作为Firestore的特定领域API-wrapper。
  • 数据输入(读取)。将Firestore文档中的键值对流转换为强类型的不可更改的数据模型。
  • 数据输出(写)。它将数据模型转换为键值对,以便写入Firestore。

在这种情况下,服务类执行简单的数据操作。与BLoCs不同,它不持有任何状态。

*关于术语的说明。其他文章在提到与第三方库对话的类时使用了Repository这个术语。甚至连Repository模式的定义也随着时间的推移而变化(更多信息请看这篇文章)。在这篇文章中,我并没有明确区分Service和Repository。

把东西放在一起:提供者包

一旦我们定义了我们的BLoCs和服务,我们就需要将它们提供给我们的小部件。

一段时间以来,我一直在使用 provider包。这是一个用于Flutter的完全依赖注入系统,基于InheritedWidget

我非常喜欢它的简单性。下面是如何使用它来添加一个认证服务。

return Provider<AuthService>(
  builder: (_) => FirebaseAuthService(), // FirebaseAuthService implements AuthService
  child: MaterialApp(...),
);

这就是我们如何使用它来创建一个BLoC。

return Provider<SignInBloc>(
  builder: (_) => SignInBloc(auth: auth),
  dispose: (_, bloc) => bloc.dispose(),
  child: Consumer<SignInBloc>(
    builder: (_, bloc, __) => SignInPage(bloc: bloc),
  ),
);

注意Provider widget如何接受一个可选的dispose callback。我们用它来处理BLoCs并关闭相应的StreamController

Provider 这给了我们一个简单而灵活的API,我们可以用它来添加任何我们想要的东西到我们的widget树。它对BLoCs、服务、价值等都有很好的作用。

我将在我即将发表的一些文章中更详细地谈论如何使用Provider 。现在,我强烈推荐谷歌I/O的这个演讲。


一个真实世界的例子。签到页面

现在我们已经看到了WABS在概念上是如何工作的,让我们用它来构建一个带有Firebase认证的登录流程。

下面是我的《使用Flutter和Firebase的参考认证流程》中的一个交互样本。

一些观察。

  • 当签到被触发时,我们禁用所有的按钮,并显示一个CircularProgressIndicator 。我们将loading 状态设置为true 来做这个。
  • 当签到成功或失败时,我们重新启用所有按钮并恢复标题Text 。我们设置loading=false 来做这件事。
  • 当签到失败时,我们呈现一个警告对话框。

这里是用于驱动这个逻辑的简化版的SignInBloc

import 'dart:async';
import 'package:firebase_auth_demo_flutter/services/auth_service.dart';
import 'package:meta/meta.dart';

class SignInBloc {
  SignInBloc({@required this.auth});
  final AuthService auth;

  final StreamController<bool> _isLoadingController = StreamController<bool>();
  Stream<bool> get isLoadingStream => _isLoadingController.stream;

  void _setIsLoading(bool isLoading) => _isLoadingController.add(isLoading);

  Future<void> signInWithGoogle() async {
    try {
      _setIsLoading(true);
      return await auth.signInWithGoogle();
    } catch (e) {
      rethrow;
    } finally {
      _setIsLoading(false);
    }
  }

  void dispose() => _isLoadingController.close();
}

注意这个BLoC的公共API如何只暴露了一个Stream 和一个Future

Stream<bool> get isLoadingStream;
Future<void> signInWithGoogle();

这与我们对Async BLoC的定义一致。

所有的魔法都发生在signInWithGoogle() 方法中。因此,让我们用注释再次回顾一下这个问题。

Future<void> signInWithGoogle() async {
  try {
    // first, add loading = true to the stream sink
    _setIsLoading(true);
    // then sign-in and await for the result
    return await auth.signInWithGoogle();
  } catch (e) {
    // if sign in failed, rethrow the exception to the calling code
    rethrow;
  } finally {
    // on success or failure, add loading = false to the stream sink
    _setIsLoading(false);
  }
}

就像普通的BLoC一样,这个方法向一个水槽添加值。

但除此之外,它还可以异步地返回一个值,或者抛出一个异常

这意味着我们可以在我们的SignInPage ,写出这样的代码。

// called by the `onPressed` callback of our button
Future<void> _signInWithGoogle(BuildContext context) async {
  try {
    await bloc.signInWithGoogle();
    // handle success
  } on PlatformException catch (e) {
    // handle error (show alert)
  }
}

这段代码看起来很简单。它应该是这样的,因为我们在这里只需要async/awaittry/catch

然而,这在BLoC的 "严格 "版本中是不可能的,它只使用一个水槽和一个流。作为参考,在Redux中实现这样的东西...呃...不好玩

Async-LoC看起来是对BLoC的一个小改进,但它带来了所有的不同


关于处理异常的说明

顺便说一下,另一种可能的处理异常的方法是在流中添加一个错误对象,就像这样。

Future<void> signInWithGoogle() async {
  try {
    // first, add loading = true to the stream sink
    _setIsLoading(true);
    // then sign-in and await for the result
    return await auth.signInWithGoogle();
  } catch (e) {
    // add error to the stream
    _isLoadingController.addError(e);
  } finally {
    // on success or failure, add loading = false to the stream sink
    _setIsLoading(false);
  }
}

然后,在widget类中我们可以写这样的代码。

class SignInPage extends StatelessWidget {
  SignInPage({@required this.bloc});
  final SignInBloc bloc;
  
  // called by the `onPressed` callback of our button
  Future<void> _signInWithGoogle(BuildContext context) async {
    await bloc.signInWithGoogle();
  }
   
  void build(BuildContext context) {
    return StreamBuilder(
      stream: isLoadingStream,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          // show error
          showDialog(...);
        }
        // build UI based on snapshot
      }
    )
  }
}

然而,这是不可取的,原因有二。

  • 它在StreamBuilder 的构建器中显示了一个对话框。这不是很好,因为构建器只应该返回一个widget,而不是执行任何指令性代码。
  • 这段代码缺乏明确性。我们显示错误的地方与我们登录的地方完全不同。

所以,不要这样做,如上所示,使用try/catch 。 😉

继续前进...

我们可以使用WABS来创建一个 "异步服务 "吗?

当然可以。正如我之前所说。

  • BLoCs可以保持和修改状态。
  • 服务则不能。

然而,它们面向公众的API也遵守同样的规则。

下面是一个数据库API的服务类的例子。

abstract class Database {
  // CRUD operations for Job
  Future<void> setJob(Job job);
  Future<void> deleteJob(Job job);
  Stream<List<Job>> jobsStream();

  // CRUD operations for Entry
  Future<void> setEntry(Entry entry);
  Future<void> deleteEntry(Entry entry);
  Stream<List<Entry>> entriesStream({Job job});
}

我们可以使用这个API来向/从Cloud Firestore写入和读取数据。

调用代码可以定义这个方法,向数据库写入一个新的Job

Future<void> _submit(Job job) async {
  try {
    await database.setJob(job);
    // handle success
  } on PlatformException catch (e) {
    // handle error (show alert) 
  }
}

同样的模式,非常简单的错误处理。

与RxVMS的比较

在这篇文章中,我介绍了Widget-Async-LoC-Service,作为Flutter中现有架构模式的一种改编。

WABS与Thomas Burkhart的RxVMS模式最为相似。

各个层之间甚至有密切的匹配。

两者之间的主要区别在于。

  • WABS使用提供者包,而RxVMS使用GetIt服务定位器
  • WABS使用简单的async 方法来处理UI事件,而RxVMS使用RxCommand

RxCommand是一个强大的抽象,用于处理UI事件和更新。它消除了用BLoCs创建StreamController/Stream 对所需的模板代码。

然而,它确实有一个更大的学习曲线。对于我的用例来说,Async-Bloc可以完成工作,而且更简单,尽管有一点额外的模板。

我还喜欢WABS不需要任何外部库就可以实现(除了Provider包)。

最终选择哪一个取决于你的用例,也取决于个人的偏好和品味。

我应该在我的应用程序中使用BLoCs吗?

BLoCs的学习曲线很陡峭。要理解它们,你还需要熟悉流和StreamBuilder

在使用流的过程中,需要考虑各种因素。

  • 流的连接状态是什么?(none,waiting,active,done)
  • 流是单个还是多个订阅?
  • StreamController 和 总是需要被处理的StreamSubscription
  • 当Flutter重建widget树时,处理嵌套的StreamBuilder,会导致棘手的调试问题。

所有这些都给我们的代码增加了更多的开销。

当更新本地应用程序的状态时(例如,将状态从一个widget传播到另一个),有比BLoC更简单的替代方案。我计划在后续文章中写到这一点。

在任何情况下,我发现在用Firestore构建实时应用程序时,BLoC非常有效,因为数据通过流从后端流入应用程序。

在这种情况下,结合流或用RxDart进行转换是很常见的。BLoCs是一个很好的地方,可以做到这一点。

总结

这篇文章是对WABS的深入介绍,这种架构模式我已经在多个项目中使用了一段时间。

说实话,随着时间的推移,我一直在完善它,在写这篇文章之前,我甚至都没有给它起个名字。

正如我之前所说,架构模式只是完成我们工作的工具。我的建议是:选择对你和你的项目更有意义的工具。

如果你在你的项目中使用WABS,让我知道它对你的作用。

编码愉快!

源代码

本文的示例代码取自我的《使用Flutter和Firebase的参考认证流程》。

反过来,这个项目补充了我的Flutter & Firebase课程中的所有深入材料。

参考资料