翻译自 REACTIVE APPS WITH MODEL-VIEW-INTENT - PART3 - STATE REDUCER 。
在上一篇 文章中,我们使用 MVI
模式,结合单向数据流,实现了一个简单的搜索功能。在这一篇文章里,我们会在 状态转换机 的帮助下,实现一个更为复杂的功能。
如果你还没有读过本系列的第二篇文章 ,最好还是先去看一下,我们探讨了 View
、 Presenter
与业务逻辑关联的方式,还有,数据单向流动的方法。
现在,我们要来实现这样一个复杂的效果:
点击查看视频
视频里展示了一个经过分类的商品列表,每一个分类一开始只显示 3 件商品,当用户点击了“加载更多”的按钮,应用就会去后台请求数据该类型的商品;除此以外,用户还可以下拉刷新整个列表;当用户滚动到底部时,应用还会联网去获取更多的分类信息;当然,这些操作都能同时进行,还要考虑网络断开的问题。
我们一步步地来看这些功能的实现。首先,先定义一下 View
的接口:
public interfaceHomeView{ /** * 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 */ voidrender(HomeViewState viewState); }
View
的具体实现非常简单,我就不在此赘述了,大家可以从 github 上看到代码。下一步,就是 Model
构建,就像我前面说过的, Model
应当反映 State
,所以,让我们看看这个叫做 HomeViewState
的 Model
:
public final classHomeViewState{ 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
就可以用来表示是否加载该分类的更多项:
public classAdditionalItemsLoadableimplementsFeedItem{ private final int moreItemsAvailableCount; private final String categoryName; private final boolean loading; // true表示正在加载 private final Throwable loadingError; // 表示加载时发生错误 // ... constructor ... // ... getters ... }
最后,不要忘了最重要的业务逻辑:
public classHomeFeedLoader{ // 通常由下拉刷新触发 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
中,这里这样写只是为了更好的阅读效果。好,第一步,加载初始数据:
classHomePresenterextendsMviBasePresenter<HomeView,HomeViewState>{ private final HomeFeedLoader feedLoader; @Override protectedvoidbindIntents(){ // 在实际项目中,这儿的某些代码应该封装到 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); } }
到目前为止,这和我们在第二部分说的实现方式都一样。现在我们试着再加上下拉刷新的功能:
classHomePresenterextendsMviBasePresenter<HomeView,HomeViewState>{ private final HomeFeedLoader feedLoader; @Override protectedvoidbindIntents(){ // 在实际项目中,这儿的某些代码应该封装到 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)
这样的代码,但我们一开始就已经说明了这并不是很好的做法(状态问题)。现在的问题就在于,我们想要将下拉刷新获取的内容与之前获取的内容相结合。
状态转换机是函数式编程中的一个概念,它将上一个状态作为输入,然后计算出一个新的状态:
publicStatereduce(State previous, Foo foo){ State newState; // ... 通过前一个状态和foo,计算出新的状态 return newState; }
它的核心思想就是通过 reduce()
这样一个方法,将 previous
与 foo
结合起来。 Foo
通常表示我们想要对上一个状态做的更改的内容。在这个例子中,我们想要“转换”上一个 HomeViewState
(来自于 loadFirstPageIntent
的计算), Foo
就是下拉刷新的结果。正好,RxJava 恰恰有一个为此而生的操作符, scan()
。我们来小小地重构一下代码。在这里,我们又会使用另一个类来表示状态的部分变化(所谓的 Foo
),以此计算新的状态。
classHomePresenterextendsMviBasePresenter<HomeView,HomeViewState>{ private final HomeFeedLoader feedLoader; @Override protectedvoidbindIntents(){ // 在实际项目中,这儿的某些代码应该封装到 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); } privateHomeViewStateviewStateReducer(HomeViewState previousState, PartialState changes){ ... } }
我们在这儿所做的,就是让 intent
的返回值变为 Observable<PartialState>
,而不再直接返回 Observable<HomeViewState>
了。然后,通过 Observable.merge()
合并结果,再由 Observable.scan()
更改状态。从根本上来说,用户每产生一个 intent
,这个 intent
就会生成一个 PartialState
对象,最终它就会转换为一个 HomeViewState
,由 HomeView.render(HomeViewState)
显示出来。现在唯一缺少的就是转换函数本身了。 HomeViewState
类是不可变的(上去看看该类的定义),但我们会增加一个 Builder
( Builder
模式),以一种更加方便的方式去创建新的 HomeViewState
对象。实现:
privateHomeViewStateviewStateReducer(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
架构的应用,它的帮助很大。
好,我想你应该已经理解了状态转换机的工作原理,那就完成剩下的部分:分页功能,加载某个分类剩余的物品。
classHomePresenterextendsMviBasePresenter<HomeView,HomeViewState>{ private final HomeFeedLoader feedLoader; @Override protectedvoidbindIntents(){ // 在实际项目中,这儿的某些代码应该封装到 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 PartialState.ProductsOfCategoryLoaded(categoryName, products)) .startWith(new PartialState.ProductsOfCategoryLoading(categoryName)) .onErrorReturn(error -> new PartialState.ProductsOfCategoryError(categoryName, error))); Observable<PartialState> allIntents = Observable.merge(loadFirstPage, pullToRefresh, nextPage, loadMoreFromCategory); HomeViewState initialState = ... ; Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer) subscribeViewState(stateObservable, HomeView::render); } privateHomeViewStateviewStateReducer(HomeViewState previousState, PartialState changes){ // ... PartialState handling for First Page and pull-to-refresh as shown in previous code snipped ... if (changes instanceof PartialState.NextPageLoading) { return previousState.builder().nextPageLoading(true).nextPageError(null).build(); } if (changes instanceof PartialState.NextPageLoadingError) { return previousState.builder() .nextPageLoading(false) .nextPageError(((PartialState.NexPageLoadingError) changes).getError()) .build(); } if (changes instanceof PartialState.NextPageLoaded) { List<FeedItem> data = new ArrayList<>(); data.addAll(previousState.getData()); // Add new data add the end of the list data.addAll(((PartialState.NextPageLoaded) changes).getData()); return previousState.builder().nextPageLoading(false).nextPageError(null).data(data).build(); } if (changes instanceof PartialState.ProductsOfCategoryLoading) { int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData()); AdditionalItemsLoadable ail = (AdditionalItemsLoadable) previousState.getData().get(indexLoadMoreItem); AdditionalItemsLoadable itemsThatIndicatesError = ail.builder() // creates a copy of the ail item .loading(true).error(null).build(); List<FeedItem> data = new ArrayList<>(); data.addAll(previousState.getData()); data.set(indexLoadMoreItem, itemsThatIndicatesError); // Will display a loading indicator return previousState.builder().data(data).build(); } if (changes instanceof PartialState.ProductsOfCategoryLoadingError) { int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData()); AdditionalItemsLoadable ail = (AdditionalItemsLoadable) previousState.getData().get(indexLoadMoreItem); AdditionalItemsLoadable itemsThatIndicatesError = ail.builder().loading(false).error( ((ProductsOfCategoryLoadingError)changes).getError()).build(); List<FeedItem> data = new ArrayList<>(); data.addAll(previousState.getData()); data.set(indexLoadMoreItem, itemsThatIndicatesError); // Will display an error / retry button return previousState.builder().data(data).build(); } if (changes instanceof PartialState.ProductsOfCategoryLoaded) { String categoryName = (ProductsOfCategoryLoaded) changes.getCategoryName(); int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData()); int indexOfSectionHeader = findSectionHeader(categoryName, previousState.getData()); List<FeedItem> data = new ArrayList<>(); data.addAll(previousState.getData()); removeItems(data, indexOfSectionHeader, indexLoadMoreItem); // Removes all items of the given category // Adds all items of the category (includes the items previously removed) data.addAll(indexOfSectionHeader + 1,((ProductsOfCategoryLoaded) changes).getData()); return previousState.builder().data(data).build(); } throw new IllegalStateException("Don't know how to reduce the partial state " + changes); } }
实现分页功能和实现下拉刷新十分类似,除了我们要把新加载内容放到原有内容之后。解决这个问题的方法实在是很有趣,要显示一个加载中的indicator,错误/重试按钮,我们只需要搜索 FeedItems
列表中相符的 AdditionalItemsLoadable
对象,然后只要更改那一项,去显示加载中,或者错误/重试按钮。如果我们成功地加载了某个分类的所有内容,那我们就用新加载的替换掉搜索到的 SectionHeader
和 AdditionalItemsLoadable
。就是这样。
这篇文章就是让你看到,在一个状态转换机的帮助下,如何用更少、更易懂的代码,去完成一个复杂的功能。现在回过头想想,如果使用传统的 MVP/MVVM
模式,没有状态转换机的帮助,你会怎么实现?能够使用状态转换机的关键,我们拥有一个能够反映 state
的 model
。因此,就想第一篇文章所说,深入理解 Model
非常重要。而且,只有在确保我们的数据源是唯一的情况下,我们才能使用状态转换机。因此,单向数据流也非常重要。到这里,我希望你更能理解一、二篇文章的重要性,当把这些点联系到一起之后,你能豁然开朗。如果没有,不要担心,我也花了很长时间(还有许多的练习,许多的错误,许多的推倒重来)。
你可能会疑惑,我们为什么没有在搜索界面使用状态转换机(看第二篇文章)。只有在依赖于上一状态的情况下,状态转换机才会如此重要,而在搜索界面,我们是不依赖于上一状态的。
最后,最重要的一点,不知道你有没有注意到(希望你没有钻进细节中),我们所有的数据都是不可变的(我们总是创建新的 HomeViewState
,从没调用过setter)。所以,多线程也变得相当简单。用户可以同时下拉刷新、加载下一页、加载某个分类的更多内容,因为状态转换机并不依赖于某个特定 http 的响应,就可以提供准确的状态。除此以外,我们用的是纯函数,没有副作用。这让我们代码的可测试性、复用性、理解性和高可并发性都有了提升。
当然,状态转换机并不是为了 MVI
发明的,你会在许多其他的库、框架、不同的语言中看到它的身影。单向数据流,代表状态的模型,状态转换机非常适合 MVI
的理念。
下一篇文章,我们会介绍怎样用 MVI
去构建一个可重用、响应式的组件。