使用 Flutter MVVM 開發登陸功能

前幾天寫了篇關於 Flutter MVVM 實現的文章 [開源] 從web端開發到app端開發也許只有一個Flutter MVVM的距離,今天咱們使用它來開發一個簡單的登陸界面,體驗使用 MVVM 數據綁定在開發過程當中的便捷。  html

本篇 完整代碼git

 

登陸功能

登陸界面中包括 UserNamePassword文本輸入框、login 按鈕、成功信息顯示文本、失敗信息顯示文本幾部分,並有以下功能點:github

  1. UserNamePassword任一輸入框內容長度小於3個字符時,login 按鈕爲不可用狀態web

  2. 點擊 login 按鈕,使用輸入框內容請求遠程服務,進行登陸驗證bash

    • 驗證成功時顯示用戶信息
    • 驗證失敗時顯示錯誤信息
  3. 請求遠程服務過程當中顯示等待狀態(將按鈕login字樣變爲轉圈圈〜)網絡

 

功能實現

建立Flutter項目(略〜)app

在項目中添加 Flutter MVVM 依賴

找到項目中 pubspec.yaml 文件, 並在 dependencies 部分加入包信息異步

dependencies:
    mvvm: ^0.1.3+4
複製代碼

爲方便講解,本篇涉及代碼均在 main.dart 文件中,在實際項目中可自行拆分async

編寫基礎代碼

  • 先建立一個空的登陸視圖模型 LoginViewModel 和登陸視圖 LoginView,先把基礎界面搭建出來

視圖模型類需從 ViewModel 類繼承。視圖類需從 View 類繼承,並指定視圖模型 LoginViewModelmvvm

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 的使用進行了相應的解釋說明,用數據綁定來減小咱們的邏輯代碼及工做量,提高代碼的可維護性。

完整代碼

相關文章
相關標籤/搜索