關於mvp在flutter中的應用

mvp模式的優點

mvp模式將視圖、業務邏輯、數據模型隔離,使用mvp模式,能使複雜的業務邏輯變得更加清晰,使代碼更具有靈活性和擴展性,正是這些優點,使mvp模式廣泛應用於原生開發中。

flutter使用mvp之前

以前原生開發頁面,只需要花費少量的時間,就可以通過原生提供的可視化拖拽功能,迅速的完成一個簡單的頁面佈局效果和配置,而邏輯代碼只需要引用佈局文件即可完成交互。然而flutter開發中目前還沒有提供可視化的拖拽功能,實現頁面佈局和控件需要一行行碼代碼,因此在頁面佈局、元素上將會花費大量的編碼時間,這對於原生開發的工程師的我來說,感覺十分不習慣。既然現在還沒有提供可視化UI編輯功能,那我們也只能按照標準一行行編寫UI了。flutter核心要素就是widget,所有頁面元素都是widget,下面來看看使用mvp之前的代碼:

import 'dart:convert';

import 'package:badge/badge.dart';
import 'package:flutter/material.dart';
import 'package:flutter_biobank/entity/IntResult.dart';
import 'package:flutter_biobank/entity/SampleResult.dart';
import 'package:flutter_biobank/entity/TextResult.dart';
import 'package:flutter_biobank/page/work/SampleCartsPage.dart';
import 'package:flutter_biobank/res/colors.dart';
import 'package:flutter_biobank/res/images.dart';
import 'package:flutter_biobank/res/urls.dart';
import 'package:flutter_biobank/util/DialogUtil.dart';
import 'package:flutter_biobank/util/HttpUtil.dart';
import 'package:flutter_biobank/util/NavigatorUtil.dart';
import 'package:flutter_biobank/widget/PageLoadView.dart';
import 'package:flutter_biobank/widget/SmartRefresh.dart';
import 'package:fluttertoast/fluttertoast.dart';
import "package:pull_to_refresh/pull_to_refresh.dart";

///樣本申領
class SampleClaimPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return new _SampleClaimPageState();
  }
}

class _SampleClaimPageState extends State<SampleClaimPage> {
  int pageIndex = 1; //當前頁碼
  RefreshController _controller;
  List<Sample> samples = new List(); //列表中的樣本集合
  List<Sample> checkedSamples = new List(); //選中的樣本集合
  int loadStatus; //當前頁面加載狀態
  int samplesCount = 0; //申領車中樣本數量

  @override
  void initState() {
    super.initState();
    _controller = new RefreshController();
    getSamples();
    getSamplesCount();
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      backgroundColor: page_background,
      appBar: new AppBar(
        title: new Text("樣本申領"),
        actions: <Widget>[
          Container(
            alignment: Alignment.center,
            margin: EdgeInsets.only(right: 8),
            child: new Badge.left(
                child: IconButton(
                  icon: ImageIcon(AssetImage(Images.IMG_ICON_CARTS)),
                  onPressed: () {
                    NavigatorUtil.startIntent(context, new SampleCartsPage());
                  },
                ),
                positionTop: 0,
                borderSize: 0,
                positionRight: 0,
                value: " $samplesCount "),
          )
        ],
      ),
      body: new PageLoadView(
        status: loadStatus,
        child: _buildContent(),
        offstage: samples.length > 0,
        onTap: () {
          setState(() {
            loadStatus = PageLoadStatus.loading;
            getSamples();
          });
        },
      ),
    );
  }

  ///構建內容顯示佈局
  Widget _buildContent() {
    return new Column(
      children: <Widget>[
        _buildRefresh(),
        _buildBottom(),
      ],
    );
  }

  ///構建底部控件
  Widget _buildBottom() {
    return Container(
      color: Colors.white,
      child: Column(
        children: <Widget>[
          new Divider(height: 0.5, color: devider_black),
          new Row(
            children: <Widget>[
              Expanded(child: Container(child: new Text("選中樣本:${checkedSamples.length}"), padding: EdgeInsets.symmetric(horizontal: 16))),
              GestureDetector(
                child: new Container(
                    alignment: Alignment.center,
                    color: Colors.red,
                    width: 120,
                    height: 60,
                    child: new Text("加入申領", style: new TextStyle(color: Colors.white, fontSize: 16))),
                onTap: () {
                  if (checkedSamples.length <= 0) {
                    Fluttertoast.showToast(msg: "請選擇樣本");
                  } else {
                    doJoinCarts();
                  }
                },
              ),
            ],
          ),
        ],
      ),
    );
  }

  ///構建刷新和加載控件
  Widget _buildRefresh() {
    return new SmartRefresh(
      controller: _controller,
      child: _buildListView(),
      onRefresh: () {
        //下拉刷新
        pageIndex = 1;
        return getSamples();
      },
      onLoadMore: (bool) {
        //上拉加載更多
        getSamples();
      },
    );
  }

  ///構建ListView
  Widget _buildListView() {
    return new ListView.builder(
      physics: new AlwaysScrollableScrollPhysics(),
      itemBuilder: _buildListViewItem,
      itemCount: samples.length,
    );
  }

  ///構建listItem
  Widget _buildListViewItem(BuildContext context, int index) {
    return Card(
      child: Container(
        padding: EdgeInsets.symmetric(vertical: 8, horizontal: 2),
        child: new Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            new Checkbox(
                value: samples[index].isSelected,
                onChanged: (bool) {
                  setState(() {
                    samples[index].isSelected = bool;
                    bool ? checkedSamples.add(samples[index]) : checkedSamples.remove(samples[index]);
                  });
                }),
            new Expanded(
                child: new Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                new Text("${samples[index].SerialNumber}", style: new TextStyle(fontSize: 18)),
                new Text(
                  "樣本名稱:${samples[index].Name}",
                  style: new TextStyle(color: Colors.black45),
                  softWrap: false,
                  overflow: TextOverflow.fade,
                ),
                new Text("可用量:${samples[index].AvailableVolume}", style: new TextStyle(color: Colors.black45)),
                new Text("位置:${samples[index].Location}", style: new TextStyle(color: Colors.black45)),
              ],
            )),
          ],
        ),
      ),
    );
  }

  ///加載數據
  Future getSamples() async {
    await HttpUtil.getInstance().post(Urls.URL_GET_SAMPLES, data: {
      "PageIndex": pageIndex,
      "PageSize": 20,
      "State": 1,
      "Sort": "asc",
      "BeginTime": "",
      "BoxCode": "",
      "EndTime": "",
      "Location": "",
      "Name": "",
      "ProjectID": null,
      "erialNumber": "",
      "StudyID": null,
    }, callBack: (success, data) {
      _controller.sendBack(false, RefreshStatus.idle);
      if (success) {
        SampleResult result = SampleResult.fromJson(json.decode(data)); //解析json
        if (result.code == 200) {
          if (pageIndex == 1) {
            samples.clear(); //下拉刷新時,先清除原來的數據
            checkedSamples.clear(); //下拉刷新時,選中的數據也清空
          }
          samples.addAll(result.rows);
          pageIndex += 1; //數據加載成功後,page+1
          samples.length >= result.total ? _controller.sendBack(false, RefreshStatus.noMore) : null;
        } else {
          Fluttertoast.showToast(msg: result.message);
          loadStatus = PageLoadStatus.failed;
        }
      } else {
        loadStatus = PageLoadStatus.failed;
      }
      setState(() {});
    });
  }

  ///加入申領車
  Future doJoinCarts() async {
    DialogUtil.showLoading(context); //顯示加載對話框
    List<int> ids = new List();
    for (Sample sample in checkedSamples) {
      ids.add(sample.ID);
    }
    await HttpUtil.getInstance().post(Urls.URL_CARTS_ADD, data: json.encode(ids), callBack: (success, data) {
      Navigator.pop(context);
      if (success) {
        TextResult result = TextResult.fromJson(json.decode(data));
        DialogUtil.showTips(context, text: result.message);
        pageIndex = 1;
        getSamples();
        getSamplesCount();
      } else {
        Fluttertoast.showToast(msg: data);
      }
    });
  }

  ///查詢申領車中樣本數量
  Future getSamplesCount() async {
    await HttpUtil.getInstance().get(Urls.URL_CARTS_SAMPLESCOUNT, callBack: (success, data) {
      if (success) {
        IntResult result = IntResult.fromJson(json.decode(data));
        if (result.code == 200) {
          setState(() {
            samplesCount = result.response;
          });
        }
      }
    });
  }
}

這只是一個簡單的頁面,調用列表查詢接口,用ListView顯示列表數據,調用數量查詢接口,查詢購物車中數量,並顯示在appBar的action中。業務邏輯和頁面代碼混合在一起,導致代碼量大,類看起來很臃腫,如果業務邏輯更加複雜的情況下,代碼閱讀、代碼審查及功能維護都不是件容易的事。

flutter使用mvp之後

下面我們使用mvp模式對上面的代碼進行改造,首先我們先將view的改變行爲抽象出來,建立viewModel

abstract class IClaimPageView {
  void querySamplesSuccess(SampleResult result);

  void querySamplesFailed();

  void queryCartsSampleCountSuccess(int count);

  void doJoinCartsSuccess(String message);

  void doJoinCartsFailed(String message);
}

該類定義的方法分別表示列表查詢成功或失敗了,查詢數量成功了,加入購物車成功或失敗了,頁面分別要做的各種事情,具體頁面要做什麼變化,就交給View去實現,也就是頁面View,實現viewModel。

class _SampleClaimPageState extends State<SampleClaimPage> implements IClaimPageView {
  @override
  void querySamplesSuccess(SampleResult result) {
      //TODO
  }

  @override
  void querySamplesFailed() {
      //TODO
  }

  @override
  void queryCartsSampleCountSuccess(int count) {
      //TODO
  }

  @override
  void doJoinCartsFailed(String message) {
      //TODO
  }

  @override
  void doJoinCartsSuccess(String message) {
      //TODO
  }
}

接下來,我們需要將業務邏輯代碼分離出去,建立Presenter類,傳入viewModel的引用,並定義方法實現業務邏輯。

///樣本申領presenter
class ClaimPresenter extends BasePresenter {
  ClaimModel _model;
  IClaimPageView _view;

  ClaimPresenter(this._view) {
    _model = new ClaimModel();
  }

  ///分頁查詢庫存中的樣本
  Future querySamples(int pageIndex) async {
    await _model.querySamples({
      "PageIndex": pageIndex,
      "PageSize": 20,
      "State": 1,
      "Sort": "asc",
      "BeginTime": "",
      "BoxCode": "",
      "EndTime": "",
      "Location": "",
      "Name": "",
      "ProjectID": null,
      "erialNumber": "",
      "StudyID": null,
    }, (bool, result) {
      if (_view == null) {
        return;
      }
      if (bool) {
        _view.querySamplesSuccess(result);
      } else {
        _view.querySamplesFailed();
      }
    });
  }

  ///查詢申領車中樣本數量
  Future queryCartsSampleCount() async {
    await _model.queryCartsSampleCount((bool, int) {
      if (_view == null) {
        return;
      }
      if (bool) {
        _view.queryCartsSampleCountSuccess(int);
      }
    });
  }

  ///加入申領車
  Future doJoinCarts(BuildContext context, List<Sample> samples) async {
    DialogUtil.showLoading(context);
    List<int> ids = new List();
    for (Sample sample in samples) {
      ids.add(sample.ID);
    }
    await _model.doJoinCarts(json.encode(ids), (bool, message) {
      Navigator.pop(context);
      if (_view == null) {
        return;
      }
      if (bool) {
        _view.doJoinCartsSuccess(message);
      } else {
        _view.doJoinCartsFailed(message);
      }
    });
  }

  @override
  void dispose() {
    _view = null;
  }
}

這裏的ClaimModel 實際上就是數據請求代碼,原本數據請求也是可以寫在presenter類中的,但是爲了使代碼更具靈活性和解耦性,我們這裏將數據請求層也抽取出去,這樣我其它頁面也需要查詢購物車中樣本數量時,只需要幾句簡單的代碼即可實現。

class ClaimModel {
  ///查詢樣本列表
  Future querySamples(data, Function(bool, Object) callBack) async {
    await HttpUtil.getInstance().post(Urls.URL_GET_SAMPLES, data: data, callBack: (success, data) {
      if (success) {
        SampleResult result = SampleResult.fromJson(json.decode(data)); //解析json
        if (result.code == 200) {
          callBack(true, result);
        } else {
          callBack(false, result.message);
        }
      } else {
        callBack(false, data);
      }
    });
  }

  ///查詢申領車中的樣本數量
  Future queryCartsSampleCount(Function(bool, int) callBack) async {
    await HttpUtil.getInstance().get(Urls.URL_CARTS_SAMPLESCOUNT, callBack: (success, data) {
      if (success) {
        IntResult result = IntResult.fromJson(json.decode(data));
        if (result.code == 200) {
          callBack(true, result.response);
        }
      }
    });
  }

  ///加入申領車
  Future doJoinCarts(data, Function(bool, String) callBack) async {
    await HttpUtil.getInstance().post(Urls.URL_CARTS_ADD, data: data, callBack: (success, data) {
      if (success) {
        TextResult result = TextResult.fromJson(json.decode(data));
        callBack(true, result.message);
      } else {
        callBack(true, data);
      }
    });
  }
}

最後就是View的完整代碼了

import 'package:badge/badge.dart';
import 'package:flutter/material.dart';
import 'package:flutter_biobank/entity/SampleResult.dart';
import 'package:flutter_biobank/page/work/SampleCartsPage.dart';
import 'package:flutter_biobank/page/work/claim/ClaimPresenter.dart';
import 'package:flutter_biobank/page/work/claim/IClaimPageView.dart';
import 'package:flutter_biobank/res/colors.dart';
import 'package:flutter_biobank/res/images.dart';
import 'package:flutter_biobank/util/DialogUtil.dart';
import 'package:flutter_biobank/util/Logger.dart';
import 'package:flutter_biobank/util/NavigatorUtil.dart';
import 'package:flutter_biobank/widget/PageLoadView.dart';
import 'package:flutter_biobank/widget/SmartRefresh.dart';
import 'package:fluttertoast/fluttertoast.dart';
import "package:pull_to_refresh/pull_to_refresh.dart";

///樣本申領
class SampleClaimPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return new _SampleClaimPageState();
  }
}

class _SampleClaimPageState extends State<SampleClaimPage> implements IClaimPageView {
  static final String TAG = "SampleClaimPageState";
  int pageIndex = 1; //當前頁碼
  RefreshController _controller; //控制器,控制加載更多的顯示狀態
  List<Sample> samples = new List(); //列表中的樣本集合
  List<Sample> checkedSamples = new List(); //選中的樣本集合
  int loadStatus; //當前頁面加載狀態
  int samplesCount = 0; //申領車中樣本數量
  ClaimPresenter _presenter;

  @override
  void initState() {
    super.initState();
    Logger.log(TAG, "initState");
    _controller = new RefreshController();
    _presenter = new ClaimPresenter(this);
    _presenter.querySamples(pageIndex);
    _presenter.queryCartsSampleCount();
  }

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

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      backgroundColor: page_background,
      appBar: new AppBar(
        title: new Text("樣本申領"),
        actions: <Widget>[
          Container(
            alignment: Alignment.center,
            margin: EdgeInsets.only(right: 8),
            child: _buildBadge(context),
          )
        ],
      ),
      body: _buildBody(),
    );
  }

  ///構建購物車按鈕
  Widget _buildBadge(BuildContext context) {
    if (samplesCount > 0) {
      return new Badge.left(
          child: IconButton(
            icon: ImageIcon(AssetImage(Images.IMG_ICON_CARTS)),
            onPressed: () {
              NavigatorUtil.startIntent(context, new SampleCartsPage());
            },
          ),
          positionTop: 0,
          borderSize: 0,
          positionRight: 0,
          value: " $samplesCount ");
    } else {
      return new IconButton(
        icon: ImageIcon(AssetImage(Images.IMG_ICON_CARTS)),
        onPressed: () {
          NavigatorUtil.startIntent(context, new SampleCartsPage());
        },
      );
    }
  }

  ///構建body
  Widget _buildBody() {
    return new PageLoadView(
      status: loadStatus,
      child: new Column(
        children: <Widget>[
          _buildRefresh(),
          _buildBottom(),
        ],
      ),
      offstage: samples.length > 0,
      onTap: () {
        setState(() {
          loadStatus = PageLoadStatus.loading;
          _presenter.querySamples(pageIndex);
        });
      },
    );
  }

  ///構建底部控件
  Widget _buildBottom() {
    return Container(
      color: Colors.white,
      child: Column(
        children: <Widget>[
          new Divider(height: 0.5, color: devider_black),
          new Row(
            children: <Widget>[
              Expanded(child: Container(child: new Text("選中樣本:${checkedSamples.length}"), padding: EdgeInsets.symmetric(horizontal: 16))),
              GestureDetector(
                child: new Container(
                    alignment: Alignment.center,
                    color: Colors.red,
                    width: 120,
                    height: 60,
                    child: new Text("加入申領", style: new TextStyle(color: Colors.white, fontSize: 16))),
                onTap: () {
                  if (checkedSamples.length <= 0) {
                    Fluttertoast.showToast(msg: "請選擇樣本");
                  } else {
                    _presenter.doJoinCarts(context, checkedSamples);
                  }
                },
              ),
            ],
          ),
        ],
      ),
    );
  }

  ///構建刷新和加載控件
  Widget _buildRefresh() {
    return new SmartRefresh(
      controller: _controller,
      child: _buildListView(),
      onRefresh: () {
        return _presenter.querySamples(pageIndex = 1); //下拉刷新
      },
      onLoadMore: (bool) {
        //上拉加載更多
        _presenter.querySamples(pageIndex);
      },
    );
  }

  ///構建ListView
  Widget _buildListView() {
    return new ListView.builder(
      physics: new AlwaysScrollableScrollPhysics(),
      itemBuilder: _buildListViewItem,
      itemCount: samples.length,
    );
  }

  ///構建listItem
  Widget _buildListViewItem(BuildContext context, int index) {
    return Card(
      child: Container(
        padding: EdgeInsets.symmetric(vertical: 8, horizontal: 2),
        child: new Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            new Checkbox(
                value: samples[index].isSelected,
                onChanged: (bool) {
                  setState(() {
                    samples[index].isSelected = bool;
                    bool ? checkedSamples.add(samples[index]) : checkedSamples.remove(samples[index]);
                  });
                }),
            new Expanded(
                child: new Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                new Text("${samples[index].SerialNumber}", style: new TextStyle(fontSize: 18)),
                new Text(
                  "樣本名稱:${samples[index].Name}",
                  style: new TextStyle(color: Colors.black45),
                  softWrap: false,
                  overflow: TextOverflow.fade,
                ),
                new Text("可用量:${samples[index].AvailableVolume}", style: new TextStyle(color: Colors.black45)),
                new Text("位置:${samples[index].Location}", style: new TextStyle(color: Colors.black45)),
              ],
            )),
          ],
        ),
      ),
    );
  }

  @override
  void querySamplesSuccess(SampleResult result) {
    //下拉刷新需要先清空列表數據
    if (pageIndex == 1) {
      samples.clear();
      checkedSamples.clear();
    }
    samples.addAll(result.rows);
    //判斷是不是最後一頁
    pageIndex * 20 >= result.total ? _controller.sendBack(false, RefreshStatus.noMore) : _controller.sendBack(false, RefreshStatus.idle);
    pageIndex += 1;
    setState(() {});
  }

  @override
  void querySamplesFailed() {
    //查詢失敗,修改頁面狀態
    _controller.sendBack(false, RefreshStatus.idle);
    loadStatus = PageLoadStatus.failed;
    setState(() {});
  }

  @override
  void queryCartsSampleCountSuccess(int count) {
    setState(() {
      samplesCount = count;
    });
  }

  @override
  void doJoinCartsFailed(String message) {
    Fluttertoast.showToast(msg: message);
  }

  @override
  void doJoinCartsSuccess(String message) {
    DialogUtil.showTips(context, text: message);
    _presenter.querySamples(pageIndex = 1);
    _presenter.queryCartsSampleCount();
  }
}

經過改動之後,會發現程序的類變多了,代碼量視乎更大了。但是我們並不是以代碼量的多少來評價代碼的質量,往往是以代碼的可閱讀性和可變性來評價。經過改動之後,SampleClaimPage 類主要負責UI的實現和UI與數據的綁定及交互。ClaimPresenter類主要負責業務邏輯的實現和數據與UI之間交互的建立。而ClaimModel 類只需要簡單的實現數據的獲取。代碼邏輯變得十分清晰。

結束語

代碼模式的設計需要便於程序員理解代碼,mvp模式特別適用於頁面邏輯較爲複雜的情況。當頁面邏輯十分簡單隻時,就無需爲了設計而設計,也就是代碼界的一句金玉良言:“不要過度設計”。歡迎大家指正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章