前幾天寫了篇關於 Flutter MVVM 實現的文章 [開源] 從web端開發到app端開發也許只有一個Flutter MVVM的距離,今天我們使用它來開發一個簡單的登入介面,體驗使用 MVVM 資料繫結在開發過程中的便捷。
本篇 完整程式碼
登入功能
登入介面中包括 UserName
、Password
文字輸入框、login
按鈕、成功資訊顯示文字、失敗資訊顯示文字幾部分,並有如下功能點:
-
UserName
、Password
任一輸入框內容長度小於3個字元時,login
按鈕為不可用狀態 -
點選
login
按鈕,使用輸入框內容請求遠端服務,進行登入驗證- 驗證成功時顯示使用者資訊
- 驗證失敗時顯示錯誤資訊
-
請求遠端服務過程中顯示等待狀態(將按鈕
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
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)
))),
// ...
])));
}
}
複製程式碼
實現功能點 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
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秒,所以中間會有一個很不友好的停滯狀態,我們接著實現對等待狀態的處理,讓它友好一點。
實現功能點 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 的使用進行了相應的解釋說明,用資料繫結來減少我們的邏輯程式碼及工作量,提升程式碼的可維護性。