Model-View-Intent 构建的响应式应用(三)状态转换机

JerryXia 发表于 , 阅读 (26)
不明,不名

Model-View-Intent 构建的响应式应用(三)状态转换机

翻译自 REACTIVE APPS WITH MODEL-VIEW-INTENT - PART3 - STATE REDUCER

上一篇 文章中,我们使用 MVI 模式,结合单向数据流,实现了一个简单的搜索功能。在这一篇文章里,我们会在 状态转换机[1] 的帮助下,实现一个更为复杂的功能。

如果你还没有读过本系列的 第二篇文章 ,最好还是先去看一下,我们探讨了 ViewPresenter 与业务逻辑关联的方式,还有,数据单向流动的方法。

现在,我们要来实现这样一个复杂的效果:

点击查看视频

视频里展示了一个经过分类的商品列表,每一个分类一开始只显示 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 ,所以,让我们看看这个叫做 HomeViewStateModel

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;
@Override 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;
@Override 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() 这样一个方法,将 previousfoo 结合起来。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;
@Override 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 类是不可变的(上去看看该类的定义),但我们会增加一个 BuilderBuilder 模式),以一种更加方便的方式去创建新的 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;
@Override 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