Flutter MVP 封裝

Cheney2006發表於2020-03-27

  在 Android 開發中經常會用到一些架構,從 MVC 到 MVVP、MVVM等,這些架構會大大的解耦我們程式碼的功能模組,讓我們的程式碼在專案中後期更容易擴充套件和維護。

  在Flutter中同樣有 MVC、MVP、MVVM等架構。在Android實際開發中,也有把專案從 MVC切換到 MVP,形成了一套 MVP 快速開發框架,且做了一個 AS 快速程式碼生成外掛。所以在 Flutter 開發中也想著是不是可以用 MVP 架構去開發,且做個一樣的程式碼生成外掛。

  所以在這是裡主要看一下在 Flutter 中如何使用 MVP 模式來開發應用。

MVC

  提到MVP就不得不提到MVC,關於MVC架構,可以看下面這張圖:

Flutter MVP 封裝
  MVC即Model View Controller,簡單來說就是通過controller的控制去操作model層的資料,並且返回給view層展示,具體見上圖。當使用者出發事件的時候,view層會傳送指令到controller層,接著controller去通知model層更新資料,model層更新完資料以後直接顯示在view層上,這就是MVC的工作原理。

  這種原理就會造成一個致命的缺陷:當很多業務邏輯寫在vidget中時,widget既充當了View層,又充當了Controller層。因此,耦合性極高,各種業務邏輯程式碼和View程式碼混合在一起,你中有我我中有你,如果要修改一個需求,改動的地方可能相當多,維護起來十分不便。

MVP

Flutter MVP 封裝
  MVP模式相當於在MVC模式中加了一個Presenter用於處理模型和邏輯,將View和Model完全獨立開來,在flutter開發中的體現就是widget僅用於顯示介面和互動,widget不參與模型結構和邏輯。

  使用MVP模式會使得程式碼多出一些介面,但是使得程式碼邏輯更加清晰,尤其是在處理複雜介面和邏輯時,可以對同一個widget將每一個業務都抽離成一個Presenter,這樣程式碼既清晰邏輯明確又方便擴充套件。當然如果業務邏輯本身就比較簡單的話使用MVP模式就顯得沒那麼必要了。所以不需要為了用它而用它,具體的還是要根據業務需要。

  簡而言之:view就是UI,model就是資料處理,而persenter則是他們的紐帶。

可能存在的問題

  1. Model進行非同步操作,獲取結果通過Presenter回傳到View時,出現View引用的空指標異常
  2. Presenter和View互相持有引用,解除不及時造成的記憶體洩漏。

因此,在進行MVP架構設計時需要考慮Presenter對View進行回傳時,View是否為空?

Presenter與View何時解除引用即Presenter能否和View層進行生命週期同步?

  好了,說了這麼多,我個人比較推薦mvp,主要是因為其相對比較簡單且易上手。下面我們來看看具體如何優雅的實現MVP的封裝。

MVP封裝

程式碼結構

Flutter MVP 封裝

具體程式碼見最後

程式碼講解

Model 封裝

/// @desc  基礎 model
/// @time 2019-04-22 10:33 am
/// @author Cheney
abstract class IModel {
  ///釋放網路請求
  void dispose();
}


import 'package:flutter_mvp/model/i_model.dart';

/// @desc  基礎 Model 生成 Tag
/// @time 2019-04-22 12:06 am
/// @author Cheney
abstract class AbstractModel implements IModel {
  String _tag;

  String get tag => _tag;

  AbstractModel() {
    _tag = '${DateTime.now().millisecondsSinceEpoch}';
  }
}

複製程式碼

IModel 介面有一個抽象的dispose,主要用於釋放網路請求。

AbstractModel抽象類實現 IModel 介面,且構造方法中生成唯一的tag 用於取消網路請求。

具體程式碼見最後

Present 封裝

import 'package:flutter_mvp/view/i_view.dart';

/// @desc  基礎 Presenter
/// @time 2019-04-22 10:30 am
/// @author Cheney
abstract class IPresenter<V extends IView> {
  ///Set or attach the view to this mPresenter
  void attachView(V view);

  ///Will be called if the view has been destroyed . Typically this method will be invoked from
  void detachView();
}


import 'package:flutter_mvp/model/i_model.dart';
import 'package:flutter_mvp/presenter/i_presenter.dart';
import 'package:flutter_mvp/view/i_view.dart';

/// @desc  基礎 Presenter,關聯 View\Model
/// @time 2019-04-22 10:51 am
/// @author Cheney
abstract class AbstractPresenter<V extends IView, M extends IModel>
    implements IPresenter {
  M _model;
  V _view;

  @override
  void attachView(IView view) {
    this._model = createModel();
    this._view = view;
  }

  @override
  void detachView() {
    if (_view != null) {
      _view = null;
    }
    if (_model != null) {
      _model.dispose();
      _model = null;
    }
  }

  V get view {
    return _view;
  }

//  V get view => _view;

  M get model => _model;

  IModel createModel();
}

複製程式碼

IPresenter介面中設定了一泛型V繼承IView,V是與presenter相關的view,且有兩個抽象方法attachView,detachView。

AbstractPresenter抽象類中設定了一泛型 V繼承 IView,一泛型 M繼承 IModel,實現了 IPresenter,該類中持有一個View的引用,一個 Model 的引用。在 attachView繫結了 View,且生成一個 建立Model物件的抽象方法供子類實現,detachView中銷燬 View、Model,這樣就解決了上面說到的相互持有引用,造成記憶體洩漏問題。

具體程式碼見最後

View封裝

/// @desc  基礎 View
/// @time 2019-04-22 10:29 am
/// @author Cheney
abstract class IView {
  ///開始載入
  void startLoading();

  ///載入成功
  void showLoadSuccess();

  ///載入失敗
  void showLoadFailure(String code, String message);

  ///無資料
  void showEmptyData({String emptyImage, String emptyText});

  ///帶引數的對話方塊
  void startSubmit({String message});

  ///隱藏對話方塊
  void showSubmitSuccess();

  ///顯示提交失敗
  void showSubmitFailure(String code, String message);

  ///顯示提示
  void showTips(String message);
}


import 'package:flutter/material.dart';
import 'package:flutter_mvp/mvp/presenter/i_present.dart';
import 'package:flutter_mvp/mvp/view/i_view.dart';

/// @desc  基礎 widget,關聯 Presenter,且與生命週期關聯
/// @time 2019-04-22 11:08 am
/// @author Cheney
abstract class AbstractView extends StatefulWidget {}

abstract class AbstractViewState<P extends IPresenter, V extends AbstractView>
    extends State<V> implements IView {
  P presenter;

  @override
  void initState() {
    super.initState();
    presenter = createPresenter();
    if (presenter != null) {
      presenter.attachView(this);
    }
  }

  P createPresenter();

  P getPresenter() {
    return presenter;
  }

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

複製程式碼

IView 介面中定義了一些公共操作(載入狀態、無資料狀態、錯誤態、提交狀態、統一提示等)的方法,這裡大家可以根據實際的需要是否需要定義這些公共方法,我這裡是預設是基類中處理,大家可參考Flutter 基類BaseWidget封裝

AbstractView抽象類繼承StatefulWidget,AbstractViewState中定義一泛型P繼承 IPresenter,一泛型 V 繼承AbstractView,實現 IView,該抽象類中持有一個 Presenter 引用,且包括兩個生命週期方法initState、dispose用於建立、銷燬Presenter,並呼叫Presenter的attachView、detachView方法關聯 View、Model,並提供抽象createPresenter供子類實現。

具體程式碼見最後

使用示例

這裡我們以登入功能模組為例:

Flutter MVP 封裝

Contract類

import 'package:flutter_mvp/model/i_model.dart';
import 'package:flutter_mvp/presenter/i_presenter.dart';
import 'package:flutter_mvp/view/i_view.dart';
import 'package:kappa_app/base/api.dart';

import 'login_bean.dart';

/// @desc 登入
/// @time 2020/3/18 4:56 PM
/// @author Cheney
abstract class View implements IView {
  ///登入成功
  void loginSuccess(LoginBean loginBean);
}

abstract class Presenter implements IPresenter {
  ///登入
  void login(String phoneNo, String password);
}

abstract class Model implements IModel {
  ///登入
  void login(
      String phoneNo,
      String password,
      SuccessCallback<LoginBean> successCallback,
      FailureCallback failureCallback);
}

複製程式碼

這裡定義了登入頁面的view介面、model介面和presenter 介面。

在view中,只定義與UI展示的相關方法,如登入成功等。

model負責資料請求,所以在介面中只定義了登入的方法。

presenter也只定義了登入的方法。

Model類

import 'package:flutter_common_utils/http/http_error.dart';
import 'package:flutter_common_utils/http/http_manager.dart';
import 'package:flutter_mvp/model/abstract_model.dart';
import 'package:kappa_app/base/api.dart';

import 'login_bean.dart';
import 'login_contract.dart';

/// @desc 登入
/// @time 2020/3/18 4:56 PM
/// @author Cheney
class LoginModel extends AbstractModel implements Model {
  @override
  void dispose() {
    HttpManager().cancel(tag);
  }

  @override
  void login(
      String phoneNo,
      String password,
      SuccessCallback<LoginBean> successCallback,
      FailureCallback failureCallback) {
    HttpManager().post(
      url: Api.login,
      data: {'phoneNo': phoneNo, 'password': password},
      successCallback: (data) {
        successCallback(LoginBean.fromJson(data));
      },
      errorCallback: (HttpError error) {
        failureCallback(error);
      },
      tag: tag,
    );
  }
}

複製程式碼

這裡建立Model實現類,重寫login方法將登入介面返回結果交給回撥、重寫dispose方法取消網路請求。

Presenter 類

import 'package:flutter_common_utils/http/http_error.dart';
import 'package:flutter_mvp/presenter/abstract_presenter.dart';

import 'login_bean.dart';
import 'login_contract.dart';
import 'login_model.dart';

/// @desc 登入
/// @time 2020/3/18 4:56 PM
/// @author Cheney
class LoginPresenter extends AbstractPresenter<View, Model>
    implements Presenter {
  @override
  Model createModel() {
    return LoginModel();
  }

  @override
  void login(String phoneNo, String password) {
    view?.startSubmit(message: '正在登入');
    model.login(phoneNo, password, (LoginBean loginBean) {
      //取消提交框
      view?.showSubmitSuccess();
      //登入成功
      view?.loginSuccess(loginBean);
    }, (HttpError error) {
      //取消提交框、顯示錯誤提示
      view?.showSubmitFailure(error.code, error.message);
    });
  }
}

複製程式碼

LoginPresenter繼承AbstractPresenter,傳入了View和Model 泛型

實現了createModel方法建立了LoginMoel物件,實現了 login 方法,呼叫了 model 中的 login 方法,在回撥中得到資料,也可以再進行一些邏輯判斷,將結果交給view的對應的方法。

注意這裡使用view?.用於解決view 為空時指標問題。

Widget類

import 'package:flutter/material.dart';
import 'package:flutter_common_utils/lcfarm_size.dart';
import 'package:kappa_app/base/base_widget.dart';
import 'package:kappa_app/base/navigator_manager.dart';
import 'package:kappa_app/base/router.dart';
import 'package:kappa_app/base/umeng_const.dart';
import 'package:kappa_app/utils/encrypt_util.dart';
import 'package:kappa_app/utils/lcfarm_color.dart';
import 'package:kappa_app/utils/lcfarm_style.dart';
import 'package:kappa_app/utils/string_util.dart';
import 'package:kappa_app/widgets/lcfarm_input.dart';
import 'package:kappa_app/widgets/lcfarm_large_button.dart';
import 'package:kappa_app/widgets/lcfarm_simple_input.dart';
import 'package:provider/provider.dart';

import 'login_bean.dart';
import 'login_contract.dart';
import 'login_notifier.dart';
import 'login_presenter.dart';

/// @desc 登入
/// @time 2020/3/18 4:56 PM
/// @author Cheney
class Login extends BaseWidget {
  ///路由
  static const String router = "login";

  Login({Object arguments}) : super(arguments: arguments, routerName: router);

  @override
  BaseWidgetState getState() {
    return _LoginState();
  }
}

class _LoginState extends BaseWidgetState<Presenter, Login> implements View {
  LoginNotifier _loginNotifier;
  GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  String _phoneNo = '';
  String _password = '';
  bool _submiting = false;

  bool isChange = false;

  @override
  void initState() {
    super.initState();
    setTitle('');
    _loginNotifier = LoginNotifier();
    isChange = StringUtil.isBoolTrue(widget.arguments);
  }

  @override
  void dispose() {
    super.dispose();
    _loginNotifier.dispose();
  }

  @override
  Widget buildWidget(BuildContext context) {
    return ChangeNotifierProvider<LoginNotifier>.value(
      value: _loginNotifier,
      child: Container(
        color: LcfarmColor.colorFFFFFF,
        child: ListView(
          children: [
            Padding(
              padding: EdgeInsets.only(
                top: LcfarmSize.dp(24.0),
                left: LcfarmSize.dp(32.0),
              ),
              child: Text(
                '密碼登入',
                style: LcfarmStyle.style80000000_32
                    .copyWith(fontWeight: FontWeight.w700),
              ),
            ),
            _formSection(),
            Padding(
              padding: EdgeInsets.only(top: LcfarmSize.dp(8.0)),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  GestureDetector(
                    child: Padding(
                      padding: EdgeInsets.all(LcfarmSize.dp(8.0)),
                      child: Text(
                        '忘記密碼',
                        style: LcfarmStyle.style3776E9_14,
                      ),
                    ),
                    behavior: HitTestBehavior.opaque,
                    onTap: () {
                      UmengConst.event(eventId: UmengConst.MMDL_WJMM);
                      NavigatorManager()
                          .pushNamed(context, Router.forgetPassword);
                    }, //點選
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  //表單
  Widget _formSection() {
    return Padding(
      padding: EdgeInsets.only(
          left: LcfarmSize.dp(32.0),
          top: LcfarmSize.dp(20.0),
          right: LcfarmSize.dp(32.0)),
      child: Form(
        key: _formKey,
        child: Column(
          children: <Widget>[
            LcfarmSimpleInput(
              hint: '',
              label: '手機號碼',
              callback: (val) {
                _phoneNo = val;
                _buttonState();
              },
              keyboardType: TextInputType.phone,
              maxLength: 11,
              /*validator: (val) {
                return val.length < 11 ? '手機號碼長度錯誤' : null;
              },*/
            ),
            LcfarmInput(
              hint: '',
              label: '登入密碼',
              callback: (val) {
                _password = val;
                _buttonState();
              },
            ),
            Consumer<LoginNotifier>(
                builder: (context, LoginNotifier loginNotifier, _) {
              return Padding(
                padding: EdgeInsets.only(top: LcfarmSize.dp(48.0)),
                child: LcfarmLargeButton(
                  label: '登入',
                  onPressed:
                      loginNotifier.isButtonDisabled ? null : _forSubmitted,
                ),
              );
            }),
          ],
        ),
      ),
    );
  }

  //輸入校驗
  bool _fieldsValidate() {
    //bool hasError = false;
    if (_phoneNo.length < 11) {
      return true;
    }
    if (_password.isEmpty) {
      return true;
    }
    return false;
  }

  //按鈕狀態更新
  void _buttonState() {
    bool hasError = _fieldsValidate();
    //狀態有變化
    if (_loginNotifier.isButtonDisabled != hasError) {
      _loginNotifier.isButtonDisabled = hasError;
    }
  }

  void _forSubmitted() {
    var _form = _formKey.currentState;
    if (_form.validate()) {
      //_form.save();
      if (!_submiting) {
        _submiting = true;
        UmengConst.event(eventId: UmengConst.MMDL_DL);
        EncryptUtil.encode(_password).then((pwd) {
          getPresenter().login(_phoneNo, pwd);
        }).catchError((e) {
          print(e);
        }).whenComplete(() {
          _submiting = false;
        });
      }
    }
  }

  @override
  void queryData() {
    disabledLoading();
  }

  @override
  Presenter createPresenter() {
    return LoginPresenter();
  }

   @override
  void loginSuccess(LoginBean loginBean) async {
    await SpUtil().putString(Const.token, loginBean.token);
    await SpUtil().putString(Const.username, _phoneNo);
    NavigatorManager().pop(context);
  }
  
}

複製程式碼

這裡的Login就是登入功能模組的view,繼承BaseWidget,傳入view和presenter泛型。 實現LoginContract.View介面,重寫介面定義好的UI方法。

在createPresenter方法中建立LoginPresenter物件並返回。這樣就可以使用getPresenter直接操作邏輯了。

程式碼外掛

使用 MVP 會額外增加一些介面、類,且它們的格式比較統一,為了統一規範程式碼,相關 MVP 的程式碼使用AS外掛來統一生成。

在 IDE中整合外掛

下載外掛下方外掛,開啟 IDE 首選項,找到 plugins , 選擇install plugin from disk,找到我們剛下載的外掛,重啟 IDE 生效。

Flutter MVP 封裝

生成程式碼

在新建的 contract 類中快捷 Generate... 找到 FlutterMvpGenerator,就會生成對應模組的 model、presenter、widget 類。

Flutter MVP 封裝

最後

使用 MVP 模式,將使得應用更加好維護,同時也可以方便我們進行測試。

如果在使用過程遇到問題,歡迎下方留言交流。

Pub庫地址

外掛地址

學習資料

請大家不吝點贊!因為您的點贊是對我最大的鼓勵,謝謝!

相關文章