前言
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全家桶一把梭,極度適合新手開發者......