Skip to main content

重拾 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),如有兴趣请移步我的另一篇文章:

React 端用 MobX 建立模型

附录

Flutter Official

[Pub] Provider 组件

[Pub] Bloc 组件

MobX Official