原文:REACTIVE APPS WITH MODEL-VIEW-INTENT - PART5 - DEBUGGING WITH EASE
作者:Hannes Dorfmann
譯者:卻把清梅嗅
前文我們探討了Model-View-Intent (MVI)架構(gòu)模式及其相關(guān)特性,在 第一篇文章 中,我們談到了 單項數(shù)據(jù)流的重要性 和 應(yīng)用狀態(tài)應(yīng)該被業(yè)務(wù)邏輯驅(qū)動。本文我們將展示這種架構(gòu)模式會怎樣回報開發(fā)者,它可以讓開發(fā)者在開發(fā)過程中更輕而易舉進行debug。
遇到過這樣的情況嘛?你得到了一個崩潰的報告,但是你無法復現(xiàn)這個BUG。聽起來似曾相識?我也是!在花了很多時間查看堆棧跟蹤和項目的源碼后,最終我選擇了放棄——關(guān)閉了這個issue,并提交了一個類似 無法復現(xiàn) 或者 某個Android生產(chǎn)商的某種特定的機型導致的特殊錯誤 的備注。
以我們的購物App舉例來說,在Home界面,用戶以某種方式進行下拉刷新,但不知道為什么,崩潰報告告訴我,當用戶執(zhí)行下拉刷新獲取最新數(shù)據(jù)的操作時,應(yīng)用拋出了一個NullPointerException。
因此,作為開發(fā)人員,您啟動App并嘗試在Home界面進行下拉刷新,但App并沒有崩潰, 它按照預(yù)期正常地運行。然后您開始仔細檢查自己的代碼,但是就是找不到哪里會導致NullPointerException的發(fā)生。你打開了debug模式,一行一行逐步執(zhí)行該界面相關(guān)的代碼,但App仍然正常的運行—— 到底怎么樣才能讓它在下拉刷新時崩潰?
問題的根本在于你不能在App崩潰發(fā)生之前復現(xiàn)狀態(tài),如果遇到崩潰的用戶可以在崩潰報告中提供他App的狀態(tài)(在崩潰發(fā)生之前)以及堆棧跟蹤,那不是很棒嗎?
通過 單向數(shù)據(jù)流 和 Model-View-Intent ,這簡直輕而易舉。
在 用戶執(zhí)行所有Intent 和 界面對Model進行渲染時,我們很方便地能夠?qū)⑺鼈冞M行打印,讓我們通過在HomePresenter中添加Log來為Home界面執(zhí)行這樣的操作(具體代碼請參考 第三節(jié),該小節(jié)我們針對狀態(tài)折疊器進行了探討)。
在以下代碼片段中,我們使用Crashlytics(譯者注:一種崩潰報告工具),使用其它的崩潰報告工具也是一樣的:
class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {
private final HomeViewState initialState; // Show loading indicator
public HomePresenter(HomeViewState initialState){
this.initialState = initialState;
}
@Override protected void bindIntents() {
Observable<PartialState> loadFirstPage = intent(HomeView::loadFirstPageIntent)
.doOnNext(intent -> Crashlytics.log("Intent: load first page"))
.flatmap(...); // 加載數(shù)據(jù)的業(yè)務(wù)邏輯
Observable<PartialState> pullToRefresh = intent(HomeView::pullToRefreshIntent)
.doOnNext(intent -> Crashlytics.log("Intent: pull-to-refresh"))
.flatmap(...); // 加載數(shù)據(jù)的業(yè)務(wù)邏輯
Observable<PartialState> nextPage = intent(HomeView::loadNextPageIntent)
.doOnNext(intent -> Crashlytics.log("Intent: load next page"))
.flatmap(...); // 加載數(shù)據(jù)的業(yè)務(wù)邏輯
Observable<PartialState> allIntents = Observable.merge(loadFirstPage, pullToRefresh, nextPage);
Observable<HomeViewState> stateObservable = allIntents
.scan(initialState, this::viewStateReducer) // 對狀態(tài)進行折疊
.doOnNext(newViewState -> Crashlytics.log( "State: "+gson.toJson(newViewState) ));
subscribeViewState(stateObservable, HomeView::render); // 展示新的狀態(tài)
}
private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
...
}
}
通過RxJava的 .doOnNext() 操作符,我們可以很輕松將每個intent和每個intent的result——也就是即將渲染在view層上的狀態(tài)進行打印。
我們將view的狀態(tài)序列化為json字符串,現(xiàn)在,我們的崩潰報告變成了這樣:

現(xiàn)在來看看這些日志,我們不僅能看到崩潰發(fā)生之前的最后一個狀態(tài),而且還能看到用戶達到這個狀態(tài)所經(jīng)歷的完整歷史記錄——為了保證可讀性,我將data字段內(nèi)的內(nèi)容替換為了[...]:
1.用戶啟動了
App,通過加載首頁數(shù)據(jù)的intent,這樣loadingFirstPage的值為true,使得加載指示器展示了出來,同時數(shù)據(jù)也被加載完畢(data[…])。2.接下來用戶滾動列表,并達到了列表的底部,這觸發(fā)了加載下一頁數(shù)據(jù)的
intent,并開始加載更多的數(shù)據(jù)(分頁),這也導致了loadingNextPage狀態(tài)的改變,它的值變成了true。3.一旦分頁數(shù)據(jù)被加載成功,
loadingNextPage狀態(tài)改變成了false,用戶再次重復操作達到了列表的底部,并又一次出發(fā)了觸發(fā)了加載下一頁數(shù)據(jù)的intent。4.接下來用戶開始嘗試下拉刷新的
intent,這導致loadingPullToRefresh狀態(tài)變更為了true,然后,App突然發(fā)生了崩潰—— 這之后就沒有更多日志了。
這些信息如何幫助我們解決這個bug呢?顯然,我們知道用戶觸發(fā)了哪些操作,因此我們完全可以手動復現(xiàn)這個崩潰。此外,因為我們將App的狀態(tài)用json進行表現(xiàn),因此我們可以簡單地使用最后一個狀態(tài),反序列化json并將此狀態(tài)作為我們的初始狀態(tài)來修復該錯誤:
String json =" {\"data\":[...],\"loadingFirstPage\":false,\"loadingNextPage\":false,\"loadingPullToRefresh\":false} ";
HomeViewState stateBeforeCrash = gson.fromJson(json, HomeViewState.class);
HomePresenter homePresenter = new HomePresenter(stateBeforeCrash);
接下來我們打開了Debug調(diào)試工具,并嘗試觸發(fā)下拉刷新的intent,事實證明,如果用戶向下滾動頁面2次,則沒有更多數(shù)據(jù)可用,并且我們的App并沒有進行相應(yīng)的處理,因此后續(xù)的下拉刷新操作導致了崩潰。
結(jié)語
一個應(yīng)用狀態(tài)隨時隨地 可快照 的App可以使我們開發(fā)人員的生活更加輕松。我們不僅能夠輕松的 復現(xiàn)崩潰,而且可以將狀態(tài)進行序列化來 編寫回歸測試,并且這幾乎沒有什么成本。
請記住,這些便利只有在App的狀態(tài)遵循 單項數(shù)據(jù)流 、不可變、純函數(shù) 的原則的情況下才能享受到(即被業(yè)務(wù)邏輯驅(qū)動),Model-View-Intent讓我們偏向了這種思想流派,而這個架構(gòu)模式中有一個非常棒并且有效的額外的效果,那就是本文所提到的構(gòu)建了一個 可快照 的App。
可快照 的應(yīng)用有什么缺陷呢?顯然我們正在將App的狀態(tài)序列化(比如通過Gson).這增加了一些額外的計算資源的負荷,平均來算的話,狀態(tài)第一次被Gson序列化大約需要30毫秒,因為Gson必須使用反射來掃描類,以確定必須序列化的字段。
在Nexus 4上,狀態(tài)的連續(xù)序列化平均需要大約6毫秒。由于序列化在.doOnNext()中運行,雖然這通常在后臺線程上運行,但的確是這樣:我的App用戶必須比其它應(yīng)用的用戶多等待6毫秒,才能在屏幕上看到新的狀態(tài)。
我的觀點是,這對于用戶來說也許并不明顯,但是對狀態(tài)進行 快照 的一個問題是,在崩潰時,崩潰報告工具從用戶設(shè)備上傳到其服務(wù)器的數(shù)據(jù)量要大得多—— 如果用戶通過wifi連接,這無關(guān)痛癢,但如果用戶處于移動網(wǎng)絡(luò)下則可能會有一定的爭議。
最后,將狀態(tài)附加在崩潰報告中時,您可能會泄漏用戶的一些敏感的數(shù)據(jù)。針對這個問題,一個方案是不序列化敏感數(shù)據(jù),但這可能導致連接到崩潰報告的狀態(tài)不完整(因此這些報告可能幾乎無用),另外一個方案則是將敏感數(shù)據(jù)進行加密——但這可能需要一些額外的CPU占用。
總結(jié)一下:我個人認為這樣 可快照 的App有很多優(yōu)點,但是,你可能需要做出一些權(quán)衡。也許您開始為內(nèi)部版本或beta版本啟用App快照,以衡量它其產(chǎn)生的作用。
系列目錄
《使用MVI打造響應(yīng)式APP》原文
《使用MVI打造響應(yīng)式APP》譯文
《使用MVI打造響應(yīng)式APP》實戰(zhàn)
關(guān)于我
Hello,我是卻把清梅嗅,如果您覺得文章對您有價值,歡迎 ??,也歡迎關(guān)注我的博客或者Github。
如果您覺得文章還差了那么點東西,也請通過關(guān)注督促我寫出更好的文章——萬一哪天我進步了呢?