【Android】Flutter項目該如何選擇狀態管理?

前言

Flutter從2018年底首次在谷歌開發者大會上亮相至今已3年多,其發展也算如火如荼。中小企業中大受歡迎,大廠也相繼投入技術研究。 但依然有不少開發者疑惑于為自己的項目要選擇哪個狀態管理框架,今天筆者將對社區內相對火熱??的狀態管理庫(Provider、BLoC、GetX)做一個技術分析和對比,幫助大家更好地為項目找到合適的狀態管理庫。

狀態管理原則

我們在開發過程中,為了提高項目的可維護度和性能,也為了讓頁面UI跟數據(本地或服務端數據)有效分離的同時又能有效同步,都會讓項目保持清晰的目錄結構、同時啟用狀態管理庫。
而MVVM模式已然成為前端項目中的主流架構。

MVVM 即 mode + view + viewModel

  • model表示頁面狀態(即頁面需要的數據)
  • view表示頁面視圖(即UI)
  • viewModel是中間層,負責model和view的雙向通信,實現頁面視圖更新驅動,同時負責的業務邏輯(例如:條件判斷、網絡請求等)的處理。

通過MVVM可以實現視圖、數據、業務邏輯完全分離,使項目數據流向清晰明朗,提高性能,提高可維護度
用戶對頁面的操作觸發數據的處理,數據的變動驅動頁面UI的刷新。所以單一數據源單向數據流是做好狀態管理的關鍵。

  1. 單一數據源:此處的UI是由單獨的數據進行綁定的,是完全可控的,不會隨意受到到其他數據的影響;
  2. 單向數據流:用戶、系統的操作,觸發數據的處理,數據的改變最終驅動視圖發生更新,這個流向必須是單向且可追溯的。即無論用戶、系統做了多少操作,最終數據都是處理好了才去更新視圖,不能在視圖更新后又反向去觸發數據處理。

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目錄結構.png
簡單使用方法
  1. 當一個組件需要使用到BLoC狀態管理時,需要在調用組件之前,需要聲明下BLoC的提供者,具體寫法如下:

    BlocProvider<BadgesBloc>(create: (context)=> BadgesBloc(),child:UserPage()); 
    
  2. 當頁面有多個BLoC提供者,或者整個App通用的BLoC提供者,即可提前在加載App之前全局聲明。可以使用MultiBlocProvider進行聲明,具體寫法如下:

    MultiBlocProvider(
        providers: [
            BlocProvider<BadgesBloc>(create:(context) => BadgesBloc()),
            BlocProvider(create: (context) =>XXX()),
        ],
        child: MaterialApp(),
    ) 
    
  3. 頁面布局將使用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)); 
    
  4. 此時在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);
    } 
    
  5. 補上event、state的代碼截圖

event.png
state.png
優缺點
  1. 【優點】BLoC的目錄結構清晰,完全符合mvvm的習慣。對于工程化項目來說會比較受歡迎,,團隊協作起來會減少出錯的概率,大家都跟著一個模式去做,維護性也提高了;
  2. 【優點】業務流清晰。使用dart stream事件流作為基礎原理,event和state都是事件驅動的,用戶行為觸發事件,事件處理完推出狀態流,穩定的數據流向往往能提高代碼的可靠性;
  3. 【缺點】BLoC使用起來相對復雜,需要創建多個文件。雖然官方引入了cubit,把event組合到bloc文件中,但強烈的結構化依然讓不少初學者難以入門;
  4. 【缺點】顆粒度的把控相對困難。通過BlocBuilder構建的視圖,在state變更時,視圖都會rebuild,想要控制顆粒度只能把bloc再拆細,這會極大的增加代碼復雜度和工作量;不過這個問題可通過引入freezed生成代碼,然后通過buildWhen等方式減少視圖刷新的頻次。

Provider

Provider是Flutter官方開發維護的,也是近些年官方最為推薦的狀態管理庫。Provider 是建立在 InheretedWidget 之上做了封裝,大大減少了我們需要編寫的代碼量。其特點是:不復雜、好理解,可控度高。我們會在項目中引入provider這個庫。

簡單使用方法
  1. 當組件需要使用到Provider狀態管理時,需要在調用組件之前,需要聲明下Provider的提供者,具體寫法如下:
ChangeNotifierProvider<LoginViewModel>.value(
    notifier: LoginViewModel(),
    child:LoginPage(),
) 
  1. 當一個頁面有多個Provider提供者,或者整個App有幾個通用的Provider提供者,有多個頁面都需要使用,即可提前在加載App之前全局聲明。可以使用MultiProvider進行聲明:
MultiProvider(
    providers: [
        ChangeNotifierProvider<LoginViewModel>( create: (_) => LoginViewModel(),),
        ChangeNotifierProvider<HomeViewModel>( create: (_) => HomeViewModel(),),
    ],
    child: MaterialApp()
) 
  1. 頁面布局需創建一個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),
            ),
        ),
    )
)] 

  1. 再來看viewModel中的寫法,class必須繼承ChangeNotifier。當有數據需要更新的時候,調用notifyListeners(),頁面就會刷新
provider_1.png

provider_2.png
實現原理
  1. Provider主要是對 InheritedWidget 組件進行上層封裝,使其更易用,通過ChangeNotifier來處理數據,從而減少了InheritedWidget的大量模版代碼。
  2. 從源碼上我們可以看到Provider直接繼承于InheritedProvider,通過工廠構造函數Provider.value傳入model和child節點,然后通過context.dependOnInheritedWidgetOfExactType<_InheritedProviderScope<T?>>();對值進行監聽。
  3. 而_InheritedProviderScope就是繼承于InheritedWidget的,所以Provider的實現真的很簡單,有用過InheritedWidget的小伙伴可以去看下源碼。
優缺點
  1. 【優點】使用簡單。model繼承ChangeNotifier,沒有更多的布局widget,只需要通過context.read / context.watch操作或者監聽model即可;
  2. 【優點】顆粒度把控簡單。為了解決widget重新build太頻繁的問題,官方推出了context.select來監聽對象的部分屬性。也可使用Consumer/Selector進行布局;
  3. 【優點】基于官方InheritedWidget的封裝,不存在任何風險,很穩定且不會給性能方面加負擔
  4. 【缺點】context強關聯,有Flutter開發經驗的都知道,context大多時候基本都是在widget中才能獲取到,在其他地方想隨時獲取 BuildContext 是不切實際的,也就意味著大多時候只能在view層去獲取到Provider提供的信息。

GetX

GetX是一個輕量級且強大的狀態管理庫,這個庫試圖完成很多工作,它不僅支持狀態管理,也支持路由、國際化、Theme等一大堆功能。GetX在Flutter狀態管理中絕對算是異軍突起,一經發布就因其簡單且全面的優勢,引得一大批簇擁者;我并未認真研究GetX,但簡單接觸后我個人并不喜歡,這種全家桶式的庫會讓我們的項目相對局限,同時讓項目開發者處于沒有進步且被動的局面。

簡單使用方法

我們直接使用 GetX 演示官方example"計數器",

  • 每次點擊都能改變狀態
  • 在不同頁面之間切換
  • 在不同頁面之間共享狀態
  • 將業務邏輯與界面分離
  1. 把MaterialApp變成GetMaterialApp
void main() => runApp(GetMaterialApp(home: Home())); 

  1. 創建你的業務邏輯類,將變量、方法和控制器放在里面。 通過".obs "使變量成為可觀察的
class Controller extends GetxController{
  var count = 0.obs;
  increment() => count++;
} 

  1. 創建界面
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后,資源該如何回收。

  1. GetX通過.obx或Obx(builder)對變量實現訂閱,以實現數據一改變就通知視圖改變的效果。這塊的實現原理還是離不開dart的stream流,通過源碼我們可以知道兩者最終都是繼承于RxNotifier,而RxNotifier with NotifyManager,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();
  }
} 

  1. 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,
      );
} 

  1. 從第一點我們知道了如何進行數據狀態的管理,同時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();
} 

優缺點
  1. 【優點】使用最簡單,用起來確實很簡單,極易上手;脫離context,隨時隨地想用就用,解決了BLoC和Provider的痛點;
  2. 【優點】全家桶式功能,使用GetX后,我們無需再單獨去做路由管理、國際化、主題、全局context等,甚至還支持服務端開發;
  3. 【缺點】第2點優點同樣也是缺點,GetX幫我們封裝了很多本來Flutter就提供的Api,減少了開發者很多的工作。這會讓項目極度依賴GetX,而在Flutter更新迭代這么快的情況下,誰也不敢保證GetX全家桶的更新節奏,一旦更新慢了,開發者只能等GetX(當然能夠參與社區開源的另當別論)。另外,GetX的使用真的太基礎了,讓初學者易上手的同時,技術也容易停留于表面。

總結

image.png

除了上訴的幾種方案,還有其他的庫,如redux / fish_redux/ RiverPod,這些庫有的過于復雜,有的剛出不久,筆者調研過程中有留意但并沒有用過,活躍度確實也沒有上面方案多。

總之,BLoC適合相對大的工程化項目團隊使用,架構清晰;Provider很純粹,也很好用;GetX全家桶一把梭,極度適合新手開發者......

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,837評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,196評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,688評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,654評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,456評論 6 406
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,955評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,044評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,195評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,725評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,608評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,802評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,318評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,048評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,422評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,673評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,424評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,762評論 2 372

推薦閱讀更多精彩內容