Flutter VIPER架构-解决复用和测试问题的利器

2,279 阅读6分钟

0.框架历史

MVC

MVC可以说是框架的经典了,但是在MVC框架的实践中,我们很难做到降低它的耦合度,我们在使用过程中,会有大量的接口都出现在controller中,导致controller中的代码非常的庞大,而在view中实现的时候,我们又习惯性的只实现页面布局相关的东西,而到了动画,页面布局逻辑,我们又会丢到controller中去处理。controller复杂的逻辑,与页面极高的耦合度,会导致我们在开发过程无法抽离测试代码,只能通过e2e的方式进行全量测试,增加程序员自测的工作量。

MVVM

MVVM架构是MVX里面目前来说最新的一个,让我们希望它在出现的时候已经考虑到了MVX模式之前所遇到的问题吧。
在一个前端的角度来讲,MVVM是一个再熟悉不过的框架了,毕竟react/vue都是在MVVM框架的基础上出现的,MVVM对于MVC来说做的最大的改造就是将controller拆解,并分给view和view-model两个部分,通过数据驱动的方式呈现页面,更加的直观。

MVVM 特点:
  • MVVM 架构把 ViewController 看做 View。
  • View 和 Model 之间没有紧耦合

VIPER 框架

VIPER 框架,可以说把层次划分到最细,天然的解耦让VIPER代码的测试工作变得异常轻松。
view与view之间是通过router相关联的,没有任何页面之间是强依赖的,这意味着你可以单独测试某张页面而不需要将全部的流程都回归一遍。
而且,viper框架生成的各个组件,都可以认为是一个独立的模块,一个独立的个体,只要你的基础架构相同,那么这些独立模块在任何系统中都可以互相嵌套使用,而不需要做重复工作单独开发这些组件。

1.了解什么是VIPER框架

VIPER框架最初起始于iOS设计中,是在MVVM框架的基础上演变而来。

从字面意思来理解,VIPER 即 View Interactor Presenter Entity Router(视图 交互 协调器 实体 路由)。VIPER 在责任划分层面进行了迭代,VIPER 分为五个层次:

  • 展示器 -- 包含 UI 层面的业务逻辑以及在交互器层面的方法调用。
  • 交互器 -- 包括关于数据和网络请求的业务逻辑,例如创建一个实体(数据),或者从服务器中获取一些数据。为了实现这些功能,需要使用服务、管理器,但是他们并不被认为是 VIPER 架构内的模块,而是外部依赖。
  • 实体 -- 普通的数据对象,不属于数据访问层次,因为数据访问属于交互器的职责。
  • 路由 -- 用来连接 VIPER 的各个模块。

完全解耦的VIPER框架图:

其中VIPER框架事件细分:

2.使用VIPER框架的优劣势

优点

VIPER的特色就是职责明确,粒度细,隔离关系明确,这样能带来很多优点:

  • 可测试性好。UI测试和业务逻辑测试可以各自单独进行。
  • 易于迭代。各部分遵循单一职责,可以很明确地知道新的代码应该放在哪里。
  • 隔离程度高,天然解耦。一个模块的代码不容易影响到另一个模块。
  • 易于团队合作。各部分分工明确,团队合作时易于统一代码风格,可以快速接手别人的代码。

缺点

VIPER因为需求的拆分粒度细,相应的会带来以下问题:

  • 一个模块内的类数量增大,代码量增大,在层与层之间需要花更多时间设计接口。使用代码模板来自动生成文件和模板代码可以减少很多重复劳动,而花费时间设计和编写接口是减少耦合的路上不可避免的,你也可以使用数据绑定这样的技术来减少一些传递的层次。
  • 模块的初始化较为复杂,打开一个新的界面需要生成View、Presenter、Interactor,并且设置互相之间的依赖关系。

3.在Flutter中的拆解与实践

VIPER框架最关键的是如何将相关接口定义出来,为了实现VIPER框架的目录结构,我们将代码实现为如下目录结构:

目录结构:


目录结构中:

  • main.dart为入口文件
  • Router为统一的路由配置文件
  • BaseClasses为VIPER框架所需要实现的虚拟类
  • MainTab为这次实验所使用的页面

代码示意:

View:

View中主要是当前页面的初始化等操作,并将页面事件传递给自己的Presenter


class MainTabView extends StatefulWidget implements BaseView {
  const MainTabView({
    Key key,
    this.appBar,
    this.views,
    this.presenter,
  });

  final MainTabPresenter presenter;

  // mainTab中的appBar使用
  final PreferredSizeWidget appBar;

  final List<TabModel> views;

  @override
  _MainTabViewState createState() => _MainTabViewState();
}

class _MainTabViewState extends State<MainTabView>
    with SingleTickerProviderStateMixin {
  TabController tabController;

  @override
  void initState() {
    super.initState();
    tabController = new TabController(length: widget.views.length, vsync: this);
  }

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

  List<Tab> createTabs() {
    List<Tab> tabs = new List<Tab>();
    widget.views.forEach((e) {
      var tab = Tab(
        text: e.tabName,
        icon: e.icon,
      );
      tabs.add(tab);
    });
    return tabs;
  }

  List<Widget> createBody() {
    List<Widget> bodies = new List<Widget>();
    widget.views.forEach((e) {
      bodies.add(e.body);
    });
    return bodies;
  }

  @override
  Widget build(BuildContext context) {
    print(widget.views.map((e) => e.body));
    return Scaffold(
      backgroundColor: Colors.blue,
      appBar: widget.appBar,
      body: Material(
        child: TabBarView(
          controller: tabController,
          children: createBody(),
        ),
      ),
      bottomNavigationBar: SafeArea(
        child: Material(
          color: Colors.blue,
          child: SafeArea(
            child: TabBar(
              onTap: (index) {
                widget.presenter.tabChanged(index);
              },
              indicator: const BoxDecoration(),
              controller: tabController,
              tabs: createTabs(),
            ),
          ),
        ),
      ),
    );
  }
}

Interactor:

Interactor中主要是实例化相关的数据,并将数据接口提供给Presenter以反馈给View使用:


class MainTabViewModel {
  List<TabModel> tabs;

  MainTabViewModel({
    this.tabs,
  });
}

class MainTabInteractor implements BaseInteractor {
  MainTabViewModel viewModel = MainTabViewModel(
    tabs: [
      TabModel(
        tabName: '测试tab1',
        body: Container(
          child: Text('测试页面1'),
        ),
      ),
      ...
    ],
  );
}

Presenter:

Presenter主要是将Interactor中处理的viewModel反馈给View,并接收View中的页面事件,进行处理。

class MainTabPresenter implements BasePresenter {
  @override
  Widget create(List<TabModel> params) {
    return MainTabView(
      views: MainTabInteractor().viewModel.tabs,
      presenter: this,
    );
  }

  void tabChanged(int index) {
    print('tab changed to: $index');
  }
}

Entity:

Entity中主要是实现当前结构中所需要使用的各种类定义,并不需要做实体化操作

class TabModel implements BaseModel {
  String tabName;
  Icon icon;
  Widget body;

  TabModel({
    this.tabName,
    this.icon,
    this.body,
  });
}

Router:

Router中主要定义push/pop操作时的一些动作,以及页面如何初始化。页面初始化均由Presenter触发。

class MainTabRouter extends BaseRouter {
  @override
  void push(context, params, title) {
    super.push(context, params, title);
    Route route = MaterialPageRoute(builder: (context) {
      return MainTabPresenter().create(params);
    });
    Navigator.push(context, route);
  }
}

在上述代码逻辑实现后:

我们在主路由中实现静态方法Push/Pop:

// 定义Router的key值,方便后续调用
enum RouterKey {
  MainTab,
}

// 实现Router类
class Router {
  static Map<RouterKey, BaseRouter> routeMap = {
    RouterKey.MainTab: MainTabRouter(),
  };

  static void push(RouterKey destination, context, {params, title}) {
    if (routeMap.containsKey(destination)) {
      var router = routeMap[destination];
      router.push(context, params, title);
    }
  }

  static void pop(context) {
    if (Navigator.canPop(context)) {
      Navigator.pop(context);
    }
  }
}

此时我们的一套完整的VIPER流程就实现完成了
此时通过main中写入一个Button,用来触发Router的页面push效果:

body: Center(
  child: MaterialButton(
    onPressed: () {
      Router.push("mainTab", context);
    },
    child: Text('push页面'),
  ),
),

之后就可以看到完整的一套流程了:

4.后续优化

1.增加页面创建脚本/插件,用于快速生成框架页面
2.抽离基类,以便于其他项目中使用

5.代码仓库

github.com/owops/Flutt…