重拾 Flutter: 状态管理和 MVVM 分割代码
目录
借着上次理解的 MVVM 思想,把客户的业务代码重构了一遍,看着美观了很多,主要用到 mobx 状态库,职责分割之后的代码文件结构如下(React Native):
文件 | 描述 |
---|---|
login.model.ts | model,远程接口调用 |
login.vm.ts | view model,管理界面状态,输入校验等 |
login.view.tsx | view,绘制界面 |
login.index.tsx | 链接MVVM组件 |
上次做 Flutter 是2年以前刚毕业的时候,当时是因为组件嵌套太多,而且很多思想和风格都不懂而放弃的。后来就做了 Vue,然后到 React。最近在 React Native 端 MVVM 重构后,回来重新整理,以应对一些跨平台的解决方案。
Flutter Provider 上下文管理
Flutter 社区有一个管理全局数据的库 provider
,可以让所有的组件都共享全局的数据。
安装: flutter pub add provider
假设在顶层组件生成 LoginApi 接口,然后交给 Provider 注入。
// 顶层组件,生成并注入 LoginApi
Provider(
create: () => LoginApi(),
child: LoginPage()
)
child 子组件就可以通过调用 build(BuildContext context)
获取到 LoginApi。
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 拿到 LoginApi 了
var loginApi = Provider.of<LoginApi>(context);
}
}
多个 Provider
遇到需要创建多个 Provider 的场合,用嵌套就可以了。
Provider(
create: () => LoginApi(), // 注入 LoginApi
child: Provider(
create: () => UserApi(), // 注入 UserApi
child: LoginPage(),
)
)
嵌套以后,LoginPage 组件在渲染时,可获得 LoginApi 或 UserApi。
Widget build(BuildContext context) {
var Api = Provider.of(context); // 可以是 LoginApi 或者 UserApi
}
这里拿到的 Api 可能是 LoginApi、也可能是 UserApi,以嵌套的顺序决定,然而作为子组件不应该关心嵌套的顺序;所以要指定一个类型给 Provider,让它筛选符合类型的 Api。
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 拿到的一定是 LoginApi
var loginApi = Provider.of<LoginApi>(context);
// 拿到的一定是 UserApi
var userApi = Provider.of<UserApi>(context);
}
}
如果 Provider 嵌套的是2个相同的 UserApi 类型,那么它无法正确返回需要的 Api,因为它是靠类型去筛选的。
到这里简单介绍了 Provider 的使用方法,是因为 Flutter 的开发经常需要上下文管理,接下来的建构 MVVM 建构也会用到这种方法。
Flutter MVVM
MVVM 只是一种思想,实现的方式有很多种,只是我觉得实现的方法应尽量简单,相比于花上大量时间针对代码库做适配器,不如实现更多的业务功能,不是吗?
这里我选用了 Flutter Bloc 来实现 MVVM。
Flutter Bloc
Bloc 是 Flutter 一个状态管理库,与 Provider 相比,Bloc 提供更细粒度的状态管理,包括自定义重绘判定。而与 mobx 类似,Bloc 可以用来构建页面级的状态,帮助 MVVM 架构。
安装: flutter pub add bloc flutter_bloc
下面以登录页面为例子,用 MVVM 描述一下职责吧。这个登录界面非常简单,它就大概这个样子:
用户名 | |
密码 | |
(仅供展示) |
仅供展示,不要输入真实数据
Model: 远程调用接口
先是建模业务接口。登录接口封装了远程调用的过程(Restful),可以作为全局数据保存起来(让Provider注入),调用的过程是异步的。
在这里,登录接口提供登入和登出方法。
abstract class ILoginApi {
Future<void> login(String username, String password);
Future<void> logout();
}
ViewModel: 登录页面行为
这个 view model 管理登录页面中的 用户名、密码 输入框,由于远程调用需要时间,所以 view model 可以加上一个 loading 表示等待远端服务响应。
同时描述一个登录的行为:简单验证用户输入的数据后,调用 loginApi 远程调用。
综上,这个 view model 应该有这些成员:
- 状态:username 用户名
- 状态:password 密码
- 状态:loading 是否加载中
- 动作:doLogin 执行登录
Cubit 是 bloc 中管理状态的容器,view model 需要继承它,然后扩展出处理业务的行为。
bloc 的结构决定了 view model 的状态要单独用一个类存放。
// view model 持有的状态
class LoginVMState {
String username = "";
String password = "";
bool loading = false;
LoginVMState({this.username = "", this.password = "", this.loading = false});
LoginVMState setUsername(String username) {
this.username = username;
return this;
}
LoginVMState setPassword(String password) {
this.password = password;
return this;
}
LoginVMState setLoading(bool loading) {
this.loading = loading;
return this;
}
clone() {
return LoginVMState(
username: username, password: password, loading: loading);
}
}
这只是 view model 的状态类,现在建构 view model 的行为。
Flutter 没有双向绑定,所以要手动处理用户输入的数据,所以除了执行登录行为,还要有处理输入的行为:
- 处理用户输入的用户名
- 处理用户输入的密码
- 执行登录
class LoginVM extends Cubit<LoginVMState> {
ILoginApi loginApi;
LoginVM({required this.loginApi}) : super(LoginVMState());
// 处理用户输入
setUsername(String username) {
this.emit(this.state.setUsername(username));
}
setPassword(String password) {
this.emit(this.state.setPassword(password));
}
// 执行登录
Future<void> doLogin() async {
var username = this.state.username;
var password = this.state.password;
this.emit(this.state.setLoading(true).clone());
this.loginApi.login(username, password).then((result) {
// 登录成功
}, onError: (err) {
// 登录失败,清空输入框的密码
this.emit(this.state.setPassword("").clone());
}).whenComplete(() {
this.emit(this.state.setLoading(false).clone());
});
}
}
View: 绘制登录界面
建构完逻辑部分,到界面实现了。按 MVVM 规则,界面层与 view model 通信,所以主要有以下动作:
- 用户名文本输入时:调用 view model 处理输入的用户名
- 密码文本输入时:调用 view model 处理输入的密码
- 按钮点击时:调用 view model 执行登录
在渲染阶段,view model 的行为可以通过 BlocProvider 拿到;而 状态(state) 要从 BlocBuilder 获取。
class LoginView extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 拿到 view model
var vm = BlocProvider.of<LoginVM>(context);
return BlocBuilder<LoginVM, LoginVMState>(
buildWhen: (prev, now) => prev.hashCode != now.hashCode,
builder: ((context, state) {
// 在 builder 期间拿到 state
var username = state.username;
var password = state.password;
var loading = state.loading;
return Container(
padding: EdgeInsets.all(24),
child: Column(
children: [
TextField(
decoration: InputDecoration(hintText: "Username"),
controller: TextEditingController(text: state.username),
onChanged: (username) => vm.setUsername(username),
),
SizedBox(height: 16),
TextField(
decoration: InputDecoration(hintText: "Password"),
controller: TextEditingController(text: state.password),
onChanged: (password) => vm.setPassword(password),
obscureText: true,
),
SizedBox(height: 16),
ElevatedButton(
onPressed: loading ? null : () => vm.doLogin(),
child: Text("Login"),
),
],
),
);
}),
);
}
}
至此建构已经完成了,现在还有最后一步,把 MVVM 组件连起来。
组建业务入口
把这些组件用 BlocProvider 注入到子组件的 BuildContext 之后,Login 的业务就连起来了。
类名 | 职责 |
---|---|
ILoginApi | model |
LoginVM | view model |
LoginView | view |
ILoginApi 是一个接口,按场景来具体实现,这里我实现了个模拟桩 StubLoginApi,调用将返回失败。
class LoginApp extends StatelessWidget {
Widget build(BuildContext ctx) {
var loginApi = StubLoginApi();
return MaterialApp(
title: "LoginApp",
home: Scaffold(
appBar: AppBar(title: Text("Login App Bar")),
body: BlocProvider<LoginVM>(
create: (buildContext) => LoginVM(loginApi: loginApi),
child: LoginView(),
),
),
);
}
}
效果预览

最终效果,对 loginApi 的实现我用了2秒延迟
注意看点击 Login 按钮的时候,密码框的指针移动到文本头部了,这说明界面发生了重绘。如果输入的时候指针一直跳到头部,用户体验是非常不好的,所以在 buildWhen
中自定义了重绘规则,在输入的时候不重绘。
- 与之前的状态对象不相同时,执行重绘
在 doLogin 的过程中,setLoading() 带了个 clone
,即新建一个状态对象,在判定时,这个对象会与原对象不同。所以最终结果是:
- 仅 loading 变化时,才执行重绘
与 React 的关系
互联网有很多关于 Flutter vs. React 的讨论,虽然大多数观点都认为看实际情况选择,对我来说,用 React 的角度寻找 Flutter 中的类似品,可以更快地熟悉 Flutter。下表列出了两边的一些概念对照:
React or JS | Flutter | 描述 |
---|---|---|
无状态 React.FC | StatelessWidget | React 通过 props 传值、Flutter 通过构造函数传值 |
useContext | Provider.of | 都是搜索上下文来获取全局变量,可以嵌套 |
Promise | Future | 异步执行,都可以配合 async、await 实现伪同步 |
EventEmitter | Stream | 用于事件处理 |
先列出这些,另外 Flutter 不支持匿名类,即所有组件都要静态声明 class,有时候可能没有 React.FC 那么灵活。
不试试 Flutter MobX?
我也有尝试过 mobx 的 Flutter 版,但是这个库需要自动生成一些代码(DO NOT EDIT 那种),才能正常地运行,虽然可以不管它,但这样增加了代码阅读的难度,所以我选择了 bloc 来实现。
但是在 JavaScript 场合,mobx 可以用 @observable
、@action
等装饰器可以非常方便地对 view model 建模,简化很多代码量(我在 React 端选择 mobx 就是因为代码量少、易读)。
总体来说,我更喜欢用 React 端的 mobx(mobx-react-lite),如有兴趣请移步我的另一篇文章: