从 MVVM 角度来尝试理解 Flutter 的状态管理

我为什么想在 Flutter 中 使用MVVM?

翻阅 Flutter 相关的文档, 各种讨论文章, 最大的感觉就是: 茫然! 因为缺乏 Flutter 必须的相关经验, 所以 我无法像阅读 iOS 主题的各种文章一样, 能快速区分出, 哪些是 有价值的, 哪些是已经过时的, 哪些是涉及底层细节 暂时不用过多关注的. 在我阅读了一定数量的 Flutter 主题的文章后, 我尝试从 “使用” 的角度, 来尝试探究 Flutter 能做些什么.

我最关心的问题是, 我能不能像 iOS 开发时那样, 继续以 MVVM 的方式, 来有序地开发页面. 此处的 MVVM 指的的是 广义上的 MVVM. 实际开发过程中, 我具体是这样做的:

  • View: 拿到设计图. 按照设计图, “无脑” 画 UI. 各种 假数据, 硬编码.一切以 尽快 高质量地 还原 设计稿 为第一要务. (约40%工作量)

  • ViewModel: 基于 View, 定义 ViewModel, 把硬编码的部分, 替换为对 ViewModel 属性或方法的调用. 然后 基于 PRD, 进一步完善 ViewModel. 比如: PRD 描述某个列表页面有 多种状态, 那我的 ViewModel 就可以添加一个 描述 status 的枚举. ViewModel 是为 View 服务的, 所以 “够用” 即可, 不需要 很“通用”.(约30%工作量)

  • Model: 此处的 Model , 通常基于 Server 接口返回的数据来定义. 主要是为了 和 Server 通信, 同时也便于不同页面间 “共享” / “传递” 一些数据. 此时, 真正需要处理的是: 将 ViewModel 中涉及 Server 的假方法, 替换为真正的 Server API 接口调用; 将 model 转换为 对应的 ViewMode. 乍听起来很复杂, 其实成本主要还是集中在 Server 接口 的调用 和 调试上. (约30%工作量)

显而易见, 使用 MVVM , 可以帮助我 最大限度地 减少 对 Server 开发 和 其他页面 的依赖, 同时尽可能减少工作被 Revert 或 意外block的风险. 基于我的观察, 设计图一般确认后, 都相对稳定, 会有些微调, 一般不会有大的 UI 重构; PRD 开发过程中, 会有部分 细节修正; Server 接口, 如果接口本身被严格自测, 联调也会很快, 但因为 前端和后端 一般是 并行开发, 所以大都只能后置联调工作. (此段描述, 不同公司, 会有差异.需要具体情况,具体分析.)

Flutter 中, 可以继续使用 MVVM 吗?

结论, 是令人鼓舞的: 我可以继续在 Flutter 页面的开发中, 继续使用自己最习惯的方式; 不仅如此, Flutter 还对其更加强大的 支持 – 或者说强制限制. 我不会试图完整描述我是如何思考的. 每个人的既往编程经历不同, 所以每个人 都应该试着自己去 理解和思考. 我能提供的一些片段线索信息是:

  • ReactNative 的开发经验, 让我对 声明式UI, 能较快地 “接受” – 尽管如此, 还是很不适应. 毕竟一些 iOS 种常用的 操作 UI 的方式, 现在大都没法 直接用 或 不推荐使用了.

  • ReactNative 时期, 关于 Redux 极其各种工具库的使用经历, 使我对 Flutter 各种 “状态管理库”, 有一些警惕. 状态管理, 必须足够简单, 简单到能让所有 参与 Flutter 项目开发的童鞋, 都能尽快地 掌握, 并用起来.

  • 关于 “依赖注入” 的编程经历, 让我很好地 理解了 状态管理库 provider 的运行原理. 它的逻辑概念非常简单: 在父控件 ”注入“ 数据, 在子控件, 直接从 ”context上下文“ 中获取 数据, 然后按需展示. 这是一个 非常酷的概念!基于此, UI 完全是 无状态的, 实现了 UI 和 业务的 彻底剥离, 用一种 极度自然的方式.

Flutter 中, 如何使用 MVVM ?

  • Model 的定义, 与其他语言中, 并没有大的不同.
1
2
3
4
5
6
/// 歌曲 Model.
class SongModel {
String name;
String lyricist;
SongModel(this.name, this.lyricist);
}
  • ViewModel 现在需要实现 ChangeNotifier 协议(接口). 这是 Flutter 官方推荐的 Provider 状态管理框架中的一个既定接口. 然后在需要触发 UI 联动的时机, 调用下 notifyListeners()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/// ViewModel: 歌曲卡片.
/// 更真实的场景中, ViewModel 会与 View 保持相关性,
/// 即: ViewModel 与 Model 无本质上的 名称关联.
class CardViewModel extends ChangeNotifier {
SongModel song;
late final _title = '${song.name}, 作词:${song.lyricist}';
String get title => _title;
bool _isLike;
bool get isLike => _isLike;

CardViewModel(this.song, this._isLike);

/// 喜欢.
void like() {
// 异步操作.模拟发送请求.
Future.delayed(const Duration(milliseconds: 300), (){
_isLike = true;

/// 通知监听者(View), 数据有变化.
notifyListeners();
});
}

/// 不喜欢.
void dislike() {
// 异步操作.模拟发送请求.
Future.delayed(const Duration(milliseconds: 300), (){
_isLike = false;
notifyListeners();
});
}
}
  • View, 则非常特殊. 综合各种信息来看, 我倾向于 View 层本身不记录状态.即由 ViewModel 层来处理 View 的状态变化. 这么做,最主要是出于性能的考虑. 越精细地 控制 UI 刷新的粒度, UI 的整体性能, 就越高. 另一个原因, 则是编程中通常说的 UI 与 业务 代码分离. 而在 Flutter中, 如果我们坚持只定义 无状态的 View – StatelessWidget, 就可以很容易地做到这些:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// View: 简单的 点赞 组件.
class LikeWidget extends StatelessWidget {
const LikeWidget({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
debugPrint("like widget build....");

// 使用watch, 以及时响应 ViewModel 的变化。
final cardViewModel = context.watch<CardViewModel>();

final bool isLike = cardViewModel.isLike;
return Icon(
isLike ? Icons.favorite : Icons.favorite_border,
color: isLike ? Colors.red : null,
);
}
}
  • 基于 provider 状态管理框架, 进行 View 与 ViewModel 的交互:

简单说, 就是在 View 的上一层级 “注入 ”, 然后在 View build 时, 从 context 中直接 “取出” 对应的 ViewModel.

1
2
3
ChangeNotifierProvider.value(value: viewModel.cards[index], child: const CardWidget(),)

context.watch<CardViewModel>()

Flutter 中初试 MVVM 的一些感受

纯感受, 仅供讨论; 每个人感受会不一样, 建议实际 深度实践体会下:

  • Flutter 中, 应该就没有 MVC 和 MVVM 之争了吧. 为了分离 数据 和 状态, 应该就必须有一个 ViewModel.

  • 现在直接 操作 UI 的状态, 变得非常困难, 应该很难 “偷懒”, 把 View 和 业务代码 混在一起 写了.

  • provider, 这个框架非常棒. 对 Native 开发, 也非常友好. 凡是接受过 “依赖注入” 概念的开发, 可能都能非常快地 Get 到它的点. 真的非常棒! 特别是 一次注入, 下一层级的 子控件 / “孙” 子控件, 都可以直接使用.

  • 声明式编程, 现在整个 UI 节点树共享同一个 context, 使开发过程, 有了各种新的可能. provider 就是一个非常好的例子.

  • 边写边预览, 保存后, 立马就能看到效果, 真的非常爽!

  • UI 性能优化, 提供了新的可能. 理论上, 现在我们精确控制 哪个节点 重绘.比如 Demo 中的例子, 只有每个cell 上的 点赞图标 会真正重绘. 更重要的是, 优化可以很容易地做到.只要我们适当注意下是使用 context.watch 还是 context.read 就行了.

完整的Demo 代码与运行效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return MaterialApp(
home:
// FutureProvider 接收一个 Future,并在其进入 complete 状态时更新依赖它的组件。
FutureProvider(
initialData: AlbumDetailViewModel("", []), // 如果有默认展示的空数据,可以放这里。
create: (context) => AlbumDetailViewModel.fetchSongs(),
child: const AlbumDetailWidget(),
)
);
}
}

/// 歌曲 Model.
class SongModel {
String name;
String lyricist;
SongModel(this.name, this.lyricist);
}

/// 专辑 Model.
class AlbumModel {
String name;
List<SongModel> songs;
AlbumModel(this.name, this.songs);
}

/// ViewModel: 歌曲卡片.
/// 更真实的场景中, ViewModel 会与 View 保持相关性,
/// 即: ViewModel 与 Model 无本质上的 名称关联.
class CardViewModel extends ChangeNotifier {
SongModel song;
late final _title = '${song.name}, 作词:${song.lyricist}';
String get title => _title;
bool _isLike;
bool get isLike => _isLike;

CardViewModel(this.song, this._isLike);

/// 喜欢.
void like() {
// 异步操作.模拟发送请求.
Future.delayed(const Duration(milliseconds: 300), (){
_isLike = true;

/// 通知监听者(View), 数据有变化.
notifyListeners();
});
}

/// 不喜欢.
void dislike() {
// 异步操作.模拟发送请求.
Future.delayed(const Duration(milliseconds: 300), (){
_isLike = false;
notifyListeners();
});
}
}

/// ViewModel: 专辑详情.
class AlbumDetailViewModel extends ChangeNotifier {
String name;
List<CardViewModel> cards;
late final _title = '$name, 共:${cards.length} 首歌';
String get title => _title;

AlbumDetailViewModel(this.name, this.cards);

/// 类方法。获取 歌曲信息.
static Future<AlbumDetailViewModel> fetchSongs() async {
// 异步操作, 模拟网络请求 。
return Future.delayed(const Duration(microseconds: 200), () {

final songs = [
SongModel("半兽人", "方文山"),
SongModel("半岛铁盒", "周杰伦"),
SongModel("暗号", "许世昌"),
SongModel("龙拳", "方文山"),
SongModel("火车叨位去", "方文山"),
SongModel("分裂", "周杰伦"),
SongModel("爷爷泡的茶", "方文山"),
SongModel("回到过去", "刘畊宏"),
SongModel("米兰的小铁匠", "方文山"),
SongModel("最后的战役", "方文山"),
].map((e) => CardViewModel(e, false)).toList();

return AlbumDetailViewModel('八度空间', songs);
});
}
}

/// View: 歌曲卡片。
class CardWidget extends StatelessWidget {
const CardWidget({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
debugPrint('build card widget ...');

// 不使用 watch, 避免非必须的 widget rebuild.
final viewModel = context.read<CardViewModel>();

return ListTile(
title: Text(viewModel.title),
trailing: const LikeWidget(), // 可以用 widget 来封装 要局部刷新的 UI, 也可以用 Consumer 包裹要动态刷新的部分。
// trailing: Consumer<CardViewModel>(builder: (context, cardViewModel, child){
// debugPrint('build like icon ...');
//
// final bool isLike = cardViewModel.isLike;
// return Icon(
// isLike ? Icons.favorite : Icons.favorite_border,
// color: isLike ? Colors.red : null,
// );
// },),
onTap: () {
// 每次都从 context 中, 读取最新状态. 因为 CardViewModel 可能已经发生了变化.
final viewModel = context.read<CardViewModel>();

final bool isLike = viewModel.isLike;
if(isLike) {
viewModel.dislike();
} else {
viewModel.like();
}
},
);
}
}

/// View: 简单的 点赞 组件.
class LikeWidget extends StatelessWidget {
const LikeWidget({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
debugPrint("like widget build....");

// 使用watch, 以及时响应 ViewModel 的变化。
final cardViewModel = context.watch<CardViewModel>();

final bool isLike = cardViewModel.isLike;
return Icon(
isLike ? Icons.favorite : Icons.favorite_border,
color: isLike ? Colors.red : null,
);
}
}

/// View:专辑详情。
class AlbumDetailWidget extends StatelessWidget {
const AlbumDetailWidget({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
debugPrint("AlbumDetailWidget build....");

// 从上下文中, 取出 "AlbumDetailViewModel"
var viewModel = context.watch<AlbumDetailViewModel>();
return Scaffold(
appBar: AppBar(
title: Text(viewModel.title),
),
body: ListView.builder(
itemCount: viewModel.cards.length,
padding: const EdgeInsets.all(16.0),
itemBuilder: (context, index) {
// "注入" 既有的 ViewModel, 必须使用 ChangeNotifierProvider.value, 否则 viewModel 会被过早释放。
return ChangeNotifierProvider.value(value: viewModel.cards[index], child: const CardWidget(),);
}
),
);
}
}

相关文章:

后记: 关于 Flutter 中 MVVM 的争议.

有人认为, Flutter 中, 应该弱化 MVVM. 主要原因是 无法很好地实现 ViewModel 和 View 的双向绑定.

对此类观点, 我是不敢苟同的.

从 MVVM 具体的使用经历来看, 我认为 MVVM 最核心的价值是 使用 ViewModel 隔离开了 View 和 Model, 进而使 View 不用过多地考虑业务, 同时也使 Model 少维护一些 View 相关的 工具属性或工具方法.

而对 ViewModel 和 View 的双向绑定这一点, 我不认为这是一个很重要很必须的 特性 – 在特定业务场景下, 为了保证 数据流 的 清晰和 可控, 我甚至会主动 切断这种 “绑定” 关系.