翻译自 REACTIVE APPS WITH MODEL-VIEW-INTENT - PART3 - STATE REDUCER 。
在 上一篇 文章中,我们使用 MVI 模式,结合单向数据流,实现了一个简单的搜索功能。在这一篇文章里,我们会在 状态转换机[1] 的帮助下,实现一个更为复杂的功能。
如果你还没有读过本系列的 第二篇文章 ,最好还是先去看一下,我们探讨了 View 、 Presenter 与业务逻辑关联的方式,还有,数据单向流动的方法。
现在,我们要来实现这样一个复杂的效果:
视频里展示了一个经过分类的商品列表,每一个分类一开始只显示 3 件商品,当用户点击了“加载更多”的按钮,应用就会去后台请求数据该类型的商品;除此以外,用户还可以下拉刷新整个列表;当用户滚动到底部时,应用还会联网去获取更多的分类信息;当然,这些操作都能同时进行,还要考虑网络断开的问题。
我们一步步地来看这些功能的实现。首先,先定义一下 View 的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | public interface HomeView { /** * intent 加载第一页 * * @return 发射的数据值(true or false)没有卵用。。。 */ Observable<Boolean> loadFirstPageIntent(); /** * intent 加载下一页(应该是指分类信息的下一页) * * @return 和上一个一样,还是没有用的值 */ Observable<Boolean> loadNextPageIntent(); /** * intent 下拉刷新 * * @return 继续忽视吧 */ Observable<Boolean> pullToRefreshIntent(); /** * intent 由给定的分类去加载更多商品 * * @return 分类的名称 */ Observable<String> loadAllProductsFromCategoryIntent(); /** * render 渲染 viewState */ void render(HomeViewState viewState); } |
View 的具体实现非常简单,我就不在此赘述了,大家可以从 github 上看到代码。下一步,就是 Model 构建,就像我前面说过的,Model 应当反映 State ,所以,让我们看看这个叫做 HomeViewState 的 Model :
1 2 3 4 5 6 7 8 9 10 11 12 | public final class HomeViewState { private final boolean loadingFirstPage; // loading页展示,recyclerView gone private final Throwable firstPageError; // 不为null,则展示error view private final List<FeedItem> data; // 在recyclerView中展示的数据 private final boolean loadingNextPage; // 展示加载下一页的loading图示 private final Throwable nextPageError; // 不为null,则分页加载错误 private final boolean loadingPullToRefresh; // 显示下拉刷新的提示按钮 private final Throwable pullToRefreshError; // 不为null,下拉刷新失败的toast // ... constructor ... // ... getters ... } |
需要注意的是,FeedItem 仅仅是一个接口,只有实现了这个接口的实体类才能在 RecyclerView 中显示。举个例子,Product implements FeedItem ,还有,分类的标题要显示得这么写:SectionHeader implements FeedItem 。UI 中那个表示用于加载某个分类的更多项的元素也是一个 FeedItem ,它自己有自己的 state ,而这个 state 就可以用来表示是否加载该分类的更多项:
1 2 3 4 5 6 7 8 9 | public class AdditionalItemsLoadable implements FeedItem { private final int moreItemsAvailableCount; private final String categoryName; private final boolean loading; // true表示正在加载 private final Throwable loadingError; // 表示加载时发生错误 // ... constructor ... // ... getters ... } |
最后,不要忘了最重要的业务逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 | public class HomeFeedLoader { // 通常由下拉刷新触发 public Observable<List<FeedItem>> loadNewestPage() { ... } // 加载第一页 public Observable<List<FeedItem>> loadFirstPage() { ... } // 加载下一页 public Observable<List<FeedItem>> loadNextPage() { ... } // 加载某项分类的其余项 public Observable<List<Product>> loadProductsOfCategory(String categoryName) { ... } } |
接下来,就可以在 Presenter 中将这些内容组合起来。注意一点,我在这儿的 Presenter 中展示的某些代码其实更应该放置到 Interactor 中,这里这样写只是为了更好的阅读效果。好,第一步,加载初始数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> { private final HomeFeedLoader feedLoader; protected void bindIntents() { // 在实际项目中,这儿的某些代码应该封装到 Interactor 中 Observable<HomeViewState> loadFirstPage = intent(HomeView::loadFirstPageIntent) .flatMap(ignored -> feedLoader.loadFirstPage() .map(items -> new HomeViewState(items, false, null)) .startWith(new HomeViewState(emptyList, true, null)) .onErrorReturn(error -> new HomeViewState(emptyList, false, error))); subscribeViewState(loadFirstPage, HomeView::render); } } |
到目前为止,这和我们在第二部分说的实现方式都一样。现在我们试着再加上下拉刷新的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> { private final HomeFeedLoader feedLoader; protected void bindIntents() { // 在实际项目中,这儿的某些代码应该封装到 Interactor 中 Observable<HomeViewState> loadFirstPage = ...; Observable<HomeViewState> pullToRefresh = intent(HomeView::pullToRefreshIntent) .flatMap(ignored -> feedLoader.loadNewestPage() .map(items -> new HomeViewState(...)) .startWith(new HomeViewState(...)) .onErrorReturn(error -> new HomeViewState(...))); Observable<HomeViewState> allIntents = Observable.merge(loadFirst, pullToRefresh); subscribeViewState(allIntents, HomeView::render); } } |
打住:feedLoader.loadNewestPage() 似乎只会返回“最新”的项,那我们已经加载的内容怎么办?在“传统的” MVP 写法中,可能会有类似 view.addNewItems(newItems) 这样的代码,但我们一开始就已经说明了这并不是很好的做法(状态问题)。现在的问题就在于,我们想要将下拉刷新获取的内容与之前获取的内容相结合。
Ladies and Gentlemen,下面掌声欢迎 状态转换机 的登场!

状态转换机是函数式编程中的一个概念,它将上一个状态作为输入,然后计算出一个新的状态:
1 2 3 4 5 | public State reduce(State previous, Foo foo) { State newState; // ... 通过前一个状态和foo,计算出新的状态 return newState; } |
它的核心思想就是通过 reduce() 这样一个方法,将 previous 与 foo 结合起来。Foo 通常表示我们想要对上一个状态做的更改的内容。在这个例子中,我们想要“转换”上一个 HomeViewState (来自于 loadFirstPageIntent 的计算),Foo 就是下拉刷新的结果。正好,RxJava 恰恰有一个为此而生的操作符,scan() 。我们来小小地重构一下代码。在这里,我们又会使用另一个类来表示状态的部分变化(所谓的 Foo ),以此计算新的状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> { private final HomeFeedLoader feedLoader; protected void bindIntents() { // 在实际项目中,这儿的某些代码应该封装到 Interactor 中 Observable<PartialState> loadFirstPage = intent(HomeView::loadFirstPageIntent) .flatMap(ignored -> feedLoader.loadFirstPage() .map(items -> new PartialState.FirstPageData(items)) .startWith(new PartialState.FirstPageLoading(true)) .onErrorReturn(error -> new PartialState.FirstPageError(error))); Observable<PartialState> pullToRefresh = intent(HomeView::pullToRefreshIntent) .flatMap(ignored -> feedLoader.loadNewestPage() .map(items -> new PartialState.PullToRefreshData(item)) .startWith(new PartialState.PullToRefreshLoading(true)) .onErrorReturn(error -> new PartialState.PullToRefreshError(error))); Observable<PartialState> allIntents = Observable.merge(loadFirstPage, pullToRefresh); HomeViewState initialState = ... ; // 展示加载中的第一页 Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer); subscribeViewState(stateObservable, HomeView::render); } private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes) { ... } } |
我们在这儿所做的,就是让 intent 的返回值变为 Observable<PartialState> ,而不再直接返回 Observable<HomeViewState> 了。然后,通过 Observable.merge() 合并结果,再由 Observable.scan() 更改状态。从根本上来说,用户每产生一个 intent ,这个 intent 就会生成一个 PartialState 对象,最终它就会转换为一个 HomeViewState ,由 HomeView.render(HomeViewState) 显示出来。现在唯一缺少的就是转换函数本身了。 HomeViewState 类是不可变的(上去看看该类的定义),但我们会增加一个 Builder (Builder 模式),以一种更加方便的方式去创建新的 HomeViewState 对象。实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | private HomeViewState viewStateReducer(HoomeViewState previousState, PartialState changes) { if (changes instanceof PartialState.FirstPageLoading) { retunr previousState.toBuilder() // 通过已有的内容创建一个Builder .firstPageLoading(true) // 显示进度条 .firstPageError(null) // 没有错误 .build(); } if (changes instanceof PartialState.FirstPageError) { return previousState.toBuilder() .firstPageLoading(false) // 隐藏进度条 .firstPageError(((PartialState.FirstPageError)changes).getError()) // 错误 .build(); } if (changes instanceof PartialState.PullToRefreshLoading) { return previousState.toBuilder() .pullToRefreshLoading(true) // 显示下拉加载indicator .nextPageError(null) .build(); } if (changes instanceof PartialState.PullToRefreshError) { return previousState.toBuilder() .pullToRefreshLoading(false) .pullToRefreshError(((PartialState.PullToRefreshError)changes).getError()) .build(); } if (changes instanceof PartialState.PullToRefreshData) { List<FeedItem> data = new ArrayList<>(); data.addAll(((PullToRefreshData)changes).getData()); data.addAll(previousState.getData()); return previousState.toBuilder() .pullToRefreshLoading(false) .pullToRefreshError(null) .data(data) .build(); } throw new IllegalStateException("Don't konw how to reduce the partial state " + changes); } |
我知道,这些 instanceof 不怎么优雅,但这不是重点。为什么一位技术博客的作者会写出像上面一样丑陋的代码呢?我们想要阐述某个主题,不会要求读者去掌握其他不相关的知识点,就想我们的购物软件,它并不需要多么深厚的设计模式的知识。因此,我个人认为在写技术文章的时候,应该尽量避免设计模式的使用,它虽然可以让代码更加优雅,但也让文章不那么通俗易懂。这篇文章的重点是状态转换机,而通过 instanceof 的使用,基本上每个人都能理解这个转换机所做的工作了。那你要在你的代码里使用 instanceof 吗?当然不要,你可以用上一些设计模式,比如,将 PartialState 设计为一个接口,它拥有一个 HomeViewState computeNewState(previousState) 类似的方法等等。我推荐一个库,RxSealedUnions ,对于 MVI 架构的应用,它的帮助很大。
好,我想你应该已经理解了状态转换机的工作原理,那就完成剩下的部分:分页功能,加载某个分类剩余的物品。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> { private final HomeFeedLoader feedLoader; protected void bindIntents() { // 在实际项目中,这儿的某些代码应该封装到 Interactor 中 Observable<PartialState> loadFirstPage = ... ; Observable<PartialState> pullToRefresh = ... ; Observable<PartialState> nextPage = intent(HomeView::loadNextPageIntent) .flatMap(ignored -> feedLoader.loadNextPage() .map(items -> new PartialState.NextPageLoaded(items)) .startWith(new PartialState.NextPageLoading()) .onErrorReturn(PartialState.NexPageLoadingError::new))); Observable<PartialState> loadMoreFromCategory = intent(HomeView::loadAllProductsFromCategoryIntent) .flatMap(categoryName -> feedLoader.loadProductsOfCategory(categoryName) .map( products -> new |