我理解的MVVM架构及实战Demo

我理解的MVVM架构及实战Demo

简介

MVVM架构模式,即Model-View-ViewModel三个层级,MVVM模式出来的时间已经很长了,网上关于MVVM模式的解析也有很多,我这里只说一下我自己的理解,基本上是和MVP模式相比较的一个差异。

先说一下MVVM中,我理解的各层的作用:

  • Model: 数据处理层,用来存储数据和处理数据,这一层的作用和MVP中的Model层类似;

  • View:UI显示更新和处理与用户的交互。这一层和MVP中的View层有一点区别,在MVP模式中,View的界面更新是由Presenter层驱动的,而在MVVM模式中,View的界面更新是采用观察者模式去动态监听数据变化,数据改变后由自己来改变,变被动为主动,这样View层与其他层的耦合度进一步降低了。并且若采用了DataBinding的形式后,View的UI数据更改完全由程序自动化更改,我们少去很多初始化工作和setText()等繁复操作。

  • ViewModel:处理逻辑中转任务的媒介,很类似于MVP模式中的Presenter层,但是它相比Presenter层的优点在于,它不用去通知View层去更新UI界面,降低了和View的耦合度,并且同一个ViewModel可以同时绑定多个View层,达到共同更新数据的目的。

MVVM模式中的一些相关知识点

Demo中会涉及一下一些关于MVVM模式的知识,不清楚的同学可以先了解一下,加深印象。

Lifecycle

Lifecycle是Google官方提供的方便管理生命周期事件的方式,可以更方便的监听生命周期变化事件,它以注解的方式作用在方法上,当生命周期方法调用时,它也会被跟随调用。

LiveData

LiveData是一种用来持有数据的对象,并且当数据改变时,去通知处于active状态的观察者去做出相应的改变。在Android中,处于STARTED或者RESUME状态下的观察者被认为是active状态。LiveData基于Lifecycle。

也就是说,当多个View层共享同一组数据时(一个Activity和多个Fragment),这组数据可以通过LiveData的形式持有,因为LiveData持有的数据是根据View的当前状态去通知的,因此,它不会产生一些类似于View被销毁后还去更改UI的一些奇怪的crash现象。

并且,LiveData通过Lifecycle的方式,已经监听了View的生命周期,它会跟随View层在View被销毁的时候销毁(旋转屏等重建行为不会受影响)。

DataBinding

Databing是Google发布的一个用来支持MVVM模式的框架,它的主要作用是用来降低布局和逻辑的耦合度。
以前,我们都是在布局xml文件中写好控件,然后通过findViewById的形式去手动更新UI,DataBindind的出现,让我们省去了查找控件和手动更新UI的操作,我们仅需要把布局中的控件和Model层的数据关联起来,当数据改变时,UI也会跟着改变,达到双向绑定的目的。

实战演练

理论知识就介绍到这里,网上有很多相关的资料介绍,大家可以自行查找。这里,我通过实战Demo来告诉一下大家我理解的MVVM模式是什么样的,若有不正确,还请指正。

准备工作

这次的Demo也是参考网上其他人的博客来写的,功能是获取Bing网站首页的图片并显示,Bing网站首页图片的获取地址是:

https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1

其中,idx的数字表示不同的图片,获取到的Json文件格式如下:

{
    "images":Array[1],
    "tooltips":{
        "loading":"正在加载...",
        "previous":"上一个图像",
        "next":"下一个图像",
        "walle":"此图片不能下载用作壁纸。",
        "walls":"下载今日美图。仅限用作桌面壁纸。"
    }
}

其中,"images":Array[1]中包含图片的URL和描述,展开后是这样的:

{
    "startdate":"20190901",
    "fullstartdate":"201909011600",
    "enddate":"20190902",
    "url":"/th?id=OHR.RamsauWimbachklamm_ZH-CN1602837695_1920x1080.jpg&rf=LaDigue_1920x1080.jpg&pid=hp",
    "urlbase":"/th?id=OHR.RamsauWimbachklamm_ZH-CN1602837695",
    "copyright":"拉姆绍的Wimbachklamm峡谷,德国巴伐利亚 (© Westend61 GmbH/Alamy)",
    "copyrightlink":"https://www.bing.com/search?q=Wimbachklamm%E5%B3%A1%E8%B0%B7&form=hpcapt&mkt=zh-cn",
    "title":"",
    "quiz":"/search?q=Bing+homepage+quiz&filters=WQOskey:%22HPQuiz_20190901_RamsauWimbachklamm%22&FORM=HPQUIZ",
    "wp":true,
    "hsh":"9f822f5607e3cec381408303a73c1986",
    "drk":1,
    "top":1,
    "bot":1,
    "hs":[

    ]
}

通过Json文件我们得到,真正的图片地址是https://www.bing.com/开头,接上Json中Array中的url,即为图片地址。

下面不多说,直接开撸代码。

因为本Demo使用了DataBinding + LiveData的形式,因此需要首先在module的build.gradle文件中加上:

android {
    ...
    
    dataBinding {
        enabled = true
    }
}

然后,依赖如下框架:

dependencies {
    ...

    implementation 'com.squareup.retrofit2:retrofit:2.4.0'
    implementation 'com.squareup.retrofit2:adapter-rxjava2:2.4.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.4.0'
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'
    implementation 'io.reactivex.rxjava2:rxjava:2.1.12'
    implementation 'com.github.bumptech.glide:glide:4.6.1'
    annotationProcessor 'com.github.bumptech.glide:compiler:4.6.1'
}

当然,还要记得在AndroidManifest文件上加上访问网络的权限:

<uses-permission android:name="android.permission.INTERNET" />

1、Model层

Model层的职责就是用来处理数据。

1.1 Bean文件

由于我们只关心Json数据中的url地址和图片描述,因此,我们的Bean文件极为简单:

获取Url和描述的Bean文件ImageUrlBean

public class ImageUrlBean {

    private List<UrlBean> images;

    public List<UrlBean> getImages() {
        return images;
    }

    public void setImages(List<UrlBean> images) {
        this.images = images;
    }

    public static class UrlBean {

        public static final String BASE_IMAGE_ADDRESS_URL = "https://cn.bing.com/";
        
        private String url;
        
        private String copyright;

        public String getUrl() {
            return url;
        }

        public void setUrl(String url) {
            this.url = url;
        }

        public String getCopyright() {
            return copyright;
        }

        public void setCopyright(String copyright) {
            this.copyright = copyright;
        }
    }

}

由于网络等其他原因会存在获取数据失败的风险,,因此在ImageUrlBean的基础上,加封了一层数据层UrlData,用来判断是否获取到了真实数据:

public class UrlData<T> {

    private T data;
    private String errorMsg;

    public UrlData(){}

    public UrlData(T data, String errorMsg) {
        this.data = data;
        this.errorMsg = errorMsg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

    public String getErrorMsg() {
        return errorMsg;
    }

    public void setErrorMsg(String errorMsg) {
        this.errorMsg = errorMsg;
    }
}

1.2 封装网络

本次Demo的网络框架用的是Retrofit2 + RxJava2,不在本次的研究范围内,不了解的朋友请自行学习。

获取网络的接口IGetUrlService

public interface IGetUrlService {

    @GET("HPImageArchive.aspx")
    Observable<ImageUrlBean> getUrl(@Query("format") String format,
                                    @Query("idx") int idx,
                                    @Query("n") int n);
}

获取到Url数据后的回调GetUrlCallback

public interface GetUrlCallback {

    void handleUrl(UrlData<ImageUrlBean.UrlBean> data);

}

网络工具类HttpUtil:

public class HttpUtil {

    private Retrofit mRetrifit;

    private HttpUtil(){
        mRetrifit = new Retrofit.Builder()
                .baseUrl(ImageUrlBean.UrlBean.BASE_IMAGE_ADDRESS_URL)
                .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                .addConverterFactory(GsonConverterFactory.create())
                .build();
    }

    public Observable<ImageUrlBean> getImageUrl(String format, int idx, int n) {
        return mRetrifit.create(IGetUrlService.class)
                .getUrl(format, idx, n);
    }

    public static HttpUtil getInstance() {
        return Holder.mInstance;
    }

    private static class Holder {
        private static final HttpUtil mInstance = new HttpUtil();
    }
}

1.3 获取数据的Model

用来获取数据的ImageUrlModel

public class ImageUrlModel {

    private HttpUtil mHttpUtil;

    public ImageUrlModel() {
        mHttpUtil = HttpUtil.getInstance();
    }

    public void getImageUrl(String format, int idx, int n, final GetUrlCallback callback) {
        mHttpUtil.getImageUrl(format, idx, n)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Observer<ImageUrlBean>() {
                    @Override
                    public void onSubscribe(Disposable d) {

                    }

                    @Override
                    public void onNext(ImageUrlBean imageUrlBean) {
                        UrlData<ImageUrlBean.UrlBean> data =
                                new UrlData<>(imageUrlBean.getImages().get(0), null);
                        callback.handleUrl(data);
                    }

                    @Override
                    public void onError(Throwable e) {
                        UrlData<ImageUrlBean.UrlBean> data = new UrlData<>(null, e.getMessage());
                        callback.handleUrl(data);
                    }

                    @Override
                    public void onComplete() {

                    }
                });
    }
}

2、ViewModel层

ViewModel层的职责是用来处理中转逻辑,将真正获取数据的操作交给Model去执行,然后将获取到的数据更改到自身的LiveData中去。LiveData中的数据改变后,会去通知监听它改变的、当前状态是可见的View们去更改UI。

ImageUrlViewModel

public class ImageUrlViewModel extends ViewModel {

    public static final int STATE_NO_PRE_IMAGE = 0; // 没有前一张图片时的状态
    public static final int STATE_NORMAL_IMAGE = 1; // 图片正常获取的状态

    /**
     * 放在LiveData中的数据,会被View监听,
     * 当数据改变且View层属于可见状态,则会去通知View层更新数据
     */
    private MutableLiveData<UrlData<ImageUrlBean.UrlBean>> mData;

    // 用来当特殊情况下更新UI,比如没有前一张图片时,提醒View层弹出Toast提醒用户
    private MutableLiveData<Integer> mState;

    /**
     * 本例没有用到Context,
     * 若需要,则必须传Application的Context,达到与View层独立的目的。
     */
    private Context mContext;

    private ImageUrlModel model;

    private GetUrlCallback callback;

    private int index; // 当前获取的哪一张图片的索引

    public ImageUrlViewModel(){
        this(null);
    }

    public ImageUrlViewModel(Context context) {
        this.mContext = context != null ?
                context.getApplicationContext() : null;
        mData = new MutableLiveData<>();
        mState = new MutableLiveData<>();
        model = new ImageUrlModel();
        callback = new GetUrlCallback() {
            @Override
            public void handleUrl(UrlData<ImageUrlBean.UrlBean> data) {
                // ViewModel层仅仅处理数据,界面的改变逻辑由Activity去执行
                mData.setValue(data);
            }
        };
    }

    public MutableLiveData<UrlData<ImageUrlBean.UrlBean>> getImageUrl() {
        return mData;
    }

    public MutableLiveData<Integer> getState() {
        return mState;
    }

    public void loadImage() {
        model.getImageUrl("js", index, 1, callback);
    }

    public void loadNextImage() {
        index++;
        model.getImageUrl("js", index, 1, callback);
    }

    public void loadPreImage() {
        if(index == 0) {
            // 没有数据时,通过更改state的值,去通知View层更改数据
            mState.setValue(STATE_NO_PRE_IMAGE);
            mState.setValue(STATE_NORMAL_IMAGE);
            return;
        }
        index--;
        model.getImageUrl("js", index, 1, callback);
    }

}

3、View层

首先编写xml布局,由于本例采用DataBinding框架,因此布局可能有所不同:

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    >

    <data>
        <variable
            name="image"
            type="com.zw.mvvmdemo.bean.ImageUrlBean.UrlBean"/>

        <!-- 用来处理点击事件 -->
        <variable
            name="clicker"
            type="com.zw.mvvmdemo.BingImageActivity.Clicker"/>

        <!-- 用来处理一些特殊UI更新变化 -->
        <variable
            name="uiChanger"
            type="Integer"/>

    </data>


    <android.support.constraint.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/iv"
            url="@{image.BASE_IMAGE_ADDRESS_URL + image.url}"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_width="match_parent"
            android:layout_height="200dp" />

        <TextView
            android:id="@+id/tv"
            android:text="@{image.copyright}"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/iv"
            android:layout_marginTop="20dp"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:textSize="20sp"/>

        <Button
            android:id="@+id/btn_pre"
            android:onClick="@{clicker.onClick}"
            android:layout_width="0dp"
            android:layout_height="50dp"
            android:text="上一张"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/tv"
            android:layout_marginTop="20dp"
            app:layout_constraintWidth_percent="0.5"/>

        <Button
            android:id="@+id/btn"
            android:onClick="@{clicker.onClick}"
            android:layout_width="0dp"
            android:layout_height="50dp"
            android:text="加载"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/btn_pre"
            android:layout_marginTop="20dp"
            app:layout_constraintWidth_percent="0.5"/>

        <Button
            android:id="@+id/btn_next"
            android:onClick="@{clicker.onClick}"
            android:layout_width="0dp"
            android:layout_height="50dp"
            android:text="下一张"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/btn"
            android:layout_marginTop="20dp"
            app:layout_constraintWidth_percent="0.5"/>


    </android.support.constraint.ConstraintLayout>

</layout>

布局和我们常见的布局不一样,根标签是<layout>,然后是两个平级的标签<data>和真正的布局文件,布局文件比较好理解,就不过多介绍了,介绍一下<data>

当你需要与布局文件中的某个控件进行双向绑定的时候,可以将你的某个对象与控件绑定。比如上述文件中的

<variable
    name="image"
    type="com.zw.mvvmdemo.bean.ImageUrlBean.UrlBean"
/>

它与布局文件中的

<ImageView
    android:id="@+id/iv"
    url="@{image.BASE_IMAGE_ADDRESS_URL + image.url}"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    android:layout_width="match_parent"
    android:layout_height="200dp" 
/>

对应,其中variable标签中:

  • name:需要绑定数据的名称,可按照自己的命名习惯命名;
  • type:需要绑定数据的类;

在此例中,当需要用到UrlBean时,可以通过@{}操作符并通过name来填写符合需求的表达式。比如在此例中,我们给ImageViewurl属性绑定了UrlBean这个实例,当UrlBeanimage.url变化时,ImageViewurl也会跟着改变。

url是我们自定义的一个属性,这个属性并不是在style.xml文件中定义的,而是通过DataBinding的注解BindingAdapter来完成的,我们可以通过自定义属性,来完成我们想要进行处理的逻辑操作。

GetBingImageAdapter

public class GetBingImageAdapter {

    @BindingAdapter("url")
    public static void setImage(ImageView iv, String url) {
        Glide.with(iv)
                .load(url)
                .into(iv);
    }
}

ImageView中的url改变时,会回调此Adapter,然后回调setImage()

然后,我们看看BingImageActivity

public class BingImageActivity extends AppCompatActivity {

    private ActivityBingImageBinding binding;
    private ImageUrlViewModel mViewModel;
    private ProgressDialog mDialog;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = DataBindingUtil
                .setContentView(this, R.layout.activity_bing_image);

         // 建立ViewModel,并将Activity的生命周期绑定到ViewModel上
        mViewModel = new ViewModelProvider(
                this,
                ViewModelProvider.AndroidViewModelFactory.getInstance(getApplication()))
                .get(ImageUrlViewModel.class);

        // 为ViewModel的UrlData建立数据监听,并监听数据变化,根据数据更新UI
        mViewModel.getImageUrl().observe(this, new Observer<UrlData<ImageUrlBean.UrlBean>>() {
            @Override
            public void onChanged(@Nullable UrlData<ImageUrlBean.UrlBean> data) {
                mDialog.dismiss();
                if(data.getErrorMsg() != null) {
                    Toast.makeText(BingImageActivity.this, data.getErrorMsg(),
                            Toast.LENGTH_LONG).show();
                }
                // 监听到数据变化后,通过databinding更改布局UI,若未使用databinding,则需要自己写相关UI更新逻辑
                binding.setImage(data.getData());
        }
        });

        // 监听特殊状态下更新UI的操作
        mViewModel.getState().observe(this, new Observer<Integer>() {
            @Override
            public void onChanged(@Nullable Integer integer) {
                mDialog.dismiss();
                if(integer == ImageUrlViewModel.STATE_NO_PRE_IMAGE) {
                    Toast.makeText(BingImageActivity.this, "没有前一张图片了!",
                            Toast.LENGTH_LONG).show();
                }
            }
        });

        binding.setClicker(new Clicker());

        mDialog = new ProgressDialog(this);
        mDialog.setTitle("加载中");
        mDialog.show();
        mViewModel.loadImage();
    }

    public class Clicker {

        public void onClick(View view) {
            mDialog.show();
            switch (view.getId()) {
                case R.id.btn_pre:
                    mViewModel.loadPreImage();
                    break;
                case R.id.btn:
                    mViewModel.loadImage();
                    break;
                case R.id.btn_next:
                    mViewModel.loadNextImage();
                    break;
            }
        }
    }
}

实战总结

这次编写的MVVM模式的Demo,让我对MVVM模式有了一点理解,它的优点和缺点都同样明显。

优点

  • 数据和布局文件实现双向绑定,我们仅关心数据的改变即可,View层UI的改变会通过数据的改变而改变。省去写findViewByIdsetText()等繁复的代码的工作。
  • View层更新UI的操作完全由自己决定,而无需其他层级的通知,变被动为主动,同时也减轻了其他层级的负担。
  • View层和ViewModel层完全解耦,并且ViewModel层可同时绑定多个View层,去同时更新数据的变化。

缺点

  • View层对ViewModel层数据变化采用监听的方式,若数据种类过多,则需要写多个监听器观测。
  • 若ViewModel层数据未改变,而需要View层去改变UI时,则非常不方便,需要使用其他方式实现(比如增加额外监听器)。
  • Android Studio对MVVM模式的支持还不是很完善,Binding类的生成可能需要多次Rebuild的操作,若Binding类过多,则编译工作会耗时很大;
  • MVVM模式下,因为View层和ViewModel层完全独立的关系,程序如果报错则很难定位具体问题,排查问题困难。

总的来说,MVVM模式我认为还是一个非常优秀的模式,它省去了MVP模式中编写巨量接口的麻烦,同时将View层和ViewModel层完全解耦,若可以接受上述总结中的缺点项,我认为项目完全可以采用MVVM模式去进行开发。

Demo下载

本Demo地址

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容