原文:REACTIVE APPS WITH MODEL-VIEW-INTENT - PART2 - VIEW AND INTENT
作者:Hannes Dorfmann
译者:却把清梅嗅
在 上文 中,我们探讨了对Model的定义、与 状态 的关系以及如何在通过良好地定义Model来解决一些Android开发中常见的问题。本文将通过 Model-View-Intent ,即MVI模式,继续我们的 响应式App 构建之旅。
如果您尚未阅读上一小节,则应在继续阅读本文之前阅读该部分。总结一下:以“传统的”MVP为例,请避免写出这样的代码:
class PersonsPresenter extends Presenter<PersonsView> {
public void load(){
getView().showLoading(true); // 展示一个 ProgressBar
backend.loadPersons(new Callback(){
public void onSuccess(List<Person> persons){
getView().showPersons(persons); // 展示用户列表
}
public void onError(Throwable error){
getView().showError(error); // 展示错误信息
}
});
}
}
我们应该创建能够反映 状态 的Model,像这样:
class PersonsModel {
// 在真实的项目中,需要定义为私有的
// 并且我们需要通过getter和setter来访问它们
final boolean loading;
final List<Person> persons;
final Throwable error;
public(boolean loading, List<Person> persons, Throwable error){
this.loading = loading;
this.persons = persons;
this.error = error;
}
}
因此,Presenter层也应该像这样进行定义:
class PersonsPresenter extends Presenter<PersonsView> {
public void load(){
getView().render( new PersonsModel(true, null, null) ); // 展示一个 ProgressBar
backend.loadPersons(new Callback(){
public void onSuccess(List<Person> persons){
getView().render( new PersonsModel(false, persons, null) ); // 展示用户列表
}
public void onError(Throwable error){
getView().render( new PersonsModel(false, null, error) ); // 展示错误信息
}
});
}
}
现在,仅需简单调用View层的render(personsModel)方法,Model就会被成功的渲染在屏幕上。在第一小节中我们同样探讨了 单项数据流 的重要性,同时您的业务逻辑应该驱动该Model。在正式将所有内容环环相扣连接之前,我们先简单了解一下MVI的核心思想。
Model-View-Intent (MVI)
该模式最初被 andrestaltz 在他写的JavaScript框架 cycle.js 中所提出; 从理论(还有数学)上讲,我们这样对Model-View-Intent的定义进行描述:
1.intent()
此函数接受来自用户的输入(即UI事件,比如点击事件)并将其转换为可传递给Model()函数的参数,该参数可能是一个简单的String对Model进行赋值,也可能像是Object这样复杂的数据结构。intent作为意图,标志着 我们试图对Model进行改变。
2.model()
model()函数将intent()函数的输出作为输入来操作Model,其函数输出是一个新的Model(状态发生了改变)。
不要对已存在的Model对象进行修改,我们需要的是不可变!对此,在上文中我们已经展示了一个计数器的具体案例,再次重申,不要修改已存在的Model!
根据intent所描述的变化,我们创建一个新的Model,请注意,Model()函数是唯一允许对Model进行创建的途径。然后这个新的Model作为该函数的输出——基本上model()函数调用我们App的业务逻辑(可以是交互、用例、Repository......您在App中使用的任何模式/术语)并作为结果提供新的Model对象。
3.view()
该方法获取model()函数返回的Model,并将其作为view()函数的输入,这之后通过某种方式将Model展示出来,view()和view.render(model)大体上是一致的。
4.本质
但是我们希望构建的是 响应式的App,不是吗?那么MVI是如何响应式的呢?响应式实际上意味着什么?
这意味着App的 UI反映了状态的变更。
因为Model反映了状态,因此,本质上我们希望 业务逻辑能够对输入的事件(即intents)进行响应,并创建对应的Model作为输出,这之后再通过调用View层的render(model)方法,对UI进行渲染。
5.通过RxJava串联
我们希望我们的数据流的单向性,因此RxJava闪亮登场。我们的App必须通过RxJava保持 数据的单向性 和 响应式 来构建吗?或者必须用MVI模式才能构建吗?当然不,我们也可以写 命令式 和 程序性 的代码。但是,基于事件编程 的RxJava实在太优秀了,既然UI是基于事件的,因此使用RxJava也是非常有意义的。
本文我们将会构建一个简单的虚拟在线商店App,其UI界面中展示的商品数据,都来源于我们向后台进行的网络请求。
我们可以精确的搜索特定的商品,并将其添加到我们的购物车中,最终App的效果如下所示:
这个项目的源码你可以在Github上找到,我们从实现一个简单的搜索界面开始做起:
首先,就像上文我们描述的那样,我们定义一个Model用于描述View层是如何被展示的—— 这个系列中,我们将用带有 ViewState 后缀的类来替代 Model;举个例子,我们将会为搜索页的Model类命名为SearchViewState。
这很好理解,因为Model反应的就是状态(State),至于为什么不用听起来有些奇怪的名称比如SearchModel,是因为担心和MVVM中的SearchViewModel类在一起会导致歧义——命名真的很难。
public interface SearchViewState {
// 搜索尚未开始
final class SearchNotStartedYet implements SearchViewState {
}
// 搜索中
final class Loading implements SearchViewState {
}
// 返回结果为空
final class EmptyResult implements SearchViewState {
private final String searchQueryText;
public EmptyResult(String searchQueryText) {
this.searchQueryText = searchQueryText;
}
public String getSearchQueryText() {
return searchQueryText;
}
}
// 有效的搜索结果,包含和搜索条件匹配的商品列表
final class SearchResult implements SearchViewState {
private final String searchQueryText;
private final List<Product> result;
public SearchResult(String searchQueryText, List<Product> result) {
this.searchQueryText = searchQueryText;
this.result = result;
}
public String getSearchQueryText() {
return searchQueryText;
}
public List<Product> getResult() {
return result;
}
}
// 表示搜索过程中发生了错误
final class Error implements SearchViewState {
private final String searchQueryText;
private final Throwable error;
public Error(String searchQueryText, Throwable error) {
this.searchQueryText = searchQueryText;
this.error = error;
}
public String getSearchQueryText() {
return searchQueryText;
}
public Throwable getError() {
return error;
}
}
}
因为Java是一种强类型的语言,因此我们可以选择一种安全的方式为我们的Model类拆分出多个不同的 子状态。
我们的业务逻辑返回的是一个 SearchViewState 类型的对象,它可能是SearchViewState.Error或者其它的一个实例。这只是我个人的偏好,们也可以通过不同的方式定义,例如:
class SearchViewState {
Throwable error; // 非空则意味着,出现了一个错误
boolean loading; // 值为true意味着正在加载中
List<Product> result; // 非空意味着商品列表的结果
boolean SearchNotStartedYet; // true意味着还未开始搜索
}
再次重申,如何定义Model纯属个人喜好,如果你用Kotlin作为编程语言,那么sealed classes是一个不错的选择。
将目光聚集回到业务代码,让我们通过 SearchInteractor 去执行搜索的功能,其输出就是我们之前说过的SearchViewState对象:
public class SearchInteractor {
final SearchEngine searchEngine; // 执行网络请求
public Observable<SearchViewState> search(String searchString) {
// 如果是空的字符串,不进行搜索
if (searchString.isEmpty()) {
return Observable.just(new SearchViewState.SearchNotStartedYet());
}
// 搜索商品列表
// 返回 Observable<List<Product>>
return searchEngine.searchFor(searchString)
.map(products -> {
if (products.isEmpty()) {
return new SearchViewState.EmptyResult(searchString);
} else {
return new SearchViewState.SearchResult(searchString, products);
}
})
.startWith(new SearchViewState.Loading())
.onErrorReturn(error -> new SearchViewState.Error(searchString, error));
}
}
来看下SearchInteractor.search()的方法签名:我们将String类型的searchString作为 输入 的参数,以及Observable<SearchViewState>类型的 输出,这意味着我们期望随着时间的推移,可以在可观察的流上会有任意多个SearchViewState的实例被发射。
在我们正式开始查询搜索之前(即SearchEngine执行网络请求),我们通过startWith()操作符发射一个SearchViewState.Loading,这将会使得View在执行搜索时展示ProgressBar。
onErrorReturn()会捕获在执行搜索时抛出的所有异常,并且发射出一个SearchViewState.Error——在订阅这个Observable时,我们为什么不去使用onError()回调呢?
这是一个对RxJava认知的普遍误解,实际上,onError()的回调意味着 整个可观察的流进入了不可恢复的状态,因此可观察的流结束了,而在我们的案例中,类似“没有网络连接”的error并非不可恢复的error:这只是我们的Model所代表的另外一个状态。
此外,我们还有另外一个可以转换到的状态,即一旦网络连接可用,我们可以通过 SearchViewState.Loading 跳转到的 加载状态。
因此,我们建立了一个可观察的流,这是一个每当状态发生了改变,从业务逻辑层就会发射一个发生了改变的Model到View层的流。
我们不想在网络连接错误时终止这个可观察的流,因此,在error发生时,类似这种可以被处理为 状态 的error(而不是终止流的那种致命的错误),可以反应为Model,被可观察的流发射。
通常,在MVI中,Model的Observable永远不会被终止(即永远不会执行onComplete()或者onError()回调)。
总结一下,SearchInteractor(即业务逻辑)提供了一个可观察的流Observable<SearchViewState>,每当状态发生了变化,就会发射一个新的SearchViewState。
6.View层的职责
接下来我们来讨论一下View应该是什么样的,View层的职责是什么?显然View层应该对Model进行展示,我们已经认可View层应该有类似 render(model) 这样的函数。此外,View应该提供一个给其他层响应用户输入的方法,在MVI中这个方法被称为 intents。
在这个案例中,我们只有一个intent:用户可以在输入框中输入一个用于检索商品的字符串进行搜索。MVP中的好习惯是为View层定义一个接口,所以在MVI中我们也可以这样做。
public interface SearchView {
// 搜索的intent
Observable<String> searchIntent();
// 对View层进行渲染
void render(SearchViewState viewState);
}
我们的案例中View层只提供了一个intent,但通常View拥有更多的intent;在 第一小节 中我们讨论了为什么一个单独的render()函数是一个不错的实践,如果你对此还不是很清楚的话,请阅读该小节并通过留言进行探讨。
在我们开始对View层进行具体的实现之前,我们先看看最终界面的展示效果:
public class SearchFragment extends Fragment implements SearchView {
@BindView(R.id.searchView) android.widget.SearchView searchView;
@BindView(R.id.container) ViewGroup container;
@BindView(R.id.loadingView) View loadingView;
@BindView(R.id.errorView) TextView errorView;
@BindView(R.id.recyclerView) RecyclerView recyclerView;
@BindView(R.id.emptyView) View emptyView;
private SearchAdapter adapter;
@Override public Observable<String> searchIntent() {
return RxSearchView.queryTextChanges(searchView) // 感谢 Jake Wharton :)
.filter(queryString -> queryString.length() > 3 || queryString.length() == 0)
.debounce(500, TimeUnit.MILLISECONDS);
}
@Override public void render(SearchViewState viewState) {
if (viewState instanceof SearchViewState.SearchNotStartedYet) {
renderSearchNotStarted();
} else if (viewState instanceof SearchViewState.Loading) {
renderLoading();
} else if (viewState instanceof SearchViewState.SearchResult) {
renderResult(((SearchViewState.SearchResult) viewState).getResult());
} else if (viewState instanceof SearchViewState.EmptyResult) {
renderEmptyResult();
} else if (viewState instanceof SearchViewState.Error) {
renderError();
} else {
throw new IllegalArgumentException("Don't know how to render viewState " + viewState);
}
}
private void renderResult(List<Product> result) {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.VISIBLE);
loadingView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
errorView.setVisibility(View.GONE);
adapter.setProducts(result);
adapter.notifyDataSetChanged();
}
private void renderSearchNotStarted() {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.GONE);
errorView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
}
private void renderLoading() {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.VISIBLE);
errorView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
}
private void renderError() {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.GONE);
errorView.setVisibility(View.VISIBLE);
emptyView.setVisibility(View.GONE);
}
private void renderEmptyResult() {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.GONE);
errorView.setVisibility(View.GONE);
emptyView.setVisibility(View.VISIBLE);
}
}
render(SearchViewState)方法的作用显而易见,searchIntent()方法中,我们使用了Jake Wharton的 RxBinding ,这是一个对Android UI组件提供了RxJava响应式支持的库。
RxSearchView.queryText()创建了一个Observable<String>,每当用户在EditText上输入了一些文字,它就会发射一个对应的字符串;我们通过filter()去保证只有当用户输入的字符数达到三个以上时才进行搜索;同时,我们不希望每当用户输入一个字符,就去请求网络,而是当用户输入结束后再去请求网络(debounce()操作符会停留500毫秒以决定用户是否输入完成)。
现在我们知道了屏幕中的searchIntent()方法就是 输入 ,而render()方法则是 输出。我们如何从 输入 获得 输出 呢,如下所示:
7.连接View和Intent
剩下的问题就是:我们如何将View的intent和业务逻辑进行连接呢?如果你认真观看了上面的流程图,你应该注意到了中间的 flatMap() 操作符,这暗示了我们还有一个尚未谈及的组件: Presenter ;Presenter负责连接这些点,就和我们在MVP中使用的方式一样。
public class SearchPresenter extends MviBasePresenter<SearchView, SearchViewState> {
private final SearchInteractor searchInteractor;
@Override protected void bindIntents() {
Observable<SearchViewState> search =
intent(SearchView::searchIntent)
// 上文中我们谈到了flatMap,但在这里switchMap更为适用
.switchMap(searchInteractor::search)
.observeOn(AndroidSchedulers.mainThread());
subscribeViewState(search, SearchView::render);
}
}
什么是 MviBasePresenter, intent() 和 subscribeViewState() 又是什么?这个类是我写的 Mosby 库的一部分(3.0版本后,Mosby已经支持了MVI)。本文并非为了讲述Mosby,但我向简单介绍一下MviBasePresenter是如何的便利——这其中没有什么黑魔法,虽然确实看起来像是那样。
让我们从生命周期开始:MviBasePresenter并未持有任何生命周期,它暴露出一个 bindIntent() 方法以供View层和业务逻辑进行绑定。通常,你通过flatMap()、switchMap()或者concatMap()操作符将intent “转移”到业务逻辑中,这个方法仅仅在View层第一次被附加到Presenter中时调用,而当View再次被附加在Presenter中时(比如,屏幕方向发生了改变),将不再被调用。
这听起来有些怪,也许有人会说:
MviBasePresenter在屏幕方向发生了改变后依然能够存活?如果是这样,Mosby如何保证Observable的流不会发生内存的泄漏?
这就是 intent() 和 subscribeViewState() 的作用所在了,intent() 在内部创建一个PublishSubject,就像是业务逻辑的“网关”一样;实际上,PublishSubject订阅了View层传过来的intent的Observable,调用intent(o1)实际返回了一个订阅了o1的PublishSubject。
屏幕发生旋转时,Mosby将View从Presenter中分离,但是,内部的PublishSubject只是暂时和View解除了订阅;而当View重新附着在Presenter上时,PublishSubject将会对View层的intent进行重新订阅。
subscribeViewState()方法做的是同样的事情,只不过将顺序调换了过来(Presenter向View层的通信)。它在内部创建一个BehaviorSubject作为从业务逻辑到View层的“网关”。
由于它是一个BehaviorSubject,因此,即使此时Presenter没有持有View,我们依然可以从业务逻辑中接收到Model的更新(比如View并未处于栈顶);BehaviorSubjects始终持有它最后的值,并在View重新依附后将其重新发射。
规则很简单:使用intent()来“包装”View层的所有intent,使用subscribeViewState()替代Observable.subscribe().
8.UnbindIntents
与bindIntent()相对应的是 unbindIntents() ,该方法只会执行一次,即View被永久销毁时才会被调用。举个例子,将一个Fragment放在栈中,直到Activity被销毁之前,该View一直不会被销毁。
由于intent()和subscribeViewState()已经对订阅进行了管理,因此您只需要实现unbindIntents()。
9.其它生命周期的事件
那么其它生命周期的事件,比如onPause()和onResume()又该如何处理?我依然认为Presenter不需要生命周期的事件,然而,如果你坚持认为你需要将这些生命周期的事件视为另一种形式的intent,您的View可以提供一个pauseIntent(),它是由android生命周期触发,而又不是按钮点击事件这样的由用户交互触发的intent——但两者都是有效的意图。
结语
第二小节中,我们探讨了Model-View-Intent的基础,并通过MVI浅尝辄止实现了一个简单的页面。也许这个例子太简单了,所以你尚未感受到MVI模式的优点:代表 状态 的Model和与传统MVP或者MVVM相比的 单项数据流。
MVP和MVVM并没有什么问题,我也并非是在说MVI比其它架构模式更优秀,但是,我认为MVI可以帮助我们 为复杂的问题编写优雅的代码 ,这也正如我们将在本系列博客的 下一小节(第3小节)中探讨的那样——届时我们将针对 状态减少 (state reducers)的问题进行探讨,欢迎关注。
系列目录
《使用MVI打造响应式APP》原文
《使用MVI打造响应式APP》译文
《使用MVI打造响应式APP》实战
关于我
Hello,我是却把清梅嗅,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的博客或者Github。
如果您觉得文章还差了那么点东西,也请通过关注督促我写出更好的文章——万一哪天我进步了呢?