Flutter项目该如何选择状态管理?
「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战」。
前言:Flutter从2018年底首次在谷歌开发者大会上亮相至今已3年多,其发展也算如火如荼。中小企业中大受欢迎,大厂也相继投入技术研究。 但依然有不少开发者疑惑于为自己的项目要选择哪个状态管理框架,今天笔者将对社区内相对火热🔥的状态管理库(
Provider、BLoC、GetX
)做一个技术分析和对比,帮助大家更好地为项目找到合适的状态管理库。
状态管理原则
我们在开发过程中,为了提高项目的可维护度和性能,也为了让页面UI跟数据(本地或服务端数据)有效分离的同时又能有效同步,都会让项目保持清晰的目录结构、同时启用状态管理库。 而MVVM模式已然成为前端项目中的主流架构。
MVVM 即 mode + view + viewModel
- model表示页面状态(即页面需要的数据)
- view表示页面视图(即UI)
- viewModel是中间层,负责model和view的双向通信,实现页面视图更新驱动,同时负责的业务逻辑(例如:条件判断、网络请求等)的处理。
通过MVVM可以实现视图、数据、业务逻辑完全分离,使项目数据流向清晰明朗,提高性能,提高可维护度。
用户对页面的操作触发数据的处理,数据的变动驱动页面UI的刷新。所以单一数据源
和单向数据流
是做好状态管理的关键。
- 单一数据源:此处的UI是由单独的数据进行绑定的,是完全可控的,不会随意受到到其他数据的影响;
- 单向数据流:用户、系统的操作,触发数据的处理,数据的改变最终驱动视图发生更新,这个流向必须是单向且可追溯的。即无论用户、系统做了多少操作,最终数据都是处理好了才去更新视图,不能在视图更新后又反向去触发数据处理。
Flutter中的状态管理库,基本也都遵循MVVM原则,所以在遵循这个原则的基础上,如何使得状态管理性能更好且易于使用,是这些库的设计宗旨。本篇文章我主要对比以下三个库:
Provider、BLoC、GetX
。
Flutter中的状态管理
在Flutter中,状态管理一直是老生常谈的问题。直到Flutter将Provider替代Provide作为官方推荐的状态管理库,Flutter关于状态管理的争论才开始趋于平静,但2021年GetX异军突起,又让众多初学者开始争论究竟使用哪个库来做状态管理。
那么状态管理为何这么重要呢?这里有一个业务场景可以给大家体会下:
假设服务器每隔十秒通过websocket给APP推送一次数据,数据包含文章内容,同时也包含阅读数、点赞数。APP有两个页面,A页面显示文章列表,点击列表项进入B页面查看文章详情。每隔十秒服务器的消息到达后,需要实时更新A、B页面的内容。
-
普通方式:为了实现以上场景,在Flutter中,我们需要在每个页面注册一个websocket接收器,每个页面收到websocket消息通知的时候,通过setState去更新页面视图;如果有10个页面,就需要定义10个接收器,每个接收器还需要分别处理数据然后setState更新视图。性能差不说,开发效率上也大打折扣,出错率极高。
-
状态管理:在上面的例子中,我们希望只在一个地方接收数据,只要数据一改变,各个视图就实时更新,无需每个页面setState。假设我们有一个更新事件的发布者,然后每个页面都是监听者。发布者在接收到数据后发出更新事件,监听者收到的同事视图便马上更新(无需setState), 那开发的体验就很完美了。这就是典型的发布订阅者模式,大部分前端包括Flutter中的状态管理,都是基于这种设计模式。
Flutter中的发布订阅模式,可以使用stream流机制。stream系统学习
以上面的需求为例:
1. 我们需要一个websocket接收器,收到消息后通过streamController.skin.add发布事件;
2. 页面中stream注册监听器streamController.stream.listen,在监听回调中通过setState刷新视图。
事实上,Flutter目前已有的状态管理,如rxdart、BLoC、fluter_redux、provider、GetX等,都离不开对stream流进行封装,再加上对Flutter InheritedWidget的封装演化出StreamBuilder、BlocBuilder等布局组件,从而达到无需setState就能实时更新视图的效果。Flutter状态管理的演变
BLoC
BLoC是谷歌提出的一种设计模式,利用stream流的方式实现界面的异步渲染和重绘,我们可以非常顺利的通过BLoC实现业务与界面的分离。一般情况下,我们会在项目中引入flutter_bloc这个库。
目录结构
一个BLoC状态管理,通常会有三个文件:bloc、event、state
简单使用方法
-
当一个组件需要使用到BLoC状态管理时,需要在调用组件之前,需要声明下BLoC的提供者,具体写法如下:
BlocProvider<BadgesBloc>(create: (context)=> BadgesBloc(),child:UserPage());
-
当页面有多个BLoC提供者,或者整个App通用的BLoC提供者,即可提前在加载App之前全局声明。可以使用MultiBlocProvider进行声明,具体写法如下:
MultiBlocProvider( providers: [ BlocProvider<BadgesBloc>(create:(context) => BadgesBloc()), BlocProvider(create: (context) =>XXX()), ], child: MaterialApp(), )
-
页面布局将使用BlocBuilder创建widget,用户在页面中通过BlocProvider.of(context).add()发起事件
/// 布局示例 BlocBuilder<BadgesBloc, BadgesState>( // 接收bloc返回的state,视图与state中的变量进行绑定 builder: (context, state) { var isShowBadge = false; if (state is BadgesInitialState) { isShowBadge = state.unReadNotification; } return Badge( showBadge: isShowBadge, shape: BadgeShape.circle, position: BadgePosition(top: -3, right: -3), child: Icon(Icons.notifications_none, color: Color(0xFFFFFFFF),), ); })
/// 页面发起事件 // 发出的重设Badge的事件,事件要求传参为bool BlocProvider.of<BadgesBloc>(context).add(ResetBadgeEvent(true));
-
此时在bloc中就会接收到事件,判断发起的事件是event中的哪个事件,然后返回对应的state,具体写法如下:
@override Stream<BadgesState> mapEventToState(BadgesEvent event) async* { if (event is ResetBadgeEvent) { yield BadgesInitialState(event.unReadNotification); } } Stream<BadgesInitialState> _mapGetActivityCountState(isShow) async* { // 此处更改状态的值,让上面的视图代码可以根据此值进行更新 yield BadgesInitialState(isShow); }
-
补上event、state的代码截图
优缺点
- 【优点】
BLoC的目录结构清晰
,完全符合mvvm的习惯。对于工程化项目来说会比较受欢迎,,团队协作起来会减少出错的概率,大家都跟着一个模式去做,维护性也提高了; - 【优点】
业务流清晰
。使用dart stream事件流作为基础原理,event和state都是事件驱动的,用户行为触发事件,事件处理完推出状态流,稳定的数据流向往往能提高代码的可靠性; - 【缺点】
BLoC使用起来相对复杂
,需要创建多个文件。虽然官方引入了cubit,把event组合到bloc文件中,但强烈的结构化依然让不少初学者难以入门; - 【缺点】
颗粒度的把控相对困难。
通过BlocBuilder构建的视图,在state变更时,视图都会rebuild,想要控制颗粒度只能把bloc再拆细,这会极大的增加代码复杂度和工作量;不过这个问题可通过引入freezed生成代码,然后通过buildWhen等方式减少视图刷新的频次。
Provider
Provider是Flutter官方开发维护的,也是近些年官方最为推荐的状态管理库。Provider
是建立在 InheretedWidget
之上做了封装,大大减少了我们需要编写的代码量。其特点是:不复杂、好理解,可控度高。我们会在项目中引入provider这个库。
简单使用方法
- 当组件需要使用到Provider状态管理时,需要在调用组件之前,需要声明下Provider的提供者,具体写法如下:
ChangeNotifierProvider<LoginViewModel>.value(
notifier: LoginViewModel(),
child:LoginPage(),
)
- 当一个页面有多个Provider提供者,或者整个App有几个通用的Provider提供者,有多个页面都需要使用,即可提前在加载App之前全局声明。可以使用MultiProvider进行声明:
MultiProvider(
providers: [
ChangeNotifierProvider<LoginViewModel>( create: (_) => LoginViewModel(),),
ChangeNotifierProvider<HomeViewModel>( create: (_) => HomeViewModel(),),
],
child: MaterialApp()
)
- 页面布局需创建一个Provider对象,之后直接在widget中绑定viewModel中的数据或者触发事件即可
/// 创建provider对象
var loginVM = Provider.of<LoginViewModel>(context);
Column(
children: <Widget>[
new Padding(
padding: EdgeInsets.only(top: 85),
child: new Container(
height: 85.h, width: 486.w,
child: TextFormField(
// 绑定viewModel的数据
controller: loginVM.userNameController,
decoration: InputDecoration(
hintText: "请输入用户名",
icon: Icon(Icons.person),
hintStyle: TextStyle(color: Colors.grey, fontSize: 24.sp),
),
validator: (value) {
return value.trim().length > 0 ? null : "必填选项"; }
)
)
),
new Padding(
padding: EdgeInsets.only(top: 40),
child: new Container(
height: 90.h, width: 486.w,
child: new RaisedButton(
// 点击触发viewModel中的方法
onPressed: () { loginVM.loginHandel(context)},
color: const Color(0xff00b4ed), shape: StadiumBorder(),
child: new Text( "登录",
style: new TextStyle(color: Colors.white, fontSize: 32.sp),
),
),
)
)]
- 再来看viewModel中的写法,class必须继承ChangeNotifier。当有数据需要更新的时候,调用notifyListeners(),页面就会刷新
实现原理
- Provider主要是对 InheritedWidget 组件进行上层封装,使其更易用,通过ChangeNotifier来处理数据,从而减少了InheritedWidget的大量模版代码。
- 从源码上我们可以看到
Provider
直接继承于InheritedProvider
,通过工厂构造函数Provider.value
传入model和child节点,然后通过context.dependOnInheritedWidgetOfExactType<_InheritedProviderScope<T?>>();
对值进行监听。 - 而_InheritedProviderScope就是继承于
InheritedWidget
的,所以Provider的实现真的很简单,有用过InheritedWidget
的小伙伴可以去看下源码。
优缺点
- 【优点】
使用简单
。model继承ChangeNotifier,没有更多的布局widget,只需要通过context.read / context.watch操作或者监听model即可; - 【优点】
颗粒度把控简单
。为了解决widget重新build太频繁的问题,官方推出了context.select
来监听对象的部分属性。也可使用Consumer/Selector进行布局; - 【优点】
基于官方InheritedWidget的封装
,不存在任何风险,很稳定且不会给性能方面加负担 - 【缺点】
context强关联
,有Flutter开发经验的都知道,context大多时候基本都是在widget中才能获取到,在其他地方想随时获取BuildContext
是不切实际的,也就意味着大多时候只能在view层去获取到Provider提供的信息。
GetX
GetX是一个轻量级且强大的状态管理库,这个库试图完成很多工作,它不仅支持状态管理,也支持路由、国际化、Theme等一大堆功能。GetX在Flutter状态管理中绝对算是异军突起,一经发布就因其简单且全面的优势,引得一大批簇拥者;我并未认真研究GetX,但简单接触后我个人并不喜欢,这种全家桶式的库会让我们的项目相对局限,同时让项目开发者处于没有进步且被动的局面。
简单使用方法
我们直接使用 GetX 演示官方example"计数器",
- 每次点击都能改变状态
- 在不同页面之间切换
- 在不同页面之间共享状态
- 将业务逻辑与界面分离
- 把MaterialApp变成GetMaterialApp
void main() => runApp(GetMaterialApp(home: Home()));
- 创建你的业务逻辑类,将变量、方法和控制器放在里面。 通过".obs "使变量成为可观察的
class Controller extends GetxController{
var count = 0.obs;
increment() => count++;
}
- 创建界面
class Home extends StatelessWidget {
@override
Widget build(context) {
// 使用Get.put()实例化你的类,使其对当下的所有子路由可用。
final Controller c = Get.put(Controller());
return Scaffold(
// 使用Obx(()=>每当改变计数时,就更新Text()。
appBar: AppBar(title: Obx(() => Text("Clicks: ${c.count}"))),
// 用一个简单的Get.to()即可代替Navigator.push那8行,无需上下文!
body: Center(child: ElevatedButton(
child: Text("Go to Other"), onPressed: () => Get.to(Other()))),
floatingActionButton:
FloatingActionButton(child: Icon(Icons.add), onPressed: c.increment));
}
}
class Other extends StatelessWidget {
// 你可以让Get找到一个正在被其他页面使用的Controller,并将它返回给你。
final Controller c = Get.find();
@override
Widget build(context){
// 访问更新后的计数变量
return Scaffold(body: Center(child: Text("${c.count}")));
}
}
可以看出确实使用非常的简单,而且已经不太遵循MVC和MVVM结构了,但影响不大,能高效的开发才是国内团队最关心的问题。更多详情见GetX readme!
实现原理
实现原理这块,我只简单解析这三点:① 如何做到数据驱动;② 如何管理路由;③ 脱离了context后,资源该如何回收。
- GetX通过.obx或Obx(builder)对变量实现订阅,以实现数据一改变就通知视图改变的效果。这块的实现原理还是离不开dart的stream流,通过源码我们可以知道两者最终都是继承于
RxNotifier
,而RxNotifier
withNotifyManager
,NotifyManager
就是提供streamSubscription的扩展类;
class RxNotifier<T> = RxInterface<T> with NotifyManager<T>;
mixin NotifyManager<T> {
// 注释:通过GetStream提供onListen;onPause;onResume的回调
GetStream<T> subject = GetStream<T>();
// 注释:Map对象,后续通过key-value键值对进行通知
final _subscriptions = <GetStream, List<StreamSubscription>>{};
bool get canUpdate => _subscriptions.isNotEmpty;
// 注释:内部方法,订阅内部流的更改
void addListener(GetStream<T> rxGetx) {
if (!_subscriptions.containsKey(rxGetx)) {
final subs = rxGetx.listen((data) {
if (!subject.isClosed) subject.add(data);
});
final listSubscriptions =
_subscriptions[rxGetx] ??= <StreamSubscription>[];
// 发出通知
listSubscriptions.add(subs);
}
}
StreamSubscription<T> listen(
void Function(T) onData, {
Function? onError,
void Function()? onDone,
bool? cancelOnError,
}) =>
subject.listen(
onData,
onError: onError,
onDone: onDone,
cancelOnError: cancelOnError ?? false,
);
/// 注释:关闭订阅,释放资源
void close() {
_subscriptions.forEach((getStream, _subscriptions) {
for (final subscription in _subscriptions) {
subscription.cancel();
}
});
_subscriptions.clear();
subject.close();
}
}
- GetX的路由管理也是通过封装Flutter的Navigator,比如:Get.toName()就是通过GetX提供的全局
NavigatorState
还是调用了pushNamed
;
Future<T?>? toNamed<T>(
String page, {
dynamic arguments,
int? id,
bool preventDuplicates = true,
Map<String, String>? parameters,
}) {
if (preventDuplicates && page == currentRoute) {
return null;
}
if (parameters != null) {
final uri = Uri(path: page, queryParameters: parameters);
page = uri.toString();
}
// 注释:global(id).currentState的就是GetMaterialApp.router中的navigatorKey
return global(id).currentState?.pushNamed<T>(
page,
arguments: arguments,
);
}
- 从第一点我们知道了如何进行数据状态的管理,同时NotifyManager也提供了close的方法去释放资源,到这里我们不禁会问:那啥时候去调用close释放资源呢? 答案是:
通过widget的dispose生命钩子调用close,从而释放资源。
@override
void dispose() {
if (widget.dispose != null) widget.dispose!(this);
if (_isCreator! || widget.assignId) {
if (widget.autoRemove && GetInstance().isRegistered<T>(tag: widget.tag)) {
GetInstance().delete<T>(tag: widget.tag);
}
}
_subs.cancel();
// 注释:在这里释放资源
_observer.close();
controller = null;
_isCreator = null;
super.dispose();
}
优缺点
- 【优点】
使用最简单
,用起来确实很简单,极易上手;脱离context,随时随地想用就用,解决了BLoC和Provider的痛点; - 【优点】
全家桶式功能
,使用GetX后,我们无需再单独去做路由管理、国际化、主题、全局context等,甚至还支持服务端开发; - 【缺点】第2点优点同样也是缺点,GetX帮我们封装了很多本来Flutter就提供的Api,减少了开发者很多的工作。这会
让项目极度依赖GetX
,而在Flutter更新迭代这么快的情况下,谁也不敢保证GetX全家桶的更新节奏,一旦更新慢了,开发者只能等GetX(当然能够参与社区开源的另当别论)。另外,GetX的使用真的太基础了,让初学者易上手的同时,技术也容易停留于表面
。
总结
除了上诉的几种方案,还有其他的库,如redux
/ fish_redux
/ RiverPod
,这些库有的过于复杂,有的刚出不久,笔者调研过程中有留意但并没有用过,活跃度确实也没有上面方案多。
总之,BLoC适合相对大的工程化项目团队使用,架构清晰;Provider很纯粹,也很好用;GetX全家桶一把梭,极度适合新手开发者......
写在最后
选择哪种状态管理没有一定的答案,难易程度、可维护性、开发成本、性能都是我们需要考虑的因素,当然也要视团队和具体应用场景而定。
如果业务场景只是一个输入框和按钮,过度的模式设计其实就会显得画蛇添足。
大家理性选择,我们下篇文章再见!
转载自:https://juejin.cn/post/7066707540502904862