原文:REACTIVE APPS WITH MODEL-VIEW-INTENT - PART3 - STATE REDUCER
作者:Hannes Dorfmann
譯者:卻把清梅嗅
在上一章節中,我們針對 如何使用單向流和 Model-View-Intent 模式構建一個簡單的頁面 進行了探討;本章節,我們將在reducer
的幫助下實現MVI
模式中更加複雜的頁面。
如果你還未閱讀前兩個章節,閱讀本文之前您應該先去閱讀它們,從而對如下兩個問題的答案有初步的瞭解:
- 1.我們如何通過
Presenter
將View
層和業務邏輯相關聯? - 2.資料流是如何保證單向性的?
如下圖所示,現在我們構建這樣一個複雜的頁面:
如你所見,螢幕中顯示的是按照類別進行歸類的商品列表;App
每次只會為每個分類展示3個條目,當使用者點選了 載入更多 按鈕時,將會通過網路請求去載入該分類下所有的條目。
此外,使用者還可以執行 下拉重新整理 的操作,並且一旦使用者向下滾動到列表末尾,分頁功能就會繼續載入下一頁的資料——當然,所有這些行為可以同時執行,並且每個行為都可能會收到失敗(即沒有網際網路連線)。
讓我們一步步來,首先,我們先對View
層的介面進行實現:
public interface HomeView {
/**
* 載入第一頁資料的intent
*
* @return 發射的資料是沒有意義的,true或者false沒有區別
*/
public Observable<Boolean> loadFirstPageIntent();
/**
* 分頁載入下一頁的intent
*
* @return 發射的資料是沒有意義的,true或者false沒有區別
*/
public Observable<Boolean> loadNextPageIntent();
/**
* 對下拉重新整理的響應intent
*
* @return 發射的資料是沒有意義的,true或者false沒有區別
*/
public Observable<Boolean> pullToRefreshIntent();
/**
* 根據當前分類載入所有條目的intent
*
* @return 指定分類,String代表分類的名字
*/
public Observable<String> loadAllProductsFromCategoryIntent();
/**
* 對ViewState進行渲染
*/
public void render(HomeViewState viewState);
}
複製程式碼
View
層具體的實現簡單明瞭,本文將不進行展示(但你可以在Github上找到它)。
接下來讓我們把目光轉向Model
,正如前文所提到的,Model
應該反應了狀態,現在我來介紹一下Model
的具體實現:HomeViewState。
public final class HomeViewState {
private final boolean loadingFirstPage; // RecyclerView載入狀態的指示器
private final Throwable firstPageError; // 如果非空,展示一個error
private final List<FeedItem> data; // 列表的資料
private final boolean loadingNextPage; // RecyclerView分頁載入狀態的指示器
private final Throwable nextPageError; // 如果非空,展示分頁error的toast
private final boolean loadingPullToRefresh; // 展示下拉重新整理狀態的指示器
private final Throwable pullToRefreshError; // 非空意味著下拉重新整理的error
// ... 構造器 ...
// ... getter方法 ...
}
複製程式碼
請注意,FeedItem 僅僅是一個介面,每個條目都需要實現該介面,然後交給RecyclerView
去展示。比如 Product 實現了 FeedItem;此外,列表中的類別標題 SectionHeader 也實現了 FeedItem;還有,作為UI中的元素之一,表示 “可以載入該類別更多” 的指示器同樣也是 FeedItem,其內部還持有了一個小狀態——該狀態代表了當前是否 正在載入更多條目 。
public class AdditionalItemsLoadable implements FeedItem {
private final int moreItemsAvailableCount;
private final String categoryName;
private final boolean loading; // true 代表item正處於載入狀態
private final Throwable loadingError; // 標誌loading時捕獲到了error
// ... 構造器 ...
// ... getter方法 ...
複製程式碼
這之後便是壓軸的業務邏輯元件 HomeFeedLoader ,它負責對 FeedItems 進行載入:
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
(互動器)中(這並非是為了更好的可讀性)。首先,我們先開始對初始化資料進行載入:
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);
}
}
複製程式碼
到目前為止感覺良好,和上一章節我們實現的Search
介面相比,沒有什麼太大的不同。
現在我們嘗試新增對 下拉重新整理 的支援:
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(loadFirstPage, pullToRefresh);
subscribeViewState(allIntents, HomeView::render);
}
}
複製程式碼
稍微等一下:feedLoader.loadNewestPage()
僅僅返回了新的條目資料,但是之前我們已經載入了的條目怎麼辦?
“傳統”的MVP
模式中,我們可以呼叫類似view.addNewItems(newItems)
的方法,但是在 第一篇文章 中,我們已經探討了為什麼這不是一個好主意(狀態問題)。
我們當前面臨的問題是,下拉重新整理依賴了之前的狀態,因為我們想要將下拉重新整理返回的條目和之前已經載入的條目進行 合併。
女士們,先生們,現在,讓我們熱情地歡迎狀態摺疊器(State Reducer)的到來!
State Reducer
是函數語言程式設計中的一個概念,它 將前一個狀態作為輸入,並根據前一個狀態計算得出一個新的狀態,就像這樣:
public State reduce( State previous, Foo foo ){
State newState;
// ... 根據前一個狀態計算得出一個新的狀態 ...
return newState;
}
複製程式碼
因此上述問題的解決方案是,我們定義一個Foo
元件,通過其類似reduce()
的函式,結合之前的狀態計算出一個新的狀態。
這個名為Foo
的元件通常意味著我們希望對之前狀態所進行的改變,在我們的案例中,我們希望將 最初通過loadFirstPageIntent
計算得到的HomeViewState
和 下拉重新整理得到的結果 進行reduce
。
你猜怎麼著,RxJava
有一個名為 scan()
的操作符,讓我們對我們的程式碼進行略微的重構,我們需要引入另外一個表示 部分改變 的類—— 上面我們將其稱之為Foo
,它將用於計算新的狀態。
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);
// 展示第一頁資料載入中...
HomeViewState initialState = ... ;
Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)
subscribeViewState(stateObservable, HomeView::render);
}
private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
...
}
}
複製程式碼
我們在這裡做了什麼?相比較直接返回Observable<HomeViewState>
,現在每個Intent
返回的是Observable<PartialState>
。這之後我們通過merge()
操作符將其全部合併為一個可觀察的流中,並最終應用到了reducer
的函式中(即Observable.scan()
)。
這意味著,無論何時使用者發起了一個intent
,這個intent
將會生產一個PartialState
的例項,然後被reduced
得到了HomeViewState
,最終,被View
層進行展示(HomeView.render(HomeViewState)
)。
唯一遺漏的部分應該就是state reducer
的函式本身了,如上文中的定義一樣,HomeViewState
類本身並未發生了改變,但是我們通過Builder
模式新增了一個Builder
,這樣我們就可以非常便捷地建立一個新的HomeViewState
例項。
現在讓我們開始實現state reducer
的函式:
private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
if (changes instanceof PartialState.FirstPageLoading)
return previousState.toBuilder() // 根據當前狀態複製一個內部同樣狀態的物件
.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()); // 將新的資料插入到當前列表的頂部
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);
}
複製程式碼
我知道,這些程式碼看起來並不優雅,但這不是本文的重點——為什麼博主會在他的文章中展示如此 “醜陋” 的程式碼?
因為我希望能夠闡述一個觀點,我認為 讀者並不應該為原始碼中錯綜複雜的邏輯買單 ,比如,我們的購物車App
中,也不需要讀者對某些設計模式有額外的知識儲備。
因此,我認為部落格文章中最好避免出現設計模式,這的確會展示出更好的程式碼,但其本身就意味著 更高的閱讀理解成本。
回顧本文,其重點是對State Reducer
進行配置,通過上述的程式碼,大家都能夠更快更準確地去了解它是什麼。但你會在實際開發中這樣編寫程式碼嗎?當然不會,我會去使用設計模式或者其它的解決方案,比如使用 public HomeViewState computeNewState(previousState)
之類的方法將PartialState
定義為介面。
好吧,我想你已經瞭解了State Reducer
是如何工作的,讓我們實現剩下來的功能:分頁以及能夠載入某個指定分類更多的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);
// 展示第一頁正在載入
HomeViewState initialState = ... ;
Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)
subscribeViewState(stateObservable, HomeView::render);
}
private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
// ... 第一頁的部分狀態處理和下拉重新整理 ...
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());
// 將新的資料新增到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() // 建立所有item的副本
.loading(true).error(null).build();
List<FeedItem> data = new ArrayList<>();
data.addAll(previousState.getData());
data.set(indexLoadMoreItem, itemsThatIndicatesError); // 這將會展示一個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); // 這將會展示一個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 (包括之前已經被移除的)
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);
}
}
複製程式碼
實現分頁載入和下拉重新整理十分相似,異同之處僅僅在於前者是把載入到的資料新增在列表末尾,而下拉重新整理則是把資料展示在介面頂部。
更有趣的是我們如何針對某個類別去載入更多條目:為了展示某個類別的載入指示器和錯誤/重試的按鈕,我們只需在所有的FeedItems
列表中找到對應的AdditionalItemsLoadable
物件,然後我們將其改變為展示載入指示器或者錯誤/重試的按鈕。
如果我們已成功載入某個類別的所有條目,我們將搜尋SectionHeader
和AdditionalItemsLoadable
,並用新載入的列表替換這裡的所有條目,僅此而已。
結語
本文的目的是向您展示 狀態摺疊器(State Reducer) 如何幫助我們通過 簡潔且易讀 的程式碼構建複雜的頁面。現在回過頭來思考,“傳統”的MVP
或者MVVM
針對這些功能,在不使用State Reducer
的前提下是如何實現這些功能的。
顯然,能夠使用State Reducer
的關鍵是我們有一個反映狀態的Model
類,這也印證了該系列的第一篇文章中所闡述的,為什麼理解 Model 是那麼的重要。
此外,只有當我們確定狀態(或準確的Model
)來自單一的資料來源時,才能使用State Reducer
,因此單向資料流同樣非常重要。
我希望我們花費在 閱讀 並 理解 前兩篇部落格的時間是有意義的,現在,所有的點都成功的連在了一起,是時候歡呼了。
如果還沒有,不用擔心,對此我也花了相當長的時間才完全理解——還有很多次練習、錯誤和重試。
在第二篇部落格中,針對搜尋介面,我們並未使用State Reducer
。這是因為如果我們以某種方式依賴於先前的狀態,State Reducer
是有意義的。而在“搜尋介面”中,我們不依賴於先前的狀態。
雖然在最後,但是我還是想重申,也許你還沒有注意到,那就是我們的data
都是不可變的——我們總是建立HomeViewState
新的例項,而不是在已有的物件上呼叫其setter
方法,這也使得多執行緒不再是問題。
使用者可以在載入下一頁的同時開始下拉重新整理並載入某個類別的更多條目,因為State Reducer
總是能夠產生正確的狀態,卻不依賴於http響應的任何特定順序。另外,我們用純函式編寫了程式碼,沒有任何副作用。這使我們的程式碼非常具有可測試性、可重現性、易於推演和高度可並行化(即多執行緒)。
當然,State Reducer
並非是MVI
發明的,您可以在多種程式語言的許多三方庫,框架和系統中找到其概念。它完全符合Model-View-Intent
的理念,具有單向的資料流和表示狀態的Model
。
在下一個部分中,我們將聚焦於如何通過MVI
構建 可複用 和 響應式 的UI元件,敬請關注。
系列目錄
《使用MVI打造響應式APP》原文
《使用MVI打造響應式APP》譯文
《使用MVI打造響應式APP》實戰
關於我
Hello,我是卻把清梅嗅,如果您覺得文章對您有價值,歡迎 ❤️,也歡迎關注我的部落格或者Github。
如果您覺得文章還差了那麼點東西,也請通過關注督促我寫出更好的文章——萬一哪天我進步了呢?