作者:vivo 互聯(lián)網(wǎng)客戶端團隊-Xu Jie
Android架構(gòu)模式飛速演進,目前已經(jīng)有MVC、MVP、MVVM、MVI。到底哪一個才是自己業(yè)務(wù)場景最需要的,不深入理解的話是無法進行選擇的。這篇文章就針對這些架構(gòu)模式逐一解讀。重點會介紹Compose為什么要結(jié)合MVI進行使用。希望知其然,然后找到適合自己業(yè)務(wù)的架構(gòu)模式
一、前言
不得不感嘆,近些年android的架構(gòu)演進速度真的是飛快,拿筆者工作這幾年接觸的架構(gòu)來說,就已經(jīng)有了MVC、MVP、MVVM。正當筆者準備把MVVM應(yīng)用到自己項目當中時,發(fā)現(xiàn)谷歌悄悄的更新了開發(fā)者文檔(應(yīng)用架構(gòu)指南 | Android 開發(fā)者 | Android Developers (google.cn))。這是一篇指導(dǎo)如何使用MVI的文章。那么這個文章到底為什么更新,想要表達什么?里面提到的Compose又是什么?難道現(xiàn)在已經(jīng)有的MVC、MVP、MVVM不夠用嗎?MVI跟已有的這些架構(gòu)又有什么不同之處呢?
有人會說,不管什么架構(gòu),都是圍繞著“ 解耦”來實現(xiàn)的,這種說法是正確的,但是耦合度高只是現(xiàn)象,采用什么手段降低耦合度?降低耦合度之后的程序方便單元測試嗎?如果我在MVC、MVP、MVVM的基礎(chǔ)上做解耦,可以做的很徹底嗎?
先告訴你答案, MVC、MVP、MVVM無法做到徹底的解耦,但是MVI+Compose可以做到徹底的解耦,也就是本文的重點講解部分。本文結(jié)合具體的代碼和案例,復(fù)雜問題簡單化,并且結(jié)合較多技術(shù)博客做了統(tǒng)一的總結(jié),相信你讀完會收獲頗豐。
那么本篇文章編寫的意義,就是為了能夠深入淺出的講解MVI+Compose,大家可以先試想下這樣的業(yè)務(wù)場景,如果是你,你會選擇哪種架構(gòu)實現(xiàn)?
業(yè)務(wù)場景考慮
- 使用手機號進行登錄
- 登錄完之后驗證是否指定的賬號A
- 如果是賬號A,則進行點贊操作
上面三個步驟是順序執(zhí)行的,手機號的登錄、賬號的驗證、點贊都是與服務(wù)端進行交互之后,獲取對應(yīng)的返回結(jié)果,然后再做下一步。
在開始介紹MVI+Compose之前,需要循序漸進,了解每個架構(gòu)模式的缺點,才知道為什么Google提出MVI+Compose。
正式開始前,按照架構(gòu)模式的提出時間來看下是如何演變的,每個模式的提出往往不是基于android提出,而是基于服務(wù)端或者前端演進而來,這也說明設(shè)計思路上都是大同小異的:
二、架構(gòu)模式過去式?
2.1 MVC已經(jīng)存在很久了
MVC模式提出時間太久了,早在1978年就被提出,所以一定不是用于android,android的MVC架構(gòu)主要還是源于服務(wù)端的SpringMVC,在2007年到2017年之間,MVC占據(jù)著主導(dǎo)地位,目前我們android中看到的MVC架構(gòu)模式是這樣的。
MVC架構(gòu)這幾個部分的含義如下,網(wǎng)上隨便找找就有一堆說明。
MVC架構(gòu)分為以下幾個部分
- 【模型層Model】:主要負責(zé)網(wǎng)絡(luò)請求,數(shù)據(jù)庫處理,I/O的操作,即頁面的數(shù)據(jù)來源
- 【視圖層View】:對應(yīng)于xml布局文件和JAVA代碼動態(tài)view部分
- 【控制層Controller】:主要負責(zé)業(yè)務(wù)邏輯,在android中由Activity承擔
(1)MVC代碼示例
我們舉個登錄驗證的例子來看下MVC架構(gòu)一般怎么實現(xiàn)。
這個是controller
MVC架構(gòu)實現(xiàn)登錄流程-controller
publicclassMvcLoginActivityextendsAppCompatActivity{ privateEditText userNameEt; privateEditText passwordEt; privateUser user; @Override protectedvoidonCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); setContentView(R.layout.activity_mvc_login); user = newUser; userNameEt = findViewById(R.id.user_name_et); passwordEt = findViewById(R.id.password_et); Button loginBtn = findViewById(R.id.login_btn); loginBtn.setOnClickListener( newView.OnClickListener { @Override publicvoidonClick(View view){ LoginUtil.getInstance.doLogin(userNameEt.getText.toString, passwordEt.getText.toString, newLoginCallBack { @Override publicvoidloginResult(@NonNull com.example.mvcmvpmvvm.mvc.Model.User success){ if( null!= user) { // 這里免不了的,會有業(yè)務(wù)處理 //1、保存用戶賬號 //2、loading消失 //3、大量的變量判斷 //4、再做進一步的其他網(wǎng)絡(luò)請求 Toast.makeText(MvcLoginActivity. this, " Login Successful", Toast.LENGTH_SHORT) .show; } else{ Toast.makeText(MvcLoginActivity. this, "Login FAIled", Toast.LENGTH_SHORT) .show; } } }); } }); } }這個是model
MVC架構(gòu)實現(xiàn)登錄流程-model
publicclassLoginService{ publicstaticLoginUtil getInstance( ) { returnnewLoginUtil; } publicvoiddoLogin( String userName, String password, LoginCallBack loginCallBack) { User user = newUser; if(userName. equals( "123456") && password. equals( "123456")) { user.setUserName(userName); user.setPassword(password); loginCallBack.loginResult(user); } else{ loginCallBack.loginResult( null); } } }例子很簡單,主要做了下面這些事情
- 寫一個專門的工具類LoginService,用來做網(wǎng)絡(luò)請求doLogin,驗證登錄賬號是否正確,然后把驗證結(jié)果返回。
- activity調(diào)用LoginService,并且把賬號信息傳遞給doLogin方法,當獲取到結(jié)果后,進行對應(yīng)的業(yè)務(wù)操作。
(2)MVC優(yōu)缺點
MVC在大部分簡單業(yè)務(wù)場景下是夠用的,主要優(yōu)點如下:
- 結(jié)構(gòu)清晰,職責(zé)劃分清晰
- 降低耦合
- 有利于組件重用
但是隨著時間的推移,你的MVC架構(gòu)可能慢慢的演化成了下面的模式。拿上面的例子來說,你只做登錄比較簡單,但是當你的頁面把登錄賬號校驗、點贊都實現(xiàn)的時候,方法會比較多,共享一個view的時候,或者共同操作一個數(shù)據(jù)源的時候,就會出現(xiàn)變量滿天飛,view四處被調(diào)用,相信大家也深有體會。
不可避免的,MVC就存在了下面的問題
歸根究底,在android里面使用MVC的時候,對于Model、View、Controller的劃分范圍,總是那么的不明確,因為本身他們之間就有無法直接分割的依賴關(guān)系。所以總是避免不了這樣的問題:
- View與Model之間還存在依賴關(guān)系,甚至有時候為了圖方便,把Model和View互傳,搞得View和Model耦合度極高,低耦合是面向?qū)ο笤O(shè)計標準之一,對于大型項目來說,高耦合會很痛苦,這在開發(fā)、測試,維護方面都需要花大量的精力。
- 那么在Controller層,Activity有時既要管理View,又要控制與用戶的交互,充當Controller,可想而知,當稍微有不規(guī)范的寫法,這個Activity就會很復(fù)雜,承擔的功能會越來越多。
花了一定篇幅介紹MVC,是讓大家對MVC中Model、View、Controller應(yīng)該各自完成什么事情能深入理解,這樣才有后面架構(gòu)不斷演進的意義。
2.2 MVP架構(gòu)的由來
(1)MVP要解決什么問題?
2016年10月, Google官方提供了MVP架構(gòu)的Sample代碼來展示這種模式的用法,成為最流行的架構(gòu)。
相對于MVC,MVP將Activity復(fù)雜的邏輯處理移至另外的一個類(Presenter)中,此時Activity就是MVP模式中的View,它負責(zé)UI元素的初始化,建立UI元素與Presenter的關(guān)聯(lián)(Listener之類),同時自己也會處理一些簡單的邏輯(復(fù)雜的邏輯交由 Presenter處理)。
那么MVP 同樣將代碼劃分為三個部分:
結(jié)構(gòu)說明
- View : 對應(yīng)于Activity與XML,只負責(zé)顯示UI,只與Presenter層交互,與Model層沒有耦合;
- Model : 負責(zé)管理業(yè)務(wù)數(shù)據(jù)邏輯,如網(wǎng)絡(luò)請求、數(shù)據(jù)庫處理;
- Presenter : 負責(zé)處理大量的邏輯操作,避免Activity的臃腫。
來看看MVP的架構(gòu)圖:
與MVC的最主要區(qū)別
View與Model并不直接交互,而是通過與Presenter交互來與Model間接交互。而在MVC中View可以與Model直接交互。
通常View與Presenter是一對一的,但復(fù)雜的View可能綁定多個Presenter來處理邏輯。而Controller回歸本源,首要職責(zé)是加載應(yīng)用的布局和初始化用戶界面,并接受并處理來自用戶的操作請求,它是基于行為的,并且可以被多個View共享,Controller可以負責(zé)決定顯示哪個View。
Presenter與View的交互是通過接口來進行的,更有利于添加單元測試。
(2)MVP代碼示意
① 先來看包結(jié)構(gòu)圖
② 建立Bean
MVP架構(gòu)實現(xiàn)登錄流程-model
publicclassUser { privateStringuserName; privateStringpassword; publicStringgetUserName { return... } publicvoidsetUserName( StringuserName) { ...; } }③ 建立Model接口 (處理業(yè)務(wù)邏輯,這里指數(shù)據(jù)讀寫),先寫接口方法,后寫實現(xiàn)
MVP架構(gòu)實現(xiàn)登錄流程-model
publicinterfaceIUserBiz { booleanlogin( StringuserName, Stringpassword); }④ 建立presenter(主導(dǎo)器,通過iView和iModel接口操作model和view),activity可以把所有邏輯給presenter處理,這樣java邏輯就從activity中分離出來。
MVP架構(gòu)實現(xiàn)登錄流程-model
publicclassLoginPresenter{ privateUserBiz userBiz; privateIMvpLoginView iMvpLoginView; publicLoginPresenter(IMvpLoginView iMvpLoginView){ this.iMvpLoginView = iMvpLoginView; this.userBiz = newUserBiz; } publicvoidlogin{ String userName = iMvpLoginView.getUserName; String password = iMvpLoginView.getPassword; booleanisLoginSuccessful = userBiz.login(userName, password); iMvpLoginView.onLoginResult(isLoginSuccessful); } }⑤ View視圖建立view,用于更新ui中的view狀態(tài),這里列出需要操作當前view的方法,也是接口IMvpLoginView
MVP架構(gòu)實現(xiàn)登錄流程-model
publicinterfaceIMvpLoginView{ String getUserName( ) ; String getPassword( ) ; voidonLoginResult( Boolean isLoginSuccess) ; }⑥ activity中實現(xiàn)IMvpLoginView接口,在其中操作view,實例化一個presenter變量。
MVP架構(gòu)實現(xiàn)登錄流程-model
publicclassMvpLoginActivityextendsAppCompatActivityimplementsIMvpLoginView{ privateEditText userNameEt; privateEditText passwordEt; privateLoginPresenter loginPresenter; @Override protectedvoidonCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); setContentView(R.layout.activity_mvp_login); userNameEt = findViewById(R.id.user_name_et); passwordEt = findViewById(R.id.password_et); Button loginBtn = findViewById(R.id.login_btn); loginPresenter = newLoginPresenter( this); loginBtn.setOnClickListener( newView.OnClickListener { @Override publicvoidonClick(View view){ loginPresenter.login; } }); } @Override publicString getUserName{ returnuserNameEt.getText.toString; } @Override publicString getPassword{ returnpasswordEt.getText.toString; } @Override publicvoidonLoginResult(Boolean isLoginSuccess){ if(isLoginSuccess) { Toast.makeText(MvpLoginActivity. this, getUserName + " Login Successful", Toast.LENGTH_SHORT) .show; } else{ Toast.makeText(MvpLoginActivity. this, "Login Failed", Toast.LENGTH_SHORT).show; } } }(3)MVP優(yōu)缺點
因此,Activity及從MVC中的Controller中解放出來了,這會Activity主要做顯示View的作用和用戶交互。每個Activity可以根據(jù)自己顯示View的不同實現(xiàn)View視圖接口IUserView。
通過對比同一實例的MVC與MVP的代碼,可以證實MVP模式的一些優(yōu)點:
- 在MVP中,Activity的代碼不臃腫;
- 在MVP中,Model(IUserModel的實現(xiàn)類)的改動不會影響Activity(View),兩者也互不干涉,而在MVC中會;
- 在MVP中,IUserView這個接口可以實現(xiàn)方便地對Presenter的測試;
- 在MVP中,UserPresenter可以用于多個視圖,但是在MVC中的Activity就不行。
但還是存在一些缺點:
- 雙向依賴:View 和 Presenter 是雙向依賴的,一旦 View 層做出改變,相應(yīng)地 Presenter 也需要做出調(diào)整。在業(yè)務(wù)語境下,View 層變化是大概率事件;
- 內(nèi)存泄漏風(fēng)險:Presenter 持有 View 層的引用,當用戶關(guān)閉了 View 層,但 Model 層仍然在進行耗時操作,就會有內(nèi)存泄漏風(fēng)險。雖然有解決辦法,但還是存在風(fēng)險點和復(fù)雜度(弱引用 / onDestroy 回收 Presenter)。
三、MVVM其實夠用了
3.1MVVM思想存在很久了
MVVM最初是在2005年由微軟提出的一個UI架構(gòu)概念。后來在2015年的時候,開始應(yīng)用于android中。
MVVM 模式改動在于中間的 Presenter 改為 ViewModel,MVVM 同樣將代碼劃分為三個部分:
- View:Activity 和 Layout XML 文件,與 MVP 中 View 的概念相同;
- Model:負責(zé)管理業(yè)務(wù)數(shù)據(jù)邏輯,如網(wǎng)絡(luò)請求、數(shù)據(jù)庫處理,與 MVP 中 Model 的概念相同;
- ViewModel:存儲視圖狀態(tài),負責(zé)處理表現(xiàn)邏輯,并將數(shù)據(jù)設(shè)置給可觀察數(shù)據(jù)容器。
與MVP唯一的區(qū)別是,它采用雙向數(shù)據(jù)綁定(data-binding):View的變動,自動反映在 ViewModel,反之亦然。
MVVM架構(gòu)圖如下所示:
可以看出MVVM與MVP的主要區(qū)別在于,你不用去主動去刷新UI了,只要Model數(shù)據(jù)變了,會自動反映到UI上。換句話說,MVVM更像是自動化的MVP。
MVVM的雙向數(shù)據(jù)綁定主要通過DataBinding實現(xiàn),但是大部分人應(yīng)該跟我一樣,不使用DataBinding,那么大家最終使用的MVVM架構(gòu)就變成了下面這樣:
總結(jié)一下:
實際使用MVVM架構(gòu)說明
- View觀察ViewModel的數(shù)據(jù)變化并自我更新,這其實是單一數(shù)據(jù)源而不是雙向數(shù)據(jù)綁定,所以MVVM的雙向綁定這一大特性我這里并沒有用到
- View通過調(diào)用ViewModel提供的方法來與ViewMdoel交互。
3.2 MVVM代碼示例
(1)建立viewModel,并且提供一個可供view調(diào)取的方法 login(String userName, String
password)
MVVM架構(gòu)實現(xiàn)登錄流程-model
publicclassLoginViewModelextendsViewModel{ privateUser user; privateMutableLiveData<Boolean> isLoginSuccessfulLD; publicLoginViewModel{ this.isLoginSuccessfulLD = newMutableLiveData<>; user = newUser; } publicMutableLiveData<Boolean> getIsLoginSuccessfulLD{ returnisLoginSuccessfulLD; } publicvoidsetIsLoginSuccessfulLD( booleanisLoginSuccessful) { isLoginSuccessfulLD.postValue(isLoginSuccessful); } publicvoidlogin(String userName, String password){ if(userName.equals( "123456") && password.equals( "123456")) { user.setUserName(userName); user.setPassword(password); setIsLoginSuccessfulLD( true); } else{ setIsLoginSuccessfulLD( false); } } publicString getUserName{ returnuser.getUserName; } }(2)在activity中聲明viewModel,并建立觀察。點擊按鈕,觸發(fā) login(String userName, String password)。持續(xù)作用的觀察者loginObserver。只要LoginViewModel 中的isLoginSuccessfulLD變化,就會對應(yīng)的有響應(yīng)
MVVM架構(gòu)實現(xiàn)登錄流程-model
publicclassMvvmLoginActivityextendsAppCompatActivity{ privateLoginViewModel loginVM; privateEditText userNameEt; privateEditText passwordEt; @Override protectedvoidonCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); setContentView(R.layout.activity_mvvm_login); userNameEt = findViewById(R.id.user_name_et); passwordEt = findViewById(R.id.password_et); Button loginBtn = findViewById(R.id.login_btn); loginBtn.setOnClickListener( newView.OnClickListener { @Override publicvoidonClick(View view){ loginVM.login(userNameEt.getText.toString, passwordEt.getText.toString); } }); loginVM = newViewModelProvider( this).get(LoginViewModel.class); loginVM.getIsLoginSuccessfulLD.observe( this, loginObserver); } privateObserver<Boolean> loginObserver = newObserver<Boolean> { @Override publicvoidonChanged(@Nullable Boolean isLoginSuccessFul){ if(isLoginSuccessFul) { Toast.makeText(MvvmLoginActivity. this, "登錄成功", Toast.LENGTH_SHORT) .show; } else{ Toast.makeText(MvvmLoginActivity. this, "登錄失敗", Toast.LENGTH_SHORT) .show; } } }; }3.3 MVVM優(yōu)缺點
通過上面的代碼,可以總結(jié)出MVVM的優(yōu)點:
在實現(xiàn)細節(jié)上,View 和 Presenter 從雙向依賴變成 View 可以向 ViewModel 發(fā)指令,但 ViewModel 不會直接向 View 回調(diào),而是讓 View 通過觀察者的模式去監(jiān)聽數(shù)據(jù)的變化,有效規(guī)避了 MVP 雙向依賴的缺點。
但 MVVM 在某些情況下,也存在一些缺點:
(1)關(guān)聯(lián)性比較強的流程,liveData太多,并且理解成本較高
當業(yè)務(wù)比較復(fù)雜的時候,在viewModel中必然存在著比較多的LiveData去管理。當然,如果你去管理好這些LiveData,讓他們?nèi)ヌ幚順I(yè)務(wù)流程,問題也不大,只不過理解的成本會高些。
(2)不便于單元測試
viewModel里面一般都是對數(shù)據(jù)庫和網(wǎng)絡(luò)數(shù)據(jù)進行處理,包含了業(yè)務(wù)邏輯在里面,當要去對某一流程進行測試時,并沒有辦法完全剝離數(shù)據(jù)邏輯的處理流程,單元測試也就增加了難度。
那么我們來看看缺點對應(yīng)的具體場景是什么,便于我們后續(xù)進一步探討MVI架構(gòu)。
(1)在上面登錄之后,需要驗證賬號信息,然后再自動進行點贊。那么,viewModel里面對應(yīng)的增加幾個方法,每個方法對應(yīng)一個LiveData
MVVM架構(gòu)實現(xiàn)登錄流程-model
publicclassLoginMultiViewModel extendsViewModel { privateUser user; // 是否登錄成功 privateMutableLiveData< Boolean> isLoginSuccessfulLD; // 是否為指定賬號 privateMutableLiveData< Boolean> isMyAccountLD; // 如果是指定賬號,進行點贊 privateMutableLiveData< Boolean> goThumbUp; publicLoginMultiViewModel { this.isLoginSuccessfulLD = newMutableLiveData<>; this.isMyAccountLD = newMutableLiveData<>; this.goThumbUp = newMutableLiveData<>; user = newUser; } publicMutableLiveData< Boolean> getIsLoginSuccessfulLD { returnisLoginSuccessfulLD; } publicMutableLiveData< Boolean> getIsMyAccountLD { returnisMyAccountLD; } publicMutableLiveData< Boolean> getGoThumbUpLD { returngoThumbUp; } ... publicvoidlogin( StringuserName, Stringpassword) { if(userName.equals( "123456") && password.equals( "123456")) { user.setUserName(userName); user.setPassword(password); setIsLoginSuccessfulLD( true); } else{ setIsLoginSuccessfulLD( false); } } publicvoidisMyAccount( @NonNullStringuserName) { try{ Thread.sleep( 1000); } catch(Exception ex) { } if(userName.equals( "123456")) { setIsMyAccountSuccessfulLD( true); } else{ setIsMyAccountSuccessfulLD( false); } } publicvoidgoThumbUp( booleanisMyAccount) { setGoThumbUpLD(isMyAccount); } publicStringgetUserName { returnuser.getUserName; } }(2)再來看看你可能使用的一種處理邏輯,在判斷登錄成功之后,使用變量isLoginSuccessFul再去做 loginVM.isMyAccount(userNameEt.getText.toString);在賬號驗證成功之后,再去通過變量isMyAccount去做loginVM.goThumbUp(true);
MVVM架構(gòu)實現(xiàn)登錄流程-model
publicclassMvvmFaultLoginActivityextendsAppCompatActivity{ privateLoginMultiViewModel loginVM; privateEditText userNameEt; privateEditText passwordEt; @Override protectedvoid onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_mvvm_fault_login); userNameEt = findViewById(R.id.user_name_et); passwordEt = findViewById(R.id.password_et); Button loginBtn = findViewById(R.id.login_btn); loginBtn.setOnClickListener(new View.OnClickListener { @Override publicvoid onClick(View view) { loginVM.login(userNameEt.getText.toString, passwordEt.getText.toString); } }); loginVM = new ViewModelProvider( this). get(LoginMultiViewModel. class); loginVM.getIsLoginSuccessfulLD.observe( this, loginObserver); loginVM.getIsMyAccountLD.observe( this, isMyAccountObserver); loginVM.getGoThumbUpLD.observe( this, goThumbUpObserver); } privateObserver< Boolean> loginObserver = new Observer< Boolean> { @Override publicvoid onChanged( @NullableBooleanisLoginSuccessFul) { if(isLoginSuccessFul) { Toast.makeText(MvvmFaultLoginActivity. this, "登錄成功,開始校驗賬號", Toast.LENGTH_SHORT).show; loginVM.isMyAccount(userNameEt.getText.toString); } else{ Toast.makeText(MvvmFaultLoginActivity. this, "登錄失敗", Toast.LENGTH_SHORT) .show; } } }; privateObserver< Boolean> isMyAccountObserver = new Observer< Boolean> { @Override publicvoid onChanged( @NullableBooleanisMyAccount) { if(isMyAccount) { Toast.makeText(MvvmFaultLoginActivity. this, "校驗成功,開始點贊", Toast.LENGTH_SHORT).show; loginVM.goThumbUp( true); } } }; privateObserver< Boolean> goThumbUpObserver = new Observer< Boolean> { @Override publicvoid onChanged( @NullableBooleanisThumbUpSuccess) { if(isThumbUpSuccess) { Toast.makeText(MvvmFaultLoginActivity. this, "點贊成功", Toast.LENGTH_SHORT) .show; } else{ Toast.makeText(MvvmFaultLoginActivity. this, "點贊失敗", Toast.LENGTH_SHORT) .show; } } }; }毫無疑問,這種交互在實際開發(fā)中是可能存在的,頁面比較復(fù)雜的時候,這種變量也就滋生了。這種場景,就有必要聊聊MVI架構(gòu)了。
四、MVI有存在的必要性嗎?
4.1 MVI的由來
MVI 模式來源于2014年的 Cycle.js(一個 Java框架),并且在主流的 JS 框架 Redux 中大行其道,然后就被一些大佬移植到了 Android 上(比如最早期用Java寫的 mosby)。
既然MVVM是目前android官方推薦的架構(gòu),又為什么要有MVI呢?其實應(yīng)用架構(gòu)指南中并沒有提出MVI的概念,而是提到了單向數(shù)據(jù)流,唯一數(shù)據(jù)源,這也是區(qū)別MVVM的特性。
不過還是要說明一點,凡是MVI做到的,只要你使用MVVM去實現(xiàn),基本上也能做得到。只是說在接下來要講的內(nèi)容里面,MVI具備的封裝思路,是可以直接使用的,并且是便于單元測試的。
MVI的思想:靠數(shù)據(jù)驅(qū)動頁面 (其實當你把這種思想應(yīng)用在各個框架的時候,你的那個框架都會更加優(yōu)雅)
MVI架構(gòu)包括以下幾個部分
- Model:主要指UI狀態(tài)(State)。例如頁面加載狀態(tài)、控件位置等都是一種UI狀態(tài)。
- Intent: 此Intent不是Activity的Intent,用戶的任何操作都被包裝成Intent后發(fā)送給Model層進行數(shù)據(jù)請求。
看下交互流程圖:
對流程圖做下解釋說明:
(1)用戶操作以Intent的形式通知Model
(2)Model基于Intent更新State。這個里面包括使用ViewModel進行網(wǎng)絡(luò)請求,更新State的操作
(3)View接收到State變化刷新UI。
4.2 MVI的代碼示例
直接看代碼吧
(1)先看下包結(jié)構(gòu)
(2)用戶點擊按鈕,發(fā)起登錄流程
loginViewModel.loginActionIntent.send(LoginActionIntent.DoLogin(userNameEt.text.toString, passwordEt.text.toString))。
此處是發(fā)送了一個Intent出去
MVI架構(gòu)代碼-View
loginBtn.setOnClickListener { lifecycleScope.launch { loginViewModel.loginActionIntent.send( LoginActionIntent. DoLogin(userNameEt.text. toString, passwordEt.text. toString)) } }(3)ViewModel對Intent進行監(jiān)聽
initActionIntent。在這里可以把按鈕點擊事件的Intent消費掉
MVI架構(gòu)代碼-Model
classLoginViewModel: ViewModel{ companionobject{ constvalTAG = "LoginViewModel" } privateval_repository = LoginRepository valloginActionIntent = Channel<LoginActionIntent>(Channel.UNLIMITED) privateval_loginActionState = MutableSharedFlow<LoginActionState> valstate: SharedFlow<LoginActionState> get= _loginActionState init{ // 可以用來初始化一些頁面或者參數(shù) initActionIntent } privatefuninitActionIntent{ viewModelScope.launch { loginActionIntent.consumeAsFlow.collect { when(it) { isLoginActionIntent.DoLogin -> { doLogin(it.username, it.password) } else-> { } } } } } }(4)使用respository進行網(wǎng)絡(luò)請求,更新state
MVI架構(gòu)代碼-Repository
classLoginRepository{ suspendfunrequestLoginData(username: String, password: String) : Boolean{ delay( 1000) if(username == "123456"&& password == "123456") { returntrue } returnfalse } suspendfunrequestIsMyAccount(username: String, password: String) : Boolean{ delay( 1000) if(username == "123456") { returntrue } returnfalse } suspendfunrequestThumbUp(username: String, password: String) : Boolean{ delay( 1000) if(username == "123456") { returntrue } returnfalse } }MVI架構(gòu)代碼-更新state
privatefundoLogin(username: String, password: String) { viewModelScope.launch { if(username.isEmpty || password.isEmpty) { return@launch } // 設(shè)置頁面正在加載 _loginActionState.emit(LoginActionState.LoginLoading(username, password)) // 開始請求數(shù)據(jù) valloginResult = _repository.requestLoginData(username, password) if(!loginResult) { //登錄失敗 _loginActionState.emit(LoginActionState.LoginFailed(username, password)) return@launch } _loginActionState.emit(LoginActionState.LoginSuccessful(username, password)) //登錄成功繼續(xù)往下 valisMyAccount = _repository.requestIsMyAccount(username, password) if(!isMyAccount) { //校驗賬號失敗 _loginActionState.emit(LoginActionState.IsMyAccountFailed(username, password)) return@launch } _loginActionState.emit(LoginActionState.IsMyAccountSuccessful(username, password)) //校驗賬號成功繼續(xù)往下 valisThumbUpSuccess = _repository.requestThumbUp(username, password) if(!isThumbUpSuccess) { //點贊失敗 _loginActionState.emit(LoginActionState.GoThumbUpFailed(username, password)) return@launch } //點贊成功繼續(xù)往下 _loginActionState.emit(LoginActionState.GoThumbUpSuccessful( true)) } }(5)在View中監(jiān)聽state的變化,做頁面刷新
MVI架構(gòu)代碼-Repository
funobserveViewModel{ lifecycleScope.launch { loginViewModel.state.collect { when(it) { isLoginActionState.LoginLoading -> { Toast.makeText(baseContext, "登錄中", Toast.LENGTH_SHORT).show } isLoginActionState.LoginFailed -> { Toast.makeText(baseContext, "登錄失敗", Toast.LENGTH_SHORT).show } isLoginActionState.LoginSuccessful -> { Toast.makeText(baseContext, "登錄成功,開始校驗賬號", Toast.LENGTH_SHORT).show } isLoginActionState.IsMyAccountSuccessful -> { Toast.makeText(baseContext, "校驗成功,開始點贊", Toast.LENGTH_SHORT).show } isLoginActionState.GoThumbUpSuccessful -> { resultView.text = "點贊成功" Toast.makeText(baseContext, "點贊成功", Toast.LENGTH_SHORT).show } else-> {} } } } }通過這個流程,可以看到用戶點擊登錄操作,一直到最后刷新頁面,是一個串行的操作。在這種場景下,使用MVI架構(gòu),再合適不過
4.2 MVI的優(yōu)缺點
(1)MVI的優(yōu)點如下:
- 可以更好的進行單元測試 針對上面的案例,使用MVI這種單向數(shù)據(jù)流的形式要比MVVM更加的合適,并且便于單元測試,每個節(jié)點都較為獨立,沒有代碼上的耦合。
- 不需要像MVVM那樣管理多個LiveData,可以直接使用一個state進行管理,相比 MVVM 是新的特性。
但MVI 本身也存在一些缺點:
- State 膨脹: 所有視圖變化都轉(zhuǎn)換為 ViewState,還需要管理不同狀態(tài)下對應(yīng)的數(shù)據(jù)。實踐中應(yīng)該根據(jù)狀態(tài)之間的關(guān)聯(lián)程度來決定使用單流還是多流;
- 內(nèi)存開銷: ViewState 是不可變類,狀態(tài)變更時需要創(chuàng)建新的對象,存在一定內(nèi)存開銷;
- 局部刷新: View 根據(jù) ViewState 響應(yīng),不易實現(xiàn)局部 Diff 刷新,可以使用 Flow#distinctUntilChanged 來刷新來減少不必要的刷新。
更關(guān)鍵的一點,即使單向數(shù)據(jù)流封裝的很多,仍然避免不了來一個新人,不遵守這個單向數(shù)據(jù)流的寫法,隨便去處理view。這時候就要去引用Compose了。
五、不妨利用Compose升級MVI
這一章節(jié)是本文的重點。
2021年,谷歌發(fā)布Jetpack Compose1.0,2022年,又更新了文章應(yīng)用架構(gòu)指南,在進行界面層的搭建時,建議方案如下:
- 在屏幕上呈現(xiàn)數(shù)據(jù)的界面元素。您可以使用 View 或 Jetpack Compose 函數(shù)構(gòu)建這些元素。
- 用于存儲數(shù)據(jù)、向界面提供數(shù)據(jù)以及處理邏輯的狀態(tài)容器(如 ViewModel 類)。
為什么這里會提到Compose?
- 使用Compose的原因之一 即使你使用了MVI架構(gòu),但是當有人不遵守這個設(shè)計理念時,從代碼層面是無法避免別人使用非MVI架構(gòu),久而久之,導(dǎo)致你的代碼混亂。 意思就是說,你在使用MVI架構(gòu)搭建頁面之后,有個人突然又引入了MVC的架構(gòu),是無法避免的。Compose可以完美解決這個問題。
接下來就是本文與其他技術(shù)博客不一樣的地方,把Compose如何使用,為什么這樣使用做下說明,不要只看理論,最好實戰(zhàn)。
5.1 Compose的主要作用
Compose可以做到界面view在一開始的時候就要綁定數(shù)據(jù)源,從而達到無法在其他地方被篡改的目的。
怎么理解?
當你有個TextView被聲明之后,按照之前的架構(gòu),可以獲取這個TextView,并且給它的text隨意賦值,這就導(dǎo)致了TextView就有可能不止是在MVI架構(gòu)里面使用,也可能在MVC架構(gòu)里面使用。
5.2 MVI+Compose的代碼示例
MVI+Compose架構(gòu)代碼
classMviComposeLoginActivity: ComponentActivity{ overridefunonCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { setContent { BoxWithConstraints( modifier = Modifier .background(colorResource(id = R.color.white)) .fillMaxSize ) { loginConstraintToDo } } } } @Composable funEditorTextField(textFieldState: TextFieldState, label : String, modifier: Modifier= Modifier) { // 定義一個可觀測的text,用來在TextField中展示 TextField( value = textFieldState.text, // 顯示文本 onValueChange = { textFieldState.text = it }, // 文字改變時,就賦值給text modifier = modifier, label = { Text(text = label) }, // label是Input placeholder = @Composable{ Text(text = "123456") }, // 不輸入內(nèi)容時的占位符 ) } @SuppressLint( "CoroutineCreationDuringComposition") @Composable internalfunloginConstraintToDo(model: ComposeLoginViewModel= viewModel ){ valstate bymodel.uiState.collectAsState valcontext = LocalContext.current loginConstraintLayout( onLoginBtnClick = { text1, text2 -> lifecycleScope.launch { model.sendEvent(TodoEvent.DoLogin(text1, text2)) } }, state.isThumbUpSuccessful ) when{ state.isLoginSuccessful -> { Toast.makeText(baseContext, "登錄成功,開始校驗賬號", Toast.LENGTH_SHORT).show model.sendEvent(TodoEvent.VerifyAccount( "123456", "123456")) } state.isAccountSuccessful -> { Toast.makeText(baseContext, "賬號校驗成功,開始點贊", Toast.LENGTH_SHORT).show model.sendEvent(TodoEvent.ThumbUp( "123456", "123456")) } state.isThumbUpSuccessful -> { Toast.makeText(baseContext, "點贊成功", Toast.LENGTH_SHORT).show } } } @Composable funloginConstraintLayout(onLoginBtnClick: ( String, String) -> Unit, thumbUpSuccessful: Boolean){ ConstraintLayout { //通過createRefs創(chuàng)建三個引用 // 初始化聲明兩個元素,如果只聲明一個,則可用 createRef 方法 // 這里聲明的類似于 View 的 id val(firstText, secondText, button, text) = createRefs valfirstEditor = remember { TextFieldState } valsecondEditor = remember { TextFieldState } EditorTextField(firstEditor, "123456", Modifier.constrainAs(firstText) { top.linkTo(parent.top, margin = 16.dp) start.linkTo(parent.start) centerHorizontallyTo(parent) // 擺放在 ConstraintLayout 水平中間 }) EditorTextField(secondEditor, "123456", Modifier.constrainAs(secondText) { top.linkTo(firstText.bottom, margin = 16.dp) start.linkTo(firstText.start) centerHorizontallyTo(parent) // 擺放在 ConstraintLayout 水平中間 }) Button( onClick = { onLoginBtnClick( "123456", "123456") }, // constrainAs 將 Composable 組件與初始化的引用關(guān)聯(lián)起來 // 關(guān)聯(lián)之后就可以在其他組件中使用并添加約束條件了 modifier = Modifier.constrainAs(button) { // 熟悉 ConstraintLayout 約束寫法的一眼就懂 // parent 引用可以直接用,跟 View 體系一樣 top.linkTo(secondText.bottom, margin = 20.dp) start.linkTo(secondText.start, margin = 10.dp) } ){ Text( "Login") } Text( if(thumbUpSuccessful) "點贊成功"else"點贊失敗", Modifier.constrainAs(text) { top.linkTo(button.bottom, margin = 36.dp) start.linkTo(button.start) centerHorizontallyTo(parent) // 擺放在 ConstraintLayout 水平中間 }) } }關(guān)鍵代碼段就在于下面:
MVI+Compose架構(gòu)代碼
Text( if(thumbUpSuccessful) "點贊成功"else"點贊失敗", Modifier.constrainAs(text) { top.linkTo(button.bottom, margin = 36.dp) start.linkTo(button.start) centerHorizontallyTo( parent) // 擺放在 ConstraintLayout 水平中間 })TextView的text在頁面初始化的時候就跟數(shù)據(jù)源中的thumbUpSuccessful變量進行了綁定,并且這個TextView不可以在其他地方二次賦值,只能通過這個變量thumbUpSuccessful進行修改數(shù)值。當然,使用這個方法,也解決了數(shù)據(jù)更新是無法diff更新的問題,堪稱完美了。
5.3 MVI+Compose的優(yōu)缺點
MVI+Compose的優(yōu)點如下:
- 保證了框架的唯一性 由于每個view是在一開始的時候就被數(shù)據(jù)源賦值的,無法被多處調(diào)用隨意修改,所以保證了框架不會被隨意打亂。更好的保證了代碼的低耦合等特點。
MVI+Compose的也存在一些缺點:
不能稱為缺點的缺點吧。
由于Compose實現(xiàn)界面,是純靠kotlin代碼實現(xiàn),沒有借助xml布局,這樣的話,一開始學(xué)習(xí)的時候,學(xué)習(xí)成本要高些。并且性能還未知,最好不要用在一級頁面。
六、如何選擇框架模式
6.1 架構(gòu)選擇的原理
通過上面這么多架構(gòu)的對比,可以總結(jié)出下面的結(jié)論。
耦合度高是現(xiàn)象,關(guān)注點分離是手段,易維護性和易測試性是結(jié)果,模式是可復(fù)用的經(jīng)驗。
再來總結(jié)一下上面幾個框架適用的場景:
6.2 框架的選擇原理
- 如果你的頁面相對來說比較簡單些,比如就是一個網(wǎng)絡(luò)請求,然后刷新列表,使用MVC就夠了。
- 如果你有很多頁面共用相同的邏輯,比如多個頁面都有網(wǎng)絡(luò)請求加載中、網(wǎng)絡(luò)請求、網(wǎng)絡(luò)請求加載完成、網(wǎng)絡(luò)請求加載失敗這種,使用MVP、MVVM、MVI,把接口封裝好更好些。
- 如果你需要在多處監(jiān)聽數(shù)據(jù)源的變化,這時候需要使用LiveData或者Flow,也就是MVVM、MVI的架構(gòu)好些。
- 如果你的操作是串行的,比如登錄之后進行賬號驗證、賬號驗證完再進行點贊,這時候使用MVI更好些。當然,MVI+Compose可以保證你的架構(gòu)不易被修改。
切勿混合使用架構(gòu)模式,分析透徹頁面結(jié)構(gòu)之后,選擇一種架構(gòu)即可,不然會導(dǎo)致頁面越來越復(fù)雜,無法維護
上面就是對所有框架模式的總結(jié),大家根據(jù)實際情況進行選擇。建議還是直接上手最新的MVI+Compose,雖然多了些學(xué)習(xí)成本,但是畢竟Compose的思想還是很值得借鑒的。
END