[譯]為什麼使用MVI模式(MVI編寫響應式安卓APP入門系列第一部分MODEL)

pcdack發表於2018-01-08

我曾經有一個瞬間覺的我的Model定義全都是錯的。經過在各種安卓開發論壇也好主題也罷的討論和頭疼的研究。無論如何,最終我選擇使用rxjava和Model-View-Intent(MVI)的方式構建響應式的安卓應用程式,就像這種組合我以前是沒有嘗試過一樣,我建立是十分被動的。當然,你也會,但是,你會比我好很多,因為,我將寫一系列文章來介紹這個模式和用法。在第一節,也就是這篇文章,我們來說說我們的Model出現了什麼問題?

我為什麼說我以前定義的Model全都是錯的咧?誠然,有很多模式將"View"和"Model"分離。在安卓開發領域,最出名的當屬Model-View-Controller(MVC),Model-View_Presenter(MVP)和Model-View-ViewModel(MVVM)。你可以從名字看出什麼東西麼?他們都有Model。但是,我發現大多數時間,我根本沒有用Model。

例子:僅僅是在後臺載入一個persons的列表,一個傳統的MVP模式的程式碼是這樣的:

class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().showLoading(true); // 顯示一個載入進度條

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

      public void onError(Throwable error){
        getView().showError(error); // 顯示錯誤資訊
      }
    });
  }
}
 
複製程式碼

但是到底什麼是"Model"?後臺請求是Model?不是,Model應當是業務邏輯。它是作為結果的列表?不是,它僅僅只做一件事情,就是我們View顯示所需要的東西,像載入指示器或錯誤資訊。因此,真正的Model“長”什麼樣的?

如果按照我對View的理解,那麼,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) ); // 顯示一個載入進度條

    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。這個概念其實不是什麼新概念。最開始的被Trygve Reenskaug在1979年定義的MVC模式的Model的定義幾乎一致:View觀察Model的變化。不幸的是,MVC這個術語被濫用來描述太多不同的模式,它們與最原始的MVC定義有了出入。例如,後端工程師使用MVC框架,iOS工程師有ViewController,在安卓開發中MVC的真正含義是什麼?Activities是Controller?那麼ClickListener意味著什麼?現在MVC與最初被Reenskaug定義的MVC來講,這個術語被誤解,濫用和錯誤使用。關於MVC的討論就此打住,在討論下去文章就要失控翻車了。

讓我們回到我剛開始說的地方。Model需要解決我們在安卓開發中經常遇到的問題:

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

讓我們討論的上面這些點,並研究傳統的MVP和MVVM如何處理這些內容,最後,在探究到底什麼樣的Model可以幫助避免共性的陷阱。

1.狀態問題

響應式App,可以說最近非常流行。難道不是麼?所謂的響應式App應該就是會根據應用的狀態改變,來改變UI。這裡還有一個單詞:"State(上文譯為狀態)"。什麼是"State(上文譯為狀態)"?大多數時間我們描述“State(上文譯為狀態)”,就是我們從螢幕上看到的東西,比如說在螢幕上顯示一個ProgressBar 就是“載入狀態”。最關鍵的地方:我們的前端開發者趨向於關注UI。這明顯不是一件壞事,因為一個好的UI決定了使用者會不會用你們家的產品,從而決定了產品能不能成功。但是,我們看一下上面最基本的MVP示例程式碼(不是用PersonsModel的例子,是最上面的例子)。Ui的狀態被Presenter協調,Presenter決定了View應該顯示什麼內容。MVVM也是同樣的。在這篇部落格中我簡單區分兩種MVVM實現:第一種是用到了Android的data binding,第二種是用到RxJava。在用data binding實現的MVVM這種方式下,狀態直接被定義到了ViewModel裡面:

class PersonsViewModel {
  ObservableBoolean loading;
  // ... Other fields left out for better readability

  public void load(){

    loading.set(true);

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
      loading.set(false);
      // ... other stuff like set list of persons
      }

      public void onError(Throwable error){
        loading.set(false);
        // ... other stuff like set error message
      }
    });
  }
}
複製程式碼

在使用RxJava實現的MVVM中,我們不需要使用data binding引擎,而是將Observable繫結到View中的UI Widget,例如:

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)
      // Could also be implemented entirely different
  }

  // Subscribed to in View (i.e. Activity / Fragment)
  public Observable<Boolean> loading(){
    return loading;
  }

  // Subscribed to in View (i.e. Activity / Fragment)
  public Observable<List<Person>> persons(){
    return persons;
  }

  // Whenever this action is triggered (calling onNext() ) we load persons
  public PublishSubject loadPersonsCommand(){
    return loadPersonsCommand;
  }
}
複製程式碼

當然,這只是一個程式碼片段不是一個完整的程式碼,你實現的可能看起來完全不一樣。重點是通常在MVP和MVVM中,狀態由Presenter或ViewModel驅動。

這導致了下面幾個問題:

  1. 業務邏輯有了自己的狀態,Presenter(或ViewModel)有了自己的狀態(你需要同步你的業務邏輯狀態,和你的Presenter的狀態,兩者需要保持一致)並且View可能也有自己的狀態(舉個栗子,您直接在檢視中設定可見性,或者Android本身在從bundle中恢復狀態)
  2. Presenter(或者ViewModel)有任意多的輸入(View的觸發,被Presenter處理),這是可以理解的,但是Presenter也有很多的輸出(或輸出一些像 view.showLoading()view.showError() 在MVP或ViewModel都提供觀察)那麼這種情況會導致View,Presenter和業務邏輯的狀態衝突,這種現象在多執行緒下尤為突出。

在最好的情況下,這隻會導致可見的錯誤,例如像這樣同時顯示載入指示符(“載入狀態”)和錯誤指示符(“錯誤狀態”)

plaid app.gif

在最壞的情況下,你有一個像Crashlytics(理解成bugly)這樣的崩潰報告工具報告給你的嚴重的錯誤,你無法重現,因此幾乎不可能修復。

如果,我們從底層(業務邏輯)到頂層(VIew)有且僅有一個狀態源。其實,我們最開始展示的第二個例子就是一個很接近這個概念的例子。

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也就只有一個明確的輸出:getView().render(PersonsModel) .這反應了一個簡單的數學函式像f(x)=y (也可以有多個輸入,例如f(a,b,c),但只有一個輸出)。數學並不是所有的人都擅長,但是,數學家不知道什麼是Bug。軟體工程師咧。

理解什麼是"Model",並且知道model如何正確的定義,是十分重要的,因為到最後Model將解決"狀態問題"。

2.螢幕方向改變

安卓螢幕方向改變是一個有挑戰性性的問題。最簡單的方法是直接忽略這個問題。當螢幕方向改變的時候,重新載入所有的東西。這是完全有效的解決方法。大多數時間,你的App在離線狀態下工作,資料是儲存在你的本地資料庫或者其他的本地快取。因此,當螢幕的方向發生改變,載入資料是很快的、然而,我個人不喜歡看到loading指示器(大神都是有點各種小脾氣的),儘管它可能只出現幾微秒的時間(這裡應該用了誇張的修辭手法),因為在我看來這不是一個無縫的使用者體驗。因此,很多人(包括我)開始使用帶有“固定的presenter”的MVP。因此View可以在螢幕方向旋轉的時候被分離(被銷燬),而presenter將被保留在記憶體中,隨後,我們的View和Presenter將會被重新連線。在用RxJava實現的MVVM中有相同的概念,但是,我們需要記在心中的是一旦View被它的ViewModel退訂那麼觀察流就被破壞。例如,你可以用Subjects來解決這個問題。在使用data binding實現的MVVM中ViewModel是通過data binding 引擎直接繫結在View上的。去避免當我們改變螢幕方向而導致的記憶體洩露。

但是固定的Presenter(或者ViewModel)有一個問題是:當螢幕旋轉的時候,我們如何將View的狀態退回旋轉前的狀態,也就是說,我們的View和Presenter是否處在相同的狀態?我寫了一個MVP庫叫做Mosby 帶有一個功能叫做ViewState ,用來同步業務邏輯和View的狀態。Moxy ,另一個MVP庫,用了一種有趣的方式解決了這個問題,解決的方法就是用到了"命令(原文為commands)"去在螢幕旋轉以後,重建View的狀態:

moxy.gif

我可以十分確定的是,肯定有其他方法來解決這個問題。讓我們退一步來說,我們總結一下上面說到的庫的解決方法:他們試圖解決我們一直在討論的狀態問題。

所以,再次強調,當有一個能夠反應確切的"狀態"的"Model",肯定只有一個方法去"渲染(原文為render)"這個"Model"解決這個問題,並且是通過一種簡單的如呼叫 getView().render(PersonsModel) 一樣。

3.在頁面堆疊中導航

Presenter(或者ViewModel)需要去維護什麼時候View不使用麼?舉個栗子,如果,Fragment(View)將被另一個Fragment替換掉,因為使用者導航到另外的頁面,那麼這個將沒有View附屬到Presenter裡。如果沒有View沒有Presenter顯然不可能用最新的從業務邏輯裡出來的資料去更新View。如果使用者返回(例如,使用者按了返回按鈕)?去重新載入資料或複用已經存在的Presenter?這是一道哲學題。通常的一旦使用者返回先前的頁面,他期望回到他原來閱讀的地方。這是個最基本的“重置View狀態的問題”,我們剛剛在2中也討論了這個問題。所以富有策略的解決方案:當"Model"代表一種狀態,我們僅僅需要當使用者返回時,呼叫getView().render(PersonModel) 去渲染檢視就可以了。

4.程式死亡

我認為這是個安卓開發普遍錯誤的理解,就是程式死亡是意見壞的事情,並且在程式死亡以後,我們需要庫去幫助我們重啟狀態(例如Presenters或者ViewModels)。第一,一個程式死亡發生的原因是:安卓作業系統需要更多的資源去給其他的App或者為了省電。但是,如果你的應用程式處於前臺,正在被你的使用者使用是決定不可能出現程式死亡的。因此,做個好市民,不要在和平臺對戰了(這裡的意思是不要再瞎折騰程式包活了)。如果你真的需要在後臺長時間執行的一些工作,請用 Service ,在安卓作業系統中,這是唯一的一種方式向系統發出你的應用程式仍然被使用的訊號。如果一個程式死亡發生,安卓提供了一些回撥像onSaveInstanceState() 去儲存狀態。State又出現了。我們應該儲存我們的View資訊到Bundle裡麼?我們的Presenter的狀態是不是也要儲存到Bundle裡?那麼業務邏輯的狀態要不要存?我們先前也一直討論這個問題:剛才1.2.3點都在討論這個問題。我們僅僅需要一個Model類,這個Model類代表了整個狀態。那麼儲存到Bundle裡,就變得很簡單了。然而,我個人意見認為大多數時間我們不儲存狀態資料,而選擇像我們啟動App時候重新載入整個螢幕,似乎更好。考慮一下新聞閱讀軟體顯示新聞列表,當我們App六小時以前被殺死,我們儲存了的新聞狀態,當使用者重新開啟我們的App的時候,我們六小時前儲存的狀態被重新顯示出來,很顯然新聞已經過期了。也許在這種場景下,不去儲存狀態(Model/State),而去重新載入資料是更好的選擇。

5. 單向不可變的資料流

我這裡不去討論不變性(immutabiliy)的先進性,因為有很多資源討論這個問題。我們需要一個不變的“Model”(代表狀態)。為啥?因為我們想要唯一的來源。當我們傳遞Model物件的時候,我們不想要在我們應用中其他元件去改變我們的Model/狀態(State)。讓我想象一下我們正在寫一個簡單的“計數器”的安卓應用程式,這個有一個增量和一個減量按鈕,並且在一個TextView中顯示當前技術的值。如果我們的Model(就是計數的值,一個Integer)是不可變的,我們如何去更改計數器?我要告訴你,我們不直接通過按鈕點選來控制TextView。一些建議:第一,我們的View應該有一個view.render(...).第二,我們的Model是不可變的,因此不可能直接修改Model。第三,有且只有一個來源:業務邏輯。我們讓點選事件“下沉”到業務邏輯層。業務邏輯知道了當前的Model(例如,當前Model有一個私有域)並且將根據舊的Model,建立一個新的帶有增量/減量值的Model。

counter

通過這樣做,我們確信有單向的資料流,並將業務邏輯作為建立不可變模型例項的單一的來源。對於一個計數器來講有點太小題大做。難道不是麼?是的,一個計數器是一個非常簡單的程式。大多數的App都是從一個簡單的程式變的複雜起來。我認為,一個單向的資料流和一個不變的Model是十分必要的,當我們工程變複雜的時候,開發將依然是簡單的。

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

此外,單向資料流保證了我們的APP的除錯非常簡單。下次如果有新的crash報告從Crashlytics(感覺類似與Bugly)傳過來,我們可以很快速的修復Crash。因為所有需要的資訊都會在crash報告裡面。什麼是“需要的資訊”?就是我們需要的當前Model和使用者執行了什麼樣的操作而導致的八阿哥(例子:點選減量按鈕)。這就是我們需要的資訊,而且這些資訊很顯然,是十分容易附加到Crash報告中的。如果,資料流不是單向的,那麼實現起來就有點困難。(例如:一些人亂用EventBus,並且將CounterModels暴露出來。譯者:EventBus沒用過,所以,這裡可能看起來怪怪的原話是someone misuses an EventBus and fires CounterModels out into the wild )或不具有不變性(這樣會導致我們不能確定誰改變了Model)。

7. 可測試

"傳統"MVP或者MVVM改善了應用程式的可測試性。MVC也是可測試的:沒人告訴我們業務邏輯一定要放在activity裡。當Model代表狀態,我們可以簡化我們的整合測試程式碼,例如,我們可以簡單的檢查assertEquals(expectedModel, model) 。這讓我們除了Model以外的所有物件都不用mock。另外,這可以消除了方法的許多驗證測試,例如 Mockito.verify(view, times(1)).showFoo() 。最後,它可以讓我們的測試程式碼可讀性更好,更容易理解,更好的可維護性,我們不需要糾結於如何實現在程式碼中實現一些細節。

##總結

作為這個系列的第一篇部落格,我們討論了很多關於理論的東西。我們真的需要花4千多字介紹Model麼?我認為理解Model的實現是十分重要的基礎,有助於防止一些問題,否則容易翻車。Model不意味著業務邏輯,它是生成Model的業務邏輯(例如,一個互動,一個用例,一個倉庫或者你在APP中呼叫的任何東西(原文:Model doesn’t mean business logic. It’s the business logic (i.e. an Interactor, a Usecase, a Repositor or whatever you call it in your app) that produces a Model.)。在第二部分,我們將要將我們學到的Model理論,用到Model-View-Intent上,來構建響應式應用程式。下面展示的,簡單的線上商城軟體將是我們以後去實現的一個例子。你可以期望在第二部分了。 敬請關注。

shop dome

相關文章