原文:REACTIVE APPS WITH MODEL-VIEW-INTENT - PART3 - STATE REDUCER
作者:Hannes Dorfmann
譯者:卻把清梅嗅
在上一章節(jié)中,我們針對(duì) 如何使用單向流和 Model-View-Intent 模式構(gòu)建一個(gè)簡(jiǎn)單的頁(yè)面 進(jìn)行了探討;本章節(jié),我們將在reducer的幫助下實(shí)現(xiàn)MVI模式中更加復(fù)雜的頁(yè)面。
如果你還未閱讀前兩個(gè)章節(jié),閱讀本文之前您應(yīng)該先去閱讀它們,從而對(duì)如下兩個(gè)問題的答案有初步的了解:
- 1.我們?nèi)绾瓮ㄟ^
Presenter將View層和業(yè)務(wù)邏輯相關(guān)聯(lián)? - 2.數(shù)據(jù)流是如何保證單向性的?
如下圖所示,現(xiàn)在我們構(gòu)建這樣一個(gè)復(fù)雜的頁(yè)面:
如你所見,屏幕中顯示的是按照類別進(jìn)行歸類的商品列表;App每次只會(huì)為每個(gè)分類展示3個(gè)條目,當(dāng)用戶點(diǎn)擊了 加載更多 按鈕時(shí),將會(huì)通過網(wǎng)絡(luò)請(qǐng)求去加載該分類下所有的條目。
此外,用戶還可以執(zhí)行 下拉刷新 的操作,并且一旦用戶向下滾動(dòng)到列表末尾,分頁(yè)功能就會(huì)繼續(xù)加載下一頁(yè)的數(shù)據(jù)——當(dāng)然,所有這些行為可以同時(shí)執(zhí)行,并且每個(gè)行為都可能會(huì)收到失?。礇]有互聯(lián)網(wǎng)連接)。
讓我們一步步來,首先,我們先對(duì)View層的接口進(jìn)行實(shí)現(xiàn):
public interface HomeView {
/**
* 加載第一頁(yè)數(shù)據(jù)的intent
*
* @return 發(fā)射的數(shù)據(jù)是沒有意義的,true或者false沒有區(qū)別
*/
public Observable<Boolean> loadFirstPageIntent();
/**
* 分頁(yè)加載下一頁(yè)的intent
*
* @return 發(fā)射的數(shù)據(jù)是沒有意義的,true或者false沒有區(qū)別
*/
public Observable<Boolean> loadNextPageIntent();
/**
* 對(duì)下拉刷新的響應(yīng)intent
*
* @return 發(fā)射的數(shù)據(jù)是沒有意義的,true或者false沒有區(qū)別
*/
public Observable<Boolean> pullToRefreshIntent();
/**
* 根據(jù)當(dāng)前分類加載所有條目的intent
*
* @return 指定分類,String代表分類的名字
*/
public Observable<String> loadAllProductsFromCategoryIntent();
/**
* 對(duì)ViewState進(jìn)行渲染
*/
public void render(HomeViewState viewState);
}
View層具體的實(shí)現(xiàn)簡(jiǎn)單明了,本文將不進(jìn)行展示(但你可以在Github上找到它)。
接下來讓我們把目光轉(zhuǎn)向Model,正如前文所提到的,Model應(yīng)該反應(yīng)了狀態(tài),現(xiàn)在我來介紹一下Model的具體實(shí)現(xiàn):HomeViewState。
public final class HomeViewState {
private final boolean loadingFirstPage; // RecyclerView加載狀態(tài)的指示器
private final Throwable firstPageError; // 如果非空,展示一個(gè)error
private final List<FeedItem> data; // 列表的數(shù)據(jù)
private final boolean loadingNextPage; // RecyclerView分頁(yè)加載狀態(tài)的指示器
private final Throwable nextPageError; // 如果非空,展示分頁(yè)error的toast
private final boolean loadingPullToRefresh; // 展示下拉刷新狀態(tài)的指示器
private final Throwable pullToRefreshError; // 非空意味著下拉刷新的error
// ... 構(gòu)造器 ...
// ... getter方法 ...
}
請(qǐng)注意,FeedItem 僅僅是一個(gè)接口,每個(gè)條目都需要實(shí)現(xiàn)該接口,然后交給RecyclerView去展示。比如 Product 實(shí)現(xiàn)了 FeedItem;此外,列表中的類別標(biāo)題 SectionHeader 也實(shí)現(xiàn)了 FeedItem;還有,作為UI中的元素之一,表示 “可以加載該類別更多” 的指示器同樣也是 FeedItem,其內(nèi)部還持有了一個(gè)小狀態(tài)——該狀態(tài)代表了當(dāng)前是否 正在加載更多條目 。
public class AdditionalItemsLoadable implements FeedItem {
private final int moreItemsAvailableCount;
private final String categoryName;
private final boolean loading; // true 代表item正處于加載狀態(tài)
private final Throwable loadingError; // 標(biāo)志loading時(shí)捕獲到了error
// ... 構(gòu)造器 ...
// ... getter方法 ...
這之后便是壓軸的業(yè)務(wù)邏輯組件 HomeFeedLoader ,它負(fù)責(zé)對(duì) FeedItems 進(jìn)行加載:
public class HomeFeedLoader {
// 通常由 下拉刷新 動(dòng)作觸發(fā)
public Observable<List<FeedItem>> loadNewestPage() { ... }
// 加載第一頁(yè)
public Observable<List<FeedItem>> loadFirstPage() { ... }
// 加載下一頁(yè)
public Observable<List<FeedItem>> loadNextPage() { ... }
// 加載某個(gè)分類的其它產(chǎn)品
public Observable<List<Product>> loadProductsOfCategory(String categoryName) { ... }
}
現(xiàn)在,讓我們一步步將這些點(diǎn)在Presenter中進(jìn)行連接。請(qǐng)注意,接下來Presenter中展示的部分代碼,在真實(shí)的開發(fā)中,應(yīng)該被轉(zhuǎn)移到Interactor(交互器)中(這并非是為了更好的可讀性)。首先,我們先開始對(duì)初始化數(shù)據(jù)進(jìn)行加載:
class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {
private final HomeFeedLoader feedLoader;
@Override protected void bindIntents() {
// 在真實(shí)的開發(fā)中,應(yīng)該被轉(zhuǎn)移到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);
}
}
到目前為止感覺良好,和上一章節(jié)我們實(shí)現(xiàn)的Search界面相比,沒有什么太大的不同。
現(xiàn)在我們嘗試添加對(duì) 下拉刷新 的支持:、
class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {
private final HomeFeedLoader feedLoader;
@Override protected void bindIntents() {
// 在真實(shí)的開發(fā)中,應(yīng)該被轉(zhuǎn)移到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(loadFirstPage, pullToRefresh);
subscribeViewState(allIntents, HomeView::render);
}
}
稍微等一下:feedLoader.loadNewestPage() 僅僅返回了新的條目數(shù)據(jù),但是之前我們已經(jīng)加載了的條目怎么辦?
“傳統(tǒng)”的MVP模式中,我們可以調(diào)用類似view.addNewItems(newItems)的方法,但是在 第一篇文章 中,我們已經(jīng)探討了為什么這不是一個(gè)好主意(狀態(tài)問題)。
我們當(dāng)前面臨的問題是,下拉刷新依賴了之前的狀態(tài),因?yàn)槲覀兿胍獙⑾吕⑿路祷氐臈l目和之前已經(jīng)加載的條目進(jìn)行 合并。
女士們,先生們,現(xiàn)在,讓我們熱情地歡迎狀態(tài)折疊器(State Reducer)的到來!
[圖片上傳失敗...(image-672708-1552483601199)]
State Reducer是函數(shù)式編程中的一個(gè)概念,它 將前一個(gè)狀態(tài)作為輸入,并根據(jù)前一個(gè)狀態(tài)計(jì)算得出一個(gè)新的狀態(tài),就像這樣:
public State reduce( State previous, Foo foo ){
State newState;
// ... 根據(jù)前一個(gè)狀態(tài)計(jì)算得出一個(gè)新的狀態(tài) ...
return newState;
}
因此上述問題的解決方案是,我們定義一個(gè)Foo組件,通過其類似reduce()的函數(shù),結(jié)合之前的狀態(tài)計(jì)算出一個(gè)新的狀態(tài)。
這個(gè)名為Foo的組件通常意味著我們希望對(duì)之前狀態(tài)所進(jìn)行的改變,在我們的案例中,我們希望將 最初通過loadFirstPageIntent計(jì)算得到的HomeViewState 和 下拉刷新得到的結(jié)果 進(jìn)行reduce。
你猜怎么著,RxJava有一個(gè)名為 scan() 的操作符,讓我們對(duì)我們的代碼進(jìn)行略微的重構(gòu),我們需要引入另外一個(gè)表示 部分改變 的類—— 上面我們將其稱之為Foo,它將用于計(jì)算新的狀態(tài)。
class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {
private final HomeFeedLoader feedLoader;
@Override protected void bindIntents() {
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(items)
.startWith(new PartialState.PullToRefreshLoading(true)))
.onErrorReturn(error -> new PartialState.PullToRefreshError(error)));
Observable<PartialState> allIntents = Observable.merge(loadFirstPage, pullToRefresh);
// 展示第一頁(yè)數(shù)據(jù)加載中...
HomeViewState initialState = ... ;
Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)
subscribeViewState(stateObservable, HomeView::render);
}
private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
...
}
}
我們?cè)谶@里做了什么?相比較直接返回Observable<HomeViewState>,現(xiàn)在每個(gè)Intent返回的是Observable<PartialState>。這之后我們通過merge()操作符將其全部合并為一個(gè)可觀察的流中,并最終應(yīng)用到了reducer的函數(shù)中(即Observable.scan())。
這意味著,無論何時(shí)用戶發(fā)起了一個(gè)intent,這個(gè)intent將會(huì)生產(chǎn)一個(gè)PartialState的實(shí)例,然后被reduced得到了HomeViewState,最終,被View層進(jìn)行展示(HomeView.render(HomeViewState))。
唯一遺漏的部分應(yīng)該就是state reducer的函數(shù)本身了,如上文中的定義一樣,HomeViewState類本身并未發(fā)生了改變,但是我們通過Builder模式添加了一個(gè)Builder,這樣我們就可以非常便捷地創(chuàng)建一個(gè)新的HomeViewState實(shí)例。
現(xiàn)在讓我們開始實(shí)現(xiàn)state reducer的函數(shù):
private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
if (changes instanceof PartialState.FirstPageLoading)
return previousState.toBuilder() // 根據(jù)當(dāng)前狀態(tài)復(fù)制一個(gè)內(nèi)部同樣狀態(tài)的對(duì)象
.firstPageLoading(true) // 展示progressBar
.firstPageError(null) // 不展示error
.build()
if (changes instanceof PartialState.FirstPageError)
return previousState.builder()
.firstPageLoading(false) // 隱藏progressBar
.firstPageError(((PartialState.FirstPageError) changes).getError()) // 展示error
.build();
if (changes instanceof PartialState.FirstPageLoaded)
return previousState.builder()
.firstPageLoading(false)
.firstPageError(null)
.data(((PartialState.FirstPageLoaded) changes).getData())
.build();
if (changes instanceof PartialState.PullToRefreshLoading)
return previousState.builder()
.pullToRefreshLoading(true) // 展示下拉刷新的UI指示器
.nextPageError(null)
.build();
if (changes instanceof PartialState.PullToRefreshError)
return previousState.builder()
.pullToRefreshLoading(false) // 隱藏下拉刷新的UI指示器
.pullToRefreshError(((PartialState.PullToRefreshError) changes).getError())
.build();
if (changes instanceof PartialState.PullToRefreshData) {
List<FeedItem> data = new ArrayList<>();
data.addAll(((PullToRefreshData) changes).getData()); // 將新的數(shù)據(jù)插入到當(dāng)前列表的頂部
data.addAll(previousState.getData());
return previousState.builder()
.pullToRefreshLoading(false)
.pullToRefreshError(null)
.data(data)
.build();
}
throw new IllegalStateException("Don't know how to reduce the partial state " + changes);
}
我知道,這些代碼看起來并不優(yōu)雅,但這不是本文的重點(diǎn)——為什么博主會(huì)在他的文章中展示如此 “丑陋” 的代碼?
因?yàn)槲蚁M軌蜿U述一個(gè)觀點(diǎn),我認(rèn)為 讀者并不應(yīng)該為源碼中錯(cuò)綜復(fù)雜的邏輯買單 ,比如,我們的購(gòu)物車App中,也不需要讀者對(duì)某些設(shè)計(jì)模式有額外的知識(shí)儲(chǔ)備。
因此,我認(rèn)為博客文章中最好避免出現(xiàn)設(shè)計(jì)模式,這的確會(huì)展示出更好的代碼,但其本身就意味著 更高的閱讀理解成本。
回顧本文,其重點(diǎn)是對(duì)State Reducer進(jìn)行配置,通過上述的代碼,大家都能夠更快更準(zhǔn)確地去了解它是什么。但你會(huì)在實(shí)際開發(fā)中這樣編寫代碼嗎?當(dāng)然不會(huì),我會(huì)去使用設(shè)計(jì)模式或者其它的解決方案,比如使用 public HomeViewState computeNewState(previousState) 之類的方法將PartialState定義為接口。
好吧,我想你已經(jīng)了解了State Reducer是如何工作的,讓我們實(shí)現(xiàn)剩下來的功能:分頁(yè)以及能夠加載某個(gè)指定分類更多的Item:
class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {
private final HomeFeedLoader feedLoader;
@Override protected void bindIntents() {
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);
// 展示第一頁(yè)正在加載
HomeViewState initialState = ... ;
Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)
subscribeViewState(stateObservable, HomeView::render);
}
private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
// ... 第一頁(yè)的部分狀態(tài)處理和下拉刷新 ...
if (changes instanceof PartialState.NextPageLoading) {
return previousState.builder().nextPageLoading(true).nextPageError(null).build();
}
if (changes instanceof PartialState.NexPageLoadingError)
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());
// 將新的數(shù)據(jù)添加到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() // 創(chuàng)建所有item的副本
.loading(true).error(null).build();
List<FeedItem> data = new ArrayList<>();
data.addAll(previousState.getData());
data.set(indexLoadMoreItem, itemsThatIndicatesError); // 這將會(huì)展示一個(gè)loading的指示器
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); // 這將會(huì)展示一個(gè)error和重試的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); // 移除指定分類下的所有item
// 添加指定分類下的所有item (包括之前已經(jīng)被移除的)
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);
}
}
實(shí)現(xiàn)分頁(yè)加載和下拉刷新十分相似,異同之處僅僅在于前者是把加載到的數(shù)據(jù)添加在列表末尾,而下拉刷新則是把數(shù)據(jù)展示在界面頂部。
更有趣的是我們?nèi)绾吾槍?duì)某個(gè)類別去加載更多條目:為了展示某個(gè)類別的加載指示器和錯(cuò)誤/重試的按鈕,我們只需在所有的FeedItems列表中找到對(duì)應(yīng)的AdditionalItemsLoadable對(duì)象,然后我們將其改變?yōu)檎故炯虞d指示器或者錯(cuò)誤/重試的按鈕。
如果我們已成功加載某個(gè)類別的所有條目,我們將搜索SectionHeader和AdditionalItemsLoadable,并用新加載的列表替換這里的所有條目,僅此而已。
結(jié)語
本文的目的是向您展示 狀態(tài)折疊器(State Reducer) 如何幫助我們通過 簡(jiǎn)潔且易讀 的代碼構(gòu)建復(fù)雜的頁(yè)面?,F(xiàn)在回過頭來思考,“傳統(tǒng)”的MVP或者MVVM針對(duì)這些功能,在不使用State Reducer的前提下是如何實(shí)現(xiàn)這些功能的。
顯然,能夠使用State Reducer的關(guān)鍵是我們有一個(gè)反映狀態(tài)的Model類,這也印證了該系列的第一篇文章中所闡述的,為什么理解 Model 是那么的重要。
此外,只有當(dāng)我們確定狀態(tài)(或準(zhǔn)確的Model)來自單一的數(shù)據(jù)源時(shí),才能使用State Reducer,因此單向數(shù)據(jù)流同樣非常重要。
我希望我們花費(fèi)在 閱讀 并 理解 前兩篇博客的時(shí)間是有意義的,現(xiàn)在,所有的點(diǎn)都成功的連在了一起,是時(shí)候歡呼了。
如果還沒有,不用擔(dān)心,對(duì)此我也花了相當(dāng)長(zhǎng)的時(shí)間才完全理解——還有很多次練習(xí)、錯(cuò)誤和重試。
在第二篇博客中,針對(duì)搜索界面,我們并未使用State Reducer。這是因?yàn)槿绻覀円阅撤N方式依賴于先前的狀態(tài),State Reducer是有意義的。而在“搜索界面”中,我們不依賴于先前的狀態(tài)。
雖然在最后,但是我還是想重申,也許你還沒有注意到,那就是我們的data都是不可變的——我們總是創(chuàng)建HomeViewState新的實(shí)例,而不是在已有的對(duì)象上調(diào)用其setter方法,這也使得多線程不再是問題。
用戶可以在加載下一頁(yè)的同時(shí)開始下拉刷新并加載某個(gè)類別的更多條目,因?yàn)?code>State Reducer總是能夠產(chǎn)生正確的狀態(tài),卻不依賴于http響應(yīng)的任何特定順序。另外,我們用純函數(shù)編寫了代碼,沒有任何副作用。這使我們的代碼非常具有可測(cè)試性、可重現(xiàn)性、易于推演和高度可并行化(即多線程)。
當(dāng)然,State Reducer并非是MVI發(fā)明的,您可以在多種編程語言的許多三方庫(kù),框架和系統(tǒng)中找到其概念。它完全符合Model-View-Intent的理念,具有單向的數(shù)據(jù)流和表示狀態(tài)的Model。
在下一個(gè)部分中,我們將聚焦于如何通過MVI 構(gòu)建 可復(fù)用 和 響應(yīng)式 的UI組件,敬請(qǐng)關(guān)注。
系列目錄
《使用MVI打造響應(yīng)式APP》原文
《使用MVI打造響應(yīng)式APP》譯文
- [譯]使用MVI打造響應(yīng)式APP(一):Model到底是什么
- [譯]使用MVI打造響應(yīng)式APP[二]:View層和Intent層
- [譯]使用MVI打造響應(yīng)式APP[三]:狀態(tài)折疊器
- [譯]使用MVI打造響應(yīng)式APP[四]:獨(dú)立性UI組件
- [譯]使用MVI打造響應(yīng)式APP[五]:輕而易舉地Debug
- [譯]使用MVI打造響應(yīng)式APP[六]:恢復(fù)狀態(tài)
- [譯]使用MVI打造響應(yīng)式APP[七]:掌握時(shí)機(jī)(SingleLiveEvent問題)
- [譯]使用MVI打造響應(yīng)式APP[八]:導(dǎo)航
《使用MVI打造響應(yīng)式APP》實(shí)戰(zhàn)
關(guān)于我
Hello,我是卻把清梅嗅,如果您覺得文章對(duì)您有價(jià)值,歡迎 ??,也歡迎關(guān)注我的博客或者Github。
如果您覺得文章還差了那么點(diǎn)東西,也請(qǐng)通過關(guān)注督促我寫出更好的文章——萬一哪天我進(jìn)步了呢?