使用 RxCommand 在 Android 上實現 MVVM
RxCommand 是一個基于 RxJava 的,UI 相關的,主要用來響應用戶觸發的異步任務,尤其是網絡訪問的庫。它分離了對異步任務的關注點,譬如任務是否處于可執行狀態,任務是否正在執行,任務返回結果,任務執行過程中發生錯誤。這些關注點以 Observable 的形式返回,可以有選擇性地訂閱,以及和其它流組合,處理復雜的業務邏輯。這個庫相當輕量級,包含注釋在內300來行代碼。
前提
你喜歡 RxJava , 并且已在項目中實踐。
為什么放棄MVP
MVP 中的 V 和 P 都擁有對對方的控制權。它們相互調用代碼,同一個邏輯流程的代碼分散各處,給閱讀和理解代碼帶來不便。而 MVVM 中的 VM 不持有 V , V 觀察 VM 中的狀態變更來刷新自己的界面,可以在一個地方處理完相關的邏輯。 所以MVP是命令式的,而MVVM是響應式的。
為什么選擇RxCommand
MVP 是Android開發社區目前比較流行的架構,而 MVVM 是iOS和前端開發社區目前比較流行的架構。造成這種局面的最大原因是語言的差異,以 Objective-C 為例, 它有一項很酷的語言特性,那就是 KVO , 全稱是鍵值觀測,一個對象的屬性自帶觀察者模式,在它的值發生變化時,你可以輕易地得到通知。 借助 ReactiveCocoa 只需要一行代碼,就可以把從 View 接收到的輸入綁定到 ViewModel.
RAC(viewModel, username) = RACObserve(view, textView);
也只需要一行代碼,就可以把 ViewModel 的狀態變更反饋到 View 上:
RAC(view, label) = RACObserve(viewModel, email);
Google 開發了 Data Biding 來幫助開發者在 Android 上實現 MVVM, 不過實現起來比較繁瑣,尤其是把代碼寫在 XML 中,實在是不雅。
RxCommand具有如下特點:
- 基于 RxJava , 和 RxJava 完美配合
- 配合 RxBinding 等框架,不需要把代碼寫在 XML 中,可以實現雙向綁定
- 分離關注點,便于有選擇地處理任務執行的狀態(執行中,錯誤,完成等等)
- ViewModel是個普通類
- 相關代碼集中,便于閱讀和維護
Demo
我們通過一個 Demo 來講解如何通過 RxCommand 來實現 V 和 VM . 看圖:
假設我們需要做一個登錄功能,這太常見了。
- 當手機號碼不合法時,獲取驗證碼按鈕處于disable狀態
- 當正在獲取驗證碼時,獲取驗證碼按鈕處于disable狀態,并顯示loading
- 當獲取驗證碼成功時,倒計時開始,按鈕仍處于disable狀態,倒計時結束,按鈕重新可用
- 當獲取驗證碼失敗時,不會開始倒計時,按鈕恢復可用狀態
- 當手機號碼和驗證碼都合法時,登錄按鈕處于可點擊狀態,否則不可點擊
- 當點擊登陸按鈕時,登錄按鈕處于不可點狀態,同時顯示loading
- 當登錄成功時,停止 loading, 跳轉到主界面
- 當登錄失敗時,停止 loading, 并提示錯誤
源碼
以下是對 Demo 源碼的解讀,如果你想直接看代碼,請看 完整的項目源碼以及 demo .
實現 View
讓我們來看看,要實現以上需求,LoginActivity 該怎么寫。
這四行代碼就完成了接收用戶輸入,驗證用戶輸入是否合法,來決定按鈕是否可點擊,以及響應用戶點擊按鈕觸發任務,在任務執行期間,按鈕不可點擊等需求。
//綁定用戶輸入到ViewModel RxTextView.textChanges(phoneEditText).subscribe(viewModel.phone()); RxTextView.textChanges(codeEditText).subscribe(viewModel.code()); //綁定按鈕和command,當command正在執行或者輸入不合法時,按鈕將處于disable狀態 RxCommandBinder.bind(codeButton, viewModel.codeCommand()); RxCommandBinder.bind(loginButton, viewModel.loginCommand());
獲取驗證碼
處理獲取驗證碼的執行狀態,如果正在獲取,顯示獲取中,如果獲取成功或發生錯誤,則重置。
viewModel.codeCommand() .executing() .subscribe(executing -> { if (executing) { codeButton.setText("fetch..."); } else { codeButton.setText("fetch code"); } });
處理獲取驗證碼成功的情形,command 內部已經過濾了發生錯誤的情形,不需要在這里處理失敗的情況
viewModel.codeCommand() .switchToLatest() .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> Toast.makeText(LoginActivity.this, result, Toast.LENGTH_LONG).show());
獲取驗證碼成功后,會開啟倒計時,避免用戶在沒及時收到短信的情況下狂點 獲取驗證碼 按鈕。 倒計時結束后, 獲取驗證碼 按鈕會自動處于可點擊狀態。這里處理的是正在倒數時, 獲取驗證碼 按鈕上顯示的文字。
viewModel.countdownCommand() .switchToLatest() .observeOn(AndroidSchedulers.mainThread()) .subscribe(s -> codeButton.setText(s));
處理倒數完成后的情形,讓 獲取驗證碼 按鈕上的文字重置。
viewModel.countdownCommand() .executing() .subscribe(executing -> { if (!executing) { codeButton.setText("fetch code"); } });
登錄
現在,我們開始處理登錄的執行狀態,當用戶點擊 登錄 按鈕時,按鈕將不可再點,并且show loading, 告訴用戶正在登錄, 一旦登錄成功或者發生異常,dismiss loading.
viewModel.loginCommand() .executing() .subscribe(executing -> { if (executing) { loginButton.setText("login..."); } else { loginButton.setText("login"); } });
這里處理了登錄成功后的情形
viewModel.loginCommand() .switchToLatest() .observeOn(AndroidSchedulers.mainThread()) .subscribe(val -> { Toast.makeText(LoginActivity.this, "login success!! Now goto the MainActivity.", Toast.LENGTH_LONG).show(); });
異常處理
萬一發生異常呢?我們在這里一并處理獲取驗證碼和登錄發生異常的情況。
Observable.merge( viewModel.codeCommand().errors(), viewModel.loginCommand().errors()) .subscribe(throwable -> Toast.makeText(LoginActivity.this, throwable.getLocalizedMessage(), Toast.LENGTH_LONG).show() );
從上面的例子可以看到,RxCommand 有效分離了enabled, executing, success, error 等異步任務常有的關注點,使我們可以有選擇地處理它們,各個處理的代碼互不依賴。
實現 ViewModel
前面我們說過, ViewModel 是一個普通類。 要實現上面整個業務流程,ViewModel 中的代碼僅有 110 行。
public class LoginViewModel { //這個 command 負責倒計時 private RxCommand<String> _countdownCommand; //這個 command 負責獲取驗證碼 private RxCommand<String> _codeCommand; //這個 command 負責登錄 private RxCommand<Boolean> _loginCommand; //用來接收用戶輸入的手機號碼 private Subject<CharSequence> _phone; //用來接收用戶輸入的驗證碼 private Subject<CharSequence> _code; //驗證用戶輸入的驗證碼是否合法 private Observable<Boolean> _codeValid; //驗證用戶輸入的手機號碼是否合法 private Observable<Boolean> _phoneValid; public LoginViewModel() { _phone = BehaviorSubject.create(); _code = BehaviorSubject.create(); _codeValid = _code.map(s -> s.toString().length() == 6); _phoneValid = _phone.map(s -> s.toString().length() == 11); } }
倒計時
讓我來看看 countdownCommand 該怎么實現
public RxCommand<String> countdownCommand() { if (_countdownCommand == null) { _countdownCommand = RxCommand.create(o -> Observable .interval(1, TimeUnit.SECONDS) .take(10)//from 0 to 9 .map(aLong -> "fetch " + (9 - aLong) + "'")); } return _countdownCommand; }
我們通過 RxCommand 的靜態工廠方法 #create 來創建一個 Command, 它接收一個函數作為參數, 這個函數接收一個 obj ( 可以為 null ), 返回一個 Observable . 這個 Observable 把倒計的時間轉換為可以在 獲取驗證碼 按鈕顯示的文字。
獲取驗證碼
RxCommand 還有另外一個靜態工廠方法,它除了接收一個返回 Observable 的函數,還接收一個發射 Boolean 的 Observable 作為參數。這個 Observable 發射的值決定了 command 是否可以執行, 反應到界面上就是按鈕是否可點擊。
public RxCommand<String> codeCommand() { if (_codeCommand == null) { //構造第一個參數,來決定獲取驗證碼按鈕是否可以點擊 Observable<Boolean> enabled = Observable.combineLatest( _phoneValid, countdownCommand().executing(), //當手機輸入合法以及倒計時 command 不在執行時 (valid, executing) -> valid && !executing); _codeCommand = RxCommand.create(enabled, o -> { String phone = _phone.blockingFirst().toString(); //通過網絡獲取驗證碼 Observable fetchCode = fetchVerificationCode(phone); //倒計時,用defer來使倒計時延遲到獲取驗證碼執行成功后才開始倒數 Observable countdown = Observable.defer( () -> countdownCommand().execute(null).ignoreElements().toObservable() ); //把獲取驗證碼和倒計時串起來,獲取驗證碼成功后就開始倒數 return Observable.concat(fetchCode, countdown); }); } return _codeCommand; }
登錄
登錄的邏輯處理比獲取驗證碼簡單多了,畢竟獲取驗證碼和倒計時這兩個 command 需要聯動。
public RxCommand<Boolean> loginCommand() { if (_loginCommand == null) { //登錄按鈕是否用,決定于用戶輸入的手機號碼以及驗證碼是否合法 Observable<Boolean> inputValid = Observable.combineLatest( _codeValid, _phoneValid, (codeValid, phoneValid) -> codeValid && phoneValid); _loginCommand = RxCommand.create(inputValid, o -> { String phone = _phoneNumber.blockingFirst().toString(); String code = _code.blockingFirst().toString(); //調用登錄的邏輯處理,這個方法返回一個Observable return login(phone, code); }); } return _loginCommand; }
來看看 login 的模擬實現
private Observable<Boolean> login(String phoneNumber, String code) { return Observable.timer(4, TimeUnit.SECONDS) .flatMap(aLong -> { if (phoneNumber.equals("18503002163")){ return Observable.error(new RuntimeException("the phone number is not yours!")); } else if (code.equals("123456")) { return Observable.just(true); } else { return Observable.error(new RuntimeException("your code is wrong!!")); } }); }
集成到項目中
dependencies { compile 'com.shundaojia:rxcommand:1.0.0' }
來自:https://listenzz.github.io/使用RxCommand在Android上實現MVVM