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

卻把清梅嗅發表於2019-03-03

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

有朝一日,我突然發現我對於Model層的定義 全部是錯誤的,更新了認知後,我發現曾經我在Android平臺上主題討論中的那些困惑或者頭痛都消失了。

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

所謂的Model層到底是什麼,我之前對Model層的定義出現了什麼問題?

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

舉例來說,一個簡單的從後端拉取Person列表情況下,傳統的MVP實現方式應該是這樣的:

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到底是指什麼呢?是指後臺的網路請求嗎?不,那只是業務邏輯。是指請求結果的使用者列表嗎?不,它和ProgressBar、錯誤資訊的展示一樣,僅僅只代表了View層所能展示內容的一小部分而已。

那麼,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;
  }
}
複製程式碼

這樣的實現,Presenter層應該這樣實現:

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) ); // 展示錯誤資訊
      }
    });
  }
}
複製程式碼

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

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

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

  • 1.狀態問題
  • 2.螢幕方向的改變
  • 3.在頁面堆疊中導航
  • 4.程式終止
  • 5.單向資料流的不變性
  • 6.可除錯和可重現的狀態
  • 7.可測試性

要討論這些關鍵的問題,我們先來看看“傳統”的MVPMVVM的實現程式碼中如何處理它們,然後再談Model如何跳過這些常見的陷阱。

1.狀態問題

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

很關鍵的一點是,我們前端開發人員傾向專注於UI。這不一定是壞事,因為一個好的UI體驗決定了使用者是否會用你的產品,從而決定了產品能否獲得成功。但是看看上述的MVP示例程式碼(不是使用了PersonModel的那個例子),這裡UI的狀態由Presenter進行協調,Presenter負責告訴View層如何進行展示。

MVVM亦然,我想在本文中對MVVM的兩種實現方式進行區分:第一種依賴DataBinding庫,第二種則依賴RxJava;對於依賴DataBinding的前者,其狀態被直接定義於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實現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)
      // 實現方式並不惟一
  }

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

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

  // 每當觸發此操作 (即呼叫 onNext()) ,載入Persons資料
  public PublishSubject loadPersonsCommand(){
    return loadPersonsCommand;
  }
}
複製程式碼

當然,這些程式碼並非完美,您的實現方式可能截然不同;我想說明的是,通常在MVP或者MVVM中,狀態 是由ViewModel或者Presenter進行驅動的。

這導致下述情況的發生:

  • 1.業務邏輯本身也擁有了狀態,Presenter(或者ViewModel)本身也擁有了狀態(並且,你還需要通過程式碼去同步它們的狀態使其保持一致),同時,View可能也有自己的狀態(比方說,呼叫ViewsetVisibility()方法設定其可見性,或者Android系統在重新建立時從bundle恢復狀態)。

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

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

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

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

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

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對映了狀態,當我想通了這點,許多狀態相關的問題迎刃而解(甚至在編碼之前就已經被避免了);現在Presenter層變得只有一個輸出了:

getView().render(PersonsModel)

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

並非對所有人來說數學都是香茗,就好像數學家並不清楚bug是什麼——但軟體工程師需要去品嚐它。

瞭解Model到底是什麼以及如何建立對應的Model非常重要,因為最終Model可以解決 狀態問題

2.螢幕方向的改變

譯者注:針對 螢幕旋轉後的狀態回溯 這個問題,已經可以通過Google官方釋出的ViewModel元件進行處理,開發者不再需要為此煩惱,但本章節仍值得一讀。

Android裝置上的 螢幕旋轉 是一個有足夠挑戰性的問題;忽視它是一個最簡單的解決方案,即 每次螢幕旋轉,都對資料重新進行載入 。這確實行之有效,大多數情況下,您的APP也在離線狀態下工作,其資料來源於資料庫或者其它本地快取,這意味著螢幕旋轉後的資料載入速度是很快的。

但是,個人而言我不喜歡看到載入框,哪怕載入速度是毫秒級別的,因為我認為這並非完美的使用者體驗,因此大家(包括我)開始使用MVP,這其中包括了 保留性的Presenter——這樣就可以 在螢幕旋轉時分離和銷燬View層,而Presenter則會儲存在記憶體中不會被銷燬,然後View層會再次連線到Presenter

使用RxJavaMVVM也可以實現相同的概念,但請牢記,一旦ViewViewModel取消了訂閱,可觀察的流就會被銷燬,這個問題你可以用Subject解決;對於DataBinding構建的MVVM來講,ViewModelDataBinding直接繫結到View層,為了避免記憶體洩露,需要我們在螢幕旋轉時及時銷燬ViewModel

對於 保留性的Presenter 或者 ViewModel 的問題是: 我們如何將View的狀態在螢幕旋轉之後回溯,保證ViewPresenter再次回到之前相同的狀態?我編寫了一個名為 Mosby 的MVP庫,其包含一個名為ViewState的功能,它基本上將業務邏輯的狀態與View同步。 Moxy,另一個MVP庫,提出了一個非常有趣的解決方案——通過使用commands在螢幕方向更改後重現View的狀態:

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

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

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

3.在頁面堆疊中導航

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

如果沒有View層和Presenter進行關聯,Presenter自然也無法根據業務邏輯,將最新的資料反映在View上。但如果使用者又回來了怎麼辦(比如按下後退按鈕),是 重新載入資料 還是 重用現有的Presenter?——這看起來像是一個哲學問題。

通常使用者一旦回到之前的介面,他會期望回到之前的介面繼續操作。這仍然像是第二小節關於View狀態恢復 的問題,解決方案簡明扼要:當使用者返回時,我們得到 代表狀態的Model ,然後只需呼叫 getView().render(PersonsModel)View層進行渲染。

4.程式終止

程式終止是一件壞事,並且我們需要依賴一些庫以幫助我們在程式終止後對狀態進行恢復——我認為這是Android開發中常見的一種誤解。

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

如果程式終止了,Android會提供一些回撥以供 儲存狀態,比如onSaveInstanceState()——沒錯,又是 狀態 。我們應該將View的資訊儲存在Bundle中嗎?我們是否也應該把Presenter中的狀態儲存到Bundle中?那麼業務邏輯的狀態呢?又是老生常談的問題,就和上面三個小節談到的一樣。

我們只需要一個代表整個狀態的Model類,我們很容易將Model儲存在Bundle中並在之後對它進行恢復。但是,我個人認為大部分情況下最好不儲存狀態,而是 重新載入整個介面,就像我們第一次啟動App一樣。 想想顯示新聞列表的 NewsReader App。 當App被殺掉,我們儲存了狀態,6小時後使用者重新開啟App並恢復了狀態,我們的App可能會顯示過時的內容。因此,這種情況下,也許不儲存Model和狀態、而對資料重新載入才是更好的策略。

5.單向資料流的不變性

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

讓我們假設編寫一個簡單的計數器App,它具有遞增和遞減的功能按鈕,並在TextView中顯示當前計數器值。 如果我們的Model(在這種情況下只是計數器值,即一個整數)是不可變的,那麼我們如何更改計數器?

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

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

我們將點選事件 下沉 到業務邏輯層。業務邏輯知道當前的Model(例如,持有一個私有的成員Model,它代表著當前的狀態), 這之後根據舊的Model,建立一個新的帶有增量/減量值的Model

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

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

6.可除錯和可重現的狀態

此外,單向資料流保證了我們的應用程式易於除錯。下次我們從Crashlytics獲得崩潰報告時,我們可以輕鬆地重現並修復此崩潰,因為所有必需的資訊都已附加到崩潰報告中了。

什麼叫做必需的資訊?那就是當前的Model和使用者使用者在崩潰發生時想要執行的操作(比如,點選減量按鈕)。這就是我們重現這次崩潰所需的全部資訊,這些資訊非常容易收集並附加在崩潰報告中。

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

7.可測試性

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

此外,這也減少了很多驗證的測試,即某些方法是否被呼叫(比如Mockito.verify(view, times(1)).showFoo()),最終,這使得我們的單元測試程式碼更具可讀性,易於理解並且易於維護,因為我們不必處理很多實際程式碼的實現細節。

總結

在這個部落格文章系列的第一部分中,我們談了很多關於理論的東西。我們真的需要關於專門討論Model的部落格嗎?

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

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

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


系列目錄

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

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

《使用MVI打造響應式APP》實戰


關於我

Hello,我是卻把清梅嗅,如果您覺得文章對您有價值,歡迎 ❤️,也歡迎關注我的部落格或者Github

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

相關文章