[譯]使用MVI打造響應式APP(一):Model到底是什么

原文:《REACTIVE APPS WITH MODEL-VIEW-INTENT - PART1 - MODEL》
作者:Hannes Dorfmann
譯者:卻把清梅嗅

有朝一日,我突然發(fā)現(xiàn)我對于Model層的定義 全部是錯誤的,更新了認知后,我發(fā)現(xiàn)曾經(jīng)我在Android平臺上主題討論中的那些困惑或者頭痛都消失了。

從結(jié)果上來說,最終我選擇使用 RxJavaModel-View-Intent(MVI) 構(gòu)建 響應式的APP,這是我從未有過的嘗試——盡管在這之前我開發(fā)的APP也是響應式的,但 響應式編程 的體現(xiàn)與這次實踐相比,完全無法相提并論,在接下來我將要講述的一系列文章中,你也會感受到這些。但作為系列文章的開始,我想先闡述一個觀點:

所謂的Model層到底是什么,我之前對Model層的定義出現(xiàn)了什么問題?

我為什么說 我對Model層有著錯誤的理解和使用方式 呢?當然,現(xiàn)在有很多架構(gòu)模式將View層和Model層進行了分離,至少在Android開發(fā)的領(lǐng)域,最著名的當屬Model-View-Controller (MVC)、Model-View-Presenter (MVP)Model-View-ViewModel (MVVM)——你注意到了嗎?這些架構(gòu)模式中,Model都是不可或缺的一環(huán),但我意識到 在絕大數(shù)情況下,我根本沒有Model。

舉例來說,一個簡單的從后端拉取Person列表情況下,傳統(tǒng)的MVP實現(xiàn)方式應該是這樣的:

class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().showLoading(true); // 展示一個 ProgressBar

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
        getView().showPersons(persons); // 展示用戶列表
      }

      public void onError(Throwable error){
        getView().showError(error); // 展示錯誤信息
      }
    });
  }
}

但是,這段代碼中的Model到底是指什么呢?是指后臺的網(wǎng)絡請求嗎?不,那只是業(yè)務邏輯。是指請求結(jié)果的用戶列表嗎?不,它和ProgressBar、錯誤信息的展示一樣,僅僅只代表了View層所能展示內(nèi)容的一小部分而已。

那么,Model層究竟是指什么呢?

從我個人理解來說,Model類應該定義成這樣:

class PersonsModel {
  // 在真實的項目中,需要定義為私有的
  // 并且我們需要通過getter和setter來訪問它們
  final boolean loading;
  final List<Person> persons;
  final Throwable error;

  public(boolean loading, List<Person> persons, Throwable error){
    this.loading = loading;
    this.persons = persons;
    this.error = error;
  }
}

這樣的實現(xiàn),Presenter層應該這樣實現(xiàn):

class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().render( new PersonsModel(true, null, null) ); // 展示一個 ProgressBar

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
        getView().render( new PersonsModel(false, persons, null) );  // 展示用戶列表
      }

      public void onError(Throwable error){
          getView().render( new PersonsModel(false, null, error) ); // 展示錯誤信息
      }
    });
  }
}

現(xiàn)在,View層持有了一個Model,并且能夠借助它對屏幕上的控件進行rendered(渲染)。這并非什么新鮮的概念,Trygve Reenskaug在1979年時,其對最初版本的MVC定義中具有相似的概念:View觀察Model的變化。

然而,MVC這個術(shù)語被用來描述太多種不同的模式,這些模式與Reenskaug在1979年制定的模式并不完全相同。比如后端開發(fā)人員使用MVC框架,iOS有ViewController,到了Android領(lǐng)域MVC又被如何定義了呢?ActivityController嗎? 那這樣的話ClickListener又算什么呢?如今,MVC這個術(shù)語變成了一個很大的誤區(qū),它錯誤地理解和使用了Reenskaug最初制定的內(nèi)容——這個話題到此為止,再繼續(xù)下去整個文章就會失控了。

言歸正傳,Model的持有將會解決許多我們在Android開發(fā)中經(jīng)常遇到的問題:

  • 1.狀態(tài)問題
  • 2.屏幕方向的改變
  • 3.在頁面堆棧中導航
  • 4.進程終止
  • 5.單向數(shù)據(jù)流的不變性
  • 6.可調(diào)試和可重現(xiàn)的狀態(tài)
  • 7.可測試性

要討論這些關(guān)鍵的問題,我們先來看看“傳統(tǒng)”的MVPMVVM的實現(xiàn)代碼中如何處理它們,然后再談Model如何跳過這些常見的陷阱。

1.狀態(tài)問題

響應式App,這是最近非常流行的話題,不是嗎?所謂的 響應式App 就是 應用會根據(jù)狀態(tài)的改變作出UI的響應,這句話里有一個非常好的單詞:狀態(tài)。什么是狀態(tài)呢?大多數(shù)時間里,我們將 狀態(tài) 描述為我們在屏幕中看到的東西,例如當界面展示ProgressBar時的loading state。

很關(guān)鍵的一點是,我們前端開發(fā)人員傾向?qū)W⒂赨I。這不一定是壞事,因為一個好的UI體驗決定了用戶是否會用你的產(chǎn)品,從而決定了產(chǎn)品能否獲得成功。但是看看上述的MVP示例代碼(不是使用了PersonModel的那個例子),這里UI的狀態(tài)由Presenter進行協(xié)調(diào),Presenter負責告訴View層如何進行展示。

MVVM亦然,我想在本文中對MVVM的兩種實現(xiàn)方式進行區(qū)分:第一種依賴DataBinding庫,第二種則依賴RxJava;對于依賴DataBinding的前者,其狀態(tài)被直接定義于ViewModel中:

class PersonsViewModel {
  ObservableBoolean loading;
  // 省略...

  public void load(){

    loading.set(true);

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
      loading.set(false);
      // 省略其它代碼,比如對persons進行渲染
      }

      public void onError(Throwable error){
        loading.set(false);
        // 省略其它代碼,比如展示錯誤信息
      }
    });
  }
}

使用RxJava實現(xiàn)MVVM的方式中,其并不依賴DataBinding引擎,而是將Observable和UI的控件進行綁定,例如:

class RxPersonsViewModel {
  private PublishSubject<Boolean> loading;
  private PublishSubject<List<Person> persons;
  private PublishSubject loadPersonsCommand;

  public RxPersonsViewModel(){
    loadPersonsCommand.flatMap(ignored -> backend.loadPersons())
      .doOnSubscribe(ignored -> loading.onNext(true))
      .doOnTerminate(ignored -> loading.onNext(false))
      .subscribe(persons)
      // 實現(xiàn)方式并不惟一
  }

  // 在View層訂閱它 (比如 Activity / Fragment)
  public Observable<Boolean> loading(){
    return loading;
  }

  // 在View層訂閱它 (比如 Activity / Fragment)
  public Observable<List<Person>> persons(){
    return persons;
  }

  // 每當觸發(fā)此操作 (即調(diào)用 onNext()) ,加載Persons數(shù)據(jù)
  public PublishSubject loadPersonsCommand(){
    return loadPersonsCommand;
  }
}

當然,這些代碼并非完美,您的實現(xiàn)方式可能截然不同;我想說明的是,通常在MVP或者MVVM中,狀態(tài) 是由ViewModel或者Presenter進行驅(qū)動的。

這導致下述情況的發(fā)生:

  • 1.業(yè)務邏輯本身也擁有了狀態(tài),Presenter(或者ViewModel)本身也擁有了狀態(tài)(并且,你還需要通過代碼去同步它們的狀態(tài)使其保持一致),同時,View可能也有自己的狀態(tài)(比方說,調(diào)用ViewsetVisibility()方法設置其可見性,或者Android系統(tǒng)在重新創(chuàng)建時從bundle恢復狀態(tài))。

  • 2.Presenter(或ViewModel)有任意多個輸入(View層觸發(fā)行為并交給Presenter處理),這是ok的,但同時Presenter也有很多輸出(或MVP中的輸出通道,如view.showLoading()view.showError();在MVVM中,ViewModel的實現(xiàn)中也提供了多個Observable,這最終導致了View層,Presenter層和業(yè)務邏輯中狀態(tài)的沖突,在處理多線程的時候,這種情況更明顯。

在好的情況下,這只會導致視覺上的錯誤,例如同時顯示加載指示符(“加載狀態(tài)”)和錯誤指示符(“錯誤狀態(tài)”),如下所示:

在最糟糕的情況下,您從崩潰報告工具(如Crashlytics)接收到了一個嚴重的錯誤報告,但您無法重現(xiàn)這個錯誤,因此也幾乎無從著手去修復它。

如果從 底層 (業(yè)務邏輯層)到 頂層 (UI視圖層),有且僅有一個真實描述狀態(tài)的源,會怎么樣呢?事實上,我們已經(jīng)在文章的開頭談論Model的時候,就已經(jīng)通過案例,把相似的概念展示了出來:

class PersonsModel {
  final boolean loading;
  final List<Person> persons;
  final Throwable error;

  public(boolean loading, List<Person> persons, Throwable error){
    this.loading = loading;
    this.persons = persons;
    this.error = error;
  }
}

你猜怎么了? Model映射了狀態(tài),當我想通了這點,許多狀態(tài)相關(guān)的問題迎刃而解(甚至在編碼之前就已經(jīng)被避免了);現(xiàn)在Presenter層變得只有一個輸出了:

getView().render(PersonsModel)

它對應了一個數(shù)學上簡單的函數(shù),比如f(x) = y,對于多個輸入的函數(shù),對應的則是f(a,b,c),但也是一個輸出。

并非對所有人來說數(shù)學都是香茗,就好像數(shù)學家并不清楚bug是什么——但軟件工程師需要去品嘗它。

了解Model到底是什么以及如何建立對應的Model非常重要,因為最終Model可以解決 狀態(tài)問題

2.屏幕方向的改變

譯者注:針對 屏幕旋轉(zhuǎn)后的狀態(tài)回溯 這個問題,已經(jīng)可以通過Google官方發(fā)布的ViewModel組件進行處理,開發(fā)者不再需要為此煩惱,但本章節(jié)仍值得一讀。

Android設備上的 屏幕旋轉(zhuǎn) 是一個有足夠挑戰(zhàn)性的問題;忽視它是一個最簡單的解決方案,即 每次屏幕旋轉(zhuǎn),都對數(shù)據(jù)重新進行加載 。這確實行之有效,大多數(shù)情況下,您的APP也在離線狀態(tài)下工作,其數(shù)據(jù)來源于數(shù)據(jù)庫或者其它本地緩存,這意味著屏幕旋轉(zhuǎn)后的數(shù)據(jù)加載速度是很快的。

但是,個人而言我不喜歡看到加載框,哪怕加載速度是毫秒級別的,因為我認為這并非完美的用戶體驗,因此大家(包括我)開始使用MVP,這其中包括了 保留性的Presenter——這樣就可以 在屏幕旋轉(zhuǎn)時分離和銷毀View層,而Presenter則會保存在內(nèi)存中不會被銷毀,然后View層會再次連接到Presenter

使用RxJavaMVVM也可以實現(xiàn)相同的概念,但請牢記,一旦ViewViewModel取消了訂閱,可觀察的流就會被銷毀,這個問題你可以用Subject解決;對于DataBinding構(gòu)建的MVVM來講,ViewModelDataBinding直接綁定到View層,為了避免內(nèi)存泄露,需要我們在屏幕旋轉(zhuǎn)時及時銷毀ViewModel。

對于 保留性的Presenter 或者 ViewModel 的問題是: 我們?nèi)绾螌?code>View的狀態(tài)在屏幕旋轉(zhuǎn)之后回溯,保證ViewPresenter再次回到之前相同的狀態(tài)?我編寫了一個名為 Mosby 的MVP庫,其包含一個名為ViewState的功能,它基本上將業(yè)務邏輯的狀態(tài)與View同步。 Moxy,另一個MVP庫,提出了一個非常有趣的解決方案——通過使用commands在屏幕方向更改后重現(xiàn)View的狀態(tài):

針對View層狀態(tài)的問題,我很確定還有其他的解決方案。讓我們退后一步,歸納一下這些庫試圖解決的問題:那就是我們已經(jīng)討論過的 狀態(tài)問題。

再次重申,我們通過一個 能反映當前狀態(tài)的Model 和一個渲染Model的方法 解決了這個問題,就像調(diào)用getView().render(PersonsModel)一樣簡單。

3.在頁面堆棧中導航

View不再使用時,是否還有保留Presenter(或ViewModel)的必要?比如,用戶跳轉(zhuǎn)到了另外一個界面,這導致Fragment(View)被另外的Fragmentreplace了,因此Presenter已經(jīng)不在被任何View持有。

如果沒有View層和Presenter進行關(guān)聯(lián),Presenter自然也無法根據(jù)業(yè)務邏輯,將最新的數(shù)據(jù)反映在View上。但如果用戶又回來了怎么辦(比如按下后退按鈕),是 重新加載數(shù)據(jù) 還是 重用現(xiàn)有的Presenter?——這看起來像是一個哲學問題。

通常用戶一旦回到之前的界面,他會期望回到之前的界面繼續(xù)操作。這仍然像是第二小節(jié)關(guān)于View狀態(tài)恢復 的問題,解決方案簡明扼要:當用戶返回時,我們得到 代表狀態(tài)的Model ,然后只需調(diào)用 getView().render(PersonsModel)View層進行渲染。

4.進程終止

進程終止是一件壞事,并且我們需要依賴一些庫以幫助我們在進程終止后對狀態(tài)進行恢復——我認為這是Android開發(fā)中常見的一種誤解。

首先,進程終止的原因只有一個,并且有足夠充分的理由——Android操作系統(tǒng)需要更多資源用于其他應用程序或節(jié)省電池。如果你的APP處于前臺并且正在被用戶主動使用時,這種情況永遠不會發(fā)生,因此,遵紀守法,不要與平臺作斗爭了(就是不要執(zhí)拗于所謂的進程保活了)。如果你真的需要在后臺進行一些長時間的工作,請使用Service,這也是向操作系統(tǒng)發(fā)出信號,告知您的App仍處于“主動使用狀態(tài)”的 唯一方式 。

如果進程終止了,Android會提供一些回調(diào)以供 保存狀態(tài),比如onSaveInstanceState()——沒錯,又是 狀態(tài) 。我們應該將View的信息保存在Bundle中嗎?我們是否也應該把Presenter中的狀態(tài)保存到Bundle中?那么業(yè)務邏輯的狀態(tài)呢?又是老生常談的問題,就和上面三個小節(jié)談到的一樣。

我們只需要一個代表整個狀態(tài)的Model類,我們很容易將Model保存在Bundle中并在之后對它進行恢復。但是,我個人認為大部分情況下最好不保存狀態(tài),而是 重新加載整個界面,就像我們第一次啟動App一樣。 想想顯示新聞列表的 NewsReader App。 當App被殺掉,我們保存了狀態(tài),6小時后用戶重新打開App并恢復了狀態(tài),我們的App可能會顯示過時的內(nèi)容。因此,這種情況下,也許不存儲Model和狀態(tài)、而對數(shù)據(jù)重新加載才是更好的策略。

5.單向數(shù)據(jù)流的不變性

在這里我不打算討論不變性(immutabiliy)的優(yōu)勢,因為有很多資源討論這個問題。我們想要一個不可變的Model(代表狀態(tài))。為什么?因為我們想要唯一的狀態(tài)源,在傳遞Model時,我們不希望App中的其他組件可以改變我們的Model或者State。

讓我們假設編寫一個簡單的計數(shù)器App,它具有遞增和遞減的功能按鈕,并在TextView中顯示當前計數(shù)器值。 如果我們的Model(在這種情況下只是計數(shù)器值,即一個整數(shù))是不可變的,那么我們?nèi)绾胃挠嫈?shù)器?

我很高興被問到這個問題,按鈕被點擊時,我們并非直接操作TextView。我的建議是:

  • 1.我們的View層應該有一個類似view.render(...)的方法;
  • 2.我們的Model是不可變的,因此不可直接修改Model;
  • 3.View的渲染有且只有一個來源:即業(yè)務邏輯。

我們將點擊事件 下沉 到業(yè)務邏輯層。業(yè)務邏輯知道當前的Model(例如,持有一個私有的成員Model,它代表著當前的狀態(tài)), 這之后根據(jù)舊的Model,創(chuàng)建一個新的帶有增量/減量值的Model。

image

這樣我們建立了一個 單向數(shù)據(jù)流,業(yè)務邏輯作為單一源用于創(chuàng)建不可變的Model實例,但對于一個計數(shù)器來講未免有點小題大做,不是嗎?誠然,是的,計數(shù)器只是一個簡單的應用程序。大多數(shù)應用程序都是以簡單的應用程序開始,但復雜性增長很快——從我的角度來看,單向數(shù)據(jù)流和不可變模型是必要的,這會使簡單的應用程序,在復雜性遞增的同時,依然保持著簡單(對開發(fā)者而言)。

6.可調(diào)試和可重現(xiàn)的狀態(tài)

此外,單向數(shù)據(jù)流保證了我們的應用程序易于調(diào)試。下次我們從Crashlytics獲得崩潰報告時,我們可以輕松地重現(xiàn)并修復此崩潰,因為所有必需的信息都已附加到崩潰報告中了。

什么叫做必需的信息?那就是當前的Model和用戶用戶在崩潰發(fā)生時想要執(zhí)行的操作(比如,點擊減量按鈕)。這就是我們重現(xiàn)這次崩潰所需的全部信息,這些信息非常容易收集并附加在崩潰報告中。

如果沒有單項數(shù)據(jù)流(比如,對EventBus的濫用,或者將CounterModels的私有域暴露出來),或者沒有不變性(這會導致我們不知道誰實際更改了Model),那么bug的復現(xiàn)就沒那么容易了。

7.可測試性

“傳統(tǒng)”的MVPMVVM提高了應用程序的可測試性。MVC也是可測試的:沒有人說我們必須將所有業(yè)務邏輯放入Activity中。使用表示狀態(tài)的Model,我們可以簡化單元測試的代碼,因為我們可以簡單地檢查assertEqual(expectedModel,model)。這使我們避免了許多必須要Mock的對象。

此外,這也減少了很多驗證的測試,即某些方法是否被調(diào)用(比如Mockito.verify(view, times(1)).showFoo()),最終,這使得我們的單元測試代碼更具可讀性,易于理解并且易于維護,因為我們不必處理很多實際代碼的實現(xiàn)細節(jié)。

總結(jié)

在這個博客文章系列的第一部分中,我們談了很多關(guān)于理論的東西。我們真的需要關(guān)于專門討論Model的博客嗎?

我認為初步地理解Model的確很重要,這也有助于我們避免一些會遇到的問題。Model并不意味著業(yè)務邏輯,它是生成Model的業(yè)務邏輯(比如,一次交互,一個用例,一個倉庫或者你在APP中調(diào)用的任何東西)。

在接下來的第二部分中,當我們最終使用Model-View-Intent構(gòu)建一個響應式App 時,我們將看到Model的實際應用。演示的APP是一個虛構(gòu)的在線商店的應用程序,敬請關(guān)注。


系列目錄

《使用MVI打造響應式APP》原文

《使用MVI打造響應式APP》譯文

《使用MVI打造響應式APP》實戰(zhàn)


關(guān)于我

Hello,我是卻把清梅嗅,如果您覺得文章對您有價值,歡迎 ??,也歡迎關(guān)注我的博客或者Github

如果您覺得文章還差了那么點東西,也請通過關(guān)注督促我寫出更好的文章——萬一哪天我進步了呢?

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,030評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,310評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,951評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,796評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,566評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,055評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,142評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,303評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,799評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,683評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,899評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,409評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,135評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,520評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,757評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,528評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,844評論 2 372