视图与逻辑分离之道序篇-使用MVVM模式管理状态(GetState)

2,664 阅读10分钟

了解 GetState

❓ 为什么做GetState

Flutter 状态管理方案百花齐放, 从 ScopeModel 到 Provide、MobX, 再到 BLoC、Redux、Provider. 特别是BLoC和Provider, 已经有了大量的用户,但是我在实际使用的时候,发现了这样几个问题:

  • 使用不便,需要手动编写大量的样板代码,状态都需要手动注册
  • 业务逻辑与UI表现逻辑, 甚至直接与UI耦合。
  • 面对大型项目无法清晰的为各层次划清界限, 单元测试代码编写繁琐。

面对这些问题,GetState应运而生

  • 自动注册状态: 解放双手, 保护头发
  • 极致的速度: GetState提供时间复杂度为O(1)的访问性能, 暴打一众O(N)的状态管理方案
  • 便于单元测试: 业务逻辑与UI代码解耦, 妈妈再也不用担心我的单元测试了, 保护头发*2
  • 状态时光机: 使用Recorder, 在过去与现在之间穿梭
  • 使用灵活: 既支持Provider使用的mutable状态,也支持BLoC,Redux使用的immutable状态
  • 强大的兼容性: 如果你已经使用了Provider, Redux, BLoC等状态管理方案, 那么切换到GetState, 你并不需要移除已有的状态管理代码, GetState可以与现有的状态管理方案共存.



GetState : 致力于解决Flutter应用UI与业务逻辑解耦问题的MVVM状态管理方案



进入正题

🛸 先放上 Pub 以及 项目地址

欢迎Star, PR, issue 😘

前三个Demo分别介绍ViewModel,View和Model,心急的可以直接跳过, 或者配合教程3阅读Demo3

以下是教程中的Demo源码


🛴了解GetState原理 - ViewModel的作用 (Demo0)

按照Flutter的惯例, 第一个Demo当然是选择经典的CounterApp了

👻 不推荐本例中的写法, Demo仅供了解GetState原理

0-确保配置yaml配置正确

dependencies:
  flutter:
    sdk: flutter
  ## 引入get_state
  get_state: <这里填写版本号>

1-编写viewmodel类-countervm

ViewModel负责简单的业务逻辑和操作视图

💡 猜一猜复杂的业务逻辑应该怎么处理

这里的操作Model的方法(如incrementCounter),相当于BLoC中的Event

ViewModel的泛型即Model的类型, 这里直接使用int类型, 当然也可以使用自定义类型, 详见后面"推荐用法"

class CounterVm extends ViewModel<int> {
  // 1.1 在ViewModel的构造中, 提供默认的初始值
  CounterVm() : super(initModel: 0);

  // 1.2 获取Model方法, 这里的model时父类中的属性,其类型用本类泛型指定
  int counter()=> m;

  // 1.3 操作Model方法,
  // 调用 父类中的vmUpdate(M m)方法更新model的值
  void incrementCounter() {
    vmUpdate(m + 1);
  }
}

2-在main方法中注册ViewModel(手动注册方式)

😃 既然有"手动注册"方式, 那么肯定有自动注册方式了, 详见后面的代码

使用 GetIt g = GetIt.instance; 获取GetIt实例.

实际上直接使用GetIt.instance或GetIt.I效果是一样的,且它们都是单例模式. 这里将其赋值给 g,只是为了便于使用.
当然, 推荐命名为 _g

添加 WidgetsFlutterBinding.ensureInitialized();以防止ViewModel注册失败

关于WidgetsFlutterBinding.ensureInitialized()的作用,这里贴出Flutter源码中的说明
"You only need to call this method if you need the binding to be initialized before calling [runApp]."

使用 GetIt.I.registerSingleton<泛型>(构造方法); 以懒单例的方式注册ViewModel

get_it 还有更多注册方式, 这里暂时只介绍懒单例注册方式

GetIt g = GetIt.instance;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // 4.手动注入依赖, 确保View可以获取到ViewModel
  g.registerSingleton<CounterVm>(CounterVm());
  runApp(MyApp());
}

3-最后,在UI代码中调用ViewMdoel的方法来操作与获取数据

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => MaterialApp(
        home: Scaffold(
          appBar: AppBar(
            title: Text('演示:0.极简使用方法'),
          ),
          body: Center(
            child: Text('测试0: ${g<CounterVm>().counter()}'),
          ),
          floatingActionButton: FloatingActionButton(
            child: Icon(Icons.add),
            onPressed: () => g<CounterVm>().incrementCounter(),
          ),
        ),
      );
}

Demo1到此结束了, 本例仅供了解GetState原理, 实际使用中, 不建议使用这样的写法.标准写法见Demo3
接下来是包装View的Demo.






🚲 包装一个View (Demo1)

直接将ViewModel和GetIt实例裸露在外一点也不优雅, 如果封装为View使用起来可就方便多了

0-先确保配置了yaml

yaml内容 跟Demo0一样

1-再编写ViewModel

这里直接使用Demo0中的ViewModel

2-编写View类(MyCounterView)

View类负责UI绘制, 控制UI的逻辑应当尽量放在View里面, 业务逻辑可以放在ViewModel中, 一个ViewModel经常会对应多个View, 根据迪米特原则, 各个View应当在其内部处理好UI绘制逻辑.

View就是最终展示出来的Widget

class MyCounterView extends View<MyCounterViewModel> {
  @override
  Widget build(BuildContext c, MyCounterViewModel vm) => ListTile(
        title: Text('测试1: ${vm.counter}'),
        trailing: RaisedButton(
          child: Icon(Icons.add),
          onPressed: () => vm.incrementCounter(),
        ),
      );
}

3-将View放到Widget树中

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => MaterialApp(
        title: '演示:1.初级使用方法',
        home: Scaffold(
          appBar: AppBar(),
          body: Column(children: <Widget>[
            // 将视图放入需要的地方
            MyCounterView(),
          ]),
        ),
      );
}

3-在main方法中注册依赖

这里还是沿用 Demo0中的方法

包装View的Demo到此结束, 这样的写法适用于Model十分简单的情况, 但实际上如果Model十分简单, 也就失去使用状态管理的意义了, 图一乐也就图一乐,真图一乐还得看Demo3






🛵 自定义 Model (Demo2)

在实际应用中, Model肯定不会是一个基本类型, 否则也就失去使用状态管理的意义了

✨ 建议自己动手的时候也按照本文中的步骤操作


0-先确保配置了yaml

dependencies:
  flutter:
    sdk: flutter
  ## 1. 引入get_state
  get_state: ^3.3.0

  ## 2- 可以通过引入equatable,省去手动覆写==和hashCode
  equatable: ^1.1.1

1-编写Model(CounterModel)

建立一个简单的状态, 内部有两个变量 number和str
Model有两种写法, 其实本质上没有区别, 先看看写法1

/// 写法1
class CounterModel {
  final int number;
  final String str;

  CounterModel(this.number, this.str);

  // todo 注意, 这里务必覆写==与hashCode, 否则无法正常刷新
  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is CounterModel &&
          runtimeType == other.runtimeType &&
          number == other.number &&
          str == other.str;

  @override
  int get hashCode => number.hashCode ^ str.hashCode;
}

✨ 这里推荐写法2, 使用Equatable贯彻"解放双手,保护头发"的理念.

虽然有IDE加持, 覆写==与hashCode方法并一般不费时间.
但如果Model中的字段很多,频繁修改字段的同时, 还要修改 ==与hashCode方法, 太过麻烦.

/// 写法2: 使用 Equatable
class CounterModel2 extends Equatable {
  final int number;
  final String str;

  CounterModel2(this.number, this.str);

  // todo 这里需要将所有的属性值都放入 props中
  @override
  List<Object> get props => [number, str];
  
  // ✨ 小技巧, 添加下面这行代码,连toString都不用动手了
  @override
  final stringify = true;
}

2-编写ViewModel(CounterVm)

这里沿用Demo0中的代码


3-编写View(MyCounterView)

这里沿用Demo1中的View


4-再将View放入Widget树

仍然沿用Demo1中的代码


5-最后不要忘记注册依赖(自动注册就不用考虑这一步了)

还是用Demo0中的依赖注册方式


GetState基础使用教程至此结束, 是不是十分简单呢? 😎





🚗 半自动注册状态与跨页状态修改 (Demo3)

😀 emmm, 不用多说, 肯定有全自动注册的方法了, 不过由于篇幅有限, 全自动注册的方法请参考 这里, 这里不再做详细说明(不建议新手使用)


0-先确保配置了yaml

❗ 这里的yaml与之前的相差较大, 注意观察

dependencies:
  flutter:
    sdk: flutter
  ## 1. 引入get_state
  get_state: ^3.3.0

  ## 2- 可以通过引入equatable,省去手动覆写==和hashCode
  equatable: ^1.2.0
  
  ## 3- 通过injectable省去手动注册步骤
  injectable: ^0.4.0+1

dev_dependencies:
  flutter_test:
    sdk: flutter
  ## 4- injectable需要额外添加下面两个依赖
  build_runner: ^1.10.0
  ## 5- 这个同样重要
  injectable_generator: ^0.4.1

1-1页面A-创建Model(CounterModel2)

本Demo将会创建两个Page, 先看第一个页面.
Model内容与上一个Demo中的CounterModel基本一致

class CounterModel2 extends Equatable {
  final int number;
  final String str;

  CounterModel2(this.number, this.str);

  // 1. 这里需要将所有的属性值都放入 props中
  @override
  List<Object> get props => [number, str];
}

1-2页面A-创建ViewModel(MyCounterViewModel)

👻 这里要注意, 一定要添加"@lazySingleton"注解, 这就是"半自动"的一部分, 千万不要省略

不是光加上注解的完事了, "半自动"还有另一半操作呢😜

@lazySingleton
class MyCounterViewModel extends ViewModel<CounterModel2> {
  MyCounterViewModel() : super(initModel: CounterModel2(3, '- -'));

  int get counter => m.number;

  void incrementCounter() {
    vmUpdate(CounterModel2(m.number + 1, '新的值'));
  }
}

1-3页面A-创建View(MyCounterView)

class MyCounterView extends View<MyCounterViewModel> {
  @override
  Widget build(BuildContext c, MyCounterViewModel vm) => ListTile(
        leading: Text('测试3: ${vm.counter}'),
        title: Text('${vm.m.str}'),
        trailing: RaisedButton(
          child: Icon(Icons.add),
          onPressed: () => vm.incrementCounter(),
        ),
      );
}

1-4页面A-将View放到Page中

这里的MapApp 跟前面的不太一样, 不要太在意这些细节, 问题不大

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(
          title: Text('演示:3.标准使用方法'),
        ),
        body: Column(children: <Widget>[
          // View 1
          MyCounterView(),
          RaisedButton(
            child: Text('跳转到新页面'),
            onPressed: () => Navigator.of(context).push(MaterialPageRoute(
              builder: (c) => Page2(),
            )),
          ),
          RaisedButton(
            child: Text('点击更改另一个页面的值'),
            onPressed: () => g<Pg2Vm>().add,
          ),
        ]),
      );
}

看这里, "跨页修改状态"就是这么简单粗暴 😎

RaisedButton(
  child: Text('点击更改另一个页面的值'),
  onPressed: () => g<Pg2Vm>().add,
),

2-1页面B-创建Model

页面1的MVVM一家已经创建完毕了, 页面2只是为了演示跨页状态的修改, 所以就随便写一下

// 你没看错, 页面2不定义Model了, 直接用int类型吧

2-2页面B-创建ViewModel(Pg2Vm)

跟上面一样, 同样不要忘记加上"@lazySingleton"

@lazySingleton
class Pg2Vm extends ViewModel<int> {
  Pg2Vm() : super(initModel: 3);

  String get strVal => "$m";

  get add => vmUpdate(m + 1);
}

2-3页面B-创建View(FooView)

再创建一个简单的View, 包装以下ViewModel

class FooView extends View<Pg2Vm> {
  @override
  Widget build(BuildContext c, Pg2Vm vm) => RaisedButton(
        child: Text('${vm.strVal}'),
        onPressed: () => vm.add,
      );
}

2-4页面B-将View放入Page中

class Page2 extends StatelessWidget{
  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(),
        body: Center(
          child: FooView(),
        ),
      );
}

3-1初始化Injectable

将下面的函数直接写在main.dart文件里面, 当然,另外创建一个新dart文件也可以, 问题不大.

函数, 一定要放在类的外面, 放在类里面的叫方法.

同样不要放了添加注解"@injectableInit".
建议直接复制下面的代码到自己项目里

写好之后, IDE会提示"找不到$initGetIt"函数, 不要着急, 这个函数还没有自动生成呢

// 添加注解
@injectableInit
Future<void> configDi() async {
  $initGetIt(g);
}

❗ 注意,这里的 configDi方法返回值是 Future, 但是函数体内没有await.
这是因为当前生成的依赖注入代码都是同步的, 如果用到了@preResolve注解, 则生成的 $initGetIt()是一个异步方法, 必须要加上await,否则会出错


3-2自动生成注入代码

打开Terminal(或者用CMD进入项目的lib同级路径), 输入

flutter pub run build_runner build --delete-conflicting-outputs

如果希望build_runner在后台持续自动生成代码,则输入

flutter pub run build_runner watch --delete-conflicting-outputs

这里的"--delete-conflicting-outputs"表示清除已经生成过的代码, 如果你之前已经生成过代码, 而第二次生成又不想重新开始, 则可以不加这个参数

如果生成失败, 注意查看错误代码, 一般情况下加上"--delete-conflicting-outputs"就能解决问题

待代码生成完毕后, 在原本报错的代码处import新生成的 xxx.iconfig.dart文件就可以了.


4-在main中添加依赖注入

GetIt g = GetIt.instance;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // 5. 添加自动依赖注入
  configDi();
  runApp(MaterialApp(home: MyApp()));
}






🎇🎇🎇大功告成🎇🎇🎇


以上Demo就是get_state的一般用法了, 不过除此之外, get_state还有更多技巧等待你的解锁😀

下面几个Demo的依赖于这个文件.dart, 直接复制粘贴是无法运行的, 具体原因是因为没有为自己生成相应的 依赖注入代码



希望各位多多点赞支持, 更欢迎大家提出意见与建议😀

有时间的话会补上后续教程的😜

后续

  • 关于上文中留下的问题

"💡 猜一猜复杂的业务逻辑应该怎么处理", 请参见GetArch介绍


新增

✨✨

未经作者授权, 禁止转载