前几天写了篇关于 Flutter MVVM 实现的文章 [开源] 从web端开发到app端开发也许只有一个Flutter MVVM的距离,今天咱们使用它来开发一个简单的登陆界面,体验使用 MVVM 数据绑定在开发过程当中的便捷。 html
本篇 完整代码git
登陆界面中包括 UserName
、Password
文本输入框、login
按钮、成功信息显示文本、失败信息显示文本几部分,并有以下功能点:github
UserName
、Password
任一输入框内容长度小于3个字符时,login
按钮为不可用状态web
点击 login
按钮,使用输入框内容请求远程服务,进行登陆验证bash
请求远程服务过程当中显示等待状态(将按钮login
字样变为转圈圈〜)网络
建立Flutter项目(略〜)app
找到项目中 pubspec.yaml 文件, 并在 dependencies 部分加入包信息异步
dependencies:
mvvm: ^0.1.3+4
复制代码
为方便讲解,本篇涉及代码均在
main.dart
文件中,在实际项目中可自行拆分async
LoginViewModel
和登陆视图 LoginView
,先把基础界面搭建出来视图模型类需从
ViewModel
类继承。视图类需从View
类继承,并指定视图模型LoginViewModel
mvvm
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()));
复制代码
此刻运行后效果
UserName
、Password
任一输入框内容长度小于3个字符时,login
按钮为不可用状态
在Flutter中文本输入框(TextField
)是经过附加一个控制器 TextEditingController
来控制其输入输出的。
首先咱们在 LoginViewModel
中建立两个 TextEditingController
, 并在视图 LoginView
中,使用 $Model
将 TextEditingController
附加到 UserName
和 Password
文本输入框上
为方便显示省略了部分代码
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)
))),
// ...
])));
}
}
复制代码
点击
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
withAsyncViewModelMixin
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秒,因此中间会有一个很不友好的停滞状态,咱们接着实现对等待状态的处理,让它友好一点。
请求远程服务过程当中显示等待状态(将按钮
login
字样变为转圈圈〜)
以前提到过,Flutter MVVM 会将异步属性的请求结果封装成 AsyncSnapshot<TValue>
,而 AsyncSnapshot<TValue>
中的 connectionState
已经为咱们提供了请求过程当中的状态变化,只要在 connectionState
为 waiting
时, 把 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 的使用进行了相应的解释说明,用数据绑定来减小咱们的逻辑代码及工做量,提高代码的可维护性。