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

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

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

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

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

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

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

class PersonsPresenter extends Presenter<PersonsView> {

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

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

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

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

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

從我個(gè)人理解來說,Model類應(yīng)該定義成這樣:

class PersonsModel {
  // 在真實(shí)的項(xiàng)目中,需要定義為私有的
  // 并且我們需要通過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;
  }
}

這樣的實(shí)現(xiàn),Presenter層應(yīng)該這樣實(shí)現(xiàn):

class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().render( new PersonsModel(true, null, null) ); // 展示一個(gè) 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) ); // 展示錯(cuò)誤信息
      }
    });
  }
}

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

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

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

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

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

1.狀態(tài)問題

響應(yīng)式App,這是最近非常流行的話題,不是嗎?所謂的 響應(yīng)式App 就是 應(yīng)用會(huì)根據(jù)狀態(tài)的改變作出UI的響應(yīng),這句話里有一個(gè)非常好的單詞:狀態(tài)。什么是狀態(tài)呢?大多數(shù)時(shí)間里,我們將 狀態(tài) 描述為我們?cè)谄聊恢锌吹降臇|西,例如當(dāng)界面展示ProgressBar時(shí)的loading state。

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

MVVM亦然,我想在本文中對(duì)MVVM的兩種實(shí)現(xiàn)方式進(jìn)行區(qū)分:第一種依賴DataBinding庫,第二種則依賴RxJava;對(duì)于依賴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);
      // 省略其它代碼,比如對(duì)persons進(jìn)行渲染
      }

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

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

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)
      // 實(shí)現(xiàn)方式并不惟一
  }

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

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

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

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

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

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

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

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

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

如果從 底層 (業(yè)務(wù)邏輯層)到 頂層 (UI視圖層),有且僅有一個(gè)真實(shí)描述狀態(tài)的源,會(huì)怎么樣呢?事實(shí)上,我們已經(jīng)在文章的開頭談?wù)?code>Model的時(shí)候,就已經(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),當(dāng)我想通了這點(diǎn),許多狀態(tài)相關(guān)的問題迎刃而解(甚至在編碼之前就已經(jīng)被避免了);現(xiàn)在Presenter層變得只有一個(gè)輸出了:

getView().render(PersonsModel)

它對(duì)應(yīng)了一個(gè)數(shù)學(xué)上簡(jiǎn)單的函數(shù),比如f(x) = y,對(duì)于多個(gè)輸入的函數(shù),對(duì)應(yīng)的則是f(a,b,c),但也是一個(gè)輸出。

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

了解Model到底是什么以及如何建立對(duì)應(yīng)的Model非常重要,因?yàn)樽罱KModel可以解決 狀態(tài)問題。

2.屏幕方向的改變

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

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

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

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

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

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

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

3.在頁面堆棧中導(dǎo)航

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

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

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

4.進(jìn)程終止

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

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

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

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

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

在這里我不打算討論不變性(immutabiliy)的優(yōu)勢(shì),因?yàn)橛泻芏噘Y源討論這個(gè)問題。我們想要一個(gè)不可變的Model(代表狀態(tài))。為什么?因?yàn)槲覀兿胍ㄒ坏臓顟B(tài)源,在傳遞Model時(shí),我們不希望App中的其他組件可以改變我們的Model或者State。

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

我很高興被問到這個(gè)問題,按鈕被點(diǎn)擊時(shí),我們并非直接操作TextView。我的建議是:

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

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

image

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

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

此外,單向數(shù)據(jù)流保證了我們的應(yīng)用程序易于調(diào)試。下次我們從Crashlytics獲得崩潰報(bào)告時(shí),我們可以輕松地重現(xiàn)并修復(fù)此崩潰,因?yàn)樗斜匦璧男畔⒍家迅郊拥奖罎?bào)告中了。

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

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

7.可測(cè)試性

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

此外,這也減少了很多驗(yàn)證的測(cè)試,即某些方法是否被調(diào)用(比如Mockito.verify(view, times(1)).showFoo()),最終,這使得我們的單元測(cè)試代碼更具可讀性,易于理解并且易于維護(hù),因?yàn)槲覀儾槐靥幚砗芏鄬?shí)際代碼的實(shí)現(xiàn)細(xì)節(jié)。

總結(jié)

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

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

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


系列目錄

《使用MVI打造響應(yīng)式APP》原文

《使用MVI打造響應(yīng)式APP》譯文

《使用MVI打造響應(yīng)式APP》實(shí)戰(zhàn)


關(guān)于我

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

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容