原文:REACTIVE APPS WITH MODEL-VIEW-INTENT - PART2 - VIEW AND INTENT
作者:Hannes Dorfmann
譯者:卻把清梅嗅
在 上文 中,我們探討了對Model
的定義、與 狀態 的關係以及如何在通過良好地定義Model
來解決一些Android
開發中常見的問題。本文將通過 Model-View-Intent
,即MVI
模式,繼續我們的 響應式App 構建之旅。
如果您尚未閱讀上一小節,則應在繼續閱讀本文之前閱讀該部分。總結一下:以“傳統的”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
,像這樣:
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
層的render(personsModel)
方法,Model
就會被成功的渲染在螢幕上。在第一小節中我們同樣探討了 單項資料流 的重要性,同時您的業務邏輯應該驅動該Model
。在正式將所有內容環環相扣連線之前,我們先簡單瞭解一下MVI
的核心思想。
Model-View-Intent (MVI)
該模式最初被 andrestaltz 在他寫的JavaScript
框架 cycle.js 中所提出; 從理論(還有數學)上講,我們這樣對Model-View-Intent
的定義進行描述:
1.intent()
此函式接受來自使用者的輸入(即UI事件,比如點選事件)並將其轉換為可傳遞給Model()
函式的引數,該引數可能是一個簡單的String
對Model
進行賦值,也可能像是Object
這樣複雜的資料結構。intent
作為意圖,標誌著 我們試圖對Model
進行改變。
2.model()
model()
函式將intent()
函式的輸出作為輸入來操作Model
,其函式輸出是一個新的Model
(狀態發生了改變)。
不要對已存在的Model
物件進行修改,我們需要的是不可變!對此,在上文中我們已經展示了一個計數器的具體案例,再次重申,不要修改已存在的Model
!
根據intent
所描述的變化,我們建立一個新的Model
,請注意,Model()
函式是唯一允許對Model
進行建立的途徑。然後這個新的Model
作為該函式的輸出——基本上model()
函式呼叫我們App
的業務邏輯(可以是互動、用例、Repository
......您在App
中使用的任何模式/術語)並作為結果提供新的Model
物件。
3.view()
該方法獲取model()
函式返回的Model
,並將其作為view()
函式的輸入,這之後通過某種方式將Model
展示出來,view()
和view.render(model)
大體上是一致的。
4.本質
但是我們希望構建的是 響應式的App,不是嗎?那麼MVI
是如何響應式的呢?響應式實際上意味著什麼?
這意味著App
的 UI反映了狀態的變更。
因為Model
反映了狀態,因此,本質上我們希望 業務邏輯能夠對輸入的事件(即intents
)進行響應,並建立對應的Model
作為輸出,這之後再通過呼叫View
層的render(model)
方法,對UI進行渲染。
5.通過RxJava串聯
我們希望我們的資料流的單向性,因此RxJava
閃亮登場。我們的App必須通過RxJava
保持 資料的單向性 和 響應式 來構建嗎?或者必須用MVI
模式才能構建嗎?當然不,我們也可以寫 命令式 和 程式性 的程式碼。但是,基於事件程式設計 的RxJava
實在太優秀了,既然UI
是基於事件的,因此使用RxJava
也是非常有意義的。
本文我們將會構建一個簡單的虛擬線上商店App
,其UI介面中展示的商品資料,都來源於我們向後臺進行的網路請求。
我們可以精確的搜尋特定的商品,並將其新增到我們的購物車中,最終App
的效果如下所示:
這個專案的原始碼你可以在Github上找到,我們從實現一個簡單的搜尋介面開始做起:
首先,就像上文我們描述的那樣,我們定義一個Model
用於描述View
層是如何被展示的—— 這個系列中,我們將用帶有 ViewState
字尾的類來替代 Model
;舉個例子,我們將會為搜尋頁的Model
類命名為SearchViewState
。
這很好理解,因為Model
反應的就是狀態(State),至於為什麼不用聽起來有些奇怪的名稱比如SearchModel
,是因為擔心和MVVM
中的SearchViewModel
類在一起會導致歧義——命名真的很難。
public interface SearchViewState {
// 搜尋尚未開始
final class SearchNotStartedYet implements SearchViewState {
}
// 搜尋中
final class Loading implements SearchViewState {
}
// 返回結果為空
final class EmptyResult implements SearchViewState {
private final String searchQueryText;
public EmptyResult(String searchQueryText) {
this.searchQueryText = searchQueryText;
}
public String getSearchQueryText() {
return searchQueryText;
}
}
// 有效的搜尋結果,包含和搜尋條件匹配的商品列表
final class SearchResult implements SearchViewState {
private final String searchQueryText;
private final List<Product> result;
public SearchResult(String searchQueryText, List<Product> result) {
this.searchQueryText = searchQueryText;
this.result = result;
}
public String getSearchQueryText() {
return searchQueryText;
}
public List<Product> getResult() {
return result;
}
}
// 表示搜尋過程中發生了錯誤
final class Error implements SearchViewState {
private final String searchQueryText;
private final Throwable error;
public Error(String searchQueryText, Throwable error) {
this.searchQueryText = searchQueryText;
this.error = error;
}
public String getSearchQueryText() {
return searchQueryText;
}
public Throwable getError() {
return error;
}
}
}
複製程式碼
因為Java
是一種強型別的語言,因此我們可以選擇一種安全的方式為我們的Model
類拆分出多個不同的 子狀態。
我們的業務邏輯返回的是一個 SearchViewState
型別的物件,它可能是SearchViewState.Error
或者其它的一個例項。這只是我個人的偏好,們也可以通過不同的方式定義,例如:
class SearchViewState {
Throwable error; // 非空則意味著,出現了一個錯誤
boolean loading; // 值為true意味著正在載入中
List<Product> result; // 非空意味著商品列表的結果
boolean SearchNotStartedYet; // true意味著還未開始搜尋
}
複製程式碼
再次重申,如何定義Model
純屬個人喜好,如果你用Kotlin
作為程式語言,那麼sealed classes
是一個不錯的選擇。
將目光聚集回到業務程式碼,讓我們通過 SearchInteractor
去執行搜尋的功能,其輸出就是我們之前說過的SearchViewState
物件:
public class SearchInteractor {
final SearchEngine searchEngine; // 執行網路請求
public Observable<SearchViewState> search(String searchString) {
// 如果是空的字串,不進行搜尋
if (searchString.isEmpty()) {
return Observable.just(new SearchViewState.SearchNotStartedYet());
}
// 搜尋商品列表
// 返回 Observable<List<Product>>
return searchEngine.searchFor(searchString)
.map(products -> {
if (products.isEmpty()) {
return new SearchViewState.EmptyResult(searchString);
} else {
return new SearchViewState.SearchResult(searchString, products);
}
})
.startWith(new SearchViewState.Loading())
.onErrorReturn(error -> new SearchViewState.Error(searchString, error));
}
}
複製程式碼
來看下SearchInteractor.search()
的方法簽名:我們將String
型別的searchString
作為 輸入 的引數,以及Observable<SearchViewState>
型別的 輸出,這意味著我們期望隨著時間的推移,可以在可觀察的流上會有任意多個SearchViewState
的例項被髮射。
在我們正式開始查詢搜尋之前(即SearchEngine
執行網路請求),我們通過startWith()
操作符發射一個SearchViewState.Loading
,這將會使得View
在執行搜尋時展示ProgressBar
。
onErrorReturn()
會捕獲在執行搜尋時丟擲的所有異常,並且發射出一個SearchViewState.Error
——在訂閱這個Observable
時,我們為什麼不去使用onError()
回撥呢?
這是一個對RxJava
認知的普遍誤解,實際上,onError()
的回撥意味著 整個可觀察的流進入了不可恢復的狀態,因此可觀察的流結束了,而在我們的案例中,類似“沒有網路連線”的error
並非不可恢復的error
:這只是我們的Model
所代表的另外一個狀態。
此外,我們還有另外一個可以轉換到的狀態,即一旦網路連線可用,我們可以通過 SearchViewState.Loading
跳轉到的 載入狀態。
因此,我們建立了一個可觀察的流,這是一個每當狀態發生了改變,從業務邏輯層就會發射一個發生了改變的Model
到View
層的流。
我們不想在網路連線錯誤時終止這個可觀察的流,因此,在error
發生時,類似這種可以被處理為 狀態 的error
(而不是終止流的那種致命的錯誤),可以反應為Model
,被可觀察的流發射。
通常,在MVI
中,Model
的Observable
永遠不會被終止(即永遠不會執行onComplete()
或者onError()
回撥)。
總結一下,SearchInteractor
(即業務邏輯)提供了一個可觀察的流Observable<SearchViewState>
,每當狀態發生了變化,就會發射一個新的SearchViewState
。
6.View層的職責
接下來我們來討論一下View
應該是什麼樣的,View
層的職責是什麼?顯然View
層應該對Model
進行展示,我們已經認可View
層應該有類似 render(model)
這樣的函式。此外,View
應該提供一個給其他層響應使用者輸入的方法,在MVI
中這個方法被稱為 intents
。
在這個案例中,我們只有一個intent
:使用者可以在輸入框中輸入一個用於檢索商品的字串進行搜尋。MVP
中的好習慣是為View
層定義一個介面,所以在MVI
中我們也可以這樣做。
public interface SearchView {
// 搜尋的intent
Observable<String> searchIntent();
// 對View層進行渲染
void render(SearchViewState viewState);
}
複製程式碼
我們的案例中View
層只提供了一個intent
,但通常View
擁有更多的intent
;在 第一小節 中我們討論了為什麼一個單獨的render()
函式是一個不錯的實踐,如果你對此還不是很清楚的話,請閱讀該小節並通過留言進行探討。
在我們開始對View
層進行具體的實現之前,我們先看看最終介面的展示效果:
public class SearchFragment extends Fragment implements SearchView {
@BindView(R.id.searchView) android.widget.SearchView searchView;
@BindView(R.id.container) ViewGroup container;
@BindView(R.id.loadingView) View loadingView;
@BindView(R.id.errorView) TextView errorView;
@BindView(R.id.recyclerView) RecyclerView recyclerView;
@BindView(R.id.emptyView) View emptyView;
private SearchAdapter adapter;
@Override public Observable<String> searchIntent() {
return RxSearchView.queryTextChanges(searchView) // 感謝 Jake Wharton :)
.filter(queryString -> queryString.length() > 3 || queryString.length() == 0)
.debounce(500, TimeUnit.MILLISECONDS);
}
@Override public void render(SearchViewState viewState) {
if (viewState instanceof SearchViewState.SearchNotStartedYet) {
renderSearchNotStarted();
} else if (viewState instanceof SearchViewState.Loading) {
renderLoading();
} else if (viewState instanceof SearchViewState.SearchResult) {
renderResult(((SearchViewState.SearchResult) viewState).getResult());
} else if (viewState instanceof SearchViewState.EmptyResult) {
renderEmptyResult();
} else if (viewState instanceof SearchViewState.Error) {
renderError();
} else {
throw new IllegalArgumentException("Don't know how to render viewState " + viewState);
}
}
private void renderResult(List<Product> result) {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.VISIBLE);
loadingView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
errorView.setVisibility(View.GONE);
adapter.setProducts(result);
adapter.notifyDataSetChanged();
}
private void renderSearchNotStarted() {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.GONE);
errorView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
}
private void renderLoading() {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.VISIBLE);
errorView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
}
private void renderError() {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.GONE);
errorView.setVisibility(View.VISIBLE);
emptyView.setVisibility(View.GONE);
}
private void renderEmptyResult() {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.GONE);
errorView.setVisibility(View.GONE);
emptyView.setVisibility(View.VISIBLE);
}
}
複製程式碼
render(SearchViewState)
方法的作用顯而易見,searchIntent()
方法中,我們使用了Jake Wharton
的 RxBinding ,這是一個對Android UI
元件提供了RxJava
響應式支援的庫。
RxSearchView.queryText()
建立了一個Observable<String>
,每當使用者在EditText
上輸入了一些文字,它就會發射一個對應的字串;我們通過filter()
去保證只有當使用者輸入的字元數達到三個以上時才進行搜尋;同時,我們不希望每當使用者輸入一個字元,就去請求網路,而是當使用者輸入結束後再去請求網路(debounce()
操作符會停留500毫秒以決定使用者是否輸入完成)。
現在我們知道了螢幕中的searchIntent()
方法就是 輸入 ,而render()
方法則是 輸出。我們如何從 輸入 獲得 輸出 呢,如下所示:
7.連線View和Intent
剩下的問題就是:我們如何將View
的intent
和業務邏輯進行連線呢?如果你認真觀看了上面的流程圖,你應該注意到了中間的 flatMap()
操作符,這暗示了我們還有一個尚未談及的元件: Presenter
;Presenter
負責連線這些點,就和我們在MVP
中使用的方式一樣。
public class SearchPresenter extends MviBasePresenter<SearchView, SearchViewState> {
private final SearchInteractor searchInteractor;
@Override protected void bindIntents() {
Observable<SearchViewState> search =
intent(SearchView::searchIntent)
// 上文中我們談到了flatMap,但在這裡switchMap更為適用
.switchMap(searchInteractor::search)
.observeOn(AndroidSchedulers.mainThread());
subscribeViewState(search, SearchView::render);
}
}
複製程式碼
什麼是 MviBasePresenter
, intent()
和 subscribeViewState()
又是什麼?這個類是我寫的 Mosby 庫的一部分(3.0版本後,Mosby
已經支援了MVI
)。本文並非為了講述Mosby
,但我向簡單介紹一下MviBasePresenter
是如何的便利——這其中沒有什麼黑魔法,雖然確實看起來像是那樣。
讓我們從生命週期開始:MviBasePresenter
並未持有任何生命週期,它暴露出一個 bindIntent()
方法以供View
層和業務邏輯進行繫結。通常,你通過flatMap()
、switchMap()
或者concatMap()
操作符將intent
“轉移”到業務邏輯中,這個方法僅僅在View
層第一次被附加到Presenter
中時呼叫,而當View
再次被附加在Presenter
中時(比如,螢幕方向發生了改變),將不再被呼叫。
這聽起來有些怪,也許有人會說:
MviBasePresenter
在螢幕方向發生了改變後依然能夠存活?如果是這樣,Mosby
如何保證Observable
的流不會發生記憶體的洩漏?
這就是 intent()
和 subscribeViewState()
的作用所在了,intent()
在內部建立一個PublishSubject
,就像是業務邏輯的“閘道器”一樣;實際上,PublishSubject
訂閱了View
層傳過來的intent
的Observable
,呼叫intent(o1)
實際返回了一個訂閱了o1
的PublishSubject
。
螢幕發生旋轉時,Mosby
將View
從Presenter
中分離,但是,內部的PublishSubject
只是暫時和View
解除了訂閱;而當View
重新附著在Presenter
上時,PublishSubject
將會對View
層的intent
進行重新訂閱。
subscribeViewState()
方法做的是同樣的事情,只不過將順序調換了過來(Presenter
向View
層的通訊)。它在內部建立一個BehaviorSubject
作為從業務邏輯到View層的“閘道器”。
由於它是一個BehaviorSubject
,因此,即使此時Presenter
沒有持有View
,我們依然可以從業務邏輯中接收到Model
的更新(比如View
並未處於棧頂);BehaviorSubjects
始終持有它最後的值,並在View
重新依附後將其重新發射。
規則很簡單:使用intent()
來“包裝”View
層的所有intent
,使用subscribeViewState()
替代Observable.subscribe()
.
8.UnbindIntents
與bindIntent()
相對應的是 unbindIntents()
,該方法只會執行一次,即View
被永久銷燬時才會被呼叫。舉個例子,將一個Fragment
放在棧中,直到Activity
被銷燬之前,該View
一直不會被銷燬。
由於intent()
和subscribeViewState()
已經對訂閱進行了管理,因此您只需要實現unbindIntents()
。
9.其它生命週期的事件
那麼其它生命週期的事件,比如onPause()
和onResume()
又該如何處理?我依然認為Presenter
不需要生命週期的事件,然而,如果你堅持認為你需要將這些生命週期的事件視為另一種形式的intent
,您的View
可以提供一個pauseIntent()
,它是由android
生命週期觸發,而又不是按鈕點選事件這樣的由使用者互動觸發的intent
——但兩者都是有效的意圖。
結語
第二小節中,我們探討了Model-View-Intent
的基礎,並通過MVI
淺嘗輒止實現了一個簡單的頁面。也許這個例子太簡單了,所以你尚未感受到MVI
模式的優點:代表 狀態 的Model
和與傳統MVP
或者MVVM
相比的 單項資料流。
MVP
和MVVM
並沒有什麼問題,我也並非是在說MVI
比其它架構模式更優秀,但是,我認為MVI
可以幫助我們 為複雜的問題編寫優雅的程式碼 ,這也正如我們將在本系列部落格的 下一小節(第3小節)中探討的那樣——屆時我們將針對 狀態減少 (state reducers)的問題進行探討,歡迎關注。
系列目錄
《使用MVI打造響應式APP》原文
《使用MVI打造響應式APP》譯文
《使用MVI打造響應式APP》實戰
關於我
Hello,我是卻把清梅嗅,如果您覺得文章對您有價值,歡迎 ❤️,也歡迎關注我的部落格或者Github。
如果您覺得文章還差了那麼點東西,也請通過關注督促我寫出更好的文章——萬一哪天我進步了呢?