MVC、MVP、BloC、Redux四種架構在Flutter上的嘗試

Yuzo發表於2019-07-15

個人部落格

前言

從進行開發OpenGit_Flutter專案以來,在專案中選擇哪種架構困擾了很久。近段時間,分別在專案中嘗試了BloCRedux這兩種架構,通過開發中遇到的問題,已經找到了合適的方案。為了演示方便,我選擇了該專案的登入流程來為大家做演示,下面對登入流程做下拆解。

  1. 登入首先需要輸入賬號和密碼,只有在賬號和密碼都有輸入的時候,底部登入按鈕才能點選,所以需要監聽賬號和密碼輸入框的輸入狀態,用來控制登入按鈕的點選狀態;
  2. 賬號輸入框需要支援一鍵刪除的功能;
  3. 密碼輸入框需要支援對密碼可見的功能;
  4. 點選登入按鈕觸發登入邏輯,在登入過程中需要展示loading介面,當登入失敗後,取消loading介面,並進行toast提示;當登入成功之後,跳轉的主介面,展示使用者的基本資訊;
  5. 使用者資料和token等資訊的儲存,在本文中不會提到,如需檢視該部分程式碼,點選OpenGit_Flutter

最終的演示效果如下所示

MVC、MVP、BloC、Redux四種架構在Flutter上的嘗試
MVC、MVP、BloC、Redux四種架構在Flutter上的嘗試

登入介面的佈局程式碼,不做過多的介紹,如果需要了解更多,可以檢視相關原始碼,地址會在本文的最後貼出。

工程結構

flutter_architecture根目錄是一個Flutter Package,其下面分別建立了blocmvcmvpredux四個工程,lib目錄分別是四個工程的公用模組,例如網路請求、日誌列印、toast提示、主頁資訊展示等。如下圖所示

MVC、MVP、BloC、Redux四種架構在Flutter上的嘗試

MVC

該架構是在寫flutter_architecture例子時最後加上的,因為在進行Android開發的過程中,經常用它來與MVP做對比。

架構檢視

MVC、MVP、BloC、Redux四種架構在Flutter上的嘗試

程式入口

main.dart是程式的入口,完成登入介面的啟動,相關程式碼如下所示

void main() => runApp(MVCApp());

class MVCApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primaryColor: Colors.black,
      ),
      home: LoginPage(),
    );
  }
}
複製程式碼

登入流程

由於登入狀態涉及到介面相關控制元件的重新整理,所以繼承的是StatefulWidget

文字監聽

文字監聽需要監聽賬號和密碼輸入框的輸入狀態,需宣告兩個TextEditingController物件,相關程式碼如下所示

final TextEditingController _nameController = new TextEditingController();
final TextEditingController _passwordController = new TextEditingController();
複製程式碼

initState處理輸入框的監聽事件,當輸入狀態改變時,並重新整理頁面,更新登入按鈕狀態,相關程式碼如下所示

@override
void initState() {
    super.initState();

    _nameController.addListener(() {
      setState(() {});
    });
    _passwordController.addListener(() {
      setState(() {});
    });
}
複製程式碼

登入按鈕狀態判斷需要通過賬號和密碼輸入字串長短來判斷,當長度都大於0的時候,按鈕才能點選,邏輯層相關程式碼如下所示

_isValidLogin() {
    String name = _nameController.text;
    String password = _passwordController.text;

    return name.length > 0 && password.length > 0;
}
複製程式碼

登入按鈕UI層程式碼如下所示

Align _buildLoginButton(BuildContext context) {
    return Align(
      child: SizedBox(
        height: 45.0,
        width: 270.0,
        child: RaisedButton(
          child: Text(
            '登入',
            style: Theme.of(context).primaryTextTheme.headline,
          ),
          color: Colors.black,
          onPressed: _isValidLogin()
              ? () {
                  _login();
                }
              : null,
          shape: StadiumBorder(side: BorderSide()),
        ),
      ),
    );
}
複製程式碼

清空賬號輸入框

清空輸入框只需呼叫TextEditingController clear方法,如下面程式碼所示

  TextFormField _buildNameTextField() {
    return new TextFormField(
      controller: _nameController,
      decoration: new InputDecoration(
        labelText: 'Github賬號:',
        suffixIcon: new GestureDetector(
          onTap: () {
            _nameController.clear();
          },
          child: new Icon(_nameController.text.length > 0 ? Icons.clear : null),
        ),
      ),
      maxLines: 1,
    );
  }
複製程式碼

密碼是否可見

密碼是否可見主要是通過更新變數_obscureText實現,點選事件處理邏輯很簡單,只是對_obscureText做下取反操作,並重新整理頁面,程式碼如下所示

TextFormField _buildPasswordTextField(BuildContext context) {
    return new TextFormField(
      controller: _passwordController,
      decoration: new InputDecoration(
        labelText: 'Github密碼:',
        suffixIcon: new GestureDetector(
          onTap: () {
            setState(() {
              _obscureText = !_obscureText;
            });
          },
          child:
              new Icon(_obscureText ? Icons.visibility_off : Icons.visibility),
        ),
      ),
      maxLines: 1,
      obscureText: _obscureText,
    );
}
複製程式碼

觸發登入

View層點選登入按鈕,觸發Control層登入邏輯,在Control層通過state控制loading介面的展示和隱藏,而loading的最終狀態是由Model層的loading狀態決定,loading UI相關程式碼如下所示:

Offstage(
    offstage: !Con.isLoading,
    child: new Container(
        width: MediaQuery.of(context).size.width,
        height: MediaQuery.of(context).size.height,
        color: Colors.black54,
            child: new Center(
                child: SpinKitCircle(
                  color: Theme.of(context).primaryColor,
                  size: 25.0,
                ),
            ),
    ),
),
複製程式碼
定義Control層

首先建立單例物件,並初始化Model層資料,向View層提供登入、載入狀態、使用者資料等狀態的介面,相關程式碼如下所示

class Con {
  factory Con() => _getInstance();

  static Con get instance => _getInstance();
  static Con _instance;

  Con._internal();

  static Con _getInstance() {
    if (_instance == null) {
      _instance = new Con._internal();
    }
    return _instance;
  }

  static final model = Model();

  static bool get isLoading => model.isLoading;

  static UserBean get userBean => model.userBean;

  Future login(State state, String name, String password) async {
    state.setState(() {
      _showLoading();
    });

    await model.login(name, password);

    state.setState(() {
      _hideLoading();
    });
  }

  void _showLoading() {
    model.showLoading();
  }

  void _hideLoading() {
    model.hideLoading();
  }
}
複製程式碼
定義Model層

Model層主要進行登入、獲取使用者資料的網路請求,並儲存loading狀態以及使用者資料,相關程式碼如下所示

class Model {
  bool get isLoading => _isLoading;
  bool _isLoading = false;

  UserBean get userBean => _userBean;
  UserBean _userBean;

  Future login(String name, String password) async {
    final login = await LoginManager.instance.login(name, password);
    //授權成功
    if (login != null) {
      final user = await LoginManager.instance.getMyUserInfo();
      _userBean = user;
    }
    return;
  }

  void showLoading() {
    _isLoading = true;
  }

  void hideLoading() {
    _isLoading = false;
  }
}
複製程式碼

網路層的相關程式碼就不再貼出,感興趣的可以在本文末尾下載原始碼進行檢視。

由上面程式碼可知,當View層觸發登入時,呼叫了Controllogin介面,在該介面內,實現了展示loading狀態,並等待登入的網路請求,當請求完成後,則取消loading狀態,最終交給View層進行資料處理,相關處理的程式碼如下所示

_login() async {
    String name = _nameController.text;
    String password = _passwordController.text;

    await Con.instance.login(this, name, password);

    if (Con.userBean != null) {
      NavigatorUtil.goHome(context, Con.userBean);
    } else {
      ToastUtil.showToast('登入失敗,請重新登入');
    }
}
複製程式碼

到此,MVC整個框架的登入流程已進行完成。

MVP

該架構是在進行Android開發時,是一種比較常用的架構。

架構檢視

MVC、MVP、BloC、Redux四種架構在Flutter上的嘗試

程式入口

main.dart是程式的入口,完成登入介面的啟動,相關程式碼如下所示

void main() => runApp(MVPApp());

class MVPApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primaryColor: Colors.black,
      ),
      home: LoginPage(),
    );
  }
}
複製程式碼

登入流程

MVC一致,可以參考MVC

文字監聽

MVC一致,可以參考MVC

清空賬號輸入框

MVC一致,可以參考MVC

密碼是否可見

MVC一致,可以參考MVC

觸發登入

View層點選登入按鈕,觸發Presenter層登入邏輯,在Presenter層通過View層提供的介面來控制loading介面的展示和隱藏,loading UI相關程式碼如下所示

Offstage(
    offstage: !isLoading,
    child: new Container(
        width: MediaQuery.of(context).size.width,
        height: MediaQuery.of(context).size.height,
        color: Colors.black54,
        child: new Center(
            child: SpinKitCircle(
                color: Theme.of(context).primaryColor,
                size: 25.0,
            ),
        ),
    ),
),
複製程式碼

上面程式碼中的isLoading狀態已在基類中做了統一封裝,下面對MVP做定義封裝。

封裝View層

觸發網路請求,需要滿足展示和隱藏loading介面,所以View的對外需要提供這兩個最基本的介面,如下面程式碼所示

abstract class IBaseView {
  showLoading();

  hideLoading();
}
複製程式碼
封裝Presenter層

Presenter層的公共介面只需提供對View層的註冊以及反註冊,如下面程式碼所示

abstract class IBasePresenter<V extends IBaseView> {
  void onAttachView(V view);

  void onDetachView();
}
複製程式碼

下面對Presenter層的程式碼實現上面所提供的介面,如下面程式碼所示

abstract class BasePresenter<V extends IBaseView> extends IBasePresenter<V> {
  V view;

  @override
  void onAttachView(IBaseView view) {
    this.view = view;
  }

  @override
  void onDetachView() {
    this.view = null;
  }
}
複製程式碼
封裝State基類

State基類中,需要提供Presenter的初始化的方法、loading狀態、資料初始化以及檢視的構建等,如下面程式碼所示

abstract class BaseState<T extends StatefulWidget, P extends BasePresenter<V>,
    V extends IBaseView> extends State<T> implements IBaseView {
  P presenter;

  bool isLoading = false;

  P initPresenter();

  Widget buildBody(BuildContext context);

  void initData() {
  }

  @override
  void initState() {
    super.initState();
    presenter = initPresenter();
    if (presenter != null) {
      presenter.onAttachView(this);
    }
    initData();
  }

  @override
  void dispose() {
    super.dispose();
    if (presenter != null) {
      presenter.onDetachView();
      presenter = null;
    }
  }

  @override
  @mustCallSuper
  Widget build(BuildContext context) {
    return new Scaffold(
      body: buildBody(context),
    );
  }

  @override
  void showLoading() {
    setState(() {
      isLoading = true;
    });
  }

  @override
  void hideLoading() {
    setState(() {
      isLoading = false;
    });
  }
}
複製程式碼

到此,MVP框架已經封裝完,下面只需對登入介面做相應的實現即可。

實現登入邏輯

在進行登入時,Presenter層需要向View層提供登入介面,當進行登入完畢後,需要向View層進行登入狀態的反饋,所以View需要提供登入成功、失敗兩個介面,如下面程式碼所示

abstract class ILoginPresenter<V extends ILoginView> extends BasePresenter<V> {
  void login(String name, String password);
}

abstract class ILoginView extends IBaseView {
  void onLoginSuccess(UserBean userBean);

  void onLoginFailed();
}
複製程式碼

當相關介面定義完畢後,首先實現登入的Presenter層的程式碼,如下面程式碼所示

class LoginPresenter extends ILoginPresenter {
  @override
  void login(String name, String password) async {
    if (view != null) {
      view.showLoading();
    }
    final login = await LoginManager.instance.login(name, password);
    //授權成功
    if (login != null) {
      final user = await LoginManager.instance.getMyUserInfo();
      if (user != null) {
        if (view != null) {
          view.hideLoading();
          view.onLoginSuccess(user);
        } else {
          view.hideLoading();
          view.onLoginFailed();
        }
      }
    } else {
      if (view != null) {
        view.hideLoading();
        view.onLoginFailed();
      }
    }
  }
}
複製程式碼

然後對登入State的程式碼進行實現,如下面程式碼所示

class _LoginPageState extends BaseState<LoginPage, LoginPresenter, ILoginView>
    implements ILoginView {

  @override
  void initData() {
    super.initData();
  }

  @override
  Widget buildBody(BuildContext context) {
   return null;
  }

  @override
  LoginPresenter initPresenter() {
    return LoginPresenter();
  }

  @override
  void onLoginSuccess(UserBean userBean) {
    NavigatorUtil.goHome(context, userBean);
  }

  @override
  void onLoginFailed() {
    ToastUtil.showToast('登入失敗,請重新登入');
  }
}
複製程式碼

相關程式碼已經封裝完畢,最後只需呼叫登入相關邏輯,如下面程式碼所示

 _login() {
    if (presenter != null) {
      String name = _nameController.text;
      String password = _passwordController.text;
      presenter.login(name, password);
    }
}
複製程式碼

BloC

關於什麼是BloC,可以參考[Flutter Package]狀態管理之BLoC的封裝Flutter | 狀態管理探索篇——BLoC(三)

架構檢視

MVC、MVP、BloC、Redux四種架構在Flutter上的嘗試

程式入口

main.dart是程式的入口,完成登入介面的啟動,相關程式碼如下所示

void main() => runApp(BlocApp());

class BlocApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primaryColor: Colors.black,
      ),
      home: BlocProvider<LoginBloc>(
        child: LoginPage(),
        bloc: LoginBloc(),
      ),
    );
  }
}
複製程式碼

上面程式碼跟MVCMVP有不同之處,傳入home的物件是BlocProvider,且其包含了childbloc例項。如下面程式碼所示

class BlocProvider<T extends BaseBloc> extends StatefulWidget {
  final T bloc;
  final Widget child;

  BlocProvider({
    Key key,
    @required this.child,
    @required this.bloc,
  }) : super(key: key);

  @override
  _BlocProviderState<T> createState() {
    return _BlocProviderState<T>();
  }

  static T of<T extends BaseBloc>(BuildContext context) {
    final type = _typeOf<BlocProvider<T>>();
    BlocProvider<T> provider = context.ancestorWidgetOfExactType(type);
    return provider.bloc;
  }

  static Type _typeOf<T>() => T;
}

class _BlocProviderState<T> extends State<BlocProvider<BaseBloc>> {
  static final String TAG = "_BlocProviderState";

  @override
  void initState() {
    super.initState();
    LogUtil.v('initState ' + T.toString(), tag: TAG);
  }

  @override
  Widget build(BuildContext context) {
    LogUtil.v('build ' + T.toString(), tag: TAG);
    return widget.child;
  }

  @override
  void dispose() {
    super.dispose();
    LogUtil.v('dispose ' + T.toString(), tag: TAG);
    widget.bloc.dispose();
  }
}
複製程式碼

登入流程

BLoC能夠允許我們分離業務邏輯,不用考慮什麼時候需要重新整理螢幕,一切交給StreamBuilder和BLoC就可以完成,所以登入頁面繼承StatelessWidget即可。如下面程式碼所示

class LoginPage extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
        return StreamBuilder(
            stream: bloc.stream,
            initialData: initialData(),
            builder: (BuildContext context,
            AsyncSnapshot<LoadingBean<LoginBlocBean>> snapshot) {
            }
        );
    }
}
複製程式碼
  • stream代表了這個stream builder監聽的流,這裡監聽的是LoginBloc的stream;
  • initData代表初始的值,因為在首次渲染的時候,還未與使用者產生互動,也就不會有事件從流中流出,所以需要給首次渲染一個初始值;
  • builder函式接收一個位置引數BuildContext和一個snapshot,snapshot就是這個流輸出的資料的一個快照,我們可以通過snapshot.data訪問快照中的資料,StreamBuilder中的builder是一個AsyncWidgetBuilder,它能夠非同步構建widget,當檢測到有資料從流中流出時,將會重新構建。

建立BloC

首先完成BloC基類的封裝,基類需要只需要滿足登入狀態,如下面程式碼所示

class LoadingBean<T> {
  bool isLoading;
  T data;

  LoadingBean({this.isLoading, this.data});

  @override
  String toString() {
    return 'LoadingBean{isLoading: $isLoading, data: $data}';
  }
}

abstract class BaseBloc<T extends LoadingBean> {
  static final String TAG = "BaseBloc";

  BehaviorSubject<T> _subject = BehaviorSubject<T>();

  Sink<T> get sink => _subject.sink;

  Stream<T> get stream => _subject.stream;

  void dispose() {
    _subject.close();
    sink.close();
  }
}
複製程式碼

建立BloC例項

在登入的BloC例項中,完成整個登入過程,我們需要監聽賬號、密碼的輸入狀態,密碼的是否可見狀態,以及登入狀態,如下面程式碼所示

class LoginBloc extends BaseBloc<LoadingBean<LoginBlocBean>> {
  LoadingBean<LoginBlocBean> bean;

  LoginBloc() {
    bean = LoadingBean<LoginBlocBean>(
      isLoading: false,
      data: LoginBlocBean(
        name: '',
        password: '',
        obscure: true,
      ),
    );
  }

  changeObscure() {
  }

  changeName(String name) {
  }

  changePassword(String password) {
  }

  login(BuildContext context) async {
  }

  void _showLoading() {
    bean.isLoading = true;
    sink.add(bean);
  }

  void _hideLoading() {
    bean.isLoading = false;
    sink.add(bean);
  }
}
複製程式碼

文字監聽

建立賬號和密碼兩個TextEditingController例項,並完成其事件監聽,如下面程式碼所示

final TextEditingController _nameController = new TextEditingController();
final TextEditingController _passwordController = new TextEditingController();

LoginBloc bloc = BlocProvider.of<LoginBloc>(context);

_nameController.addListener(() {
    bloc.changeName(_nameController.text);
});
_passwordController.addListener(() {
    bloc.changePassword(_passwordController.text);
});
複製程式碼

當文字發生改變時,會呼叫LoginBloc裡相應的改變方法,並對相應的文字進行重新複雜,在通過sink.add()更新介面,如下面程式碼所示

changeName(String name) {
    bean.data.name = name;
    sink.add(bean);
}

changePassword(String password) {
    bean.data.password = password;
    sink.add(bean);
}
複製程式碼

清空賬號輸入框

MVC一致,可以參考MVC

密碼是否可見

需要改變可見狀態,呼叫LoginBloc中的changeObscure方法,如下面程式碼所示

changeObscure() {
    bean.data.obscure = !bean.data.obscure;
    sink.add(bean);
}
複製程式碼

觸發登入

需要進行網路請求,控制loading的展示和隱藏,這裡需要呼叫LoginBloc中的login方法,當登入成功後,則跳轉主頁展示基本資訊,不成功則toast提示,如下面程式碼所示

login(BuildContext context) async {
    _showLoading();

    final login =
        await LoginManager.instance.login(bean.data.name, bean.data.password);
    //授權成功
    if (login != null) {
      final user = await LoginManager.instance.getMyUserInfo();
      if (user != null) {
        NavigatorUtil.goHome(context, user);
      } else {
        ToastUtil.showToast('登入失敗,請重新登入');
      }
    } else {
      ToastUtil.showToast('登入失敗,請重新登入');
    }

    _hideLoading();
}
複製程式碼

Redux

Redux是網頁開發著廣泛使用的設計模式,比如用在React.js中。關於它的介紹可以參考文章Flutter主題切換之flutter redux

架構檢視

MVC、MVP、BloC、Redux四種架構在Flutter上的嘗試

程式入口

main.dart是程式的入口,完成登入介面的啟動,相關程式碼如下所示

void main() {
  final store = new Store<AppState>(
    appReducer,
    initialState: AppState.initial(),
    middleware: [
      LoginMiddleware(),
    ],
  );

  runApp(
    ReduxApp(
      store: store,
    ),
  );
}

class ReduxApp extends StatelessWidget {
  final Store<AppState> store;

  const ReduxApp({Key key, this.store}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return StoreProvider<AppState>(
      store: store,
      child: StoreConnector<AppState, _ViewModel>(
        converter: _ViewModel.fromStore,
        builder: (context, vm) {
          return MaterialApp(
            theme: ThemeData(
              primaryColor: Colors.black,
            ),
            home: LoginPage(),
          );
        },
      ),
    );
  }
}

class _ViewModel {
  _ViewModel();

  static _ViewModel fromStore(Store<AppState> store) {
    return _ViewModel();
  }
}
複製程式碼

在程式的入口處,對Store進行了初始化工作,完成了對reducerstatemiddleware初始化工作。

定義action

完成登入需要有請求登入、請求載入中、請求錯誤、請求成功等幾個狀態,如下面程式碼所示

class FetchLoginAction {
  final BuildContext context;
  final String userName;
  final String password;

  FetchLoginAction(this.context, this.userName, this.password);
}

class ReceivedLoginAction {
  ReceivedLoginAction(
    this.token,
    this.userBean,
  );

  final String token;
  final UserBean userBean;
}

class RequestingLoginAction {}

class ErrorLoadingLoginAction {}
複製程式碼

初始化state

目前只有一個登入功能,所以只需一個登入的state,如下面程式碼所示

class AppState {
  final LoginState loginState;

  AppState({
    this.loginState,
  });

  factory AppState.initial() => AppState(
        loginState: LoginState.initial(),
      );
}

class LoginState {
  final bool isLoading;
  final String token;

  LoginState({this.isLoading, this.token});

  factory LoginState.initial() {
    return LoginState(
      isLoading: false,
      token: '',
    );
  }

  LoginState copyWith({bool isLoading, String token}) {
    return LoginState(
      isLoading: isLoading ?? this.isLoading,
      token: token ?? this.token,
    );
  }
}
複製程式碼

初始化reducer

目前只有一個登入功能,所以只需一個登入的reducer,如下面程式碼所示

AppState appReducer(AppState state, action) {
  return AppState(
    loginState: loginReducer(state.loginState, action),
  );
}

final loginReducer = combineReducers<LoginState>([
  TypedReducer<LoginState, RequestingLoginAction>(_requestingLogin),
  TypedReducer<LoginState, ReceivedLoginAction>(_receivedLogin),
  TypedReducer<LoginState, ErrorLoadingLoginAction>(_errorLoadingLogin),
]);
複製程式碼

初始化middleware

登入的中介軟體暫時只對其做個簡單的初始化過程,如下面程式碼所示

class LoginMiddleware extends MiddlewareClass<AppState> {
  static final String TAG = "LoginMiddleware";

  @override
  void call(Store store, action, NextDispatcher next) {
  }
}
複製程式碼

登入流程

Redux能夠允許我們分離業務邏輯,不用考慮什麼時候需要重新整理螢幕,一切交給StoreConnector可以完成,所以登入頁面繼承StatelessWidget即可。如下面程式碼所示

class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StoreConnector<AppState, LoginPageViewModel>(
      distinct: true,
      converter: (store) => LoginPageViewModel.fromStore(store, context),
      builder: (_, viewModel) => LoginPageContent(viewModel),
    );
  }
}
複製程式碼

LoginPageViewModel只負責登入狀態以及登入行為,如下面程式碼所示

typedef OnLogin = void Function(String name, String password);

class LoginPageViewModel {
  static final String TAG = "LoginPageViewModel";

  final OnLogin onLogin;
  final bool isLoading;

  LoginPageViewModel({this.onLogin, this.isLoading});

  static LoginPageViewModel fromStore(
      Store<AppState> store, BuildContext context) {
    return LoginPageViewModel(
      isLoading: store.state.loginState.isLoading,
      onLogin: (String name, String password) {
        LogUtil.v('name is $name, password is $password', tag: TAG);
        store.dispatch(FetchLoginAction(context, name, password));
      },
    );
  }
}
複製程式碼

文字監聽

MVC一致,可以參考MVC

清空賬號輸入框

MVC一致,可以參考MVC

密碼是否可見

MVC一致,可以參考MVC

觸發登入

在進行登入時,我們只需呼叫LoginPageViewModel內的onLogin方法,該方法會通過store分發出FetchLoginAction,此時中介軟體LoginMiddleware會收到該行為,並對其進行處理。

@override
void call(Store store, action, NextDispatcher next) {
    next(action);
    if (action is FetchLoginAction) {
      _doLogin(next, action.context, action.userName, action.password);
    }
}
複製程式碼

上面對收到的行為繼續分發出去,如果是自己感興趣的行為,就自己進行操作,處理FetchLoginAction行為,相關程式碼如下所示

Future<void> _doLogin(NextDispatcher next, BuildContext context,
      String userName, String password) async {
    next(RequestingLoginAction());

    try {
      LoginBean loginBean =
          await LoginManager.instance.login(userName, password);
      if (loginBean != null) {
        String token = loginBean.token;
        LoginManager.instance.setToken(loginBean.token, true);
        UserBean userBean = await LoginManager.instance.getMyUserInfo();
        if (userBean != null) {
          next(ReceivedLoginAction(token, userBean));
          NavigatorUtil.goHome(context, userBean);
        } else {
          ToastUtil.showToast('登入失敗請重新登入');
          LoginManager.instance.setToken(null, true);
        }
      } else {
        ToastUtil.showToast('登入失敗請重新登入');
        next(ErrorLoadingLoginAction());
      }
    } catch (e) {
      LogUtil.v(e, tag: TAG);
      ToastUtil.showToast('登入失敗請重新登入');
      next(ErrorLoadingLoginAction());
    }
}
複製程式碼

在進行登入的過程中,最初會發出正在請求的行為RequestingLoginAction,當登入成功後也會發出行為ReceivedLoginAction,登入失敗後發出行為ErrorLoadingLoginAction,而這些發出的行為都會被reducer收到,並對資料進行處理,在通知UI重新整理。loginReducer相關處理邏輯如下面程式碼所示

LoginState _requestingLogin(LoginState state, action) {
  LogUtil.v('_requestingLogin', tag: TAG);
  return state.copyWith(isLoading: true);
}

LoginState _receivedLogin(LoginState state, action) {
  LogUtil.v('_receivedLogin', tag: TAG);
  return state.copyWith(isLoading: false, token: action.token);
}

LoginState _errorLoadingLogin(LoginState state, action) {
  LogUtil.v('_errorLoadingLogin', tag: TAG);
  return state.copyWith(isLoading: false);
}
複製程式碼

總結

區域性狀態和全域性狀態

上面的登入例子中,登入表單的任何驗證型別,都可以考慮為區域性狀態,因為這些規則僅適用於這個元件,而App的其他部分不需要知道這個型別。但是從後臺獲取的token和使用者資料,就需要考慮成全域性狀態,因為它影響整個app的作用域(未登入和已登陸),而且可能別的元件會依賴它。

選擇

對比上面四種架構的好壞,最終還是的迴歸到狀態管理上來。MVCMVP的狀態管理都是採用setState方式,而BloCRedux都有自己的一套狀態管理。

當專案最初不是很複雜的時候,採用setState方式更新資料是可以的。但是隨著功能的增加,你的專案將會有幾十個甚至上百個狀態,setState出現的次數便會顯著增加,每次setState都會重新呼叫build方法,這勢必對於效能以及程式碼的可閱讀性帶來一定的影響。所以就放棄了MVCMVP這兩種架構。

最初對OpenGit_Flutter進行架構重構的時候,用到的是Redux,到涉及到多個頁面複用時,例如專案中的專案頁,每涉及到一個複用頁面就需要在state內定義一些列的變數,這是個很痛苦的過程,所以後面就放棄了用Redux,但是Redux在儲存全域性狀態有優勢,例如主題、語言、使用者資料等。後面又嘗試了BloC,該架構在多頁面複用時,就沒存在Redux的問題。

所以最後我採用的架構是Bloc+Redux,用BloC控制區域性狀態,用Redux控制全域性狀態。同時大家也可以參考文章[譯]讓我來幫你理解和選擇Flutter狀態管理方案

專案地址

相關文章