Flutter - 實戰指導,使用ScopedModel管理狀態

小紅星閃啊閃發表於2020-06-13

ScopedModel已經過度到了Provider的模式了。不用深入本文,就可以看到ScopedMode裡的VM這一層都是通過呼叫notifyListeners方法來通知介面更新的,ScopedModelScopedModelDescendant也和Provider模式下的Consumer相差無幾,底層也許有區別不過本質都是一個元件。而且也是用在需要更新的元件子樹上一層來保證更新範圍最小。在VM的組織上基本也是一樣,用VM層來呼叫各種服務。所以,如果你已經瞭解Provider模式,那麼本片可以不用看。不瞭解Provider也可以直接跳過本文看Provider模式。

本文希望在儘量接近實戰的條件下能清晰的講解如何使用ScopedModel架構。視訊教程在這裡

ScopedModel實戰指南

起因

我(作者)在幫一個客戶使用Flutter重製一個App。設計差強人意,效能更是差的離譜。但是我(作者)接手這個專案的時候還只用了Flutter三個星期。調研了ScopedMode和Redux之後就準備用ScopedModel了,BLoC完全不在考慮範圍內。

我發現ScopedModel非常容易使用,而且從我開發這個app裡我也有很多的收穫。

實現風格

ScopedModel不止有一種實現方式。根據功能組織Model,或者根據頁面來組織Model。兩種方法裡model都需要和服務(service)互動,服務則處理所有的邏輯並且根據返回的資料處理狀態(state)。我們來快速的過一下這兩種方式。

一個AppModel和FeatureModel mixin

Flutter - 實戰指導,使用ScopedModel管理狀態

在這個情況下你有一個AppModel,它會從根元件(root widget)一直傳遞到需要的子元件上。AppModel可以通過mixin的方式來擴充套件它所支援的功能比如:

/// Feature model for authentication
class AuthModel extends Model {
    // ...
}

/// App model
class AppModel extends Model with AuthModel {}
複製程式碼

如果你還是不清楚是怎麼回事的話,可以看這個例子

每個頁面或者元件一個Model

Flutter - 實戰指導,使用ScopedModel管理狀態

這樣一個ScopedModel就直接和一個頁面或者元件關聯了。但是也會產生很多的固定模式的程式碼,畢竟你要為每個頁面寫一個Model。

在(作者)的生產app上,使用了單一AppModel和多個功能mixin的方式。隨著App規模的變大,經常會有一個model處理多個頁面(元件)的狀態的情況,這樣就有點鬱悶了。於是就遷移到了另外一種做法上。每個頁面/元件一個Model,加上GetIt做為IoC容器,這樣就簡單了很多。本文的剩餘部分也會繼續講述這個模式。

如果要動手實踐的話可以從這個repo裡代程式碼開始。用你喜歡的IDE開啟start目錄。

實現概述

這麼做是為了更加容易開始,也容易找到切入點。每個檢視會有一個根Model繼承自ScopedModel。ScopedModel物件將會從locator裡獲得。叫做locator是因為它就是用來定位服務和Model的。每個頁面/元件的model都會代理專門的服務的方法,比如網路請求或者資料庫操作等,並根據返回的結果更新元件的狀態。

首先,我們來安裝GetItScopedModel

實現

配置和安裝ScopedModel和依賴注入

在我們的包清單pubspec裡新增scoped_modelget_it依賴:

...
dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2
  # scoped model
  scoped_model: ^1.0.1
  # dependency injection
  get_it: ^1.0.3
...
複製程式碼

lib目錄下新建一個service_locator.dart檔案。新增如下程式碼:

import 'package:get_it/get_it.dart';

GetIt locator = GetIt();

void setupLocator() {
  // Register services

  // Register models
}
複製程式碼

你會在這裡註冊你所有的Model和服務物件。之後在main.dart檔案裡新增setupLocator()的呼叫, 如下:

...
import 'service_locator.dart';

void main() {
  // setup locator
  setupLocator();

  runApp(MyApp());
}
...
複製程式碼

以上就配置完了app所需要的全部依賴了。

新增元件和Model

我們來新增一個Home頁面。現在是每個頁面都有一個scoped model,那麼也新建一個相關的model,並通過locator把他們兩個關聯起來。首先我們準備好他們要存放的地方。在lib目錄下新建一個ui目錄,在裡面再新建一個view目錄用來存放拆分出來的檢視。

lib目錄下新建scoped_model目錄來存放model。

首先在view目錄下新建一個home_view.dart的檔案。

import 'package:flutter/material.dart';
import 'package:scoped_model/scoped_model.dart';

class HomeView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ScopedModel<HomeModel>(
      child: Scaffold(

    ));
  }
}
複製程式碼

我們需要一個HomeModel來獲取各種我們需要的對應的資訊。在lib/scoped_model目錄先新建home_model.dart檔案。

import 'package:scoped_model/scoped_model.dart';

class HomeModel extends Model {
  
}
複製程式碼

接下來我們要把我們的頁面和scoped model關聯到一起。這個時候就該之前提到的locator上場了。但是,還要完成一些locator的註冊工作。要適用locator就需要先註冊。

import 'package:scoped_guide/scoped_models/home_model.dart';
...

void setupLocator() {
  // register services
  // register models
  locator.registerFactory<HomeModel>(() => HomeModel());
}
複製程式碼

HomeModel已經在locator裡完成了註冊。我們可以在任何地方通過locator拿到它的例項了。

首先需要引入ScopedModel,這裡用到了泛型,所以它的型別引數就是我們定義的HomeModel。把它作為一個元件放進build方法裡。model屬性就用到了locator。在用到HomeModel例項的地方使用ScopedModelDescendant。它也需要一個型別引數,這裡同樣是HomeModel

import 'package:flutter/material.dart';
import 'package:scoped_model/scoped_model.dart';
import 'package:scoped_guide/scoped_models/home_model.dart';
import 'package:scoped_guide/service_locator.dart';

class HomeView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ScopedModel<HomeModel>(
      model: locator<HomeModel>(),
      child: ScopedModelDescendant<HomeModel>(
        builder: (context, child, model) => Scaffold(
          body: Center(
            child: Text(model.title),
          ),
        )));
  }
}
複製程式碼

這裡的model的title屬性可以設定為HomeModel

新增服務

新建一個lib/services目錄。這裡我們會新增一個假的服務,它只會延時兩秒執行,之後返回一個true。新增一個storage_service.dart檔案。

class StorageService {
  Future<bool> saveData() async {
    await Future.delayed(Duration(seconds: 2));
    return true;
  }
}
複製程式碼

locator裡註冊這個服務:

import 'package:scoped_guide/services/storage_service.dart';
...
void setupLocator() {
  // register services
  locator.registerLazySingleton<StorageService>(() => StorageService());

  // register models
  locator.registerFactory<HomeModel>(() => HomeModel());
}
複製程式碼

就如上文所述,我們用service來完成需要的工作,並使用返回的資料來更新需要更新的元件。但是,這裡還有一個model作為代理。所以我們需要用locator來把註冊好的服務和model關聯。

import 'package:scoped_guide/service_locator.dart';
import 'package:scoped_guide/services/storage_service.dart';
import 'package:scoped_model/scoped_model.dart';

class HomeModel extends Model {
  StorageService storageService = locator<StorageService>();

  String title = "HomeModel";

  Future saveData() async {
    setTitle("Saving Data");
    await storageService.saveData();
    setTitle("Data Saved");
  }

  void setTitle(String value) {
    title = value;
    notifyListeners();
  }
}
複製程式碼

HomeModel裡的saveData方法才是元件需要呼叫到的。這個方法也就是服務的一個大力方法。具體的可以參考MVVM的模式,這裡就不過多敘述。

saveData方法裡,存資料完成之後呼叫了setTitle方法。這個方法根據service返回的值設定了title屬性,並呼叫了notifyListeners方法發出通知。通知需要更新的元件可以把資料顯示上去了。

HomeViewScaffold裡新增一個浮動按鈕,並在裡面呼叫HomeModelsaveData方法。那麼,從接收使用者的輸入到“儲存資料”,再到最後的更新介面一套流程在程式碼裡就全部實現完成了。

回顧一下基礎內容

我們一起來回顧一下在實際開發中經常會用到的內容。

狀態管理

如果你的app要從網路或者本地資料庫讀取資料,那麼就會有四個基本狀態需要處理:idel(空閒),busy(獲取資料中),retrieved(成功取得資料)和error。所有的檢視的檢視都會用到這四個狀態,所以比較好的選擇的是在一開始的時候就把他們寫到model裡。

新建lib/enum目錄,在裡面新建一個view_states.dart檔案。

/// Represents a view's state from the ScopedModel
enum ViewState {
  Idle,
  Busy,
  Retrieved,
  Error
}
複製程式碼

現在檢視的model就可以引入ViewState了。

import 'package:scoped_guide/service_locator.dart';
import 'package:scoped_guide/services/storage_service.dart';
import 'package:scoped_model/scoped_model.dart';
import 'package:scoped_guide/enums/view_state.dart';

class HomeModel extends Model {
  StorageService storageService = locator<StorageService>();

  String title = "HomeModel";

  ViewState _state;
  ViewState get state => _state;

  Future saveData() async {
    _setState(ViewState.Busy);
    title = "Saving Data";
    await storageService.saveData();
    title = "Data Saved";
    _setState(ViewState.Retrieved);
  }

  void _setState(ViewState newState) {
    _state = newState;
    notifyListeners();
  }
}
複製程式碼

ViewState會通過一個getter暴露出去。同樣的,這些狀態也都需要對應的檢視可以捕捉到,並在發生變化的時候更新介面。所以,狀態變化的時候也需要呼叫notifyListeners來通知檢視,或者說更新檢視的狀態。

你可以看到,狀態變化的時候一個叫做_setState的方法被呼叫了。這個方法專門去負責呼叫notifyListeners來通知檢視去做更新。

現在我們呼叫了_setStateScopedModel就會收到通知,然後UI裡的某部分就回發生更改。我們會顯示一個旋轉的菊花來表明服務正在請求資料,也許是通過網路獲取後端資料也許是本地資料庫的資料。現在來更新一下Scaffold的程式碼:

...
body: Center(
    child: Column(
      mainAxisSize: MainAxisSize.min,  
      children: <Widget>[
        _getBodyUi(model.state),
        Text(model.title),
      ]
    )
  )
...
  
Widget _getBodyUi(ViewState state) {
  switch (state) {
    case ViewState.Busy:
      return CircularProgressIndicator();
    case ViewState.Retrieved:
    default:
      return Text('Done');
  }
}  
複製程式碼

_getBodyUi方法會更具ViewState的值來顯示不同的介面。

多個檢視

一個資料的變化會影響到多個介面的情況是實際開發中經常發生的。在處理完單個介面更新的簡單情況後我們可以開始處理多個介面的問題了。

在前面的例子中你會看到很多的模板程式碼,比如:ScopedModelScopedModelDescendant以及從locator裡獲取model、service之類的物件。這些都是模板程式碼,不是很多,但是我們還可以讓它更少。

首先,我們來新建一個BaseView

import 'package:flutter/material.dart';
import 'package:scoped_model/scoped_model.dart';
import 'package:scoped_guide/service_locator.dart';

class BaseView<T extends Model> extends StatelessWidget {
  
  final ScopedModelDescendantBuilder<T> _builder;

  BaseView({ScopedModelDescendantBuilder<T> builder})
      : _builder = builder;

  @override
  Widget build(BuildContext context) {
    return ScopedModel<T>(
        model: locator<T>(), 
        child: ScopedModelDescendant<T>(
          builder: _builder));
  }
}
複製程式碼

BaseView裡已經有了ScopedModelScopedModelDescendant的呼叫。那麼就不不要在每個介面裡都放這些呼叫了。比如HomeView就使用BaseView並去掉這些無關的程式碼了。

...
import 'base_view.dart';

@override
Widget build(BuildContext context) {
  return BaseView<HomeModel> (
      builder: (context, child, model) => Scaffold(
        ...
  ));
}
複製程式碼

這樣我們可以用更少的程式碼做更多的事了。你可以給IDE裡註冊一段程式碼段,這樣幾個字元輸入了就可以有一段基本完整的功能的程式碼出現了。我們在lib/ui/views目錄新建一個模板檔案template_view.dart

import 'package:flutter/material.dart';
import 'package:scoped_guide/scoped_models/home_model.dart';

import 'base_view.dart';

class Template extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BaseView<HomeModel>(
      builder: (context, child, model) => Scaffold(
         body: Center(child: Text(this.runtimeType.toString()),),
      ));
  }
}
複製程式碼

我們分發出去的狀態也不是隻是專屬於一個介面的,而是可以多個介面共享的,所以我們也新建一個BaseModel來處理這個問題。

import 'package:scoped_guide/enums/view_state.dart';
import 'package:scoped_model/scoped_model.dart';

class BaseModel extends Model {
  ViewState _state;
  ViewState get state => _state;

  void setState(ViewState newState) {
    _state = newState;
    notifyListeners();
  }
}
複製程式碼

修改HomeModel的程式碼,讓他從BaseModel繼承。

...
class HomeModel extends BaseModel {
  ...
  Future saveData() async {
    setState(ViewState.Busy);
    title = "Saving Data";
    await storageService.saveData();
    title = "Data Saved";
    setState(ViewState.Retrieved);
  }
}
複製程式碼

對於多個介面的支援的程式碼準備都完成了。我們有BaseViewBaseModel可以分別服務於檢視和model了。

接下來就是導航了。根據template_view.dart來新建兩個檢視error_view.dartsuccess_view.dart。記得在這些程式碼裡面做適當的修改。

接下來新建兩個model,一個是SuccessModel一個是ErrorModel。他們都繼承自BaseModel,而不是Model。然後記得在locator裡面註冊這些model。

導航

基本的導航都很類似。我們可以使用導航器(Navigator)來初始導航棧上的檢視。

現在對我們的HomeModel#saveData來做一些更改。

Future<bool> saveData() async {
    _setState(ViewState.Busy);
    title = "Saving Data";
    await storageService.saveData();
    title = "Data Saved";
    _setState(ViewState.Retrieved);
    
    return true;
}
複製程式碼

HomeView裡,我們來更新浮動按鈕的onPress方法。讓它成為一個非同步方法,等待saveData執行的結果,並根據結果導航到對應的介面。

floatingActionButton: FloatingActionButton(
    onPressed: () async {
      var whereToNavigate = await model.saveData();
      if (whereToNavigate) {
        Navigator.push(context,MaterialPageRoute(builder: (context) => SuccessView()));
      } else {
        Navigator.push(context,MaterialPageRoute(builder: (context) => ErrorView()));
      }
    }
)
複製程式碼

共享的檢視

在多個幾面裡都有獲取資料的服務,那麼他們也就都需要顯示忙碌狀態:一個旋轉的菊花。那麼,這個元件就是可以在不同的介面之間共享的。

新建一個BusyOverlay元件,把它放在lib/ui/views目錄,命名為busy_overlay.dart

import 'package:flutter/material.dart';

class BusyOverlay extends StatelessWidget {
  final Widget child;
  final String title;
  final bool show;

  const BusyOverlay({this.child,
      this.title = 'Please wait...',
      this.show = false});

  @override
  Widget build(BuildContext context) {
    var screenSize = MediaQuery.of(context).size;
    return Material(
        child: Stack(children: <Widget>[
      child,
      IgnorePointer(
        child: Opacity(
            opacity: show ? 1.0 : 0.0,
            child: Container(
              width: screenSize.width,
              height: screenSize.height,
              alignment: Alignment.center,
              color: Color.fromARGB(100, 0, 0, 0),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: <Widget>[
                  CircularProgressIndicator(),
                  Text(title,
                      style: TextStyle(
                          fontSize: 16.0,
                          fontWeight: FontWeight.bold,
                          color: Colors.white)),
                ],
              ),
            )),
      ),
    ]));
  }
}
複製程式碼

現在我們可以介面裡使用這個元件了。在HomeView裡,把Scaffold放進BusyOverlay裡面:

@override
Widget build(BuildContext context) {
  return BaseView<HomeModel>(builder: (context, child, model) =>
     BusyOverlay(
      show: model.state == ViewState.Busy,
      child: Scaffold(
      ...
      )));
}
複製程式碼

現在,當你點選浮動按鈕的時候你會看到一個“請稍等”的提示。你也可以把BusyOverlay元件的呼叫放進BaseView裡面。記住你的忙碌提示組要在builder裡面,這樣它才能更具model的返回值作出正確的反應。

非同步問題的處理

我們已經處理了根據不同的model返回值來顯示對應的介面。現在我們要處理另外一個常見的問題,那就是非同步問題的處理。

載入頁面,並獲取資料

當你有一個列表,點了某行要看到更多的詳細資訊的時候基本就會遇到一個非同步場景。當進入詳情頁面的時候,我們就會根據傳過來的這個特定資料的ID等相關資料來請求後端獲得更多的詳細資料。

請求一般都是發生在StatefulWidgetinitState方法內。本例不打算新增太多的介面,我們只關注在架構上面。我們會寫死一個返回值,讓這個值在“請求成功”的時候返回給介面。

首先,我們來更新SuccessModel

import 'package:scoped_guide/scoped_models/base_model.dart';

class SuccessModel extends BaseModel {
  String title = "no text yet";

  Future fetchDuplicatedText(String text) async {
    setState(ViewState.Busy);
    await Future.delayed(Duration(seconds: 2));
    title = '$text $text';

    setState(ViewState.Retrieved);
  }
}
複製程式碼

現在我們可以在檢視建立的時候呼叫model的方法了。不過這需要我們把BaseView換成StatefulWidget。在BaseViewinitState方法裡呼叫model的非同步方法。

import 'package:flutter/material.dart';
import 'package:scoped_model/scoped_model.dart';
import 'package:scoped_guide/service_locator.dart';

class BaseView<T extends Model> extends StatefulWidget {
  final ScopedModelDescendantBuilder<T> _builder;
  final Function(T) onModelReady;

  BaseView({ScopedModelDescendantBuilder<T> builder, this.onModelReady})
      : _builder = builder;

  @override
  _BaseViewState<T> createState() => _BaseViewState<T>();
}

class _BaseViewState<T extends Model> extends State<BaseView<T>> {
  T _model = locator<T>();

  @override
  void initState() {
    if(widget.onModelReady != null) {
      widget.onModelReady(_model);
    }
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return ScopedModel<T>(
        model: _model, 
        child: ScopedModelDescendant<T>(
          child: Container(color: Colors.red),
          builder: widget._builder));
  }
}
複製程式碼

然後更新你的SuccessView,在onMondelReady屬性裡傳入你要呼叫的方法。

class SuccessView extends StatelessWidget {
  final String title;

  SuccessView({this.title});

  @override
  Widget build(BuildContext context) {
    return BaseView<SuccessModel>(
        onModelReady: (model) => model.fetchDuplicatedText(title),
        builder: (context, child, model) => BusyOverlay(
            show: model.state == ViewState.Busy,
            child: Scaffold(
              body: Center(child: Text(model.title)),
            )));
  }
}
複製程式碼

最後在導航的時候傳入引數。

Navigator.push(context, MaterialPageRoute(builder: (context) = > SuccessView(title: 'Pass in from home')));
複製程式碼

這樣就可以了。現在你可以在ScopedModel架構下跑起來你的app了。

全部完成

本文基本覆蓋了使用ScopedModel開發app所需要的全部內容。在這個時候你已經可以來實現你自己的服務了。一個很重要但是本文沒有提到的問題是測試。

我們也可以通過建構函式來實現依賴注入,比如通過建構函式的依賴注入來往model裡注入service。這樣我們也可以注入一些假的service。我(作者)是沒有對model層做測試的,因為他們都是完全的依賴於服務層。而服務層我都做了充分的測試。

相關文章