在第一部分我們討論了關(guān)于什么才是真正的Model截歉,Model和狀態(tài)的關(guān)系厢绝,并且討論了什么樣的Model才能避免安卓開(kāi)發(fā)過(guò)程中的共性問(wèn)題炸庞。在這篇我們通過(guò)講Model-View-Intent模式去構(gòu)建響應(yīng)式安卓程序最欠,繼續(xù)我們的“響應(yīng)式APP開(kāi)發(fā)”探索之旅。
如果你沒(méi)有閱讀第一部分积担,你應(yīng)該先讀那篇然后再讀這篇陨晶。我在這里先簡(jiǎn)單的回顧一下上一部分的主要內(nèi)容:我們不要寫(xiě)類似于下面的代碼(傳統(tǒng)的MVP的例子)
class PersonsPresenter extends Presenter<PersonsView> {
public void load(){
getView().showLoading(true); // Displays a ProgressBar on the screen
backend.loadPersons(new Callback(){
public void onSuccess(List<Person> persons){
getView().showPersons(persons); // Displays a list of Persons on the screen
}
public void onError(Throwable error){
getView().showError(error); // Displays a error message on the screen
}
});
}
}
我們應(yīng)該創(chuàng)建一個(gè)反應(yīng)"狀態(tài)(State)"的"Model":
class PersonsModel {
// 在正式的項(xiàng)目里應(yīng)當(dāng)為私有
// 我們需要用get方法來(lái)獲取它們的值
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的實(shí)現(xiàn)類似于下面這樣:
class PersonsPresenter extends Presenter<PersonsView> {
public void load(){
getView().render( new PersonsModel(true, null, null) ); //顯示加載進(jìn)度條
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,通過(guò)調(diào)用render(personsModel) 方法,將數(shù)據(jù)渲染到UI上。在上一篇文章里我們也討論了單向數(shù)據(jù)流的重要性攻谁,并且你的業(yè)務(wù)邏輯應(yīng)當(dāng)驅(qū)動(dòng)你的Model采幌。在我們把所有的內(nèi)容連起來(lái)之前末早,我們先快速的了解一下MVI的大意。
Model-View-Intent(MVI)
這個(gè)模式被 André Medeiros (Staltz) 為了他寫(xiě)的一個(gè)JavaScript的框架而提出的,這個(gè)框架的名字叫做 cycle.js 。從理論上(數(shù)學(xué)上)來(lái)看铃芦,我們可以用下面的表達(dá)式來(lái)描述Model-View-Intent:
- intent() :這個(gè)函數(shù)接受用戶的輸入(例如雅镊,UI事件,像點(diǎn)擊事件之類的)并把它轉(zhuǎn)化成model函數(shù)的可接收的參數(shù)刃滓。這個(gè)參數(shù)可能是一個(gè)簡(jiǎn)單的String仁烹,也可能是其他復(fù)雜的結(jié)構(gòu)的數(shù)據(jù),像Object咧虎。我們可以說(shuō)我們通過(guò)intent()的意圖去改變Model卓缰。
- model() :model()函數(shù)接收intent()函數(shù)的輸出作為輸入,去操作Model砰诵。它的輸出是一個(gè)新的Model(因?yàn)闋顟B(tài)改變)征唬。因此我們不應(yīng)該去更新已經(jīng)存在的Model。因?yàn)槲覀冃枰狹odel具有不變性! 在第一部分茁彭,我具體用”計(jì)數(shù)APP“作為簡(jiǎn)單的例子講了數(shù)據(jù)不變性的重要性总寒。再次強(qiáng)調(diào),我們不要去修改已經(jīng)存在的Model實(shí)例尉间。我們?cè)趍odel()方法里創(chuàng)建新的偿乖,根據(jù)intent的輸出變化以后的Model。請(qǐng)注意哲嘲,model()方法是你唯一能夠創(chuàng)建新的Model對(duì)象的地方∠苯基本上眠副,我們稱model()方法為我們App的業(yè)務(wù)邏輯(可以是Interactor,Usecase竣稽,Repository ...您在應(yīng)用中使用的任何模式/術(shù)語(yǔ))并且傳遞新的Model對(duì)象作為結(jié)果囱怕。
- view() :這個(gè)方法接收model()方法的輸出值。然后根據(jù)model()的輸出值來(lái)渲染到UI上毫别。view()方法大致上類似于view.render(model) 娃弓。
但是,我們不是去構(gòu)建一個(gè)”響應(yīng)式的APP“岛宦,不是么?所以台丛,MVI是如何做到"響應(yīng)式"的?"響應(yīng)式"到底意味著什么?先回答最后一個(gè)問(wèn)題砾肺,”響應(yīng)式“就是我們的app根據(jù)狀態(tài)不同而去改變UI挽霉。在MVI中,”狀態(tài)“被"Model"所代表变汪,實(shí)質(zhì)上我們期望侠坎,我們的業(yè)務(wù)邏輯根據(jù)用戶的輸入事件(intent)產(chǎn)生新的"Model",然后再將新的"Model"通過(guò)調(diào)用view的render(Model)方法改變?cè)赨I裙盾。這就是MVI實(shí)現(xiàn)響應(yīng)式的基本思路实胸。
使用RxJava來(lái)連接不同的點(diǎn)(這里的點(diǎn)是指?Model,View,Intent原本是相互獨(dú)立的點(diǎn))
我們想要讓我們的數(shù)據(jù)流是單向的他嫡。RxJava在這里起到了作用。我們必須使用RxJava構(gòu)建單向數(shù)據(jù)流的響應(yīng)式App或MVI模式的App么庐完?不是的钢属,我們可以用其他的代碼實(shí)現(xiàn)。然而假褪,RxJava對(duì)于事件基礎(chǔ)的編程是很好用的署咽。既然用戶界面是基于事件的,使用RxJava也就很有意義的生音。
在這個(gè)系列博客宁否,我們將要開(kāi)發(fā)一個(gè)簡(jiǎn)單的電商應(yīng)用。我們?cè)诤笈_(tái)進(jìn)行http請(qǐng)求缀遍,去加載我們需要顯示商品慕匠。我們可以搜索商品和添加商品到購(gòu)物車。綜上所述整個(gè)App看起來(lái)想下面這個(gè)動(dòng)圖:
這個(gè)項(xiàng)目的源代碼你可以在 github 上找到域醇。我們先去實(shí)現(xiàn)一個(gè)簡(jiǎn)單的頁(yè)面:實(shí)現(xiàn)搜索頁(yè)面台谊。首先,我們先定義一個(gè)最終將被View顯示的Model譬挚。在這個(gè)系列博客我們采用"ViewState"標(biāo)示來(lái)標(biāo)示Model ,例如:我們的搜索頁(yè)面的Model類叫做SearchViewState ,因?yàn)镸odel代表狀態(tài)(State)锅铅。至于為什么不使用SearchModel這樣的名字,是因?yàn)榕屡cMVVM的類似于SearchViewModel的命名混淆减宣。命名真的很難盐须。
public interface SearchViewState {
/**
*搜索還沒(méi)有開(kāi)始
*/
final class SearchNotStartedYet implements SearchViewState {
}
/**
* 加載: 等待加載
*/
final class Loading implements SearchViewState {
}
/**
*標(biāo)識(shí)返回一個(gè)空結(jié)果
*/
final class EmptyResult implements SearchViewState {
private final String searchQueryText;
public EmptyResult(String searchQueryText) {
this.searchQueryText = searchQueryText;
}
public String getSearchQueryText() {
return searchQueryText;
}
}
/**
* 驗(yàn)證搜索結(jié)果. 包含符合搜索條件的項(xiàng)目列表。
*/
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;
}
}
/**
*標(biāo)識(shí)搜索出現(xiàn)的錯(cuò)誤狀態(tài)
*/
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是個(gè)強(qiáng)類型的語(yǔ)言漆腌,我們需要為我們的Model選擇一個(gè)安全的類型贼邓。我們的業(yè)務(wù)邏輯返回的是 SearchViewState 類型的。當(dāng)然這種定義方法是我個(gè)人的偏好闷尿。我們也可以通過(guò)不同的方式定義塑径,例如:
class SearchViewState {
Throwable error; // if not null, an error has occurred
boolean loading; // if true loading data is in progress
List<Product> result; // if not null this is the result of the search
boolean SearchNotStartedYet; // if true, we have the search not started yet
}
再次強(qiáng)調(diào),你可以按照你的方式來(lái)定義你的Model填具。如果统舀,你會(huì)使用kotlin語(yǔ)言的話,那么sealed classes是一個(gè)很好的選擇灌旧。
下一步绑咱,讓我將聚焦點(diǎn)重新回到業(yè)務(wù)邏輯。讓我們看一下負(fù)責(zé)執(zhí)行搜索的 SearchInteractor 如何去實(shí)現(xiàn)枢泰。先前已經(jīng)說(shuō)過(guò)了它的"輸出"應(yīng)該是一個(gè) SearchViewState 對(duì)象描融。
public class SearchInteractor {
final SearchEngine searchEngine; // 進(jìn)行http請(qǐng)求
public Observable<SearchViewState> search(String searchString) {
// 空的字符串,所以沒(méi)搜索
if (searchString.isEmpty()) {
return Observable.just(new SearchViewState.SearchNotStartedYet());
}
// 搜索商品
return searchEngine.searchFor(searchString) // Observable<List<Product>>
.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()的方法簽名:我們有一個(gè)字符串類型的searchString作為輸入?yún)?shù)衡蚂,和Observable<SearchViewState> 作為輸出窿克。這已經(jīng)暗示我們期望隨著時(shí)間的推移在這個(gè)可觀察的流上發(fā)射任意多個(gè)SearchViewState實(shí)例骏庸。startWith() 是在我們開(kāi)始查詢(通過(guò)http請(qǐng)求)之前調(diào)用的。我們?cè)趕tartWith這里發(fā)射SearchViewState.Loading 年叮。目的是具被,當(dāng)我們點(diǎn)擊搜索按鈕,會(huì)有一個(gè)進(jìn)度條出現(xiàn)只损。
onErrorReturn() 捕獲所有的在執(zhí)行搜索的時(shí)候出現(xiàn)的異常一姿,并且,發(fā)射一個(gè)SearchViewState.Error 跃惫。當(dāng)我們訂閱這個(gè)Observable的時(shí)候叮叹,我們?yōu)槭裁床恢挥胦nError的回調(diào)?這是對(duì)RxJava一個(gè)共性的誤解:onError回調(diào)意味著我們整個(gè)觀察流進(jìn)入了一個(gè)不可恢復(fù)的狀態(tài)爆存,也就是整個(gè)觀察流已經(jīng)被終止了蛉顽。但是,在我們這里的錯(cuò)誤先较,像無(wú)網(wǎng)絡(luò)之類的携冤,不是不可恢復(fù)的錯(cuò)誤。這僅僅是另一種狀態(tài)(被Model代表)闲勺。此外曾棕,之后,我們可以移動(dòng)到其他狀態(tài)菜循。例如睁蕾,一旦我們的網(wǎng)絡(luò)重新連接起來(lái),那么我們可以移動(dòng)到被SearchViewState.Loading 代表的“加載狀態(tài)”债朵。因此,我們建立了一個(gè)從我們的業(yè)務(wù)邏輯到View的觀察流,每次發(fā)射一個(gè)改變后的Model瀑凝,我們的"狀態(tài)"也會(huì)隨著改變序芦。我們肯定不希望我們的觀察流因?yàn)榫W(wǎng)絡(luò)錯(cuò)誤而終止。因此粤咪,這類錯(cuò)誤被處理為一種被Model代表的狀態(tài)(除去那些致命錯(cuò)誤)谚中。通常情況下,在MVI中可觀察對(duì)象Model不會(huì)被終止(永遠(yuǎn)不會(huì)執(zhí)行onComplete()或onError())寥枝。
對(duì)上面部分做個(gè)總結(jié):SearchInteractor(業(yè)務(wù)邏輯)提供了一個(gè)觀察流Observable<SearchViewState> 宪塔,并且當(dāng)每次狀態(tài)變化的時(shí)候,發(fā)射一個(gè)新的SearchViewState囊拜。
下一步某筐,讓我討論View層長(zhǎng)什么樣子的。View層應(yīng)該做什么?顯然的冠跷,view應(yīng)該去顯示Model南誊。我們已經(jīng)同意身诺,View應(yīng)當(dāng)有一個(gè)像render(model) 這樣的方法。另外抄囚,View需要提供一個(gè)方法給其他層用來(lái)接收用戶輸入的事件霉赡。這些事件在MVI中被稱作 intents 。在這個(gè)例子中幔托,我們僅僅只有一個(gè)intent:用戶可以通過(guò)在輸入?yún)^(qū)輸入字符串來(lái)搜索穴亏。在MVP中一個(gè)好的做法是我們可以為View定義接口,所以重挑,在MVI中嗓化,我們也可以這樣做。
public interface SearchView {
/**
* The search intent
*
* @return An observable emitting the search query text
*/
Observable<String> searchIntent();
/**
* Renders the View
*
* @param viewState The current viewState state that should be displayed
*/
void render(SearchViewState viewState);
}
在這種情況下攒驰,我們的View僅僅提供一個(gè)intent蟆湖,但是,在其他業(yè)務(wù)情況下玻粪,可能需要多個(gè)intent隅津。在第一部分我們討論了為什么單個(gè)render()方法(譯者:渲染方法)是一個(gè)好的方式,如果劲室,你不清楚為什么我們需要單個(gè)render()伦仍,你可以先去閱讀第一部分。在我們具體實(shí)現(xiàn)View層之前很洋,我們先看一下最后搜索頁(yè)面是什么樣的
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) // Thanks 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) 這個(gè)方法充蓝,我們通過(guò)看,就知道它是干什么的喉磁。在 searchIntent() 方法中我們用到了Jake Wharton’s的RxBindings 庫(kù)谓苟,它使RxJava像綁定可觀察對(duì)象一樣綁定安卓UI控件。 RxSearchView.queryText()創(chuàng)建一個(gè) Observable<String>對(duì)象协怒,每當(dāng)用戶在EditText輸入的一些字符涝焙,發(fā)射需要搜索的字符串。我們用filter()去保證只有當(dāng)用戶輸入的字符數(shù)超過(guò)三個(gè)的時(shí)候孕暇,才開(kāi)始搜索仑撞。并且,我們不希望每當(dāng)用戶輸入一個(gè)新字符的時(shí)候就請(qǐng)求網(wǎng)絡(luò)妖滔,而是當(dāng)用戶輸入完成以后再去請(qǐng)求網(wǎng)絡(luò)(debounce()停留500毫秒隧哮,決定用戶是否輸入完成)。
因此座舍,我們知道對(duì)于這個(gè)頁(yè)面而言沮翔,輸入是searchIntent(),輸出是render()簸州。我們?nèi)绾螐摹拜斎搿钡健拜敵觥奔撸肯旅娴囊曨l將這個(gè)過(guò)程可視化了:
其余的問(wèn)題是誰(shuí)或如何把我們的View的意圖(intent)和業(yè)務(wù)邏輯聯(lián)系起來(lái)?如果你已經(jīng)看過(guò)了上面的視頻歧譬,可以看到在中間有一個(gè)RxJava的操作符 flatMap() 。這暗示了我們需要調(diào)用額外的組件搏存,但是瑰步,我們至今為止還沒(méi)有討論,它就是 Presenter 。Presenter將所有分離的不同點(diǎn)(譯者:這里指Model,View,Intent這三個(gè)點(diǎn))聯(lián)系起來(lái)璧眠。它與MVP中的Presenter類似缩焦。
public class SearchPresenter extends MviBasePresenter<SearchView, SearchViewState> {
private final SearchInteractor searchInteractor;
@Override protected void bindIntents() {
Observable<SearchViewState> search =
intent(SearchView::searchIntent)
.switchMap(searchInteractor::search) // 我在上面視頻中用flatMap()但是 switchMap() 在這里更加適用
.observeOn(AndroidSchedulers.mainThread());
subscribeViewState(search, SearchView::render);
}
}
MviBasePresenter 是什么?這個(gè)是我寫(xiě)的一個(gè)庫(kù)叫 Mosby (Mosby3.0已經(jīng)添加了MVI組件)责静。這篇博客不是為介紹Mosby而寫(xiě)的袁滥,但是,我想對(duì)MviBasePresenter做個(gè)簡(jiǎn)短的介紹灾螃。介紹一下MviBasePresenter如何讓你方便使用的题翻。這個(gè)庫(kù)里面沒(méi)有什么黑魔法。讓我們從lifecycle(生命周期)開(kāi)始說(shuō):MviBasePresenter事實(shí)上沒(méi)有l(wèi)ifecyle(生命周期)腰鬼。有一個(gè) bindIntent() 方法將視圖的意圖(intent)與業(yè)務(wù)邏輯綁定嵌赠。通常,你用flatMap()或switchMap 亦或concatMap()熄赡,將意圖(intent)傳遞給業(yè)務(wù)邏輯姜挺。這個(gè)方法的調(diào)用僅僅在View第一次被附加到Presenter。當(dāng)View重新附加到Presenter時(shí)彼硫,將不會(huì)被調(diào)用(例如炊豪,當(dāng)屏幕方向改變)。
這聽(tīng)起來(lái)很奇怪拧篮,也許有人會(huì)說(shuō):“MviBasePresenter在屏幕方向變化的時(shí)候都能保持词渤?如果是的話,Mosby是如何確贝ǎ可觀察流的數(shù)據(jù)在內(nèi)存中掖肋,而不被丟失?”,這是intent()和 subscribeViewState() 的就是用來(lái)回答這個(gè)問(wèn)題的赏参。intent() 在內(nèi)部創(chuàng)建一個(gè)PublishSubject ,并將其用作你的業(yè)務(wù)邏輯的“門戶”沿盅。所以實(shí)際上這個(gè)PublishSubject訂閱了View的意圖(intent)可觀察對(duì)象( Observable)把篓。調(diào)用intent(o1)實(shí)際上返回一個(gè)訂閱了o1的PublishSubject。
當(dāng)方向改變的時(shí)候腰涧,Mosby從Presenter分離View韧掩,但是,僅僅只是暫時(shí)的取消訂閱內(nèi)部的PublishSubject窖铡。并且疗锐,當(dāng)View重新連接到Presenter的時(shí)候,將PublishSubject重新訂閱View的意圖(intent)坊谁。
subscribeViewState() 用不同的方式做的是同樣的事情(Presenter到View的通信)。它在內(nèi)部創(chuàng)建一個(gè)BehaviorSubject 作為業(yè)務(wù)邏輯到View的“門戶”滑臊。既然是BahaviorSubject口芍,我們可以從業(yè)務(wù)邏輯收到“模型更新”的信息,即使是目前沒(méi)有view附加(例如雇卷,View正處于返回棧)鬓椭。BehaviorSubjects總是保留最后時(shí)刻的值,每當(dāng)有View附加到上面的時(shí)候关划,它就開(kāi)始重新接收小染,或者將它保留的值傳遞給View。
規(guī)則很簡(jiǎn)單:用intent()去“包裝”所有View的意圖(點(diǎn)擊事件等)贮折。用subscribeViewState()而不是Observable.subscribe(...)裤翩。
和bindIntent()對(duì)應(yīng)的是unbindIntents() ,這兩個(gè)方法僅僅會(huì)被調(diào)用一次调榄,當(dāng)unbindIntents()調(diào)用的時(shí)候踊赠,那么View就會(huì)被永久銷毀。舉個(gè)例子振峻,將fragment處于返回棧臼疫,不去永久銷毀view,但是如果一個(gè)Activity結(jié)束了它的生命周期,就會(huì)永久銷毀view扣孟。由于intent()和subscribeViewState()已經(jīng)負(fù)責(zé)訂閱管理烫堤,所以你幾乎不需要實(shí)現(xiàn)unbindIntents()。
那么關(guān)于我們生命周期中的onPause() 和 onResume() 是如何處理的?我認(rèn)為Presenters是不需要關(guān)注生命周期 凤价。如果鸽斟,你非要在Presenter中處理生命周期,比如你將onPause()作為intent利诺。你的View需要提供一個(gè)pauseIntent() 方法富蓄,這個(gè)方法是由生命周期觸發(fā)的,而不是用戶交互觸發(fā)的慢逾,但兩者都是有效的意圖立倍。
總結(jié)
在第二部分,我們討論了關(guān)于Model-View-Intent的基礎(chǔ)侣滩,并且用MVI實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的搜索頁(yè)面口注。讓我們?nèi)腴T。也許這個(gè)例子太簡(jiǎn)單了君珠。你無(wú)法看出MVI的優(yōu)勢(shì)寝志,Model代表狀態(tài)和單向數(shù)據(jù)流同樣適用于傳統(tǒng)的MVP或MVVM。MVP和MVVM都很優(yōu)秀。MVI也許并沒(méi)有它們優(yōu)秀材部。即使如此毫缆,我認(rèn)為MVI幫助我們面對(duì)復(fù)雜問(wèn)題的時(shí)候?qū)憙?yōu)雅的代碼。我們將在這個(gè)系列博客第三部分乐导,討論狀態(tài)減少苦丁。