使用 Flutter MVVM 开发登录功能

5,195 阅读5分钟

前几天写了篇关于 Flutter MVVM 实现的文章 [开源] 从web端开发到app端开发也许只有一个Flutter MVVM的距离,今天我们使用它来开发一个简单的登录界面,体验使用 MVVM 数据绑定在开发过程中的便捷。  

本篇 完整代码

 

登录功能

登录界面中包括 UserNamePassword文本输入框、login 按钮、成功信息显示文本、失败信息显示文本几部分,并有如下功能点:

  1. UserNamePassword任一输入框内容长度小于3个字符时,login 按钮为不可用状态

  2. 点击 login 按钮,使用输入框内容请求远程服务,进行登录验证

    • 验证成功时显示用户信息
    • 验证失败时显示错误信息
  3. 请求远程服务过程中显示等待状态(将按钮login字样变为转圈圈〜)

 

功能实现

创建Flutter项目(略〜)

在项目中添加 Flutter MVVM 依赖

找到项目中 pubspec.yaml 文件, 并在 dependencies 部分加入包信息

dependencies:
    mvvm: ^0.1.3+4

为方便讲解,本篇涉及代码均在 main.dart 文件中,在实际项目中可自行拆分

编写基础代码

  • 先创建一个空的登录视图模型 LoginViewModel 和登录视图 LoginView,先把基础界面搭建出来

视图模型类需从 ViewModel 类继承。视图类需从 View 类继承,并指定视图模型 LoginViewModel

class LoginViewModel extends ViewModel {
}

class LoginView extends View<LoginViewModel> {
  LoginView() : super(LoginViewModel());

  @override
  Widget buildCore(BuildContext context) {
    return Scaffold(
        body: Container(
            margin: EdgeInsets.only(top: 100, bottom: 30),
            padding: EdgeInsets.all(40),
            child:
                Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
              SizedBox(height: 10),
              TextField(
                decoration: InputDecoration(
                  border: UnderlineInputBorder(),
                  labelText: 'UserName',
                ),
              ),
              SizedBox(height: 10),
              TextField(
                obscureText: true,
                decoration: InputDecoration(
                  border: UnderlineInputBorder(),
                  labelText: 'Password',
                ),
              ),
              SizedBox(height: 10),
              Text("Error info.",
                  style: TextStyle(color: Colors.redAccent, fontSize: 16)),
              Container(
                  margin: EdgeInsets.only(top: 80),
                  width: double.infinity,
                  child: RaisedButton(
                      onPressed: () {},
                      child: Text("login"),
                      color: Colors.blueAccent,
                      textColor: Colors.white)),
              SizedBox(height: 20),
              Text("Success info.",
                  style: TextStyle(color: Colors.blueAccent, fontSize: 20))
            ])));
  }
}

应用到启动页

void main() => runApp(MaterialApp(home: LoginView()));

此刻运行后效果

 

实现功能点 1

UserNamePassword任一输入框内容长度小于3个字符时,login 按钮为不可用状态

在Flutter中文本输入框(TextField)是通过附加一个控制器 TextEditingController来控制其输入输出的。

首先我们在 LoginViewModel 中创建两个 TextEditingController, 并在视图 LoginView 中,使用 $ModelTextEditingController 附加到 UserNamePassword文本输入框上

为方便显示省略了部分代码

class LoginViewModel extends ViewModel {
    final TextEditingController userNameCtrl = TextEditingController();
    final TextEditingController passwordCtrl = TextEditingController();
}

class LoginView extends View<LoginViewModel> {
  LoginView() : super(LoginViewModel());

  @override
  Widget buildCore(BuildContext context) {
    return Scaffold(
        body: Container(
              // ...
              TextField(
                controller: $Model.userNameCtrl, //这里
                decoration: InputDecoration(
                  border: UnderlineInputBorder(),
                  labelText: 'UserName',
                ),
              ),
              SizedBox(height: 10),
              TextField(
                controller: $Model.passwordCtrl, //这里
                obscureText: true,
                decoration: InputDecoration(
                  border: UnderlineInputBorder(),
                  labelText: 'Password',
                ),
              ),
              // ...
            ])));
  }
}

 

添加适配属性

为了 LoginView 中能监视两个输入框内容变化,我们在 LoginViewModel 中添加两个适配属性

当输入框内容变化时,对应的 TextEditingController 会提供变更通知,所以我们要做的就是将它适配到我们的绑定属性上。在 Flutter MVVM 中已经封装了现成的方法 propertyAdaptive (API)

class LoginViewModel extends ViewModel {
    final TextEditingController userNameCtrl = TextEditingController();
    final TextEditingController passwordCtrl = TextEditingController();
    
    LoginViewModel() {
        // 使用 #userName 做为键创建适配到 TextEditingController 的属性
        propertyAdaptive<String, TextEditingController>(
            #userName, userNameCtrl, (v) => v.text, (a, v) => a.text = v,
            initial: "");

        propertyAdaptive<String, TextEditingController>(
            #password, passwordCtrl, (v) => v.text, (a, v) => a.text = v,
            initial: "");
    }
}

现在我们可以在 LoginView 中监视两个属性的变化了

为方便显示省略了部分代码

class LoginView extends View<LoginViewModel> {
  LoginView() : super(LoginViewModel(RemoteService()));

  @override
  Widget buildCore(BuildContext context) {
    return Scaffold(
        body: Container(
              // ...
              Text("Error info.",
                  style: TextStyle(color: Colors.redAccent, fontSize: 16)),
              Container(
                  margin: EdgeInsets.only(top: 80),
                  width: double.infinity,
                  // 使用 $.watchAnyFor 来监视 #userName, #password 属性变化
                  child: $.watchAnyFor<String>([#userName, #password],
                      builder: (_, values, child) {
                    // 当任一属性值发生变化时此方法被调用
                    // values 为变化后的值集合
                    var userName = values.elementAt(0),
                        password = values.elementAt(1);
                    return RaisedButton(
                        // 根据 #userName, #password 属性值是否符合要求
                        // 启用或禁用按钮
                        onPressed: userName.length > 2 && password.length > 2
                            ? () {}
                            : null,
                        child: Text("login"),
                        color: Colors.blueAccent,
                        textColor: Colors.white);
                  })),
              // ...
            ])));
  }
}

运行查看效果

为了能更加便于维护,我们可以将 LoginView 中对输入验证的逻辑放入 LoginViewModel 中。

class LoginViewModel extends ViewModel {
    final TextEditingController userNameCtrl = TextEditingController();
    final TextEditingController passwordCtrl = TextEditingController();
    
    LoginViewModel() {
        propertyAdaptive<String, TextEditingController>(
            #userName, userNameCtrl, (v) => v.text, (a, v) => a.text = v,
            initial: "");

        propertyAdaptive<String, TextEditingController>(
            #password, passwordCtrl, (v) => v.text, (a, v) => a.text = v,
            initial: "");
    }
    
    // 将 LoginView 中 userName.length > 2 && password.length > 2 逻辑
    // 移到 LoginViewModel 中,方便以后变更规则
    bool get inputValid =>
      userNameCtrl.text.length > 2 && passwordCtrl.text.length > 2;
}

class LoginView extends View<LoginViewModel> {
  LoginView() : super(LoginViewModel(RemoteService()));

  @override
  Widget buildCore(BuildContext context) {
    return Scaffold(
        body: Container(
              // ...
              Text("Error info.",
                  style: TextStyle(color: Colors.redAccent, fontSize: 16)),
              Container(
                  margin: EdgeInsets.only(top: 80),
                  width: double.infinity,
                  // 使用 $.watchAnyFor 来监视 #userName, #password 属性变化
                  child: $.watchAnyFor<String>([#userName, #password],
                      // $.builder0 用于生成一个无参的builder
                      builder: $.builder0(() => RaisedButton(
                        // 使用 LoginViewModel 中的 inputValid 
                        // 启用或禁用按钮
                        onPressed: $Model.inputValid
                            ? () {}
                            : null,
                        child: Text("login"),
                        color: Colors.blueAccent,
                        textColor: Colors.white)
                  ))),
              // ...
            ])));
  }
}

 

实现功能点 2

点击 login 按钮,使用输入框内容请求远程服务,进行登录验证

创建远程服务类

创建一个模拟远程服务类来完成登录验证功能,这个服务类只有一个 login 方法,当userName="tom" password="123"时即为合法用户,否则登录失败抛出错误信息。并且为了模拟网络效果,将延迟3秒返回结果

class User {
    String name;
    String displayName;
    User(this.name, this.displayName);
}

// mock service
class RemoteService {
    Future<User> login(String userName, String password) async {
        return Future.delayed(Duration(seconds: 3), () {
            if (userName == "tom" && password == "123") 
                return User(userName, "$userName cat~");
            throw "mock error.";
        });
    }
}

 

添加异步属性

为了 LoginView 中能监视登录请求变化,我们在 LoginViewModel 中添加异步属性,同时将模拟服务注入进来以备使用

在 Flutter MVVM 中封装了现成的创建异步属生方法 propertyAsync (API),propertyAsync 并没有内置在 ViewModel 类中,要使用它需要 LoginViewModel with AsyncViewModelMixin

class LoginViewModel extends ViewModel with AsyncViewModelMixin {
  final RemoteService _service;

  final TextEditingController userNameCtrl = TextEditingController();
  final TextEditingController passwordCtrl = TextEditingController();

  // 注入服务
  LoginViewModel(this._service) {
    
    // 使用 #login 做为键创建一个异步属性
    // 并提供一个用于获取 Future<User> 的方法
    // 我们使用模拟服务的 login 方法,并将 userName、password 传递给它
    propertyAsync<User>(
        #login, () => _service.login(userNameCtrl.text, passwordCtrl.text));
        
        
    propertyAdaptive<String, TextEditingController>(
        #userName, userNameCtrl, (v) => v.text, (a, v) => a.text = v,
        initial: "");

    propertyAdaptive<String, TextEditingController>(
        #password, passwordCtrl, (v) => v.text, (a, v) => a.text = v,
        initial: "");
  }

  bool get inputValid =>
      userNameCtrl.text.length > 2 && passwordCtrl.text.length > 2;
}

LoginView 中使用异步属性

当我们创建异步属性后,除了提供基于这个属性的绑定功能外,Flutter MVVM 还会为我们提供基于这个属性的 getInvoke (API)、 invoke (API) 和 link (API) 方法,getInvoke 会返回一个用于发起请求的方法,而 invoke 则会直接发起请求,link 等同于 getInvoke, 是它的别名方法

需要注意的是,当绑定异步属性时,Flutter MVVM 会将属性值(请求结果)封装成 AsyncSnapshot<TValue>

class LoginView extends View<LoginViewModel> {
  // 注入服务实例
  LoginView() : super(LoginViewModel(RemoteService()));

  @override
  Widget buildCore(BuildContext context) {
    return Scaffold(
        body: Container(
              // ...
              SizedBox(height: 10),
              // $.$ifFor 来监视 #login 属性值变化
              // 当属性值变化时使用 valueHandle 结果来控制 widget 是否显示
              // snapshot.hasError 表示请求结果中有错误时显示
              $.$ifFor<AsyncSnapshot>(#login,
                  valueHandle: (AsyncSnapshot snapshot) => snapshot.hasError,
                  builder: $.builder1((AsyncSnapshot snapshot) => Text(
                      "${snapshot.error}",
                      style:
                          TextStyle(color: Colors.redAccent, fontSize: 16)))),
              Container(
                  margin: EdgeInsets.only(top: 80),
                  width: double.infinity,
                  child: $.watchAnyFor<String>([#userName, #password],
                      builder: $.builder0(() => RaisedButton(
                          // 使用 $Model.link 将发起异步请求方法挂接到事件
                          onPressed:
                              $Model.inputValid ? $Model.link(#login) : null,
                          child: Text("login"),
                          color: Colors.blueAccent,
                          textColor: Colors.white)))),
              SizedBox(height: 20),
              // $.$ifFor 来监视 #login 属性值变化
              // 当属性值变化时使用 valueHandle 结果来控制 widget 是否显示
              // snapshot.hasData 表示请求正确返回数据时显示
              $.$ifFor<AsyncSnapshot<User>>(#login,
                  valueHandle: (AsyncSnapshot snapshot) => snapshot.hasData,
                  // 绑定验证成功后的用户显示名
                  builder: $.builder1((AsyncSnapshot<User> snapshot) => Text(
                      "${snapshot.data?.displayName}",
                      style:
                          TextStyle(color: Colors.blueAccent, fontSize: 20))))
            ])));
  }
}

运行后效果

因为模拟服务延迟了3秒,所以中间会有一个很不友好的停滞状态,我们接着实现对等待状态的处理,让它友好一点。

 

实现功能点 3

请求远程服务过程中显示等待状态(将按钮login字样变为转圈圈〜)

之前提到过,Flutter MVVM 会将异步属性的请求结果封装成 AsyncSnapshot<TValue>,而 AsyncSnapshot<TValue> 中的 connectionState 已经为我们提供了请求过程中的状态变化,只要在 connectionStatewaiting 时, 把 login 按钮的 child 变成转圈圈动画就可以了

class LoginView extends View<LoginViewModel> {
  LoginView() : super(LoginViewModel(RemoteService()));

  @override
  Widget buildCore(BuildContext context) {
    return Scaffold(
        body: Container(
              // ...
              Container(
                  margin: EdgeInsets.only(top: 80),
                  width: double.infinity,
                  child: $.watchAnyFor<String>([#userName, #password],
                      builder: $.builder0(() => RaisedButton(
                          onPressed:
                              $Model.inputValid ? $Model.link(#login) : null,
                          // 使用 $.watchFor 监视 #login 状态变化
                          // waiting 时显示转圈圈〜
                          child: $.watchFor(#login,
                              builder: $.builder1((AsyncSnapshot snapshot) =>
                                  snapshot.connectionState ==
                                          ConnectionState.waiting
                                      ? SizedBox(
                                          width: 20,
                                          height: 20,
                                          child: CircularProgressIndicator(
                                            backgroundColor: Colors.white,
                                            strokeWidth: 2,
                                          ))
                                      : Text("login"))),
                          color: Colors.blueAccent,
                          textColor: Colors.white)))),
              SizedBox(height: 20),
              // ...
            ])));
  }
}

运行后效果

这里细心的小伙伴应该会注意到一个小问题,第一次登录失败显示了错误信息,但只有当再次发起登录请求并结果返回时,第一次登录失败的错误信息才被更新,这也是一个不太好的体验,我们只需在 $Model.link(#login) 处稍加改动 在每次发起请求时立即重置一下状态。

    // ...
    RaisedButton(
        // 使用 resetOnBefore
        onPressed:
            $Model.inputValid ? $Model.link(#login, resetOnBefore: true) : null,
    // ...

运行后效果

对于服务验证成功后跳转页面的场景,可以在创建异步属性时指定 onSuccess 方法,当异步请求成功返回结果时定制后续操作。(更多异步属性参数可查看 API)

 

提升性能

我们已经基本实现了预期的登录功能,但因为我们在 $.watchAnyFor<String>([#userName, #password], builder: ..)builder 方法内部又嵌套使用了 $.watchFor(#login, ..),所以这会导致一个问题是当上层的 #userName, #password 发生变化时,不管其 builder 内部监视的 #login 是否变化都会连同上层同时触发变化 (在两个 builder 方法中加入调试信息可查看现象),这并不是我们预期想要的结果,造成了不必要的性能损失。解决方法很简单,只需要将内部嵌套的 $.watchFor(#login, ..) 移到上层 $.watchAnyFor<String>([#userName, #password], ..) 方法的 child 参数中。

class LoginView extends View<LoginViewModel> {
  LoginView() : super(LoginViewModel(RemoteService()));

  @override
  Widget buildCore(BuildContext context) {
    return Scaffold(
        body: Container(
              // ...
              Container(
                  margin: EdgeInsets.only(top: 80),
                  width: double.infinity,
                  child: $.watchAnyFor<String>([#userName, #password],
                      builder: $.builder2((_, child) => RaisedButton(
                          onPressed:
                              $Model.inputValid ? $Model.link(#login) : null,
                          // 使用从外部传入的 child
                          child: child,
                          color: Colors.blueAccent,
                          textColor: Colors.white)),
                      // 将按钮 child 的构造移到此处
                      child: $.watchFor(#login,
                              builder: $.builder1((AsyncSnapshot snapshot) =>
                                  snapshot.connectionState ==
                                          ConnectionState.waiting
                                      ? SizedBox(
                                          width: 20,
                                          height: 20,
                                          child: CircularProgressIndicator(
                                            backgroundColor: Colors.white,
                                            strokeWidth: 2,
                                          ))
                                      : Text("login"))))),
              SizedBox(height: 20),
              // ...
            ])));
  }
}

现在哪里有变化只会更新哪里,不会存在不该有的多余更新,至此,我们已经实现了完整的登录功能。

 

最后

文章篇幅有点长,但其实内容并不多,主要对 Flutter MVVM 的使用进行了相应的解释说明,用数据绑定来减少我们的逻辑代码及工作量,提升代码的可维护性。

完整代码