使用 Flutter MVVM 開發登入功能

unicreators發表於2019-09-09

前幾天寫了篇關於 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()));
複製程式碼

此刻執行後效果

使用 Flutter MVVM 開發登入功能

 

實現功能點 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);
                  })),
              // ...
            ])));
  }
}

複製程式碼

執行檢視效果

使用 Flutter MVVM 開發登入功能

為了能更加便於維護,我們可以將 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))))
            ])));
  }
}

複製程式碼

執行後效果

使用 Flutter MVVM 開發登入功能

因為模擬服務延遲了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),
              // ...
            ])));
  }
}

複製程式碼

執行後效果

使用 Flutter MVVM 開發登入功能

這裡細心的小夥伴應該會注意到一個小問題,第一次登入失敗顯示了錯誤資訊,但只有當再次發起登入請求並結果返回時,第一次登入失敗的錯誤資訊才被更新,這也是一個不太好的體驗,我們只需在 $Model.link(#login) 處稍加改動 在每次發起請求時立即重置一下狀態。

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

執行後效果

使用 Flutter MVVM 開發登入功能

對於服務驗證成功後跳轉頁面的場景,可以在建立非同步屬性時指定 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 的使用進行了相應的解釋說明,用資料繫結來減少我們的邏輯程式碼及工作量,提升程式碼的可維護性。

完整程式碼

相關文章