前言
Flutter從2018年底首次在谷歌開發(fā)者大會(huì)上亮相至今已3年多,其發(fā)展也算如火如荼。中小企業(yè)中大受歡迎,大廠也相繼投入技術(shù)研究。 但依然有不少開發(fā)者疑惑于為自己的項(xiàng)目要選擇哪個(gè)狀態(tài)管理框架,今天筆者將對社區(qū)內(nèi)相對火熱??的狀態(tài)管理庫(Provider、BLoC、GetX)做一個(gè)技術(shù)分析和對比,幫助大家更好地為項(xiàng)目找到合適的狀態(tài)管理庫。
狀態(tài)管理原則
我們在開發(fā)過程中,為了提高項(xiàng)目的可維護(hù)度和性能,也為了讓頁面UI跟數(shù)據(jù)(本地或服務(wù)端數(shù)據(jù))有效分離的同時(shí)又能有效同步,都會(huì)讓項(xiàng)目保持清晰的目錄結(jié)構(gòu)、同時(shí)啟用狀態(tài)管理庫。
而MVVM模式已然成為前端項(xiàng)目中的主流架構(gòu)。
MVVM 即 mode + view + viewModel
- model表示頁面狀態(tài)(即頁面需要的數(shù)據(jù))
- view表示頁面視圖(即UI)
- viewModel是中間層,負(fù)責(zé)model和view的雙向通信,實(shí)現(xiàn)頁面視圖更新驅(qū)動(dòng),同時(shí)負(fù)責(zé)的業(yè)務(wù)邏輯(例如:條件判斷、網(wǎng)絡(luò)請求等)的處理。
通過MVVM可以實(shí)現(xiàn)視圖、數(shù)據(jù)、業(yè)務(wù)邏輯完全分離,使項(xiàng)目數(shù)據(jù)流向清晰明朗,提高性能,提高可維護(hù)度。
用戶對頁面的操作觸發(fā)數(shù)據(jù)的處理,數(shù)據(jù)的變動(dòng)驅(qū)動(dòng)頁面UI的刷新。所以單一數(shù)據(jù)源和單向數(shù)據(jù)流是做好狀態(tài)管理的關(guān)鍵。
- 單一數(shù)據(jù)源:此處的UI是由單獨(dú)的數(shù)據(jù)進(jìn)行綁定的,是完全可控的,不會(huì)隨意受到到其他數(shù)據(jù)的影響;
- 單向數(shù)據(jù)流:用戶、系統(tǒng)的操作,觸發(fā)數(shù)據(jù)的處理,數(shù)據(jù)的改變最終驅(qū)動(dòng)視圖發(fā)生更新,這個(gè)流向必須是單向且可追溯的。即無論用戶、系統(tǒng)做了多少操作,最終數(shù)據(jù)都是處理好了才去更新視圖,不能在視圖更新后又反向去觸發(fā)數(shù)據(jù)處理。
Flutter中的狀態(tài)管理庫,基本也都遵循MVVM原則,所以在遵循這個(gè)原則的基礎(chǔ)上,如何使得狀態(tài)管理性能更好且易于使用,是這些庫的設(shè)計(jì)宗旨。本篇文章我主要對比以下三個(gè)庫:Provider、BLoC、GetX。
Flutter中的狀態(tài)管理
在Flutter中,狀態(tài)管理一直是老生常談的問題。直到Flutter將Provider替代Provide作為官方推薦的狀態(tài)管理庫,F(xiàn)lutter關(guān)于狀態(tài)管理的爭論才開始趨于平靜,但2021年GetX異軍突起,又讓眾多初學(xué)者開始爭論究竟使用哪個(gè)庫來做狀態(tài)管理。
那么狀態(tài)管理為何這么重要呢?這里有一個(gè)業(yè)務(wù)場景可以給大家體會(huì)下:
假設(shè)服務(wù)器每隔十秒通過websocket給APP推送一次數(shù)據(jù),數(shù)據(jù)包含文章內(nèi)容,同時(shí)也包含閱讀數(shù)、點(diǎn)贊數(shù)。APP有兩個(gè)頁面,A頁面顯示文章列表,點(diǎn)擊列表項(xiàng)進(jìn)入B頁面查看文章詳情。每隔十秒服務(wù)器的消息到達(dá)后,需要實(shí)時(shí)更新A、B頁面的內(nèi)容。
- 普通方式:為了實(shí)現(xiàn)以上場景,在Flutter中,我們需要在每個(gè)頁面注冊一個(gè)websocket接收器,每個(gè)頁面收到websocket消息通知的時(shí)候,通過setState去更新頁面視圖;如果有10個(gè)頁面,就需要定義10個(gè)接收器,每個(gè)接收器還需要分別處理數(shù)據(jù)然后setState更新視圖。性能差不說,開發(fā)效率上也大打折扣,出錯(cuò)率極高。
- 狀態(tài)管理:在上面的例子中,我們希望只在一個(gè)地方接收數(shù)據(jù),只要數(shù)據(jù)一改變,各個(gè)視圖就實(shí)時(shí)更新,無需每個(gè)頁面setState。假設(shè)我們有一個(gè)更新事件的發(fā)布者,然后每個(gè)頁面都是監(jiān)聽者。發(fā)布者在接收到數(shù)據(jù)后發(fā)出更新事件,監(jiān)聽者收到的同事視圖便馬上更新(無需setState), 那開發(fā)的體驗(yàn)就很完美了。
這就是典型的發(fā)布訂閱者模式,大部分前端包括Flutter中的狀態(tài)管理,都是基于這種設(shè)計(jì)模式。
Flutter中的發(fā)布訂閱模式,可以使用stream流機(jī)制。stream系統(tǒng)學(xué)習(xí)
以上面的需求為例:
1. 我們需要一個(gè)websocket接收器,收到消息后通過streamController.skin.add發(fā)布事件;
2. 頁面中stream注冊監(jiān)聽器streamController.stream.listen,在監(jiān)聽回調(diào)中通過setState刷新視圖。
事實(shí)上,F(xiàn)lutter目前已有的狀態(tài)管理,如rxdart、BLoC、fluter_redux、provider、GetX等,都離不開對stream流進(jìn)行封裝,再加上對Flutter InheritedWidget的封裝演化出StreamBuilder、BlocBuilder等布局組件,從而達(dá)到無需setState就能實(shí)時(shí)更新視圖的效果。Flutter狀態(tài)管理的演變
BLoC
BLoC是谷歌提出的一種設(shè)計(jì)模式,利用stream流的方式實(shí)現(xiàn)界面的異步渲染和重繪,我們可以非常順利的通過BLoC實(shí)現(xiàn)業(yè)務(wù)與界面的分離。一般情況下,我們會(huì)在項(xiàng)目中引入flutter_bloc這個(gè)庫。
目錄結(jié)構(gòu)
一個(gè)BLoC狀態(tài)管理,通常會(huì)有三個(gè)文件:bloc、event、state
簡單使用方法
-
當(dāng)一個(gè)組件需要使用到BLoC狀態(tài)管理時(shí),需要在調(diào)用組件之前,需要聲明下BLoC的提供者,具體寫法如下:
BlocProvider<BadgesBloc>(create: (context)=> BadgesBloc(),child:UserPage()); -
當(dāng)頁面有多個(gè)BLoC提供者,或者整個(gè)App通用的BLoC提供者,即可提前在加載App之前全局聲明??梢允褂肕ultiBlocProvider進(jìn)行聲明,具體寫法如下:
MultiBlocProvider( providers: [ BlocProvider<BadgesBloc>(create:(context) => BadgesBloc()), BlocProvider(create: (context) =>XXX()), ], child: MaterialApp(), ) -
頁面布局將使用BlocBuilder創(chuàng)建widget,用戶在頁面中通過BlocProvider.of(context).add()發(fā)起事件
/// 布局示例 BlocBuilder<BadgesBloc, BadgesState>( // 接收bloc返回的state,視圖與state中的變量進(jìn)行綁定 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),), ); })/// 頁面發(fā)起事件 // 發(fā)出的重設(shè)Badge的事件,事件要求傳參為bool BlocProvider.of<BadgesBloc>(context).add(ResetBadgeEvent(true)); -
此時(shí)在bloc中就會(huì)接收到事件,判斷發(fā)起的事件是event中的哪個(gè)事件,然后返回對應(yīng)的state,具體寫法如下:
@override Stream<BadgesState> mapEventToState(BadgesEvent event) async* { if (event is ResetBadgeEvent) { yield BadgesInitialState(event.unReadNotification); } } Stream<BadgesInitialState> _mapGetActivityCountState(isShow) async* { // 此處更改狀態(tài)的值,讓上面的視圖代碼可以根據(jù)此值進(jìn)行更新 yield BadgesInitialState(isShow); }
優(yōu)缺點(diǎn)
- 【優(yōu)點(diǎn)】
BLoC的目錄結(jié)構(gòu)清晰,完全符合mvvm的習(xí)慣。對于工程化項(xiàng)目來說會(huì)比較受歡迎,,團(tuán)隊(duì)協(xié)作起來會(huì)減少出錯(cuò)的概率,大家都跟著一個(gè)模式去做,維護(hù)性也提高了; - 【優(yōu)點(diǎn)】
業(yè)務(wù)流清晰。使用dart stream事件流作為基礎(chǔ)原理,event和state都是事件驅(qū)動(dòng)的,用戶行為觸發(fā)事件,事件處理完推出狀態(tài)流,穩(wěn)定的數(shù)據(jù)流向往往能提高代碼的可靠性; - 【缺點(diǎn)】
BLoC使用起來相對復(fù)雜,需要?jiǎng)?chuàng)建多個(gè)文件。雖然官方引入了cubit,把event組合到bloc文件中,但強(qiáng)烈的結(jié)構(gòu)化依然讓不少初學(xué)者難以入門; - 【缺點(diǎn)】
顆粒度的把控相對困難。通過BlocBuilder構(gòu)建的視圖,在state變更時(shí),視圖都會(huì)rebuild,想要控制顆粒度只能把bloc再拆細(xì),這會(huì)極大的增加代碼復(fù)雜度和工作量;不過這個(gè)問題可通過引入freezed生成代碼,然后通過buildWhen等方式減少視圖刷新的頻次。
Provider
Provider是Flutter官方開發(fā)維護(hù)的,也是近些年官方最為推薦的狀態(tài)管理庫。Provider 是建立在 InheretedWidget 之上做了封裝,大大減少了我們需要編寫的代碼量。其特點(diǎn)是:不復(fù)雜、好理解,可控度高。我們會(huì)在項(xiàng)目中引入provider這個(gè)庫。
簡單使用方法
- 當(dāng)組件需要使用到Provider狀態(tài)管理時(shí),需要在調(diào)用組件之前,需要聲明下Provider的提供者,具體寫法如下:
ChangeNotifierProvider<LoginViewModel>.value(
notifier: LoginViewModel(),
child:LoginPage(),
)
- 當(dāng)一個(gè)頁面有多個(gè)Provider提供者,或者整個(gè)App有幾個(gè)通用的Provider提供者,有多個(gè)頁面都需要使用,即可提前在加載App之前全局聲明??梢允褂肕ultiProvider進(jìn)行聲明:
MultiProvider(
providers: [
ChangeNotifierProvider<LoginViewModel>( create: (_) => LoginViewModel(),),
ChangeNotifierProvider<HomeViewModel>( create: (_) => HomeViewModel(),),
],
child: MaterialApp()
)
- 頁面布局需創(chuàng)建一個(gè)Provider對象,之后直接在widget中綁定viewModel中的數(shù)據(jù)或者觸發(fā)事件即可
/// 創(chuàng)建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的數(shù)據(jù)
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 : "必填選項(xiàng)"; }
)
)
),
new Padding(
padding: EdgeInsets.only(top: 40),
child: new Container(
height: 90.h, width: 486.w,
child: new RaisedButton(
// 點(diǎn)擊觸發(fā)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。當(dāng)有數(shù)據(jù)需要更新的時(shí)候,調(diào)用notifyListeners(),頁面就會(huì)刷新
實(shí)現(xiàn)原理
- Provider主要是對 InheritedWidget 組件進(jìn)行上層封裝,使其更易用,通過ChangeNotifier來處理數(shù)據(jù),從而減少了InheritedWidget的大量模版代碼。
- 從源碼上我們可以看到
Provider直接繼承于InheritedProvider,通過工廠構(gòu)造函數(shù)Provider.value傳入model和child節(jié)點(diǎn),然后通過context.dependOnInheritedWidgetOfExactType<_InheritedProviderScope<T?>>();對值進(jìn)行監(jiān)聽。 - 而_InheritedProviderScope就是繼承于
InheritedWidget的,所以Provider的實(shí)現(xiàn)真的很簡單,有用過InheritedWidget的小伙伴可以去看下源碼。
優(yōu)缺點(diǎn)
- 【優(yōu)點(diǎn)】
使用簡單。model繼承ChangeNotifier,沒有更多的布局widget,只需要通過context.read / context.watch操作或者監(jiān)聽model即可; - 【優(yōu)點(diǎn)】
顆粒度把控簡單。為了解決widget重新build太頻繁的問題,官方推出了context.select來監(jiān)聽對象的部分屬性。也可使用Consumer/Selector進(jìn)行布局; - 【優(yōu)點(diǎn)】
基于官方InheritedWidget的封裝,不存在任何風(fēng)險(xiǎn),很穩(wěn)定且不會(huì)給性能方面加負(fù)擔(dān) - 【缺點(diǎn)】
context強(qiáng)關(guān)聯(lián),有Flutter開發(fā)經(jīng)驗(yàn)的都知道,context大多時(shí)候基本都是在widget中才能獲取到,在其他地方想隨時(shí)獲取BuildContext是不切實(shí)際的,也就意味著大多時(shí)候只能在view層去獲取到Provider提供的信息。
GetX
GetX是一個(gè)輕量級且強(qiáng)大的狀態(tài)管理庫,這個(gè)庫試圖完成很多工作,它不僅支持狀態(tài)管理,也支持路由、國際化、Theme等一大堆功能。GetX在Flutter狀態(tài)管理中絕對算是異軍突起,一經(jīng)發(fā)布就因其簡單且全面的優(yōu)勢,引得一大批簇?fù)碚?;我并未認(rèn)真研究GetX,但簡單接觸后我個(gè)人并不喜歡,這種全家桶式的庫會(huì)讓我們的項(xiàng)目相對局限,同時(shí)讓項(xiàng)目開發(fā)者處于沒有進(jìn)步且被動(dòng)的局面。
簡單使用方法
我們直接使用 GetX 演示官方example"計(jì)數(shù)器",
- 每次點(diǎn)擊都能改變狀態(tài)
- 在不同頁面之間切換
- 在不同頁面之間共享狀態(tài)
- 將業(yè)務(wù)邏輯與界面分離
- 把MaterialApp變成GetMaterialApp
void main() => runApp(GetMaterialApp(home: Home()));
- 創(chuàng)建你的業(yè)務(wù)邏輯類,將變量、方法和控制器放在里面。 通過".obs "使變量成為可觀察的
class Controller extends GetxController{
var count = 0.obs;
increment() => count++;
}
- 創(chuàng)建界面
class Home extends StatelessWidget {
@override
Widget build(context) {
// 使用Get.put()實(shí)例化你的類,使其對當(dāng)下的所有子路由可用。
final Controller c = Get.put(Controller());
return Scaffold(
// 使用Obx(()=>每當(dāng)改變計(jì)數(shù)時(shí),就更新Text()。
appBar: AppBar(title: Obx(() => Text("Clicks: ${c.count}"))),
// 用一個(gè)簡單的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找到一個(gè)正在被其他頁面使用的Controller,并將它返回給你。
final Controller c = Get.find();
@override
Widget build(context){
// 訪問更新后的計(jì)數(shù)變量
return Scaffold(body: Center(child: Text("${c.count}")));
}
}
可以看出確實(shí)使用非常的簡單,而且已經(jīng)不太遵循MVC和MVVM結(jié)構(gòu)了,但影響不大,能高效的開發(fā)才是國內(nèi)團(tuán)隊(duì)最關(guān)心的問題。更多詳情見GetX readme!
實(shí)現(xiàn)原理
實(shí)現(xiàn)原理這塊,我只簡單解析這三點(diǎn):① 如何做到數(shù)據(jù)驅(qū)動(dòng);② 如何管理路由;③ 脫離了context后,資源該如何回收。
- GetX通過.obx或Obx(builder)對變量實(shí)現(xiàn)訂閱,以實(shí)現(xiàn)數(shù)據(jù)一改變就通知視圖改變的效果。這塊的實(shí)現(xiàn)原理還是離不開dart的stream流,通過源碼我們可以知道兩者最終都是繼承于
RxNotifier,而RxNotifierwithNotifyManager,NotifyManager就是提供streamSubscription的擴(kuò)展類;
class RxNotifier<T> = RxInterface<T> with NotifyManager<T>;
mixin NotifyManager<T> {
// 注釋:通過GetStream提供onListen;onPause;onResume的回調(diào)
GetStream<T> subject = GetStream<T>();
// 注釋:Map對象,后續(xù)通過key-value鍵值對進(jìn)行通知
final _subscriptions = <GetStream, List<StreamSubscription>>{};
bool get canUpdate => _subscriptions.isNotEmpty;
// 注釋:內(nèi)部方法,訂閱內(nèi)部流的更改
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>[];
// 發(fā)出通知
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,
);
/// 注釋:關(guān)閉訂閱,釋放資源
void close() {
_subscriptions.forEach((getStream, _subscriptions) {
for (final subscription in _subscriptions) {
subscription.cancel();
}
});
_subscriptions.clear();
subject.close();
}
}
- GetX的路由管理也是通過封裝Flutter的Navigator,比如:Get.toName()就是通過GetX提供的全局
NavigatorState還是調(diào)用了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,
);
}
- 從第一點(diǎn)我們知道了如何進(jìn)行數(shù)據(jù)狀態(tài)的管理,同時(shí)NotifyManager也提供了close的方法去釋放資源,到這里我們不禁會(huì)問:那啥時(shí)候去調(diào)用close釋放資源呢?
答案是:通過widget的dispose生命鉤子調(diào)用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();
}
優(yōu)缺點(diǎn)
- 【優(yōu)點(diǎn)】
使用最簡單,用起來確實(shí)很簡單,極易上手;脫離context,隨時(shí)隨地想用就用,解決了BLoC和Provider的痛點(diǎn); - 【優(yōu)點(diǎn)】
全家桶式功能,使用GetX后,我們無需再單獨(dú)去做路由管理、國際化、主題、全局context等,甚至還支持服務(wù)端開發(fā); - 【缺點(diǎn)】第2點(diǎn)優(yōu)點(diǎn)同樣也是缺點(diǎn),GetX幫我們封裝了很多本來Flutter就提供的Api,減少了開發(fā)者很多的工作。這會(huì)
讓項(xiàng)目極度依賴GetX,而在Flutter更新迭代這么快的情況下,誰也不敢保證GetX全家桶的更新節(jié)奏,一旦更新慢了,開發(fā)者只能等GetX(當(dāng)然能夠參與社區(qū)開源的另當(dāng)別論)。另外,GetX的使用真的太基礎(chǔ)了,讓初學(xué)者易上手的同時(shí),技術(shù)也容易停留于表面。
總結(jié)
除了上訴的幾種方案,還有其他的庫,如redux / fish_redux/ RiverPod,這些庫有的過于復(fù)雜,有的剛出不久,筆者調(diào)研過程中有留意但并沒有用過,活躍度確實(shí)也沒有上面方案多。
總之,BLoC適合相對大的工程化項(xiàng)目團(tuán)隊(duì)使用,架構(gòu)清晰;Provider很純粹,也很好用;GetX全家桶一把梭,極度適合新手開發(fā)者......