Android架构模式之MVC、MVP、MVVM

在开始讲解各种架构模式时,我们先来看下没有经过设计的代码是如何编写的。为了不分散重点,笔者举的例子会比较简单,初始时从数据库缓存中获取用户信息展示到界面上,点击刷新按钮可以从服务器上拉取最新的用户信息并进行展示。

由于从数据库和服务器上获取数据都属于更底层的逻辑,因此这两个操作一开始就会进行封装,不会列入讨论范围,并且为了使程序更加简单,这两个操作都是使用的测试代码进行模拟。

User.java

// User实体类,再没有封装意识的人,实体类总会有一个吧
public class User {
    public String name;
    public int age;
}

DbUtils.java

// 数据库工具
public class DbUtils {
    // 查询数据库记录并返回cursor,这里使用测试代码直接返回null
    public static Cursor query(String sql) {
        return null;
    }
    
    // 更新数据库记录,这里使用测试代码不进行任何实际处理
    public static void update(String sql) {
        
    }
}

HttpUtils.java

// http工具
public class HttpUtils {
    private static Handler sHandler = new Handler(Looper.getMainLooper());

    public interface ResponseCallback {
        void onResponseSuccessed(String json);
        void onResponseFailed(int reason);
    }

    // 发起http请求,这里使用模拟的数据,并有一定机率请求失败
    public static void request(Map params, final ResponseCallback callback) {
        sHandler.postDelayed(new Runnable() {
            @Override
            public void run() {
                int value = new Random(System.currentTimeMillis()).nextInt(5);
                if(callback != null) {
                    if(value == 2) {
                        callback.onResponseFailed(1);
                    } else {
                        callback.onResponseSuccessed("{\"name\": \"纯爷们\", \"age\": 20}");
                    }
                }
            }
        }, 500);
    }
}

activity_user.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="10dp">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/tv_name"/>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:id="@+id/tv_age"/>

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:layout_marginTop="20dp"
        android:text="刷新"
        android:id="@+id/btn_refresh"/>

</LinearLayout>

UserActivity.java

public class UserActivity extends Activity {
    private TextView mNameView;
    private TextView mAgeView;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_user);

        mNameView = (TextView)findViewById(R.id.tv_name);
        mAgeView = (TextView)findViewById(R.id.tv_age);
        
        // 加载缓存的用户数据并展示
        User user = loadUser();
        if(user != null) {
            mNameView.setText("昵称:" + user.name);
            mAgeView.setText("年龄:" + user.age);
        }

        findViewById(R.id.btn_refresh).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 从服务器拉取最新用户数据并显示
                refresh();
            }
        });
    }

    private User loadUser() {
        // 这里本应从cursor中获取数据,但为求程序尽量简单,我们直接使用模拟数据。之所以要加入query这段代码,是为了尽可能模拟真实的流程。
        Cursor cursor = DbUtils.query(null);
        if(cursor != null) {
            try {

            } catch (Exception e) {

            } finally {
                cursor.close();
            }
        }
        User user = new User();
        user.name = "小虾米";
        user.age = 19;
        return user;
    }

    private void refresh() {
        HttpUtils.request(null, new HttpUtils.ResponseCallback() {
            @Override
            public void onResponseSuccessed(String json) {
                try {
                    User user = new Gson().fromJson(json, User.class);
                    
                    // 用户信息更新了,要同步更新数据库中的记录
                    String updateSql = null;
                    DbUtils.update(updateSql);
                   
                    mNameView.setText("昵称:" + user.name);
                    mAgeView.setText("年龄:" + user.age);
                } catch (Exception e) {
                }
            }

            @Override
            public void onResponseFailed(int reason) {
                Toast.makeText(UserActivity.this, "刷新失败", Toast.LENGTH_SHORT).show();
            }
        });
    }
}

点击刷新前显示如下界面

image

点击刷新后显示如下界面
image

上面的例子请读者务必记劳,后续讲到的几种架构模式全部使用的都是这个例子。

上述例子一个非常突出的问题是,用户信息可能在多个界面上都需要显示,而在这些界面上,从数据库和服务器上获取用户信息的流程都要写一遍,重复编写不仅容易出错也不容易维护。解决该问题的方法就是封装一个可复用的Model,MXX模式也由此产生。

MVC

MVC由Model、View、Controller组成,Android提供的xml布局是View层的主要部分,View本身已经是相对比较独立的了,因此MVC中主要考虑的就是Model的设计。我们来看下MVC中各层在Android中的主要应用:

  1. Model:表示模型层,模型的一个核心特点就是可复用。包含数据业务实体(entity/bean)本身,以及围绕该实体进行的业务操作(本地或远程增删改查操作)。即Model层主要针对数据,包含数据实体和数据访问,如果非要以模型来称呼和理解的话,前者为数据模型,后者为业务模型。
  2. View:表示视图层,负责界面数据的展示,以及响应用户操作。
  3. Controller:表示控制层,负责逻辑处理,由其连接Model和View。Controller通过Model获取数据并传递给View进行展示;通过响应View传递过来的用户事件调用Model的接口进行业务处理。

后续内容都使用简称,M代表Model,V代表View,C代表Controller。

其中,V和C一般又统称为UI层,由于Android已经提供了xml布局,因此在Android中V和C并不需要刻意区分,可以统一以UI层来理解,UI层的核心代码包含xml和Activity(或Fragment,或另外封装的控制器),后者既扮演着部分V的角色,又扮演着全部的C角色。我们来看下使用MVC重构后的例子,增加了UserBusiness类,修改了UserActivity的代码,以下只贴出更新的部分代码,其余代码请参考之前的例子。

UserBusiness.java

public class UserBusiness {
    private static final UserBusiness INSTANCE = new UserBusiness();

    private List<UserListener> mListeners = new LinkedList<>();

    public static UserBusiness get() {
        return INSTANCE;
    }

    public void addListener(UserListener listener) {
        if(listener == null) {
            return;
        }
        synchronized (mListeners) {
            if(!mListeners.contains(listener)) {
                mListeners.add(listener);
            }
        }
    }

    public void removeListener(UserListener listener) {
        if(listener == null) {
            return;
        }
        synchronized (mListeners) {
            mListeners.remove(listener);
        }
    }

    public User getUser() {
        Cursor cursor = DbUtils.query(null);
        if(cursor != null) {
            try {

            } catch (Exception e) {

            } finally {
                cursor.close();
            }
        }
        User user = new User();
        user.name = "小虾米";
        user.age = 19;
        return user;
    }

    public void requestUser() {
        HttpUtils.request(null, new HttpUtils.ResponseCallback() {
            @Override
            public void onResponseSuccessed(String json) {
                User user = null;
                try {
                    user = new Gson().fromJson(json, User.class);
                } catch (Exception e) {
                }
                if(user != null) {
                    String updateSql = null;
                    DbUtils.update(updateSql);
                    notifyRequestUser(0, user);
                } else {
                    notifyRequestUser(1, null);
                }
            }

            @Override
            public void onResponseFailed(int reason) {
                notifyRequestUser(reason, null);
            }
        });
    }

    private void notifyRequestUser(int code, User user) {
        List<UserListener> listeners = new LinkedList<>();
        synchronized (mListeners) {
            listeners.addAll(mListeners);
        }
        for(UserListener listener : listeners) {
            listener.onRequestUserResult(code, user);
        }
    }

    public interface UserListener {
        void onRequestUserResult(int code, User user);
    }
}

UserActivity.java

public class UserActivity extends Activity implements UserBusiness.UserListener {
    private TextView mNameView;
    private TextView mAgeView;
    private UserBusiness mUserBusiness = UserBusiness.get();

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_user);

        mNameView = (TextView)findViewById(R.id.tv_name);
        mAgeView = (TextView)findViewById(R.id.tv_age);

        // 加载缓存的用户数据并展示
        User user = mUserBusiness.getUser();
        if(user != null) {
            mNameView.setText("昵称:" + user.name);
            mAgeView.setText("年龄:" + user.age);
        }

        findViewById(R.id.btn_refresh).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 从服务器拉取最新用户数据并显示
                mUserBusiness.requestUser();
            }
        });

        mUserBusiness.addListener(this);
    }

    @Override
    protected void onDestroy() {
        mUserBusiness.removeListener(this);
        super.onDestroy();
    }

    @Override
    public void onRequestUserResult(int code, User user) {
        if(code == 0) {
            mNameView.setText("昵称:" + user.name);
            mAgeView.setText("年龄:" + user.age);
        } else {
            Toast.makeText(UserActivity.this, "刷新失败", Toast.LENGTH_SHORT).show();
        }
    }
}

重构后的代码有如下优点:

  1. Activity的代码变得简单和整洁了,Activity现在只需要处理控制逻辑(UI逻辑),以及作为V和M通信的桥梁。
  2. 业务代码封装在UserBusiness中,一是隐藏了数据操作(业务流程)的具体细节,使得UI层在访问时更简单了;二是可以复用,任何模块都可以轻松访问,且可以通过在UserBusiness中注册一个监听器来监听用户业务的相关事件。

但MVC仍具有以下缺点:

  1. V不可复用,然而在Android中V复用没有意义,需要复用的话完全可以封装可复用的控件,然后V组装这些控件。
  2. C不可复用。
  3. V和C之间还存在部分耦合,因此除了M外V和C都无法进行单元测试。

为了解决以上缺点,便有了MVP。

MVP

MVP由Model、View和Presenter组成,M和V就不再重复解释了,P和C一样,承担着控制层的责任。MVP相比MVC作了如下改进(或者说变化,是否改进视情况而定):

  1. P可复用,这意味着不能再使用Activity(或...)作为P了,很简单,Activity不能复用(使用继承达到复用的场景不在这讨论范围之内)。由此,Activity从控制层的角色转向了视图层,即在MVP中V由xml和Activity组成。

    也可以另外抽离一个V,然后将Activity作为创建V和P的管理器,并负责将V和P进行绑定。但不建议采用这种方式,额外增加了代码,并且也没带来多少益处,除非想要复用V或者界面异常复杂而拆分了多个V和P。

  2. MVP三者皆可以独立完成单元测试,为了达到这个目的,P和V需要做到完全解耦,解耦一般使用接口。

我们来看下使用MVP重构过的代码,在mvc的基础上主要是改动了UserActivity.java,然后增加了几个类。

PresenterContext.java

public interface PresenterContext {
    Activity getActivity();
}

UserPresenter.java

public interface UserPresenter {
    void onRefresh();
    void onInited();
    void onDestroyed();
}

UserPresenterImpl.java

public class UserPresenterImpl implements UserPresenter, UserBusiness.UserListener {
    private PresenterContext mContext;
    private UserView mView;
    private UserBusiness mUserBusiness = UserBusiness.get();

    public UserPresenterImpl(PresenterContext context, UserView view) {
        mContext = context;
        mView = view;
    }

    @Override
    public void onRefresh() {
        mUserBusiness.requestUser();
    }

    @Override
    public void onInited() {
        mUserBusiness.addListener(this);
        User user = mUserBusiness.getUser();
        if(user != null) {
            mView.updateName(user.name);
            mView.updateAge(user.age);
        }

    }

    @Override
    public void onDestroyed() {
        mUserBusiness.removeListener(this);
    }

    @Override
    public void onRequestUserResult(int code, User user) {
        if(code == 0) {
            mView.updateName(user.name);
            mView.updateAge(user.age);
        } else {
            Toast.makeText(mContext.getActivity(), "刷新失败", Toast.LENGTH_SHORT).show();
        }
    }
}

UserView.java

public interface UserView {
    void updateName(String name);
    void updateAge(int age);
}

UserActivity.java

public class UserActivity extends Activity implements UserView, PresenterContext {
    private TextView mNameView;
    private TextView mAgeView;
    private UserPresenter mPresenter;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_user);

        mNameView = (TextView)findViewById(R.id.tv_name);
        mAgeView = (TextView)findViewById(R.id.tv_age);

        mPresenter = new UserPresenterImpl(this, this);

        findViewById(R.id.btn_refresh).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mPresenter.onRefresh();
            }
        });

        mPresenter.onInited();
    }

    @Override
    protected void onDestroy() {
        mPresenter.onDestroyed();
        super.onDestroy();
    }

    @Override
    public void updateName(String name) {
        mNameView.setText("昵称:" + name);
    }

    @Override
    public void updateAge(int age) {
        mAgeView.setText("年龄:" + age);
    }

    @Override
    public Activity getActivity() {
        return this;
    }
}

我们来讲解下:

  1. 由于Presenter不像Activity一样,它没有上下文信息,因此增加了PresenterContext类作为Presenter的上下文信息。

    这里PresenterContext只是用来获取Activity,是因为示例是力求简单,实际项目中它可以获取的信息会更多。

  2. UserActivity的代码被拆分成了两部分,一部分仍然在UserActivity中,作为View的代码,一部分抽离到UserPresenterImpl中,作为Presenter的代码。至此,将视图和控制层的代码完全分离了。
  3. 新建了UserViewUserPresenter两个接口用来表示V和P,V的实现方UserActivity持有UserPresenter接口而非具体的实现类,P的实现方持有UserView接口而非具体的实现类,从而达到V和P解耦。

    需要对P进行单元测试时,只需要创建一个类简单地实现UserView的类,然后和UserPresenterImpl绑定即可;需要对V进行单元测试时,只需要创建一个简单实现UserPresenter的类,然后在UserActivity中将构建P的那行代码修改下即可。

MVP相对MVC具有P复用及方便做单元测试的优点,然而,在实际Android项目中,P复用的场景基本不存在,且多数公司并没有做单元测试。因此,多数情况下,MVC可能比MVP更适合Android项目,毕竟MVP多引入了不入类和代码,且带来解耦的同时也使得代码更加“绕”。

MVVM

不管是MVC还是MVP都存在几下问题:

  1. 在每个界面都要编写不少的findViewByIdsetOnClickListener之类的代码。
  2. 数据更新后,要手动调用setText之类的代码刷新视图。

以上几点都不是什么大问题,编写这些代码也不会轻易出错,但总有达人追求极致,MVVM便由此产生了。Android解决第问题1的方法是在xml中直接嵌入代码(类似JSX的写法),解决问题2的方法是提供了DataBinding方案绑定视图和数据。MVVM真正地将V层完全地体现在xml上,M层还是老样子(模式怎么变它都不变),VM(ViewModel)用来代替C和P,以MVC作为改造,VM包括ActivityDataBinding(自动生成)。

本文主要讲解几种架构模式的应用场景和区别,因此不会过多讲解MVVM在Android中如何使用,没接触过MVVM的建议先看下这篇入门文章或查阅官方文档。

使用DataBinding需要在build.gradle中加入如下代码:

android {
    dataBinding {
        enabled = true
    }
}

配置了之后build时会下载DataBinding的依赖包以及自动生成部分代码,自动生成的代码后续遇到时会提到。

接着我们来看下在MVC的基础上变更后的MVVM代码。

activity_user.xml

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

    <data>
        <variable
            name="user"
            type="com.sean.mvvm.model.entity.User" />

        <variable
            name="host"
            type="com.sean.mvvm.UserActivity"/>
    </data>

    <LinearLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="10dp">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text='@{"昵称:" + user.name}' />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:text='@{"年龄:" + String.valueOf(user.age)}'/>

        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:layout_marginTop="20dp"
            android:text="刷新"
            android:onClick="@{host.onRefresh}"/>

    </LinearLayout>

</layout>

User.java

public class User extends BaseObservable {
    @Bindable
    public String name;

    @Bindable
    public int age;

    public void setName(String name) {
        this.name = name;
        notifyPropertyChanged(BR.name);
    }

    public void setAge(int age) {
        this.age = age;
        notifyPropertyChanged(BR.age);
    }
}

UserActivity.java

public class UserActivity extends Activity implements UserBusiness.UserListener {
    private User mUser;
    private UserBusiness mUserBusiness = UserBusiness.get();

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

        ActivityUserBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_user);
        mUser = mUserBusiness.getUser();
        if(mUser == null) {
            mUser = new User();
        }
        binding.setUser(mUser);
        binding.setHost(this);

        mUserBusiness.addListener(this);
    }

    @Override
    protected void onDestroy() {
        mUserBusiness.removeListener(this);
        super.onDestroy();
    }

    @Override
    public void onRequestUserResult(int code, User user) {
        if(code == 0) {
            mUser.setName(user.name);
            mUser.setAge(user.age);
        } else {
            Toast.makeText(UserActivity.this, "刷新失败", Toast.LENGTH_SHORT).show();
        }
    }

    public void onRefresh(View v) {
        mUserBusiness.requestUser();
    }
}

分析一下:

  1. xml布局的顶部标签变成了layoutdata标签存储非布局相关代码,variable定义变量。
  2. 编译时会根据xml名称和内容自动生成binding类,如例子中的ActivityUserBindingvariable定义的变量在binding中都有对应的set、get方法。
  3. 数据改变时要做到自动刷新UI,实体类必须继承BaseObservable,且需要自动触发的字段必须以Bindable注解,并在字段值变化时调用notifyPropertyChanged方法。

PS:强烈建议至少熟读一个自动生成的binding类,绑定的所有原理都在这里,代码很容易理解,也不需要去网络上寻求答案。

上面的例子实现了数据的单向绑定(数据更新触发UI更新)和事件的绑定,而Android是支持数据双向绑定的,现在来看下当UI更新时如何触发数据的更新。使用方式其实很简单,在xml中小小修改下就行:

android:text='@={user.name}'

注意到@后面多了个=,同时昵称:去掉了,=表示数据双向绑定,但当=存在时,右侧的表达式只能是个变量,因此昵称:只能去掉了。当xml改成这样后,TextView的文本发生变化了,user.name的值也会随之更新。

总结下MVVM的优缺点:

  1. 新型xml更加成熟,可以独立支撑View层。然而,这可能也是缺点,毕竟这种xml编程方式和Android传统的方式差异较大。

    如果不想改变xml的编写方式,又希望使用MVVM,那么可以仿照自动生成的binding类自己编写一个ViewModel,但是有没有这个必要呢。。。

  2. 只关心数据变化,而不需要关注视图的刷新,刷新由自动生成的binding处理了。
  3. 数据双向绑定是个伪命题,实际上并没有完全做到自动化,还是需要手动编写额外的代码,并且也有条件限制。
  4. 在非主module(一般为app)中无法编译新型xml(这个也可能是笔者使用不当,有待确认)。

综上,MVVM相比MVC、MVP并没有多大优势,但可以通知配置减少一些重复的逻辑代码。使用哪种模式根据实际情况而定,没有谁比谁更好。

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

推荐阅读更多精彩内容