Flutter 移动端架构实践:Widget-Async-Bloc-Service

原文链接:https://medium.com/coding-with-flutter/widget-async-bloc-service-a-practical-architecture-for-flutter-apps-250a28f9251b

作者:Andrea Bizzotto

难度评级:中高级,本文观点仅代表作者本人。

概述

如今,状态管理 是Flutter的热门话题。

在过去的一年中,各种不同的状态管理技术被提出,但截至目前,Flutter的团队和相关社区还没有得出单一的 首选解决方案。

这可以理解,因为不同的app有着不同的业务需求,选择最合适的技术取决于我们正在尝试开发什么样的功能。

事实上,一些状态管理的技术被普遍使用:

Scoped Model以其简单而著称

BLoC也被广泛使用,借助于Streams和RxDart,它适用于更复杂的应用程序

在最近的Google I/O大会上,Flutter团队向我们展示了如何使用Provider包和ChangeNotifier,用于在组件之间传递状态的更改。

有多种选择终归是件好事,但同时也可能会导致困惑,因此,选择一种能够随着app的迭代依然能良好地运行、且具有优秀拓展性的技术非常重要。

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

我对状态管理和app架构的看法

过去的一年中,我构建了若干大大小小的Flutter app,期间我遇到并解决了许多问题,这让我明白了状态管理没有银弹。

然而,在构建完成并将它们一次次的重构之后,我调整出了一种在我所有项目中都能够运行完好的开发体系,因此,在本文中,我将介绍一种我定义的新的架构模式:

从现有的开发模式中借鉴了很多思想;

调整它们以满足实际开发Flutter app的需求。

在揭晓其真面目之前,我先来定义一些目标,这种模式应该:

1.只要基本模块清晰,代码就会更 简单易懂

2.能够 依葫芦画瓢 轻易追加新的功能

3.建立在 Clean 架构的原则之上

4.编写 响应式 的 Flutter app时,该架构也能胜任

5.需要很少甚至没有样板代码

6.保证代码的可测试性

7.保证代码的可移植性

8.支持小型、可组合的小部件和类

9.与异步API轻松集成(Futures和Streams)

10.适用于体量和复杂度逐步增长的应用程序。

在Flutter现有的状态管理技术中,该模式在很大程度上依赖于 BLoCs ,并且非常类似于 RxVMS 架构。

闲言少叙,接下来我很荣幸地介绍:

Widget-Async-BLoC-Service 模式

简称:WABS (这很酷,会因为它包含我的缩写 😄)。

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

  1. Widget-Bloc-Service

  2. Widget-Service

  3. Widget-Bloc

  4. Widget only

请注意:除了Widget项外,BLoC和Service项 都是可选的。

换句话说:您可以根据具体情况适当地 使用 或 省略 它们。

现在,让我们通过更详细的图表探究完整的实现:

首先,该图表定义了应用三个的层级:

UI层 :当然不可或缺,因为它代表着控件所在的位置

数据层(可选):这是我们添加逻辑和修改状态的地方

服务层(可选):这是我们与外部服务进行通信的地方

接下来,让我们为每个层级定义一些可做和不可做的规则。

UI层

这是我们添置控件的地方。

控件可以是无状态或有状态的,但它们都不应包含任何 显式 状态管理的逻辑。

显式 状态管理的示例是 Flutter 计数器,当增量按钮被按下时,程序通过 setState() 对计数器进行值的递增。

隐式 状态管理的示例是 StatefulWidget,它包含由 TextEditingController 管理的 TextField。 这种情况下,我们需要StatefulWidget,因为TextEditingController引入了副作用——这样的好处是我们没有明确地管理任何状态。

UI层的控件可以自由调用由BLoC或Service定义的 同步 或 异步 方法,并可以通过StreamBuilder对流进行订阅。

请注意上图是如何将单个控件连接到BLoC的输入与输出,我们也可以使用这种模式将一个控件连接到输入,然后将另外一个控件连接到输出:

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

WABS 模式鼓励我们将所有状态管理的逻辑都移动到数据层,我们马上将了解它。

数据层

在数据层中,我们可以定义 局部 或 全局 应用程序的状态,以及修改它的代码。

这是通过业务逻辑组件(BLoCs)完成的,这是在2018 DartConf时首次引入的模式。

理想化的BLoC是 将业务逻辑与UI层分离 ,并能够跨多个平台保证代码的高度可复用性。

在BLoC模式下,控件能够:

将事件分发给接收器;

通过流通知状态的更新。

根据最初的定义,我们只能通过 接收器 和 流 与BLoC进行通信。

虽然我喜欢这个定义,但我发现它在许多场景下限制性太强。 因此,在WABS中,我使用了一种名为 Async BLoC 的BLoC变体。

它和BLoC一样,我们有可以订阅的输出流;但是,BLoC输入可以包括 同步接收器、异步方法 甚至 共同的两者。

换句话说,我们从这样:

变成了这样:

异步的方法可以:

1.将零个,一个或多个值添加到输入接收器。

2.返回一个Future的结果,调用的代码可以等待结果并相应地执行某些操作。

3.抛出一个异常,调用的代码可以通过try/catch捕获它,并在需要时展示一个警告。

稍后,我们将看到一个完整的例子,说明它在实践中的用处。

更多关于BLoC的信息

一个Async BLoC可以定义一个StreamController/Stream对,如果使用RxDart,则等效对应定义一个BehaviorSubject/Observable。

如果有需要,我们甚至可以执行高级的流操作,例如通过combineLatest将流组合在一起。 但是要明确:

1.如果需要以某种方式组合,我建议在单个BLoC中使用多个流。

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

数据层/BLoC中的行为

1.BLoC应该是纯Dart的——没有UI代码,没有导入Flutter相关类和文件,也没有在BLoC中使用BuildContext。

2.BLoC不应 直接 调用第三方相关代码,这应该是Service做的。

3.控件和BLoC之间的接口应该和BLoC和Service之间的接口保证一致,也就是说,BloC可以通过同步/异步方法直接与服务类通信,并通过流通知更新。

服务层

Service类应该具有和BLoC相同的输入/输出接口。但是,Service和BLoC之间存在一个本质性的区别,那就是:

BLoC可以持有和修改状态。

Service不能持有和修改状态。

换句话说,我们可以将Service视为 纯粹 的功能组件, 它可以修改和转换从第三方库收到的数据。

示例: Firestore service

我们可以实现一个FirestoreDatabase的Service作为Firestore的指定域的API包装器。

输入的数据(读取):将来自Firestore文档的键值对的流转换为强类型的不可变数据Model。

数据输出(写入):将数据Model转换为键值对,以便写入Firestore。

这种情况下,Service类执行简单的数据操作。与BLoC不同,Service不具有任何状态。

关于术语的说明:对于与三方服务的通信的类,其他文章通常使用Repository来表述;甚至对于Repository的定义也随着时间的推移而发展(有关更多信息,请参阅此文章)。 在本文中,我没有明确区分Service和Repository。

将其聚集在一起:使用Provider包

一旦我们定义了BLoC和Service,我们就需要将其与控件相关联。

这段时间以来,我一直在使用 Remi Rousselet 的 Provider 包。 这是一个纯粹基于InheritedWidget的Flutter 依赖注入系统。

我真的很喜欢它的简洁性,下述代码是如何使用它来添加身份验证服务:


return Provider<AuthService>(

  builder: (_) => FirebaseAuthService(), //  实现了AuthService的FirebaseAuthService

  child: MaterialApp(...),

);

我们如何使用它来创建BLoC:


return Provider<SignInBloc>(

  builder: (_) => SignInBloc(auth: auth),

  dispose: (_, bloc) => bloc.dispose(),

  child: Consumer<SignInBloc>(

    builder: (_, bloc, __) => SignInPage(bloc: bloc),

  ),

);
请注意Provider控件是如何对可选的dispose回调进行配置的,我们使用它来处理BLoC并关闭相应的StreamControllers。

Provider为我们提供了一个简单灵活的API,我们可以使用它来向控件树添加任何我们想要的东西。它适用于BLoC、Service、数值甚至更多。

我将在稍后的一些文章中更详细地讨论如何使用Provider。 目前为止,我强烈推荐Google IO大会上的这个演讲:

https://www.youtube.com/watch?v=d_m5csmrf7I

实战项目:登录页面

现在我们已经了解了WABS在概念上的工作原理,让我们使用它来构建Firebase的身份验证流程。

以下是我用Flutter和Firebase实现的身份验证流程的示例:

观察到的结果:

当触发了登录事件,我们禁用了所有按钮并显示CircularProgressIndicator,我们将加载状态设置为true来达到该效果。

登录成功或失败后,我们重新启用所有按钮并恢复标题的内容,我们通过设置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仅向外暴漏了Stream和Future的公共API:


Stream<bool> get isLoadingStream;

Future<void> signInWithGoogle();

这符合我们对Async BLoC的定义。

所有的魔法都发生在signInWithGoogle()方法中。让我们通过注释再次回顾这些代码:


Future<void> signInWithGoogle() async {

  try {

    // 首先通过将loading=true交给流的接收器

    _setIsLoading(true);

    // 然后登录并等待结果

    return await auth.signInWithGoogle();

  } catch (e) {

    // 登录失败,将调用代码的异常重新抛出

    rethrow;

  } finally {

    // 登录成功或者失败, 将loading=false交给流的接收器

    _setIsLoading(false);

  }

}

和一般的BLoC一样,该方法会向接收器添加值;但除此之外,它也可以异步返回一个值,或抛出一个异常。

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


Future<void> _signInWithGoogle(BuildContext context) async {

  try {

    await bloc.signInWithGoogle();

    // 处理成功

  } on PlatformException catch (e) {

    // 处理失败

  }

}

这段代码看起来很简单,事实上也确实如此,因为我们需要的仅仅是async/ await和try/catch。

然而,对于仅使用接收器和流的“严格”版本的BLoC,这是不可能的。

仅供参考,在Redux中实现这样的功能…嗯…并不是那么有趣!😅

——虽然看起来Async-BLoC似乎对BLoC来说只是一个很小的改进,但它们完全不同。

处理异常时的注意事项

处理异常的另一种可行性是向流中添加一个error的对象,如下所示:


Future<void> signInWithGoogle() async {

  try {

    // 首先通过将loading=true交给流的接收器

    _setIsLoading(true);

    // 然后登录并等待结果

    return await auth.signInWithGoogle();

  } catch (e) {

    // 向流中添加一个error

    _isLoadingController.addError(e);

  } finally {

    // 登录成功或者失败, 将loading=false交给流的接收器

    _setIsLoading(false);

  }

}

这样,在widget类中,我们可以编写如下代码:


class SignInPage extends StatelessWidget {

  SignInPage({@required this.bloc});

  final SignInBloc bloc;

  // 由按钮的`onPressed`回调方法进行调用

  Future<void> _signInWithGoogle(BuildContext context) async {

    await bloc.signInWithGoogle();

  }

  void build(BuildContext context) {

    return StreamBuilder(

      stream: isLoadingStream,

      builder: (context, snapshot) {

        if (snapshot.hasError) {

          // 展示error

          showDialog(...);

        }

        // 基于快照渲染UI

      }

    )

  }

}

但这样并不优雅,原因有二:
1.它在StreamBuilder的builder中显示了一个对话框,这不是很好,因为builder只应该返回一个控件,而不是执行任何命令式的代码。
2.代码可读性并不高,我们显示错误的地方与执行登录的地方并不一致。

所以,不要这样做,也不要使用上文所展示的try/catch。😉

我们能通过WABS创建异步服务吗?

当然,正如我之前所说的:

BLoC可以持有和修改状态。

Service不能持有和修改状态。

但是,他们向外暴露的API遵循相同的规则。

以下是数据库API的Service类示例:


abstract class Database {

  // Job 的CRUD操作

  Future<void> setJob(Job job);

  Future<void> deleteJob(Job job);

  Stream<List<Job>> jobsStream();

  // Entry的CRUD操作

  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);

    // 处理成功

  } on PlatformException catch (e) {

    // 处理失败(展示警告)

  }

}

相同的模式,非常简洁的错误处理。

与RxVMS比较

在本文中,作为Flutter中已有架构模式的改良,介绍了Widget-Async-BLoC-Service。

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

下面是两者各个层之间的对比:

两者之间的主要区别在于:
WABS使用 Provider 包,而RxVMS使用GetIt服务定位器。
WABS使用简单的异步方法来处理UI事件,而RxVMS使用的是 RxCommand。
RxCommand是抽象处理UI事件和更新UI的库,它删除了使用BLoC创建StreamController/Stream对所需的样板代码。
RxCommand很强大,然而,它确实也带来了更陡峭的学习曲线。我的感受是,尽管需要一些额外的样板代码,但是Async-Bloc可以保证完成工作并且更简单。

我也喜欢WABS可以在没有任何外部库的情况下实现(除了Provider包)。

最终选择哪一个取决于您的实际开发场景,这也和个人喜好和品味息息相关。

我应该在我的应用中使用BLoC吗?
BLoC具有陡峭的学习曲线。要了解它们,您还需要熟悉Stream和StreamBuilder。

使用Stream时,需要考虑以下因素:

流的连接状态是什么(没有,等待,活跃,完成)?

流是被单次还是多次订阅?

StreamController和StreamSubscription始终需要被disposed。

当Flutter重建窗口控件树时,处理嵌套的StreamBuilders会导致调试过程变得很棘手。

这些因素都会让代码有额外的开销。

当更新app本地的状态(例如,将状态从一个控件传递到另一个控件中)时,BLoC有更简单的替代方案,这个后文再提。

无论如何,我发现BLoCs在使用Firestore构建app时效果非常明显,其中数据通过流从后端流入app。

在这种情况下,通常将流进行组合或使用RxDart对其执行转换,BLoC很擅长这个。

结论

本文是对WABS的深入介绍,WABS是我在多个项目中使用了一段时间后探索得出的架构模式。

说实话,随着时间的推移我一直在改进它,在我写这篇文章之前它都还没有名字。

正如我之前所说,架构模式只是一种工具;我的建议是,选择对您和您的项目更有意义的工具。

如果您在项目中使用了WABS,请让我知道它是行之有效的方案。😉

愉快地编码吧!

本文源码

Flutter & Firebase构建的身份验证流程:

https://github.com/bizz84/firebase_auth_demo_flutter

接下来的这个项目,它针对我的Flutter和Firebase Udemy课程中相关深入的资料进行了补充,链接如下:

Flutter&Firebase:构建一个完整的iOS和Android的应用程序:

https://www.udemy.com/flutter-firebase-build-a-complete-app-for-ios-android/?couponCode=DART15&password=codingwithflutter

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容