使用MVI構建響應式 APP — 第七部分 — TIMING (SINGLELIVEEVENT 問題)
在我前面系列部落格中, 我們討論了正確的狀態管理的重要性,並且也闡述了為什麼我認為一個像在谷歌架構元件的 github 中討論的 SingleLiveEvent 不是一個好的主意。因為,它僅僅隱藏了真正底部的問題:狀態管理。在這篇部落格中,我想去討論,SingleLiveEvent 聲稱能解決的問題,使用 Model-View-Intent 和正確的狀態管理是如何解決的。
這個問題可以用一個常見的場景來舉例說明:當一個錯誤發生的時候彈出一個snackbar。SnackBar 不會一直保持在一個位置,一兩秒後它就會消失。這個問題是我們如何用 model 來控制錯誤狀態和讓其消失?
讓我們看下下面的的視訊,這樣可以讓你們更好的理解,我在說什麼:
這個簡單的 app 顯示了一個國家的列表,這些國家的資料是通過 CountriesRepository 載入的。如果,我們點選一個國家,我們開啟了第二個 Activity ,這個 Activity 會顯示一些「細節」(國家的名字)。當我們返回到國家列表,我們期待看到與點選前相同「狀態」顯示到螢幕上。到目前為止一切都很正常,但是如果,我觸發下拉重新整理時,在資料載入的時候出現了錯誤,這個錯誤會讓 Snackbar 顯示在螢幕上,用來提示錯誤資訊,會發生什麼? 正如你在上面視訊中看到的那樣,無論何時我們回到國家列表,這個 SnackBar 都會再次顯示。但是,這肯定不是使用者所期待的,對吧?
這個問題發生在這個螢幕處在「顯示錯誤」的狀態。谷歌的架構元件的例子是基於 ViewModel 和 LiveData 用一個 SingleLiveEvent 去解決這個問題。使用的方法是:無論何時 view 被它的 ViewModel 重新訂閱(在從「細節」頁面返回之後),SingleLiveEvent 確保「錯誤狀態」不會被重新觸發。這防止了 Snackbar 的復現,它真正解決問題了麼?
時機就是一切(對於 Snackbar 來說)
再次強調一下,我仍然認為這種解決方法是不正確的方法。我們可以做的更好麼?我認為正確狀態管理和單向的資料流是更好的解決方法。Model-View-Intent 是一個架構元件並且遵循一定的原則。因此,我們在 MVI 中,如何解決上面的「Snackbar 問題」,首先,讓我們定義 state:
public class CountriesViewState {
// True if progressbar should be displayed
boolean loading;
// List of countries (country names) if loaded
List<String> countries;
// true if pull to refresh indicator should be displayed
boolean pullToRefresh;
// true if an error has occurred while pull to refresh -> Show Snackbar.
boolean pullToRefreshError;
}
複製程式碼
在 MVI 中的解決思路是 View 層得到一個(不變的)CountriesViewState,然後,僅僅顯示這個狀態。因此,如果,pullToRefreshError 是 true,那麼顯示 Snackbar,其他情況不顯示。
public class CountriesActivity extends MviActivity<CountriesView, CountriesPresenter>
implements CountriesView {
private Snackbar snackbar;
private ArrayAdapter<String> adapter;
@BindView(R.id.refreshLayout) SwipeRefreshLayout refreshLayout;
@BindView(R.id.listView) ListView listView;
@BindView(R.id.progressBar) ProgressBar progressBar;
...
@Override public void render(CountriesViewState viewState) {
if (viewState.isLoading()) {
progressBar.setVisibility(View.VISIBLE);
refreshLayout.setVisibility(View.GONE);
} else {
// show countries
progressBar.setVisibility(View.GONE);
refreshLayout.setVisibility(View.VISIBLE);
adapter.setCountries(viewState.getCountries());
refreshLayout.setRefreshing(viewState.isPullToRefresh());
if (viewState.isPullToRefreshError()) {
showSnackbar();
} else {
dismissSnackbar();
}
}
}
private void dismissSnackbar() {
if (snackbar != null)
snackbar.dismiss();
}
private void showSnackbar() {
snackbar = Snackbar.make(refreshLayout, "An Error has occurred", Snackbar.LENGTH_INDEFINITE);
snackbar.show();
}
}
複製程式碼
這裡的重點是 Snackbar.Length_INDEFINITE 這就意味著 Snackbar 會一直存在,直到我們 dismiss 它。因此,我們不讓 android 系統來控制 SnackBar 的顯示和隱藏。此外,我們不能讓 android 系統擾亂狀態,也不讓它引入一個不同於業務邏輯的 UI 狀態。取而代之,用 Snackbar.LENGTH_SHORT 來使 Snackbar 顯示兩秒,我們寧願讓業務邏輯使 CountriesViewState.pullToRefreshError 設定為 true 兩秒鐘,然後,將再它置為 false。
我們如何使用 RxJava 來做到這一點咧?我們可以用 Observable.timer() 和 startWith() 操作符。
public class CountriesPresenter extends MviBasePresenter<CountriesView, CountriesViewState> {
private final CountriesRepositroy repositroy = new CountriesRepositroy();
@Override protected void bindIntents() {
Observable<RepositoryState> loadingData =
intent(CountriesView::loadCountriesIntent).switchMap(ignored -> repositroy.loadCountries());
Observable<RepositoryState> pullToRefreshData =
intent(CountriesView::pullToRefreshIntent).switchMap(
ignored -> repositroy.reload().switchMap(repoState -> {
if (repoState instanceof PullToRefreshError) {
// Let's show Snackbar for 2 seconds and then dismiss it
return Observable.timer(2, TimeUnit.SECONDS)
.map(ignoredTime -> new ShowCountries()) // Show just the list
.startWith(repoState); // repoState == PullToRefreshError
} else {
return Observable.just(repoState);
}
}));
// 初始狀態顯示 Loading
CountriesViewState initialState = CountriesViewState.showLoadingState();
Observable<CountriesViewState> viewState = Observable.merge(loadingData, pullToRefreshData)
.scan(initialState, (oldState, repoState) -> repoState.reduce(oldState))
subscribeViewState(viewState, CountriesView::render);
}
}
複製程式碼
CountriesRepositroy 有一個 reload() 方法,這個方法返回一個 Observable< RepoState>。RepoState(在這個系列的前面幾篇文章中叫做 PattialViewState) 僅僅是個 POJO 類,用來表示 repository 是否取到資料,是成功的取到資料,或者產生了錯誤(原始碼)。然後,我們使用狀態摺疊器去完成我們 View 的狀態(scan() 操作符)。如果你讀過 MVI 前面的文章,那麼你應當很熟悉狀態摺疊器。新的東西是:
repositroy.reload().switchMap(repoState -> {
if (repoState instanceof PullToRefreshError) {
//讓 Snackbar 顯示兩秒然後讓其消失
return Observable.timer(2, TimeUnit.SECONDS)
.map(ignoredTime -> new ShowCountries()) // Show just the list
.startWith(repoState); // repoState == PullToRefreshError
} else {
return Observable.just(repoState);
}
複製程式碼
這一小段程式碼做了下面這些事:如果我們的程式跑錯了(repoState instanceof PullToRefreshError),然後,我們觸發了這個錯誤的狀態(PullToRefreshError),這將造成狀態摺疊器去設定 CountriesViewState.pullToRefreshError =true。兩秒過後 Observable.timer() 觸發了 ShowCountries 狀態,這將造成狀態摺疊器設定CountriesViewState.pullToRefreshError = false。
bingo~這就是我們在 MVI 中如何顯示和隱藏 Snackbar。
請注意,這和 SingleLiveEvent 解決方法不一樣。這是一種正確的狀態管理,並且 view 僅僅顯示或「渲染」給定的狀態。因此,一旦我們的 APP 從詳情頁返回到國家列表。他再也不會看到 Snackbar 了,因為,狀態已經同時發生了改變,變成了CountriesViewState.pullToRefreshError = false 因此,Snackbar 不會再次顯示。
使用者撤銷 Snackbar
如果,我們想要允許使用者通過輕掃手勢撤銷 Snackbar。這非常簡單。撤銷 Snackbar 也是一種改變狀態的意圖。要想在原有的程式碼中新增這種功能,我們僅僅需要確保,無論計時器或者輕掃滑動去撤銷CountriesViewState.pullToRefreshError = false 的意圖設定。你僅僅需要記住的唯一一件事情是,在你輕輕滑動之前,你的計時器已經被取消掉了。這聽起來很複雜,但是,實現起來很簡單,這要感謝 RxJava 偉大的操作符和 API:
Observable<Long> dismissPullToRefreshErrorIntent = intent(CountriesView::dismissPullToRefreshErrorIntent)
...
repositroy.reload().switchMap(repoState -> {
if (repoState instanceof PullToRefreshError) {
//讓 Snackbar 顯示兩秒然後讓其消失
return Observable.timer(2, TimeUnit.SECONDS)
.mergeWith(dismissPullToRefreshErrorIntent) // 合併定時器並解除意圖
.take(1) // 僅僅取先觸發的那個(解除意圖或計時器)
.map(ignoredTime -> new ShowCountries()) // Show just the list
.startWith(repoState); // repoState == PullToRefreshError
} else {
return Observable.just(repoState);
}
複製程式碼
通過使用 mergeWith() 操作符,我們可以將 timer 和撤銷意圖聯合起來到一個可觀察物件,然後,第一個發射 take(1) 。如果輕掃撤銷觸發在 timer 之前,那麼,take(1) 取消 timer,反之亦然:如果 timer 先觸發,則不要觸發退出意圖。.
總結
因此,讓我們嘗試能不能把 UI 搞亂吧。讓我們做下拉重新整理的動作,退出 Snackbar 並且,讓 timer 計時:
正如你在視訊上所看到的,無論我多努力的嘗試,view 都能夠在 UI 上正確顯示,因為,單項資料流和業務邏輯驅使狀態(view 層是無狀態的,view 是從底層得到狀態的,並且,僅僅起到顯示作用)。例如:我們從來我們從來沒有見過載入指示器和 Snackbar 同時顯示(除去 Snackbar 退出過程中,一個小的疊加情況)。
當然,Snackbar 例子十分簡單,但是,我認為它向我們展示了能夠嚴格進行狀態管理像 Model-View-Intent 這類模式的力量。不難想象,這種模式用在複雜的頁面和使用者需求上也會很棒。
dome app 的原始碼已經在 Github 上了。
這篇部落格是 "用 MVI 開發響應式App"中的一篇部落格。下面是內容表:
- Part 1: Model
- Part 2: View and Intent
- Part 3: State Reducer
- Part 4: Independent UI Components
- Part 5: Debugging with ease
- Part 6: Restoring State
- Part 7: Timing (SingleLiveEvent problem)
這是中文翻譯:
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。