新手也能看得懂的 Android MVP 讲解

前言

作为菜鸟一只,学习的新知识都要记下来,以便日后复习。

本文侧重点在于介绍 Android MVP 的优劣,通过 Google 官方的to-do-mvp 系列项目了解官方是如何使用 MVP 的,并通过自己动手写一个小小的 MVP-demo 来加深对该模式的理解。

不废话了,下面进入正文。

MVC

谈到 MVP,就不能不提它的“前身”- MVC,但为了更好的了解,我们还需要向上追溯到 三层架构

  1. 界面层:与用户交互的界面
  2. 业务逻辑层:界面层和数据访问层的桥梁,实现业务逻辑。
  3. 数据访问层:和数据库打交道,类似DAO。

而 MVC 实际上更多只涉及前两层,目的在于解除业务逻辑和视图之间的耦合。可以说不只是 Android ,甚至在整个软件开发中都是使用最广的系统架构之一。

MVC 将整个结构分为三个组件--Model、View、Controller

  1. Model(模型):Model 是应用程序的数据源,同时包括对业务逻辑的封装。它接受 Controller 的请求并完成相应的业务处理,并将处理后的数据通过 View 显示给用户。数据源可以是Web、本地数据库(sqlite)等。
  2. View(视图):该组件直接与用户交互,并负责用户如何查看我们的应用程序。View 可以直接与 Model 进行交互,在MVC中,XML(也可以说是 Activity )被视为视图。
  3. Controller(控制器):这是MVC模式的重要部分,Controller是操作、编辑、使用 Model 并通过 View 显示给用户的组件。Controller 负责收集所有数据,在Model 和 View 之间充当中间人。Activity/Fragment 被认为是Android 的 Controller 。
MVC

一句话概括 MVC 的工作机理就是:当 User 触发事件时,View 发送指令到Controller,之后 Controller 通知 Model 更新数据,之后将结果显示到 View 中。

过程很理想是吧?但是在 Android 却并不怎么令人满意,我们来看看在 Android 中是个什么情况。

首先 布局.xml 毫无疑问是 View 吧,然后一些 java bean 之类的就是 Model,而 Controller 则是 Activity/Fragment 咯,但是理想很丰满,现实很骨感,作为 View 而言,xml 显然是不能胜任的,它只能展示最基础的静态界面,比如当我们动态隐藏显示一个界面时候,我们必须在 Activity/Fragment 中去实现,这也就导致了 Activity/Fragment 既是 Controller 但是又承担了一部分 View 的工作。

可以说 Android 中的 MVC 只做到了 M-V,因为所有一切都和 Activity 紧密相连。

MVC 在 Android 中的表现大致如下:

上述结果就是,Activity 中的代码轻轻松松上千行。如果只是我们自己写,自己维护的话,上千行似乎并非不能接受。但是一但需要你去看别人的上千行代码,想想就很难受。(更别说需要研读 Android 破万行的源码了。。)

为了解决这个重大问题,我们需要将 Activity 承担的工作拆分,Activity 只控制 View,另外新建一个 Controller ,以此避免 Activity 越来越大,难以维护。

于是就衍生出了 MVP。

MVP

MVP 作为 MVC 的衍生,将 Controller 和 View 从 Activity 中分割开开。对于 Android 来说,MVP 的 Model 和 MVC 中的 Model 是一样的,而 Activity/Fragment 不再是 Controller,而是纯粹的 View,所有关于用户事件的处理都通过 Presenter。

1*1P4n9JkHChEUVr5umQx4Zw

我们可以看到,最明显的差别就是 Model 和 View 不再相连,取而代之的是 Presenter 在二者之间充当桥梁,分别与 Model 和 View 双向通信。

工作流程大致为:

  1. View 接受用户的交互请求
  2. View 将事件传递到 Presenter
  3. Presenter 操作 Model 进行数据处理
  4. Model 处理完成后,通知 Presenter 处理已完成
  5. Presenter 根据处理后的数据更新 View 的显示

至于 Presenter 如何与 Model、View 交互的,还记得设计模式中提到的 面向接口编程 的思想么?没错,这里我们也是采取接口的形式,比如 Activity/Fragment 实现已经定义好的接口,在对应的 Presenter 中通过接口调用方法。

下面我们就看一看 Google 为我们提供的 MVP 示例中是如何编程的。

如果读者对 面向接口编程 的思想不了解,那么建议先去Google 一下,有基本的了解之后,再继续阅读,不然只能是徒增痛苦。。。。

Google todo-mvp 项目介绍

Google 在 GayHub 上推出了一个项目 Android Architecture Blueprints,用来展示 Android 使用各种各样的 MVP 架构,虽然Google 表示其中的示例只是用来做参考,并不是要做标准,但是作为 Google 脑残粉,相信 Google 出品,必属精品

项目中设计多种架构,但作为菜鸡一枚,还是只从最基础的 todo-mvp 入手,分析如何实现 MVP 架构。

总体结构

这里写图片描述

以 StatisticsContract 为例(其他类似):

这里写图片描述

基类

//在 Fragment 的 onResume()中调用方法,作用是 presenter 开始获取数据并调用 view 中方法改变界面显示
public interface BasePresenter {
    void start();
}

//在 presenter 实现类中调用方法,作用是将 presenter 实例传入 view 中
public interface BaseView<T> {
    void setPresenter(T presenter);
}

“契约类”-XXXContract

public interface StatisticsContract {

    interface View extends BaseView<Presenter> {

        void setProgressIndicator(boolean active);

        void showStatistics(int numberOfIncompleteTasks, int numberOfCompletedTasks);

        void showLoadingStatisticsError();

        boolean isActive();
    }

    interface Presenter extends BasePresenter {

    }
}

Google 似乎很喜欢这种写法,编写一个契约类来管理 View 和 Presenter 的所有接口,这种方式使得我们很清楚的知道这二者有哪些功能,方便维护。

Activity 的作用

在子模块中扮演着 模块管理者 的角色,负责创建 Presenter 实例以及 创建 View(Fragment),并将二者联系起来:

@Override
protected void onCreate(Bundle savedInstanceState) {
    //。。。。
    StatisticsFragment statisticsFragment = (StatisticsFragment) getSupportFragmentManager()
        .findFragmentById(R.id.contentFrame);
    if (statisticsFragment == null) {
        statisticsFragment = StatisticsFragment.newInstance();
        ActivityUtils.addFragmentToActivity(getSupportFragmentManager(),statisticsFragment,         R.id.contentFrame);
    }

    new StatisticsPresenter(
        Injection.provideTasksRepository(getApplicationContext()), statisticsFragment);
    //。。。。
}

在创建 StatisticsPresenter 时,我们传入了 statisticsFragment,再看看 StatisticsPresenter 的构造函数:

public StatisticsPresenter(@NonNull TasksRepository tasksRepository,
                           @NonNull StatisticsContract.View statisticsView) {
    mTasksRepository = checkNotNull(tasksRepository, "tasksRepository cannot be null");
    mStatisticsView = checkNotNull(statisticsView, "StatisticsView cannot be null!");

    mStatisticsView.setPresenter(this);
}

也就是说 这时 StatisticsPresenter 获取到了 statisticsFragment 的引用,且其实现了 view 接口,那么就可以调用 view 的方法了。

View <--> Presenter

分析一下 View 如何与 Presenter 双向通信,上源码:

public class StatisticsFragment extends Fragment implements StatisticsContract.View {

    private TextView mStatisticsTV;

    private StatisticsContract.Presenter mPresenter;

    public static StatisticsFragment newInstance() {
        return new StatisticsFragment();
    }

    @Override
    public void setPresenter(@NonNull StatisticsContract.Presenter presenter) {
        mPresenter = checkNotNull(presenter);
    }
    
    @Override
    public void onResume() {
        super.onResume();
        mPresenter.start();
    }
    
    //。。。。
}

可以看到,Fragment 作为 View ,同时在 setPresenter 方法中得到 Presenter 实例(结合 Presenter 的构造方法),从而可以调用 Presenter 中的方法。

而上面我们也提到过在 presenter 的构造方法中获取到了 fragment 也就是view 的引用。于是 二者就可以互相通信了。

Model <--> Presenter

分析完 View-Presenter,再来看看 Model 如何与 Presenter 双向交互,源码:

public class StatisticsPresenter implements StatisticsContract.Presenter {

    private final TasksRepository mTasksRepository;

    public StatisticsPresenter(@NonNull TasksRepository tasksRepository,
                               @NonNull StatisticsContract.View statisticsView) {
        mTasksRepository = checkNotNull(tasksRepository, "tasksRepository cannot be null");
        mStatisticsView = checkNotNull(statisticsView, "StatisticsView cannot be null!");

        mStatisticsView.setPresenter(this);
    }

    @Override
    public void start() {
        loadStatistics();
    }

    private void loadStatistics() {
        mStatisticsView.setProgressIndicator(true);

        // The network request might be handled in a different thread so make sure Espresso knows
        // that the app is busy until the response is handled.
        EspressoIdlingResource.increment(); // App is busy until further notice

        mTasksRepository.getTasks(new TasksDataSource.LoadTasksCallback() {
            @Override
            public void onTasksLoaded(List<Task> tasks) {
                int activeTasks = 0;
                int completedTasks = 0;

                //.....
                mStatisticsView.setProgressIndicator(false);

                mStatisticsView.showStatistics(activeTasks, completedTasks);
            }

            @Override
            public void onDataNotAvailable() {
                // The view may not be able to handle UI updates anymore
                if (!mStatisticsView.isActive()) {
                    return;
                }
                mStatisticsView.showLoadingStatisticsError();
            }
        });
    }
}

上述代码中的 TasksRepository 即为 Model,仍然是在构造函数中获取到其引用,同时在 loadStatistics() 方法中调用 mTasksRepository.getTasks()方法,体现的 presenter 调用 model 的方法;同时 getTasks() 方法中传入了 TasksDataSource.LoadTasksCallback() 参数,该接口定义如下:

public interface TasksDataSource {

    interface LoadTasksCallback {

        void onTasksLoaded(List<Task> tasks);

        void onDataNotAvailable();
    }
    //。。。。

而在 TasksRepository(Model)中 getTasks() 方法定义如下:

public class TasksRepository implements TasksDataSource {
@Override
    public void getTasks(@NonNull final LoadTasksCallback callback) {
        checkNotNull(callback);

        // Respond immediately with cache if available and not dirty
        if (mCachedTasks != null && !mCacheIsDirty) {
            callback.onTasksLoaded(new ArrayList<>(mCachedTasks.values()));
            return;
        }

        if (mCacheIsDirty) {
            // If the cache is dirty we need to fetch new data from the network.
            getTasksFromRemoteDataSource(callback);
        } else {
            // Query the local storage if available. If not, query the network.
            mTasksLocalDataSource.getTasks(new LoadTasksCallback() {
                @Override
                public void onTasksLoaded(List<Task> tasks) {
                    refreshCache(tasks);
                    callback.onTasksLoaded(new ArrayList<>(mCachedTasks.values()));
                }

                @Override
                public void onDataNotAvailable() {
                    getTasksFromRemoteDataSource(callback);
                }
            });
        }
    }
    //。。。。。
}

也就是说,在getTasks()方法中,TasksRepository 处理完数据之后,回调了 StatisticsPresenter 中实现的方法,也就体现了 Model -> Presenter。

小结

到这里,关于 todo-mvp 的架构就分析的差不多了,总体上看,MVP 的使用使得整个结构十分的清晰,毕竟我这样的菜鸟都能去分析源码了,虽然代码量略微增多,但是每个模块的界限很清晰,责任单一,高度的解耦,使得维护起来很轻松。

动手撸一个 Demo

上面我们分析了Google的源码,但看懂毕竟只是看懂,距离我们深入理解还差得远,下面就动手实践一下,撸一个 MVP 的简易 Demo。

Demo 地址见文章结尾。

先放上最终的效果:


这里写图片描述

接下来是 Demo 的结构图:

这里写图片描述

下面我们就来一点点实现。

Model

首先肯定需要有一个实体类 User,然后对于这个 Demo,业务逻辑只有一个:登录,那么我们也就至少有一个 login() 方法来实现登录业务。同时,我还需要能告知 presenter 我是否登陆成功了,那么我就需要写一个 回调接口 供 presenter 来实现。

User.java

package com.bit.whdalive.demomvp.bean;

public class User {

    private String username;
    private String password;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

IUserModel.java

package com.bit.whdalive.demomvp.mvp;

public interface IUserModel {

    void login(String username,String password,OnLoginListener listener);
    
    //回调接口,我放到IUserModel中,实际上也可以单独抽离出来,或者放到 presenter 的接口中都是可以的,毕竟这个demo功能太单一了
    public interface OnLoginListener{

        void loginSuccess();

        void loginFailed();
    }
}

IUserModelImpl.java

package com.bit.whdalive.demomvp.mvp;

public class UserModelImpl implements IUserModel{

    private IUserLoginPresenter mIUserLoginPresenter;

    public UserModelImpl(IUserLoginPresenter IUserLoginPresenter) {
        mIUserLoginPresenter = IUserLoginPresenter;
    }

    @Override
    public void login(final String username, final String password,final IUserModel.OnLoginListener listener) {
        new Thread(){
            @Override
            public void run() {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if("whdalive".equals(username)&&"123...".equals(password)){
                    User user = new User();
                    user.setUsername(username);
                    user.setPassword(password);
                    listener.loginSuccess();
                }else{
                    listener.loginFailed();
                }
            }
        }.start();
    }
}

依旧是 面向接口编程 的思想,将 Model 层的方法抽离成一个接口,日后彼此交互也都是通过传递接口类型的引用,避免强耦合。

View

我们考虑一下 View 中应该有哪些功能,首先效果图中有两个按钮,login 和 clear。

想要实现 login ,就需要能够提供我们输入的文本,对应如下方法:

String getUserName();

String getPassword();

实现 clear,那么意味着 View 需要有清除 输入文本 的功能,也就是需要如下两个方法:

void clearUserName();

void clearPassword();

同时,我们看到登录时有个 Progressbar 来提示登录过程(毕竟实际上这是个耗时的过程),那么就需要能够显示和隐藏它:

void showLoading();

void hideLoading();

最后,无论我们登录成功与否,都需要有个提示显示我们是否登录成功了:

void toMainActivity();

void showFailedError();

综上,完整的接口定义为:

IUserLoginView

package com.bit.whdalive.demomvp.mvp;

public interface IUserLoginView {

    String getUserName();

    String getPassword();

    void clearUserName();

    void clearPassword();

    void showLoading();

    void hideLoading();

    void toMainActivity();

    void showFailedError();

}

接下来就是写它的实现类了(实际上就是个纯碎的Activity)

UserLoginActivity

package com.bit.whdalive.demomvp.mvp;

public class UserLoginActivity extends AppCompatActivity implements IUserLoginView {

    private EditText mEdtUsername,mEdtPwd;
    private Button mBtnLogin,mBtnClear;
    private ProgressBar mPbLoading;

    private IUserLoginPresenter mIUserLoginPresenter;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        initViews();
    }
    private void initViews(){
        mIUserLoginPresenter = new UserLoginPresenterImpl(this);

        mEdtUsername = findViewById(R.id.input_account);
        mEdtPwd = findViewById(R.id.input_password);

        mBtnClear = findViewById(R.id.btn_clear);
        mBtnLogin = findViewById(R.id.btn_login);

        mPbLoading = findViewById(R.id.pb_loading);

        mBtnLogin.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mIUserLoginPresenter.doLogin();
            }
        });

        mBtnClear.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mIUserLoginPresenter.clear();
            }
        });

    }

    @Override
    public String getUserName() {
        return mEdtUsername.getText().toString();
    }

    @Override
    public String getPassword() {
        return mEdtPwd.getText().toString();
    }

    @Override
    public void clearUserName() {
        mEdtUsername.setText("");
    }

    @Override
    public void clearPassword() {
        mEdtPwd.setText("");
    }

    @Override
    public void showLoading() {
        mPbLoading.setVisibility(View.VISIBLE);
    }

    @Override
    public void hideLoading() {
        mPbLoading.setVisibility(View.GONE);
}

    @Override
    public void toMainActivity() {
        Toast.makeText(this,"login success, to MainActivity",Toast.LENGTH_SHORT).show();

    }

    @Override
    public void showFailedError() {
        Toast.makeText(this,"Login failed",Toast.LENGTH_SHORT).show();
    }

}

对于 View 而言,因为其只和 用户的交互 打交道,因此我们只需要考虑好 哪些操作需要改动界面显示?哪些操作需要什么反馈? 并以此来编写对应方法并抽象成接口,而一旦写好了接口,那么实现类就是手到擒来的事情了。

Presenter

Presenter 作为 Model 和 View 的桥梁,需要能够调用 Model 的方法,来执行具体的业务方法;同时需要调用 View 的方法,来更新界面。

在这个 Demo 中,就只有两个功能可言:doLogin 和 clear。

对于 doLogin 实际就是调用了 Model 中的 login方法,clear 则是调用了 View 中的 clearUserName() 和 clearPassword() 来清除文本。

IUserLoginPresenter.java

package com.bit.whdalive.demomvp.mvp;

public interface IUserLoginPresenter {
    
    void doLogin();
    
    void clear();

}

UserLoginPresenterImpl.java

package com.bit.whdalive.demomvp.mvp;

import android.os.Handler;

public class UserLoginPresenterImpl implements IUserLoginPresenter,IUserModel.OnLoginListener {

    private IUserLoginView mIUserLoginView;
    private IUserModel mIUserModel;

    private Handler mHandler = new Handler();

    public UserLoginPresenterImpl(IUserLoginView IUserLoginView) {
        mIUserLoginView = IUserLoginView;
        mIUserModel = new UserModelImpl(this);
    }

    @Override
    public void doLogin() {
        String username = mIUserLoginView.getUserName();
        String password = mIUserLoginView.getPassword();
        mIUserLoginView.showLoading();
        mIUserModel.login(username,password,this);
    }

    @Override
    public void loginSuccess() {
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                mIUserLoginView.hideLoading();
                mIUserLoginView.toMainActivity();
            }
        });
    }

    @Override
    public void loginFailed() {
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                mIUserLoginView.hideLoading();
                mIUserLoginView.showFailedError();
            }
        });

    }

    @Override
    public void clear() {
        mIUserLoginView.clearUserName();
        mIUserLoginView.clearPassword();
    }
}

上述代码中,由于 Model 需要通知 Presenter 是否登陆成功,因此 presenter 实现了 IUserModel.OnLoginListener 接口。

同时由于Presenter 分别和 Model、View 双向通信,因此 Presenter 持有后两者的引用,而 Model和View彼此不持有对方的引用,都只有 Presenter 的引用。

契约类写法

当然如果读者偏爱于 契约类Contracts 的写法,问题也不大:

LoginContract.java

package com.bit.whdalive.demomvp.mvp_contracts;

import com.bit.whdalive.demomvp.bean.User;

public interface LoginContract {

    public interface View {

        String getUserName();

        String getPassword();

        void clearUserName();

        void clearPassword();

        void showLoading();

        void hideLoading();

        void toMainActivity();

        void showFailedError();
    }

    public interface Presenter {
        
        void login();

        void clear();
    }
}

之后,在实现类中对实现接口的名字从 IUserLoginPresenter/IUserLoginView 改为 LoginContract.Presenter/LoginContract.View 即可。

小结

最后以 Login 登录功能捋顺一下该demo的执行流程:

  1. 用户输入账号密码
  2. 点击Login按钮,View 将该事件传递给 Presenter
  3. Presenter 接收到 login 请求,从 View 中提取 账号密码文本,并一并交给 Model 执行具体的 login 操作(也就是调用 Model 的login()方法)
  4. Model 执行 login 操作后,通知 Presenter 是否登录成功
  5. Presenter 接收到 Model 的反馈,通知 View 更新页面
  6. View 根据 Presenter 的指令,更改当前页面。

上面我们就通过一步步拆解,逐步讲解如何手撸一个简易的Demo,读者也可以自己找一些简单的场景加以练习。

附上 Demo 地址:

GitHub-whdalive:Demo-MVP

最后,愿本文对大家有所帮助。

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

推荐阅读更多精彩内容