Android MVVM理解以及運用

簡述:

    說到Android MVVM,相信大家都會想到Google 2015年推出的DataBinding框架。然而兩者的概念是不一樣的,不能混爲一談。MVVM是一種架構模式,而DataBinding是一個實現數據和UI綁定的框架,是構建MVVM模式的一個工具。之前看過很多關於Android MVVM的博客,但大多數提到的都是DataBinding的基本用法,很少有文章仔細講解在Android中是如何通過DataBinding去構建MVVM的應用框架的。View、ViewModel、Model每一層的職責如何?它們之間聯繫怎樣、分工如何、代碼應該如何設計?這是我寫這篇文章的初衷。

 常用 MVC、MVP、MVVM

  • MVC

View:XML佈局文件。
Model:實體模型(數據的獲取、存儲、數據狀態變化)。
Controllor:對應於Activity,處理數據、業務和UI。
從上面這個結構來看,Android本身的設計還是符合MVC架構的,但是Android中純粹作爲View的XML視圖功能太弱,我們大量處理View的邏輯只能寫在Activity中,這樣Activity就充當了View和Controller兩個角色,直接導致Activity中的代碼大爆炸。相信大多數Android開發者都遇到過一個Acitivty數以千行的代碼情況吧!所以,更貼切的說法是,這個MVC結構最終其實只是一個Model-View(Activity:View&Controller)的結構。

  • MVP

View: 對應於Activity和XML,負責View的繪製以及與用戶的交互。
Model: 依然是實體模型。
Presenter: 負責完成View與Model間的交互和業務邏輯。
前面我們說,Activity充當了View和Controller兩個角色,MVP就能很好地解決這個問題,其核心理念是通過一個抽象的View接口(不是真正的View層)將Presenter與真正的View層進行解耦。Persenter持有該View接口,對該接口進行操作,而不是直接操作View層。這樣就可以把視圖操作和業務邏輯解耦,從而讓Activity成爲真正的View層。
但MVP也存在一些弊端:
Presenter(以下簡稱P)層與View(以下簡稱V)層是通過接口進行交互的,接口粒度不好控制。粒度太小,就會存在大量接口的情況,使代碼太過碎版化;粒度太大,解耦效果不好。同時對於UI的輸入和數據的變化,需要手動調用V層或者P層相關的接口,相對來說缺乏自動性、監聽性。如果數據的變化能自動響應到UI、UI的輸入能自動更新到數據,那該多好!
MVP是以UI爲驅動的模型,更新UI都需要保證能獲取到控件的引用,同時更新UI的時候要考慮當前是否是UI線程,也要考慮Activity的生命週期(是否已經銷燬等)。
MVP是以UI和事件爲驅動的傳統模型,數據都是被動地通過UI控件做展示,但是由於數據的時變性,我們更希望數據能轉被動爲主動,希望數據能更有活性,由數據來驅動UI。
V層與P層還是有一定的耦合度。一旦V層某個UI元素更改,那麼對應的接口就必須得改,數據如何映射到UI上、事件監聽接口這些都需要轉變,牽一髮而動全身。如果這一層也能解耦就更好了。
複雜的業務同時也可能會導致P層太大,代碼臃腫的問題依然不能解決。

  • MVVM

View: 對應於Activity和XML,負責View的繪製以及與用戶交互。
Model: 實體模型。
ViewModel: 負責完成View與Model間的交互,負責業務邏輯。
MVVM的目標和思想與MVP類似,利用數據綁定(Data Binding)、依賴屬性(Dependency Property)、命令(Command)、路由事件(Routed Event)等新特性,打造了一個更加靈活高效的架構。
數據驅動
在常規的開發模式中,數據變化需要更新UI的時候,需要先獲取UI控件的引用,然後再更新UI。獲取用戶的輸入和操作也需要通過UI控件的引用。在MVVM中,這些都是通過數據驅動來自動完成的,數據變化後會自動更新UI,UI的改變也能自動反饋到數據層,數據成爲主導因素。這樣MVVM層在業務邏輯處理中只要關心數據,不需要直接和UI打交道,在業務處理過程中簡單方便很多。
低耦合度
MVVM模式中,數據是獨立於UI的。
數據和業務邏輯處於一個獨立的ViewModel中,ViewModel只需要關注數據和業務邏輯,不需要和UI或者控件打交道。UI想怎麼處理數據都由UI自己決定,ViewModel不涉及任何和UI相關的事,也不持有UI控件的引用。即便是控件改變了(比如:TextView換成EditText),ViewModel也幾乎不需要更改任何代碼。它非常完美的解耦了View層和ViewModel,解決了上面我們所說的MVP的痛點。
更新UI
在MVVM中,數據發生變化後,我們在工作線程直接修改(在數據是線程安全的情況下)ViewModel的數據即可,不用再考慮要切到主線程更新UI了,這些事情相關框架都幫我們做了。
團隊協作
MVVM的分工是非常明顯的,由於View和ViewModel之間是鬆散耦合的:一個是處理業務和數據、一個是專門的UI處理。所以,完全由兩個人分工來做,一個做UI(XML和Activity)一個寫ViewModel,效率更高。
可複用性
一個ViewModel可以複用到多個View中。同樣的一份數據,可以提供給不同的UI去做展示。對於版本迭代中頻繁的UI改動,更新或新增一套View即可。如果想在UI上做A/B Testing,那MVVM是你不二選擇。
單元測試
有些同學一看到單元測試,可能腦袋都大。是啊,寫成一團漿糊的代碼怎麼可能做單元測試?如果你們以代碼太爛無法寫單元測試而逃避,那可真是不好的消息了。這時候,你需要MVVM來拯救。
我們前面說過了,ViewModel層做的事是數據處理和業務邏輯,View層中關注的是UI,兩者完全沒有依賴。不管是UI的單元測試還是業務邏輯的單元測試,都是低耦合的。在MVVM中數據是直接綁定到UI控件上的(部分數據是可以直接反映出UI上的內容),那麼我們就可以直接通過修改綁定的數據源來間接做一些Android UI上的測試。
通過上面的簡述以及模式的對比,我們可以發現MVVM的優勢還是非常明顯的。雖然目前Android開發中可能真正在使用MVVM的很少,但是值得我們去做一些探討和調研。
如何構建MVVM應用框架
如何分工
構建MVVM框架首先要具體瞭解各個模塊的分工。接下來我們來講解View、ViewModel、Model它們各自的職責所在。
View
View層做的就是和UI相關的工作,我們只在XML、Activity和Fragment寫View層的代碼,View層不做和業務相關的事,也就是我們在Activity不寫業務邏輯和業務數據相關的代碼,更新UI通過數據綁定實現,儘量在ViewModel裏面做(更新綁定的數據源即可),Activity要做的事就是初始化一些控件(如控件的顏色,添加RecyclerView的分割線),View層可以提供更新UI的接口(但是我們更傾向所有的UI元素都是通過數據來驅動更改UI),View層可以處理事件(但是我們更希望UI事件通過Command來綁定)。簡單地說:View層不做任何業務邏輯、不涉及操作數據、不處理數據,UI和數據嚴格的分開。
ViewModel
ViewModel層做的事情剛好和View層相反,ViewModel只做和業務邏輯和業務數據相關的事,不做任何和UI相關的事情,ViewModel 層不會持有任何控件的引用,更不會在ViewModel中通過UI控件的引用去做更新UI的事情。ViewModel就是專注於業務的邏輯處理,做的事情也都只是對數據的操作(這些數據綁定在相應的控件上會自動去更改UI)。同時DataBinding框架已經支持雙向綁定,讓我們可以通過雙向綁定獲取View層反饋給ViewModel層的數據,並對這些數據上進行操作。關於對UI控件事件的處理,我們也希望能把這些事件處理綁定到控件上,並把這些事件的處理統一化,爲此我們通過BindingAdapter對一些常用的事件做了封裝,把一個個事件封裝成一個個Command,對於每個事件我們用一個ReplyCommand去處理就行了,ReplyCommand會把你可能需要的數據帶給你,這使得我們在ViewModel層處理事件的時候只需要關心處理數據就行了,具體見MVVM Light Toolkit 使用指南的 Command 部分。再強調一遍:ViewModel 不做和UI相關的事。
Model
Model層最大的特點是被賦予了數據獲取的職責,與我們平常Model層只定義實體對象的行爲截然不同。實例中,數據的獲取、存儲、數據狀態變化都是Model層的任務。Model包括實體模型(Bean)、Retrofit的Service ,獲取網絡數據接口,本地存儲(增刪改查)接口,數據變化監聽等。Model提供數據獲取接口供ViewModel調用,經數據轉換和操作並最終映射綁定到View層某個UI元素的屬性上。

  效果圖:

整體架構MVVM,網絡請求用的是retrofit2+rxjava2,圖片加載用的Glide,通過DataBinding實現將數據與ui進行了綁定

 MainActivity

public class MainActivity extends BaseActivity implements INewsView, XRecyclerView.LoadingListener {

    private ActivityMainBinding binding;
    private NewsAdapter newsAdapter;
    private NewsVM newsVM;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
    }

    @Override
    protected void initview() {
        binding.newsRv.setRefreshProgressStyle(ProgressStyle.BallClipRotate); //設置下拉刷新的樣式
        binding.newsRv.setLoadingMoreProgressStyle(ProgressStyle.BallClipRotate); //設置上拉加載更多的樣式
        binding.newsRv.setArrowImageView(R.mipmap.pull_down_arrow);
        binding.newsRv.setLoadingListener(this);
        LinearLayoutManager layoutManager = new LinearLayoutManager(this);
        binding.newsRv.setLayoutManager(layoutManager);
        newsAdapter = new NewsAdapter(this);
        binding.newsRv.setAdapter(newsAdapter);

    }

    @Override
    protected void setState() {
        newsVM = new NewsVM(this, newsAdapter);
    }

    /**
     * 初始化RecyclerView
     */
    private void initRecyclerView() {

    }

    @Override
    public void onRefresh() {
        //下拉刷新
        newsVM.loadRefreshData();
    }

    @Override
    public void onLoadMore() {
        //上拉加載更多
        newsVM.loadMoreData();
    }

    @Override
    public void loadStart(int loadType) {
        if (loadType == FIRST_LOAD) {
            DialogHelper.getInstance().show(this, "加載中...");
        }
    }

    @Override
    public void loadComplete() {
        DialogHelper.getInstance().close();
        binding.newsRv.loadMoreComplete(); //結束加載
        binding.newsRv.refreshComplete(); //結束刷新
    }

    @Override
    public void loadFailure(String message) {
        DialogHelper.getInstance().close();
        binding.newsRv.loadMoreComplete(); //結束加載
        binding.newsRv.refreshComplete(); //結束刷新
        ToastUtils.show(this, message);
    }
}

  創建了對應的ViewModel對象,可以通過這個對象來完成一些操作。這裏的Activity基本上可以稱之爲比較純粹的View了,因爲確實只做了和UI相關的工作。

2.Model層去獲取解析網絡數據,並通過接口回調給ViewModel

public class NewsModelImpl implements INewsModel {

    private static final String TAG = "NewsModelImpl";
    List<SimpleNewsBean> simpleNewsBeanList = new ArrayList<SimpleNewsBean>();

    @Override
    public void loadNewsData(final int page, final BaseLoadListener<SimpleNewsBean> loadListener) {
        HttpUtils.getNewsData()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new DisposableObserver<NewsBean>() {
                    @Override
                    public void onNext(@NonNull NewsBean newsBean) {
                        Log.i(TAG, "onNext: ");
                        List<NewsBean.OthersBean> othersBeanList = newsBean.getOthers();
                        simpleNewsBeanList.clear();
                        if (othersBeanList != null && othersBeanList.size() > 0) {
                            for (NewsBean.OthersBean othersBean : othersBeanList) {
                                String thumbnail = othersBean.getThumbnail();
                                String name = othersBean.getName();
                                String description = othersBean.getDescription();
                                Log.i(TAG, "thumbnail:---->" + thumbnail);
                                Log.i(TAG, "name:---->" + name);
                                Log.i(TAG, "description:---->" + description);

                                //構造Adapter所需的數據源
                                SimpleNewsBean simpleNewsBean = new SimpleNewsBean();
                                simpleNewsBean.thumbnail.set(thumbnail);
                                simpleNewsBean.name.set(name);
                                simpleNewsBean.description.set(description);
                                simpleNewsBeanList.add(simpleNewsBean);

                                if (page > 1) {
                                    //這個接口暫時沒有分頁的數據,這裏爲了模擬分頁,通過取第1條數據作爲分頁的數據
                                    break;
                                }
                            }
                        }
                    }

                    @Override
                    protected void onStart() {
                        super.onStart();
                        Log.i(TAG, "onStart: ");
                        loadListener.loadStart();
                    }

                    @Override
                    public void onError(@NonNull Throwable throwable) {
                        Log.i(TAG, "onError: " + throwable.getMessage());
                        loadListener.loadFailure(throwable.getMessage());
                    }

                    @Override
                    public void onComplete() {
                        Log.i(TAG, "onComplete: ");
                        new Handler().postDelayed(new Runnable() {
                            @Override
                            public void run() {
                                loadListener.loadSuccess(simpleNewsBeanList);
                                loadListener.loadComplete();
                            }
                        }, 2000);
                    }
                });
    }
}

3.NewsVM

public class NewsVM implements BaseLoadListener<SimpleNewsBean> {
    private static final String TAG = "NewsVM";
    private INewsModel mNewsModel;
    private INewsView mNewsView;
    private NewsAdapter mAdapter;
    private int currPage = 1; //當前頁數
    private int loadType; //加載數據的類型

    public NewsVM(INewsView mNewsView, NewsAdapter mAdapter) {
        this.mNewsView = mNewsView;
        this.mAdapter = mAdapter;
        mNewsModel = new NewsModelImpl();
        getNewsData();
    }

    /**
     * 第一次獲取新聞數據
     */
    private void getNewsData() {
        loadType = MainConstant.LoadData.FIRST_LOAD;
        mNewsModel.loadNewsData(currPage, this);
    }

    /**
     * 獲取下拉刷新的數據
     */
    public void loadRefreshData() {
        loadType = MainConstant.LoadData.REFRESH;
        currPage = 1;
        mNewsModel.loadNewsData(currPage, this);
    }

    /**
     * 獲取上拉加載更多的數據
     */
    public void loadMoreData() {
        loadType = MainConstant.LoadData.LOAD_MORE;
        currPage++;
        mNewsModel.loadNewsData(currPage, this);
    }

    @Override
    public void loadSuccess(List<SimpleNewsBean> list) {
        if (currPage > 1) {
            //上拉加載的數據
            mAdapter.loadMoreData(list);
        } else {
            //第一次加載或者下拉刷新的數據
            mAdapter.refreshData(list);
        }
    }

    @Override
    public void loadFailure(String message) {
        // 加載失敗後的提示
        if (currPage > 1) {
            //加載失敗需要回到加載之前的頁數
            currPage--;
        }
        mNewsView.loadFailure(message);
    }

    @Override
    public void loadStart() {
        mNewsView.loadStart(loadType);
    }

    @Override
    public void loadComplete() {
        mNewsView.loadComplete();
    }
}

這裏只是做了數據和業務邏輯的處理,並沒有任何更新UI的操作,也沒有通過binding對象去操作UI,所有的UI都是通過view接口回調到activity去處理。再次強調一下ViewModel中持有的對象是view和mode這兩個接口,處理的是業務邏輯,而不應該是databing對象,對ui的具體操作還是應該放在view層

4.NewsAdapter

public class NewsAdapter extends BaseAdapter<SimpleNewsBean, BaseViewHolder> {

    public NewsAdapter(Context context) {
        super(context);
    }

    @Override
    public BaseViewHolder onCreateVH(ViewGroup parent, int viewType) {
        ViewDataBinding dataBinding = DataBindingUtil.inflate(inflater, R.layout.item_news, parent, false);
        return new BaseViewHolder(dataBinding);
    }

    @Override
    public void onBindVH(BaseViewHolder baseViewHolder, int position) {
        ViewDataBinding binding = baseViewHolder.getBinding();
        binding.setVariable(BR.simpleNewsBean, mList.get(position));
        binding.setVariable(BR.position,position);
        binding.setVariable(BR.adapter,this);
        binding.executePendingBindings(); //防止閃爍
    }


    /**
     * 點贊
     *
     * @param simpleNewsBean
     * @param position
     */
    public void clickDianZan(SimpleNewsBean simpleNewsBean, int position) {
        if (simpleNewsBean.isGood.get()) {
            simpleNewsBean.isGood.set(false);
            ToastUtils.show(mContext, "取消點贊 position=" + position);
        } else {
            simpleNewsBean.isGood.set(true);
            ToastUtils.show(mContext, "點贊成功 position=" + position);
        }
    }
}

   這裏我們的onBindViewHolder()裏面沒有任何的更新UI的操作,沒有一對的setXX(),只是設置了幾個變量,以及一個點擊方法而已。

5.item_news,列表的item的佈局

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <import type="com.zx.mvvm.R" />

        <variable
            name="simpleNewsBean"
            type="com.zx.mvvm.bean.SimpleNewsBean" />

        <variable
            name="adapter"
            type="com.zx.mvvm.adapter.NewsAdapter" />

        <variable
            name="position"
            type="int" />
    </data>

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="15dp">

        <ImageView
            android:id="@+id/header_iv"
            android:layout_width="120dp"
            android:layout_height="60dp"
            app:imageUrl="@{simpleNewsBean.thumbnail}" />

        <!--標題-->
        <TextView
            android:id="@+id/title_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="15dp"
            android:layout_toEndOf="@id/header_iv"
            android:text="@{simpleNewsBean.name}"
            android:textColor="#000"
            android:textSize="16sp" />

        <!--描述-->
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignStart="@id/title_tv"
            android:layout_below="@id/title_tv"
            android:layout_marginTop="8dp"
            android:text="@{simpleNewsBean.description}"
            android:textSize="14sp" />

        <!--點贊或者取消點贊,onClick()用的lambda表達式-->
        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentEnd="true"
            android:layout_below="@id/header_iv"
            android:layout_marginEnd="15dp"
            android:layout_marginTop="8dp"
            android:onClick="@{()->adapter.clickDianZan(simpleNewsBean,position)}"
            app:resId="@{simpleNewsBean.isGood ? R.mipmap.dianzan_press : R.mipmap.dianzan_normal }" />

    </RelativeLayout>
</layout>

注意:

1.這個ImageView的onclick方法是通過lambda表達式來實現的,它的點擊事件事件就是adapter的clickDianZan()方法來完成的,裏面引入的幾個變量都是在adapter中設置的。

2.因爲我們沒有 獲取具體的binding類型,所以我們通過調用setVariable(a,b)來設置。 a代表:通過BR類來查找xml中variable標籤中屬性name定義的名字 ,b代表:事件或數據。當然,你也可以根據item佈局對應的具體的Binding來實現,比如這裏就是ItemNewsBinding

源碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章