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

35次阅读

共计 12153 个字符,预计需要花费 31 分钟才能阅读完成。

ScopedModel 已经过度到了 Provider 的模式了。不用深入本文,就可以看到 ScopedMode 里的 VM 这一层都是通过调用 notifyListeners 方法来通知界面更新的,ScopedModelScopedModelDescendant 也和 Provider 模式下的 Consumer 相差无几,底层也许有区别不过本质都是一个组件。而且也是用在需要更新的组件子树上一层来保证更新范围最小。在 VM 的组织上基本也是一样,用 VM 层来调用各种服务。所以,如果你已经了解 Provider 模式,那么本片可以不用看。不了解 Provider 也可以直接跳过本文看 Provider 模式。

本文希望在尽量接近实战的条件下能清晰的讲解如何使用 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 层做测试的,因为他们都是完全的依赖于服务层。而服务层我都做了充分的测试。

正文完
 0