小之的架構之路——Android MVVM 面向介面型框架封裝和單元測試

WeaponZhi發表於2017-10-15

大家好,今天給大家帶來一個我自己開發改造的 MVVM 封裝框架。程式碼不難,但我更想說一些我在開發這樣一個架構過程中的想法和思路,我們不僅要善於作一個搬運工,更要自己多多造輪子,我們程式設計師就是會折騰嘛。

思維導圖
思維導圖

先送上原始碼地址:WeaponApp

多提一句,這個 App 是我和朋友最近正在努力開發的一款 app,涵蓋絕大多數使用場景和技術(RxJava+Retrofit+MVVM+外掛化+元件化+全平臺分享+服務端)。儘量使用最優雅和最高階的方式來開發業務程式碼。使用這套框架可以快速構建 app,並能夠進行高效的維護。

希望大家可以 star 一下,提一些建議,幫助我們更好地完善它!


在講具體的實現和思路之前,我們需要多說一些東西,可以說是封裝的動機吧,或者可以解釋為什麼要用面向介面的思想來封裝。

去年的時候,MVP在移動端比較火熱,一直持續到現在,MVVM作為更為高雅和清晰的開發架構,使用的人不是很多。不像MVP,我在研究的時候,想搜尋一些封裝的資料,發現多數只能找到dataBinding的資料,但很少有教你怎麼封裝的。 「Google」爸爸的databinding為我們提供好了輪子,我們實際上按照官方的使用方式來使用MVVM已經是比較簡單了,只需要在 View 裡構建VM,在VM裡維持一個Model引用,進行相關資料的繫結即可。可以說是非常好用了。

那麼,為什麼要特別地再封裝一下呢?

這就和我們設計架構的目的和思路有關了。當然了,還有作為程式設計師,肯定還是希望能寫出最優雅、最簡潔、最高階的程式碼,我們都是偏執狂

設計思路:測試驅動、面向介面、隱蔽實現

首先,我們要明確一點,不論是MVP還是MVVM,它們都不一定會讓你用更少的程式碼來實現一個頁面,程式碼量可能會更多。它們能做到的就是做到資料、邏輯、檢視關係的解耦,提升程式碼的可維護性、可讀性、設計性和可測性

MVVM 中,ViewModel 層是 View 和 Model 的中轉層,View 專門用來處理 UI 的操作,Model 是一些資料實體,ViewModel 操作一些和資料處理相關的繫結操作,因為 databinding 的雙向繫結特性,最好的封裝應該是讓 View 層只有繫結 ViewModel 和一些必要的 UI 操作,整體的邏輯和思路乾淨整齊,ViewModel 是一個個功能單一方法的集合。

「單一原則」是我們寫程式碼的時候一定要養成的好習慣,它不僅能幫助我們寫出更優雅的程式碼,也是程式碼具有可測性、邏輯性和可維護性的要求。

MVVM 單元測試很方便,因為有了雙向繫結。只需要測一下 ViewModel 的方法,方法通過了即可驗證資料和 UI 邏輯。我們寫程式碼的時候,就應該保持好設計性,儘量做到讓程式碼的可測性很強,保持單一原則,隔離好 View 和 Model 的邏輯,讓程式碼通過驗證方法而不需要真正構造 Activity 例項就能有足夠的可測性。為了讓程式碼保持可測行,要求我們程式碼需要具有設計性,而程式碼的設計性和單一原則又是單元測試的一個本身要求,兩者相互影響,相互驅動。

這就是測試驅動開發。

好了,現在我們程式碼寫的也設計性了,方法也夠單一了,但單元測試的時候,ViewModel 作為 View 和 Model 的橋樑,它實際上應該持有 View 和 Model 的引用的,可是單元測試構造 Activity 物件不方便,我們既然是要使用單元測試,就應該儘量避免需要開啟頁面這樣的操作,雖然我們有一些非常強大的第三方單元測試框架能夠構造 Activity 和 Fragment 甚至可以驗證一些 UI 的操作,但總而言之還是一個比較麻煩而妥協的做法,所以我根據AndroidFire這個專案上的 MVP 封裝思路,進行了 MVVM 的改造,實現了編譯期的多型,通過反射構造型別引數的具體物件,在 Contact 中定義各個層級的介面,ViewModel 進行跨層呼叫的時候,只關注具體介面的形式,而不關心介面的具體實現和到底是哪個例項實現了他。

這就是面向介面了。

同時,我們隱藏了 databinding 的繫結操作,整合了一些ListViewRecyclerViewViewPager的 databinding 第三方使用庫,再通過自定義一些@BindAdapter幫助更好的進行 MVVM 開發。即使開發者之前不瞭解 databinding,按照我們封裝的操作流程,開發介面就像堆磚塊一樣簡單高效。

面向介面的框架在作單元測試的時候,我們只需要自己構建出一個空實現的介面例項,即可跳過一些 View 層的 UI 操作或者 Model 層的請求操作,做到真正意義上的單元測試。

說的很抽象,下一節我們來看一下具體程式碼。

MVVM 封裝核心實現

我們先來看下封裝的一些基類設計思路。因為「WeaponApp」的頁面全是用 Fragment 進行開發的,只需要一個佔坑 Activity 作為容器來展示 Fragment,所以我們只針對 Fragment 進行了基類封裝:

public abstract class BaseFragment<VM extends BaseViewModel<? extends BaseView, ? extends BaseModel>,
        M extends BaseModel>
        extends Fragment
        implements BaseView {}複製程式碼

emm...這是什麼。。看著這麼多泛型疊加,是不是有點頭暈,別急,我們從後往前慢慢看。

BaseView 是一個介面,裡面定義了一些必須要實現的方法,比如databinding 需要的BR檔案,init初始化方法等,最重要的是定義了一個基類型別,表示專案中所有的 Fragment 都是這個介面型別,輔助編譯期檢查。

M extends BaseModel:定義具體的 Model 型別。

VM extends BaseViewModel<? extends BaseViewModel<? extends BaseView,? extends BaseModel>>: VM 的泛型是比較複雜的,Android 中的列表控制元件都是需要一個 Adapter ,為了管理這些列表 item 的 VM,並且做到統一處理,所以 BaseViewModel 中的兩個泛型型別都是沒有 extends 來限制範圍的,那麼為了區分是頁面 VM 還是 item 的 VM。在 BaseFragment 中,通過萬用字元來限定範圍,在編譯期提醒開發者。

因為使用了binding-collection-adapter,所以在使用像 ListView,RecyclerView 和 ViewPager 這類控制元件的時候,是不需要通過 adapter 來進行管理的,全部都是通過 item 的 VM,通過 MVVM 的形式來配置。

好了,看好了類的定義程式碼,我們來下最關鍵的onCreateView()方法:

 @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        super.onCreateView(inflater, container, savedInstanceState);
        return initFragment(inflater, container);
    }複製程式碼

繼續跟進initFragment方法:

private View initFragment(LayoutInflater inflater, ViewGroup container) {
    if (mViewDataBinding == null) {
        mContext = getActivity();
        mViewDataBinding = DataBindingUtil.inflate(inflater, getLayoutId(), container, false);

       //反射生成泛型類物件
        mViewModel = TUtil.getT(this, 0);
        M model = TUtil.getT(this, 1);

       //VM 和 View 繫結
       if (mViewModel != null) {
           mViewModel.setContext(mContext);
           try {
               Method setModel = mViewModel.getClass().getMethod("setModel",Object.class);
               Method attachView = mViewModel.getClass().getMethod("attachView", Object.class);
               setModel.invoke(mViewModel, model);
               attachView.invoke(mViewModel, this);
           } catch (Exception e) {
               e.printStackTrace();
           }
      }

       //Model 和 VM 繫結
       if (model != null) {
           model.attachViewModel(mViewModel);
       }

       //DataBinding 繫結
       mViewDataBinding.setVariable(getBR(), mViewModel);

       initView();
 }複製程式碼

這裡有一些 databinding 的繫結操作,就不多細說了,我們來看下中間的部分。

mViewModel = TUtil.getT(this,0);
M model = TUtil.getT(this,1);複製程式碼

這裡的 mViewModel 的型別實際上是 VM,TUtil.getT(this,0)方法的第二個引數傳入的是類上定義的泛型位置,比如 VM 在 BaseFragment 中的位置是第一個,那麼就傳入 0,M 是第二個,那麼就傳入 1 。該方法將返回具體泛型引數型別的例項。這樣做的好處就是我們不需要手動操作構建物件並將引用儲存到成員變數上了,只需要定義好具體型別引數的泛型型別,即可通過getViewModel獲取 ViewModel 的具體例項。

繼續看程式碼。model.attachViewModel將 ViewModel 繫結到 Model,ViewModel 和 View 的繫結以及將 Model 繫結到 ViewModel 是中間一段程式碼做到的:

Method setModel = mViewModel.getClass().getMethod("setModel",Object.class);
Method attachView = mViewModel.getClass().getMethod("attachView", Object.class);
setModel.invoke(mViewModel, model);
attachView.invoke(mViewModel, this);複製程式碼

萬用字元實際上是一種具體但未知型別的型別。ViewModel 的attachViewsetModel方法的引數都是泛型引數,所以這裡必須通過反射來獲取具體的方法例項,再通過invoke進行呼叫方法。

舉個栗子??

OK,那麼我們來看看到底怎麼就「傻瓜式」開發了,怎麼就單元測試很好使了。比如現在專案中的我的介面,用這個封裝框架來寫介面的時候,先寫一個介面定義類 Contact :

interface MineContact{
    interface View extends BaseView{
        void testType();
    }

    abstract class ViewModel extends BaseViewModel<View,MineModel>{
        abstract void onHttpResponse();//資料請求成功回撥
        abstract void onHttpError();//資料請求失敗回撥
    }

    abstract class Model extends BaseModel<ViewModel>{
        abstract void loadData();//請求資料
    }

}複製程式碼

這裡定義了 MVVM 三層的型別和介面。當你需要新增介面的時候,只需要在這裡新增即可。下面是MineFragmentMineViewModelMineModel的類定義:

//View
public class MineFragment extends BaseFragment<MineViewModel,MineModel> implements MineContact.View{

    private ShareView mShareView;
    @Override
    public int getLayoutId() {
        return R.layout.fragment_mine;
    }

    @Override
    public void initView() {

    }

    @Override
    public int getBR() {
        return com.weapon.joker.app.mine.BR.model;
    }

    @Override
    public void testType(){

    }
}

//ViewModel
public class MineViewModel extends MineContact.ViewModel{

    public void init(){
        setTestString("反射封裝測試成功");
        getView().testType();
        getModel.loadData();
    }

    @Bindable
    public String getTestString(){
        return getModel().testString;
    }

    public void setTestString(String testString){
        getModel().testString = testString;
        notifyPropertyChanged(BR.testString);
    }

    public void onHttpResponse(){}
    public void onHttpError(){}
}

//Model
public class MineModel extends MineContact.Model{
    @Bindable
    public String testString;

    public void loadData(){
        getViewModel().onHttpResponse();
        getViewModel().onHttpError();
    }
}複製程式碼

我們可以看到我們寫具體類中,所有類的整合格式是一樣的,並且我們內部可以通過我們剛剛在 Contact 中定義的介面進行各個層級之間的通訊,在編譯期,我們並不用關心各個介面具體的實現是什麼,具體的實現將被移步到執行期中,這極大的方便了我們的單元測試,這也是多型和裡式替換原則的應用。同時我們發現 MVVM 的很多操作在 ViewModel 層都被隱藏了,如果你想使用 BR 檔案,就自己定義相對應的 get 方法,並不需要具體的儲存一個 model 的成員變數了。下面我們來看看具體的單元測試該怎麼寫:

比如我們現在要測試 VM 中的 init 方法,其中的 View 介面 testType() 是一個吐司顯示,為了通過這個方法,我們如果構建一個 MineFragment 例項,無疑非常麻煩,但在我們這套封裝中,我們只需要這樣寫即可:

public class Test{
    @Test
    public void main(){
        MineContact.View view = new MineContact.View(){
             @Override
             public void testType() {}

             @Override
             public int getLayoutId() {
             return 0;
             }

             @Override
             public void initView() {}

             @Override
             public int getBR() {
             return 0;
             }    
        };

    MineContact.Model model = new MineContact.Model(){
        @Override
        void loadData() {}
    };

    MineViewModel vm = new MineViewModel();
    vm.attachView(view);
    vm.setModel(model);
    //呼叫 init() 方法
    vm.init();
    }
}複製程式碼

我們成功的在單元測試中呼叫了 VM 的 init 方法,也沒有構造真正的 MineFragment,只是自己定義了一個和 MineFragment 同型別的介面,因為面向介面的原因,VM 仍然能對其進行呼叫操作,我們依然不需要關心 testType() 方法內部到底是不是和 MineFragment 定義的 testType() 方法是不是一樣的,因為這裡都是 UI 操作,我們不需要在 MVVM 的單元測試中測試它。

MVVM 的強大當然不止於此,還需要讀者自己多多發掘。當然,在學習別人的輪子的時候,一定要多多思考,舉一反三,不能一味的搬運。


我的公眾號:WeaponZhi

相關文章