Flutter - 实战指导,使用ScopedModel管理状态

1,254 阅读12分钟

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

在这个情况下你有一个AppModel,它会从根组件(root widget)一直传递到需要的子组件上。AppModel可以通过mixin的方式来扩展它所支持的功能比如:

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

/// App model
class AppModel extends Model with AuthModel {}

如果你还是不清楚是怎么回事的话,可以看这个例子

每个页面或者组件一个Model

这样一个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层做测试的,因为他们都是完全的依赖于服务层。而服务层我都做了充分的测试。