使用 RxCommand 在 Android 上实现 MVVM
pinkboy55
8年前
<p>RxCommand 是一个基于 RxJava 的,UI 相关的,主要用来响应用户触发的异步任务,尤其是网络访问的库。它分离了对异步任务的关注点,譬如任务是否处于可执行状态,任务是否正在执行,任务返回结果,任务执行过程中发生错误。这些关注点以 Observable 的形式返回,可以有选择性地订阅,以及和其它流组合,处理复杂的业务逻辑。这个库相当轻量级,包含注释在内300来行代码。</p> <h2>前提</h2> <p>你喜欢 RxJava , 并且已在项目中实践。</p> <h2>为什么放弃MVP</h2> <p>MVP 中的 V 和 P 都拥有对对方的控制权。它们相互调用代码,同一个逻辑流程的代码分散各处,给阅读和理解代码带来不便。而 MVVM 中的 VM 不持有 V , V 观察 VM 中的状态变更来刷新自己的界面,可以在一个地方处理完相关的逻辑。 所以MVP是命令式的,而MVVM是响应式的。</p> <h2>为什么选择RxCommand</h2> <p>MVP 是Android开发社区目前比较流行的架构,而 MVVM 是iOS和前端开发社区目前比较流行的架构。造成这种局面的最大原因是语言的差异,以 Objective-C 为例, 它有一项很酷的语言特性,那就是 KVO , 全称是键值观测,一个对象的属性自带观察者模式,在它的值发生变化时,你可以轻易地得到通知。 借助 ReactiveCocoa 只需要一行代码,就可以把从 View 接收到的输入绑定到 ViewModel.</p> <pre> RAC(viewModel, username) = RACObserve(view, textView);</pre> <p>也只需要一行代码,就可以把 ViewModel 的状态变更反馈到 View 上:</p> <pre> RAC(view, label) = RACObserve(viewModel, email);</pre> <p>Google 开发了 Data Biding 来帮助开发者在 Android 上实现 MVVM, 不过实现起来比较繁琐,尤其是把代码写在 XML 中,实在是不雅。</p> <p>RxCommand具有如下特点:</p> <ul> <li>基于 RxJava , 和 RxJava 完美配合</li> <li>配合 RxBinding 等框架,不需要把代码写在 XML 中,可以实现双向绑定</li> <li>分离关注点,便于有选择地处理任务执行的状态(执行中,错误,完成等等)</li> <li>ViewModel是个普通类</li> <li>相关代码集中,便于阅读和维护</li> </ul> <h2>Demo</h2> <p>我们通过一个 Demo 来讲解如何通过 RxCommand 来实现 V 和 VM . 看图:</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/3790f7bc7f95186924036610faf1acd7.png"></p> <p>假设我们需要做一个登录功能,这太常见了。</p> <ul> <li>当手机号码不合法时,获取验证码按钮处于disable状态</li> <li>当正在获取验证码时,获取验证码按钮处于disable状态,并显示loading</li> <li>当获取验证码成功时,倒计时开始,按钮仍处于disable状态,倒计时结束,按钮重新可用</li> <li>当获取验证码失败时,不会开始倒计时,按钮恢复可用状态</li> <li>当手机号码和验证码都合法时,登录按钮处于可点击状态,否则不可点击</li> <li>当点击登陆按钮时,登录按钮处于不可点状态,同时显示loading</li> <li>当登录成功时,停止 loading, 跳转到主界面</li> <li>当登录失败时,停止 loading, 并提示错误</li> </ul> <h3>源码</h3> <p>以下是对 Demo 源码的解读,如果你想直接看代码,请看 <a href="/misc/goto?guid=4959746054490169978" rel="nofollow,noindex">完整的项目源码以及 demo</a> .</p> <h3>实现 View</h3> <p>让我们来看看,要实现以上需求,LoginActivity 该怎么写。</p> <p>这四行代码就完成了接收用户输入,验证用户输入是否合法,来决定按钮是否可点击,以及响应用户点击按钮触发任务,在任务执行期间,按钮不可点击等需求。</p> <pre> //绑定用户输入到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());</pre> <p>获取验证码</p> <p>处理获取验证码的执行状态,如果正在获取,显示获取中,如果获取成功或发生错误,则重置。</p> <pre> viewModel.codeCommand() .executing() .subscribe(executing -> { if (executing) { codeButton.setText("fetch..."); } else { codeButton.setText("fetch code"); } });</pre> <p>处理获取验证码成功的情形,command 内部已经过滤了发生错误的情形,不需要在这里处理失败的情况</p> <pre> viewModel.codeCommand() .switchToLatest() .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> Toast.makeText(LoginActivity.this, result, Toast.LENGTH_LONG).show());</pre> <p>获取验证码成功后,会开启倒计时,避免用户在没及时收到短信的情况下狂点 <strong> 获取验证码 </strong> 按钮。 倒计时结束后, <strong> 获取验证码 </strong> 按钮会自动处于可点击状态。这里处理的是正在倒数时, <strong> 获取验证码 </strong> 按钮上显示的文字。</p> <pre> viewModel.countdownCommand() .switchToLatest() .observeOn(AndroidSchedulers.mainThread()) .subscribe(s -> codeButton.setText(s));</pre> <p>处理倒数完成后的情形,让 <em>获取验证码</em> 按钮上的文字重置。</p> <pre> viewModel.countdownCommand() .executing() .subscribe(executing -> { if (!executing) { codeButton.setText("fetch code"); } });</pre> <p>登录</p> <p>现在,我们开始处理登录的执行状态,当用户点击 <strong> 登录 </strong> 按钮时,按钮将不可再点,并且show loading, 告诉用户正在登录, 一旦登录成功或者发生异常,dismiss loading.</p> <pre> viewModel.loginCommand() .executing() .subscribe(executing -> { if (executing) { loginButton.setText("login..."); } else { loginButton.setText("login"); } });</pre> <p>这里处理了登录成功后的情形</p> <pre> viewModel.loginCommand() .switchToLatest() .observeOn(AndroidSchedulers.mainThread()) .subscribe(val -> { Toast.makeText(LoginActivity.this, "login success!! Now goto the MainActivity.", Toast.LENGTH_LONG).show(); });</pre> <p>异常处理</p> <p>万一发生异常呢?我们在这里一并处理获取验证码和登录发生异常的情况。</p> <pre> Observable.merge( viewModel.codeCommand().errors(), viewModel.loginCommand().errors()) .subscribe(throwable -> Toast.makeText(LoginActivity.this, throwable.getLocalizedMessage(), Toast.LENGTH_LONG).show() );</pre> <p>从上面的例子可以看到,RxCommand 有效分离了enabled, executing, success, error 等异步任务常有的关注点,使我们可以有选择地处理它们,各个处理的代码互不依赖。</p> <h3>实现 ViewModel</h3> <p>前面我们说过, ViewModel 是一个普通类。 要实现上面整个业务流程,ViewModel 中的代码仅有 110 行。</p> <pre> 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); } }</pre> <p>倒计时</p> <p>让我来看看 countdownCommand 该怎么实现</p> <pre> 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; }</pre> <p>我们通过 RxCommand 的静态工厂方法 #create 来创建一个 Command, 它接收一个函数作为参数, 这个函数接收一个 obj ( 可以为 null ), 返回一个 Observable . 这个 Observable 把倒计的时间转换为可以在 <strong> 获取验证码 </strong> 按钮显示的文字。</p> <p>获取验证码</p> <p>RxCommand 还有另外一个静态工厂方法,它除了接收一个返回 Observable 的函数,还接收一个发射 Boolean 的 Observable 作为参数。这个 Observable 发射的值决定了 command 是否可以执行, 反应到界面上就是按钮是否可点击。</p> <pre> 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; }</pre> <p>登录</p> <p>登录的逻辑处理比获取验证码简单多了,毕竟获取验证码和倒计时这两个 command 需要联动。</p> <pre> 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; }</pre> <p>来看看 login 的模拟实现</p> <pre> 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!!")); } }); }</pre> <h3>集成到项目中</h3> <pre> dependencies { compile 'com.shundaojia:rxcommand:1.0.0' }</pre> <p> </p> <p>来自:https://listenzz.github.io/使用RxCommand在Android上实现MVVM</p> <p> </p>