原文:REACTIVE APPS WITH MODEL-VIEW-INTENT - PART7 - TIMING (SINGLELIVEEVENT PROBLEM)
作者:Hannes Dorfmann
譯者:卻把清梅嗅
在之前的文章中,我們探討了正確狀態(tài)管理的重要性,以及我為什么認(rèn)為使用類(lèi)似 Github上Google架構(gòu)組件的這個(gè)repo 中的 SingleLiveEvent 并不是一個(gè)好主意——這種解決方案只是隱藏了真正的潛在問(wèn)題,那就是狀態(tài)管理。本文我將會(huì)闡述SingleLiveEvent聲稱(chēng)能解決的問(wèn)題,在Model-View-Intent中如何通過(guò)狀態(tài)管理正確地解決。
譯者注:關(guān)于
SingleLiveEvent的這個(gè)issue 從17年討論到19年至今還未close,各方大佬(還有g(shù)oogle的巨佬)針對(duì)SingleLiveEvent進(jìn)行了激烈的討論,堪稱(chēng)Android論壇的一場(chǎng)神仙大戰(zhàn),非常值得一看。
當(dāng)error發(fā)生時(shí),Snackbar 將被展示—— 這是一個(gè)常見(jiàn)的場(chǎng)景可以用來(lái)描述這個(gè)問(wèn)題。Snackbar并非一直展示,當(dāng)錯(cuò)誤信息被展示幾秒鐘之后它將消失,問(wèn)題在于,我們?nèi)绾文M這種錯(cuò)誤狀態(tài)并控制Snackbar的消失呢?
通過(guò)下面的視頻,你就能了解我在說(shuō)什么:
這個(gè)示例顯示了如何從 CountriesRepository 中加載國(guó)家的列表,當(dāng)我們點(diǎn)擊某個(gè)國(guó)家的條目時(shí),程序?qū)?huì)跳轉(zhuǎn)到第二個(gè)界面去展示“詳情”(僅僅是國(guó)家的名稱(chēng))。當(dāng)我們返回到國(guó)家列表的界面時(shí),我們希望和之前點(diǎn)擊國(guó)家條目時(shí)的狀態(tài)一致。
目前為止一切正常,但是當(dāng)我們執(zhí)行下拉刷新操作時(shí),一個(gè)異常出現(xiàn)了,并通過(guò)在屏幕中展示一個(gè)Snackbar來(lái)展示錯(cuò)誤信息。正如您在視頻中看到的,當(dāng)我們?cè)俅螐膰?guó)家詳情返回國(guó)家列表時(shí),Snackbar和相關(guān)的錯(cuò)誤信息再次展示了出來(lái),這和用戶(hù)預(yù)期的并不一致,不是嗎?
問(wèn)題的根源是展示了錯(cuò)誤的狀態(tài)。Google基于ViewModel和LiveData的架構(gòu)組件示例中使用了 SingleLiveEvent 來(lái)解決這個(gè)問(wèn)題。其解決思路是:當(dāng)View重新訂閱了其ViewModel(當(dāng)從“國(guó)家詳情”界面返回時(shí)),SingleLiveEvent確保錯(cuò)誤的狀態(tài)不會(huì)再次被發(fā)射,這確實(shí)預(yù)防了SnakeBar的再次顯示,但是這真的解決問(wèn)題了嗎?
時(shí)機(jī)就是一切(也適用于SnakeBar)
重申,我依然認(rèn)為這種解決方案并不恰當(dāng),我們能做的更好嗎?我認(rèn)為合理的運(yùn)用 狀態(tài)管理 和 單向的數(shù)據(jù)流 是更好的答案,而Model-View-Intent架構(gòu)模式遵循了這些規(guī)則。因此在MVI中我們?nèi)绾谓鉀QSnakeBar的問(wèn)題呢?首先,我們對(duì)Model狀態(tài)進(jìn)行定義:
public class CountriesViewState {
// true意味著Progress將被展示
boolean loading;
// 被加載的國(guó)家列表
List<String> countries;
// true意味著`SwipeRefreshLayout`將被展示
boolean pullToRefresh;
// true意味著下拉刷新出現(xiàn)了error,SnakeBar將被展示
boolean pullToRefreshError;
}
MVI的思想是View層同時(shí)只會(huì)展示一個(gè)不可變的CountriesViewState,因此當(dāng)pullToRefreshError為true時(shí)SnakeBar將被展示,反之則會(huì)消失。
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 {
// 展示國(guó)家列表
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();
}
}
關(guān)鍵點(diǎn)在于 Snackbar.LENGTH_INDEFINITE,這意味著Snackbar將會(huì)一直顯示直到我們主動(dòng)控制其消失——我們不需要讓Android系統(tǒng)控制它顯示與否。
我們不會(huì)讓Android系統(tǒng)將狀態(tài)搞亂,也不會(huì)讓系統(tǒng)為UI引入一個(gè)與業(yè)務(wù)邏輯狀態(tài)不一致的狀態(tài)。我們寧愿讓業(yè)務(wù)邏輯將CountriesViewState.pullToRefreshError設(shè)置為true兩秒鐘,然后將其設(shè)置為false,而不愿使用Snackbar.LENGTH_SHORT來(lái)顯示Snackbar兩秒鐘。
在RxJava中我們?cè)趺醋瞿??我們可以使?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) {
// 展示snakebar2秒中,然后dismiss它
return Observable.timer(2, TimeUnit.SECONDS)
.map(ignoredTime -> new ShowCountries()) // 僅僅展示列表
.startWith(repoState); // repoState == PullToRefreshError
} else {
return Observable.just(repoState);
}
}));
// 作為初始狀態(tài),展示加載中
CountriesViewState initialState = CountriesViewState.showLoadingState();
Observable<CountriesViewState> viewState = Observable.merge(loadingData, pullToRefreshData)
.scan(initialState, (oldState, repoState) -> repoState.reduce(oldState))
subscribeViewState(viewState, CountriesView::render);
}
CountriesRepositroy 的 reload() 方法返回了一個(gè) Observable<RepoState>。RepoState(前文中叫做PattialViewState) 僅僅是個(gè)POJO類(lèi),用來(lái)表示repository是否取到數(shù)據(jù),是成功的取到數(shù)據(jù),或者產(chǎn)生了錯(cuò)誤(點(diǎn)擊查看源碼)。
這之后,我們使用狀態(tài)折疊器去計(jì)算View的狀態(tài)(通過(guò)scan()操作符),如果您已閱讀我之前的MVI系列文章,那么這應(yīng)該此曾相識(shí), “新”的東西是:
repositroy.reload().switchMap(repoState -> {
if (repoState instanceof PullToRefreshError) {
// 展示snakebar2秒中,然后dismiss它
return Observable.timer(2, TimeUnit.SECONDS)
.map(ignoredTime -> new ShowCountries()) // 僅僅展示列表
.startWith(repoState); // repoState == PullToRefreshError
} else {
return Observable.just(repoState);
}
這段代碼執(zhí)行以下操作:如果我們得到了一個(gè)error(repoState instanceof PullToRefreshError),我們會(huì)發(fā)射一個(gè)錯(cuò)誤的狀態(tài)(PullToRefreshError),這使得狀態(tài)折疊器設(shè)置 CountriesViewState.pullToRefreshError = true。2秒后,Observable.timer()將發(fā)射ShowCountries狀態(tài),狀態(tài)折疊器會(huì)設(shè)置 CountriesViewState.pullToRefreshError = false。
OK, 現(xiàn)在你可以看到MVI中我們?nèi)绾物@示和隱藏SnakeBar:
請(qǐng)記住這并非像是SingleLiveEvent這樣的解決方案。這是正確的狀態(tài)管理,View只是顯示或“渲染”給定的狀態(tài)。因此用戶(hù)如果再次從“國(guó)家詳情”中返回,Snackbar不再會(huì)被展示,因?yàn)闋顟B(tài)在 CountriesViewState.pullToRefreshError = false 時(shí)已經(jīng)發(fā)生了改變。
用戶(hù)消除Snakebar
如果我們希望用戶(hù)能夠通過(guò)滑動(dòng)操作主動(dòng)消除Snakebar呢。這非常簡(jiǎn)單,消除Snakebar 本身也是改變狀態(tài)的一種intent,要將它添加到目前的代碼中,我們只需要確保定時(shí)器或者滑動(dòng)的意圖能夠設(shè)置CountriesViewState.pullToRefreshError = false。
我們唯一需要處理的是,如果在計(jì)時(shí)結(jié)束之前出發(fā)了滑動(dòng)解除的intent,我們必須結(jié)束定時(shí)器的計(jì)時(shí)行為,這聽(tīng)起來(lái)很復(fù)雜,但得益于RxJava的優(yōu)秀api和操作符,這輕而易舉:
Observable<Long> dismissPullToRefreshErrorIntent = intent(CountriesView::dismissPullToRefreshErrorIntent)
...
repositroy.reload().switchMap(repoState -> {
if (repoState instanceof PullToRefreshError) {
// 展示Snakebar,并在2秒后dismiss它
return Observable.timer(2, TimeUnit.SECONDS)
.mergeWith(dismissPullToRefreshErrorIntent) // 合并計(jì)時(shí)器和滑動(dòng)dismiss的intent
.take(1) // 二者只會(huì)觸發(fā)其中一個(gè)
.map(ignoredTime -> new ShowCountries()) // 展示列表
.startWith(repoState); // repoState == PullToRefreshError
} else {
return Observable.just(repoState);
}
使用mergeWith(),我們將計(jì)時(shí)器和滑動(dòng)消失的intent組合成一個(gè)observable,然后使用take(1)僅將它們中的第一個(gè)事件進(jìn)行發(fā)射。如果在計(jì)時(shí)器計(jì)時(shí)結(jié)束之前滑動(dòng)Snakebar,則取消計(jì)時(shí)器,反之則取消滑動(dòng)消失的intent。
結(jié)語(yǔ)
現(xiàn)在讓我們來(lái)嘗試將UI搞亂,我們嘗試下拉刷新、并在計(jì)時(shí)過(guò)程中手動(dòng)取消Snakebar:
如你所見(jiàn),無(wú)論我們?nèi)绾螄L試,都沒(méi)有問(wèn)題發(fā)生,由于 單向數(shù)據(jù)流 和 業(yè)務(wù)邏輯驅(qū)動(dòng)的狀態(tài),View可以正確顯示UI小部件(View層是無(wú)狀態(tài)的,它從底層獲取狀態(tài)并只能對(duì)它進(jìn)行展示)。比如,我們從未看到下拉刷新指示器和Snakebar同時(shí)顯示(除了Snackbar退出過(guò)程中,兩者的疊加情況)。
當(dāng)然,Snackbar這個(gè)示例非常簡(jiǎn)單,但我認(rèn)為它證明了像Model-View-Intent這樣 嚴(yán)格規(guī)范下對(duì)狀態(tài)進(jìn)行管理 的架構(gòu)模式的強(qiáng)大。不難想象這種模式對(duì)于更復(fù)雜的屏幕和使用場(chǎng)景同樣也會(huì)非常棒。
本文的示例源碼你可以從這里獲取。
系列目錄
《使用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問(wèn)題)
- [譯]使用MVI打造響應(yīng)式APP[八]:導(dǎo)航
《使用MVI打造響應(yīng)式APP》實(shí)戰(zhàn)
關(guān)于我
Hello,我是卻把清梅嗅,如果您覺(jué)得文章對(duì)您有價(jià)值,歡迎 ??,也歡迎關(guān)注我的博客或者Github。
如果您覺(jué)得文章還差了那么點(diǎn)東西,也請(qǐng)通過(guò)關(guān)注督促我寫(xiě)出更好的文章——萬(wàn)一哪天我進(jìn)步了呢?