Flutter VIPER架構-解決複用和測試問題的利器

餓了麼新餐飲前端團隊發表於2020-02-25

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框架圖:

Flutter VIPER架構-解決複用和測試問題的利器

其中VIPER框架事件細分:

Flutter VIPER架構-解決複用和測試問題的利器

2.使用VIPER框架的優劣勢

優點

VIPER的特色就是職責明確,粒度細,隔離關係明確,這樣能帶來很多優點:

  • 可測試性好。UI測試和業務邏輯測試可以各自單獨進行。
  • 易於迭代。各部分遵循單一職責,可以很明確地知道新的程式碼應該放在哪裡。
  • 隔離程度高,天然解耦。一個模組的程式碼不容易影響到另一個模組。
  • 易於團隊合作。各部分分工明確,團隊合作時易於統一程式碼風格,可以快速接手別人的程式碼。

缺點

VIPER因為需求的拆分粒度細,相應的會帶來以下問題:

  • 一個模組內的類數量增大,程式碼量增大,在層與層之間需要花更多時間設計介面。使用程式碼模板來自動生成檔案和模板程式碼可以減少很多重複勞動,而花費時間設計和編寫介面是減少耦合的路上不可避免的,你也可以使用資料繫結這樣的技術來減少一些傳遞的層次。
  • 模組的初始化較為複雜,開啟一個新的介面需要生成View、Presenter、Interactor,並且設定互相之間的依賴關係。

3.在Flutter中的拆解與實踐

VIPER框架最關鍵的是如何將相關介面定義出來,為了實現VIPER框架的目錄結構,我們將程式碼實現為如下目錄結構:

目錄結構:

Flutter 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頁面'),
  ),
),
複製程式碼

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

Flutter VIPER架構-解決複用和測試問題的利器

4.後續優化

1.增加頁面建立指令碼/外掛,用於快速生成框架頁面
2.抽離基類,以便於其他專案中使用

5.程式碼倉庫

github.com/owops/Flutt…

相關文章