Flutter 狀態管理之 Scoped Model & Redux

Neal_yang發表於2019-01-16

前言

文章原文地址:Nealyang/PersonalBlog

可能作為一個前端,在學習 Flutter 的過程中,總感覺非常非常相似 React Native,甚至於,其中還是有state的概念 setState,所以在 Flutter 中,也當然會存在非常多的解決方案,比如 redux 、RxDart 還有 Scoped Model等解決方案。今天,我們主要介紹下常用的兩種 State 管理解決方案:redux、scoped model。

Scoped Model

介紹

Scoped Model 是 package 上 Dart 的一個第三方庫scoped_model。Scoped Model 主要是通過資料model的概念來實現資料傳遞,表現上類似於 react 中 context 的概念。它提供了讓子代widget輕鬆獲取父級資料model的功能。

從官網中的介紹可以瞭解到,它直接來自於Google正在開發的新系統Fuchsia核心 Widgets 中對 Model 類的簡單提取,作為獨立使用的獨立 Flutter 外掛釋出。

在直接上手之前,我們先著重說一下 Scoped Model 中幾個重要的概念

  • Model 類,通過繼承 Model 類來建立自己的資料 model,例如 SearchModel 或者 UserModel ,並且還可以監聽 資料model的變化
  • ScopedModelDescendant widget , 如果你需要傳遞資料 model 到很深層級裡面的 widget ,那麼你就需要用 ScopedModel 來包裹 Model,這樣的話,後面所有的子widget 都可以使用該資料 model 了(是不是更有一種 context 的感覺)
  • ScopedModelDescendant widget ,使用此 widget 可以在 widget tree 中找到相應的 Scope的Model ,當 資料 model 發生變化的時候,該 widget 會重新構建

當然,在 Scoped Model 的文件中,也介紹了一些 實現原理

  • Model類實現了Listenable介面
    • AnimationController和TextEditingController也是Listenables
  • 使用InheritedWidget將資料 model 傳遞到Widget樹。 重建 InheritedWidget 時,它將手動重建依賴於其資料的所有Widgets。 無需管理訂閱!
  • 它使用 AnimatedBuilder Widget來監聽Model並在模型更改時重建InheritedWidget

實操Demo

demo地址

img

從gif上可以看到我們們的需求非常的簡單,就是在當前頁面更新了count後,在第二個頁面也能夠傳遞過去。當然,new ResultPage(count:count)就沒意思啦~ 我們不討論哈

新建資料 model

lib/model/counter_model.dart

  import 'package:scoped_model/scoped_model.dart';
  
  class CounterModel extends Model{
  
    int _counter = 0;
  
    int get counter => _counter;
  
    void increment(){
  
      _counter++;
  
      // 通知所有的 listener
      notifyListeners();
    }
  }
複製程式碼
  • 這一步非常的簡單,新建一個類去繼承 Model
  • 裡面定義了一個 get方法,以便於後面取資料model
  • 定義了 increment 方法,去改變我們的資料 model ,呼叫 package 中的 通知方法 notifyListeners

lib/main.dart

  import 'package:flutter/material.dart';
  import './model/counter_model.dart';
  import 'package:scoped_model/scoped_model.dart';
  import './count_page.dart';
  
  void main() {
    runApp(MyApp(
      model: CounterModel(),
    ));
  }
  
  class MyApp extends StatelessWidget {
  
    final CounterModel model;
  
    const MyApp({Key key,@required this.model}):super(key:key);
  
    @override
    Widget build(BuildContext context) {
      return ScopedModel(
        model: model,
        child: MaterialApp(
          title: 'Scoped Model Demo',
          home:CountPage(),
        ),
      );
    }
  }
複製程式碼

這是 app 的入口檔案,劃重點

  • MyApp 類 中,我們傳入一個定義好的資料 model ,方便後面傳遞給子類
  • MaterialAppScopedModel 包裹一下,作用上面已經介紹了,方便子類可以拿到 ,類似於 reduxProvider 包裹一下
  • 一定需要將資料 model 傳遞給 ScopedModel 的 model 屬性中

lib/count_page.dart

  class CountPage extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
      return Scaffold(
        appBar: AppBar(
          title: Text('Scoped Model'),
          actions: <Widget>[
            IconButton(
              tooltip: 'to result',
              icon: Icon(Icons.home),
              onPressed: (){
                Navigator.push(context,MaterialPageRoute(builder: (context)=>ResultPage()));
              },
            )
          ],
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text('你都點選'),
              ScopedModelDescendant<CounterModel>(
                builder: (context, child, model) {
                  return Text(
                    '${model.counter.toString()} 次了',
                    style: TextStyle(
                      color: Colors.red,
                      fontSize: 33.0,
                    ),
                  );
                },
              )
            ],
          ),
        ),
        floatingActionButton: ScopedModelDescendant<CounterModel>(
          builder: (context,child,model){
            return FloatingActionButton(
              onPressed: model.increment,
              tooltip: 'add',
              child: Icon(Icons.add),
            );
          },
        ),
      );
    }
  }
複製程式碼

常規佈局和widget這裡不再重複介紹,我們說下主角:Scoped Model

  • 簡單一句,哪裡需要用資料 model ,哪裡就需要用 ScopedModelDescendant
  • ScopedModelDescendant中的build方法需要返回一個widget,在這個widget中我們可以使用資料 model中的方法、資料等

最後在 lib/result_page.dart中就可以看到我們資料 model 中的 count 值了,注意這裡跳轉頁面,我們並沒有通過引數傳遞的形式傳遞 Navigator.push(context,MaterialPageRoute(builder: (context)=>ResultPage()));

完整專案程式碼:flutter_scoped_model

flutter_redux

相信作為一個前端對於 redux 一定不會陌生,而 Flutter 中也同樣存在 state 的概念,其實說白了,UI 只是資料(state)的另一種展現形式。study-redux是筆者之前學習redux時候的一些筆記和心得。這裡為了防止有新人不太清楚redux,我們再來介紹下redux的一些基本概念

state

state 我們可以理解為前端UI的狀態(資料)庫,它儲存著這個應用所有需要的資料。

img

action

既然這些state已經有了,那麼我們是如何實現管理這些state中的資料的呢,當然,這裡就要說到action了。 什麼是action?E:action:動作。 是的,就是這麼簡單。。。

只有當某一個動作發生的時候才能夠觸發這個state去改變,那麼,觸發state變化的原因那麼多,比如這裡的我們的點選事件,還有網路請求,頁面進入,滑鼠移入。。。所以action的出現,就是為了把這些操作所產生或者改變的資料從應用傳到store中的有效載荷。 需要說明的是,action是state的唯一訊號來源。

reducer

reducer決定了state的最終格式。 reducer是一個純函式,也就是說,只要傳入引數相同,返回計算得到的下一個 state 就一定相同。沒有特殊情況、沒有副作用,沒有 API 請求、沒有變數修改,單純執行計算。reducer對傳入的action進行判斷,然後返回一個通過判斷後的state,這就是reducer的全部職責。 從程式碼可以簡單地看出:

  import {INCREMENT_COUNTER,DECREMENT_COUNTER} from '../actions';
  
  export default function counter(state = 0,action) {
      switch (action.type){
          case INCREMENT_COUNTER:
              return state+1;
          case DECREMENT_COUNTER:
              return state-1;
          default:
              return state;
      }
  }
複製程式碼

對於一個比較大一點的應用來說,我們是需要將reducer拆分的,最後通過redux提供的combineReducers方法組合到一起。 比如:

  const rootReducer = combineReducers({
      counter
  });
  
  export default rootReducer;
複製程式碼

這裡你要明白:每個 reducer 只負責管理全域性 state 中它負責的一部分。每個 reducer 的 state 引數都不同,分別對應它管理的那部分 state 資料。 combineReducers() 所做的只是生成一個函式,這個函式來呼叫你的一系列 reducer,每個 reducer 根據它們的 key 來篩選出 state 中的一部分資料並處理, 然後這個生成的函式再將所有 reducer 的結果合併成一個大的物件。

store

store是對之前說到一個聯絡和管理。具有如下職責

  • 維持應用的 state;
  • 提供 getState() 方法獲取 state
  • 提供 dispatch(action) 方法更新 state;
  • 通過 subscribe(listener) 註冊監聽器;
  • 通過 subscribe(listener) 返回的函式登出監聽器。

再次強調一下 Redux 應用只有一個單一的 store。當需要拆分資料處理邏輯時,你應該使用 reducer 組合 而不是建立多個 store。 store的建立通過redux的createStore方法建立,這個方法還需要傳入reducer,很容易理解:畢竟我需要dispatch一個action來改變state嘛。 應用一般會有一個初始化的state,所以可選為第二個引數,這個引數通常是有服務端提供的,傳說中的Universal渲染。後面會說。。。 第三個引數一般是需要使用的中介軟體,通過applyMiddleware傳入。

說了這麼多,action,store,action creator,reducer關係就是這麼如下的簡單明瞭:

img

結合 flutter_redux

一些工具集讓你輕鬆地使用 redux 來輕鬆構建 Flutter widget,版本要求是 redux.dart 3.0.0+

Redux Widgets

  • StoreProvider :基礎元件,它將給定的 Redux Store 傳遞給所欲請求它的的子代元件
  • StoreBuilder : 一個子代元件,它從 StoreProvider 獲取 Store 並將其傳遞給 widget 的 builder 方法中
  • StoreConnector :獲取 Store 的一個子代元件

StoreProvider ancestor,使用給定的 converter 函式將 Store 轉換為 ViewModel ,並將ViewModel傳遞給 builder。 只要 Store 發出更改事件(action),Widget就會自動重建。 無需管理訂閱!

注意

Dart 2需要更嚴格的型別!

1、確認你正使用的是 redux 3.0.0+ 2、在你的元件樹中,將 new StoreProvider(...) 改為 new StoreProvider<StateClass>(...) 3、如果需要從StoreProvider<AppState> 中直接獲取 Store<AppState> ,則需要將 new StoreProvider.of(context) 改為 StoreProvider.of<StateClass> .不需要直接訪問 Store 中的欄位,因為Dart2可以使用靜態函式推斷出正確的型別

實操演練

官方demo的程式碼先大概解釋一下

  import 'package:flutter/material.dart';
  import 'package:flutter_redux/flutter_redux.dart';
  import 'package:redux/redux.dart';
  
  //定義一個action: Increment
  enum Actions { Increment }
  
  // 定義一個 reducer,響應傳進來的 action
  int counterReducer(int state, dynamic action) {
    if (action == Actions.Increment) {
      return state + 1;
    }
  
    return state;
  }
  
  void main() {
    // 在 基礎 widget 中建立一個 store,用final關鍵字修飾  這比直接在build方法中建立要好很多
    final store = new Store<int>(counterReducer, initialState: 0);
  
    runApp(new FlutterReduxApp(
      title: 'Flutter Redux Demo',
      store: store,
    ));
  }
  
  class FlutterReduxApp extends StatelessWidget {
    final Store<int> store;
    final String title;
  
    FlutterReduxApp({Key key, this.store, this.title}) : super(key: key);
  
    @override
    Widget build(BuildContext context) {
      // 用  StoreProvider 來包裹你的 MaterialApp 或者別的 widget ,這樣能夠確保下面所有的widget能夠獲取到store中的資料
      return new StoreProvider<int>(
        // 將 store  傳遞給 StoreProvider
        // Widgets 將使用 store 變數來使用它
        store: store,
        child: new MaterialApp(
          theme: new ThemeData.dark(),
          title: title,
          home: new Scaffold(
            appBar: new AppBar(
              title: new Text(title),
            ),
            body: new Center(
              child: new Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  new Text(
                    'You have pushed the button this many times:',
                  ),
                  // 通過 StoreConnector 將 store 和 Text 連線起來,以便於 Text直接render
                  // store 中的值。類似於 react-redux 中的connect
                  //
                  // 將 Text widget 包裹在 StoreConnector 中,
                  // `StoreConnector`將會在最近的一個祖先元素中找到 StoreProvider
                  // 拿到對應的值,然後傳遞給build函式
                  //
                  // 每次點選按鈕的時候,將會 dispatch 一個 action並且被reducer所接受。
                  // 等reducer處理得出最新結果後, widget將會自動重建
                  new StoreConnector<int, String>(
                    converter: (store) => store.state.toString(),
                    builder: (context, count) {
                      return new Text(
                        count,
                        style: Theme.of(context).textTheme.display1,
                      );
                    },
                  )
                ],
              ),
            ),
            // 同樣使用 StoreConnector 來連線Store 和FloatingActionButton
            // 在這個demo中,我們使用store 去構建一個包含dispatch、Increment 
            // action的回撥函式
            //
            // 將這個回撥函式丟給 onPressed
            floatingActionButton: new StoreConnector<int, VoidCallback>(
              converter: (store) {
                return () => store.dispatch(Actions.Increment);
              },
              builder: (context, callback) {
                return new FloatingActionButton(
                  onPressed: callback,
                  tooltip: 'Increment',
                  child: new Icon(Icons.add),
                );
              },
            ),
          ),
        ),
      );
    }
  }
複製程式碼

上面的例子比較簡單,鑑於小冊Flutter入門實戰:從0到1仿寫web版掘金App下面有哥們在登陸那塊評論了Flutter狀態管理,

這裡我簡單使用redux模擬了一個登陸的demo

img

lib/reducer/reducers.dart

首先我們定義action需要的一些action type

  enum Actions{
    Login,
    LoginSuccess,
    LogoutSuccess
  }
複製程式碼

然後定義相應的類來管理登陸狀態

  class AuthState{
    bool isLogin;     //是否登入
    String account;   //使用者名稱
    AuthState({this.isLogin:false,this.account});
  
    @override
    String toString() {
      return "{account:$account,isLogin:$isLogin}";
    }
  }
複製程式碼

然後我們需要定義一些action,定義個基類,然後定義登陸成功的action

  class Action{
    final Actions type;
    Action({this.type});
  }
  
  class LoginSuccessAction extends Action{
  
    final String account;
  
    LoginSuccessAction({
      this.account
    }):super( type:Actions.LoginSuccess );
  }
複製程式碼

最後定義 AppState 以及我們自定義的一箇中介軟體。

  // 應用程式狀態
  class AppState {
    AuthState auth; //登入
    MainPageState main; //主頁
  
    AppState({this.main, this.auth});
  
    @override
    String toString() {
      return "{auth:$auth,main:$main}";
    }
  }
  
  AppState mainReducer(AppState state, dynamic action) {
  
    if (Actions.LogoutSuccess == action) {
      state.auth.isLogin = false;
      state.auth.account = null;
    }
  
    if (action is LoginSuccessAction) {
      state.auth.isLogin = true;
      state.auth.account = action.account;
    }
  
    print("state changed:$state");
  
    return state;
  }
  
  loggingMiddleware(Store<AppState> store, action, NextDispatcher next) {
    print('${new DateTime.now()}: $action');
  
    next(action);
  }
複製程式碼

在稍微大一點的專案中,其實就是reducer 、 state 和 action 的組織會比較麻煩,當然,羅馬也不是一日建成的, 龐大的state也是一點一點累計起來的。

下面就是在入口檔案中使用 redux 的程式碼了,跟基礎demo沒有差異。

  import 'package:flutter/material.dart';
  import 'package:flutter_redux/flutter_redux.dart';
  import 'package:redux/redux.dart';
  import 'dart:async' as Async;
  import './reducer/reducers.dart';
  import './login_page.dart';
  
  void main() {
    Store<AppState> store = Store<AppState>(mainReducer,
        initialState: AppState(
          main: MainPageState(),
          auth: AuthState(),
        ),
        middleware: [loggingMiddleware]);
  
    runApp(new MyApp(
      store: store,
    ));
  }
  
  class MyApp extends StatelessWidget {
    final Store<AppState> store;
  
    MyApp({Key key, this.store}) : super(key: key);
  
    @override
    Widget build(BuildContext context) {
      return new StoreProvider(store: store, child: new MaterialApp(
        title: 'Flutter Demo',
        theme: new ThemeData(
          primarySwatch: Colors.blue,
        ),
        home:  new StoreConnector<AppState,AppState>(builder: (BuildContext context,AppState state){
          print("isLogin:${state.auth.isLogin}");
          return new MyHomePage(title: 'Flutter Demo Home Page',
            counter:state.main.counter,
              isLogin: state.auth.isLogin,
              account:state.auth.account);
        }, converter: (Store<AppState> store){
          return store.state;
        }) ,
        routes: {
          "login":(BuildContext context)=>new StoreConnector(builder: ( BuildContext context,Store<AppState> store ){
  
            return new LoginPage(callLogin: (String account,String pwd) async{
              print("正在登入,賬號$account,密碼:$pwd");
              // 為了模擬實際登入,這裡等待一秒
              await new Async.Future.delayed(new Duration(milliseconds: 1000));
              if(pwd != "123456"){
                throw ("登入失敗,密碼必須是123456");
              }
              print("登入成功!");
              store.dispatch(new LoginSuccessAction(account: account));
  
            },);
          }, converter: (Store<AppState> store){
            return store;
          }),
  
        },
      ));
    }
  }
  
  
  
  
  class MyHomePage extends StatelessWidget {
    MyHomePage({Key key, this.title, this.counter, this.isLogin, this.account})
        : super(key: key);
    final String title;
    final int counter;
    final bool isLogin;
    final String account;
  
    @override
    Widget build(BuildContext context) {
      print("build:$isLogin");
      Widget loginPane;
      if (isLogin) {
        loginPane = new StoreConnector(
            key: new ValueKey("login"),
            builder: (BuildContext context, VoidCallback logout) {
              return new RaisedButton(
                onPressed: logout, child: new Text("您好:$account,點選退出"),);
            }, converter: (Store<AppState> store) {
          return () =>
              store.dispatch(
                  Actions.LogoutSuccess
              );
        });
      } else {
        loginPane = new RaisedButton(onPressed: () {
          Navigator.of(context).pushNamed("login");
        }, child: new Text("登入"),);
      }
      return new Scaffold(
        appBar: new AppBar(
          title: new Text(title),
        ),
        body: new Center(
          child: new Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
  
              /// 有登入,展示你好:xxx,沒登入,展示登入按鈕
              loginPane
            ],
          ),
        ),
  
      );
    }
  }

複製程式碼

完整專案程式碼:Nealyang/Flutter

Flutter Go

更多學習 Flutter的小夥伴,歡迎入QQ群 Flutter Go :679476515

關於 Flutter 元件以及更多的學習,敬請關注我們正在開發的: alibaba/flutter-go

git

參考

flutter_architecture_samples flutter_redux flutter example scoped_model

相關文章