使用MVP模式重构代码
之前写了两篇关于MVP模式的文章,主要讲得都是一些概念,这里谈谈自己在Android项目中使用MVP模式的真实感受,并以实例的形式一起尝试来使用MVP模式去重构我们现有的代码。
有兴趣的童鞋可以先去阅读之前的文章,因为这里将不再重复概念的部分了,本文会假设你对MVP有一点了解了:
1. 在谈MVP之前,你真的懂MVC吗?
2. MVP模式是你的救命稻草吗?
臃肿的Activity
大部分谈Android架构的时候,都基本会提到Activity越来越臃肿的问题,这几乎是一个普遍现象,而包括我本人在内的,都会首先将这个罪责推到MVC架构上,但如果你真的花时间去重构activity的时候,你会发现问题其实往往出在自己身上。
一般的MVC里的 Controller
需要做的事情:
- 负责获取和处理用户的输入。
- 负责将输入传给负责业务逻辑层去做数据上的操作(如增删改查)。
- 负责将业务逻辑层对于数据操作的结果,传给View层去做展示。
因此如果完全按照这种定义的话,你应该很难看到一个非常臃肿的Controller,因为Controller在MVC模式中,本来就应该是很轻的,而不是很重的部分,重的应该是M层,甚至在前端交互复杂的时候,V层都应该比C层要重。
我认为对于Controller的理解,就是一个站在M和V两者之间的一个翻译家,M来自地球,V来自火星。而如果站在中间的这个翻译者,话比他两的话还多,老是抢话,自言自语,这样显然是不合适的。
那么我们再来看典型的Activity的代码,处理的业务是常见的登录页面:
public class UserActivity extends Activity {
private RequestQueue mQueue = Volley.newRequestQueue(this);
private TextView mUsernameTextView;
private TextView mPasswordTextView;
private Button mLoginBtn;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.login);
mUsernameTextView = (TextView) findViewById(R.id.username);
mPasswordTextView = (TextView) findViewById(R.id.password);
mLoginBtn = (Button) findViewById(R.id.login_btn);
mLoginBtn.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
String username = mUsernameTextView.getText().toString();
String password = mPasswordTextView.getText().toString();
String loginUrl = "http://somesite.com/login.php";
JSONObjectRequest request = new JSONObjectRequest(loginUrl, Method.POST,
new Response.Listener<JSONObject>() {
public void onResponse(JSONObject json) {
if (json != null && json.get("isOk") == true) {
Toast.makeToast(getApplicationContext(), "Login Success", Toast.LENGTH_SHORT).show();
startActivity(new Intent(LoginActivity.this, MainActivity.class));
} else {
Toast.makeToast(getApplicationContext(), "Login Fail", Toast.LENGTH_SHORT).show();
}
}
},
new Response.ErrorListener(){
public void onError(VolleyError error) {
Toast.makeToast(getApplicationContext(), "Login Fail", Toast.LENGTH_SHORT).show();
}
});
mQueue.add(request);
}
});
}
@Override
protected void onStop() {
super.onStop();
if (mQueue != null) {
mQueue.cancelAll();
}
}
}
其实这已经是*度简化过的代码了,真正的一个LoginActivity很容易就会超过几千行,不信你去看自己项目里面的代码就明白我在说什么了。
而一对比概念,我相信大部分人一下子就会发现问题,我们还是来看Activity作为一个 Controller
到底都负责干什么了:
- 首先activity必须去操作View的控件,设置它们的回调函数,有时也需要用代码去控制它们如何展示的属性。
- 然后activity一定需要去处理用户的输入,例如输入的值,以及点击事件等用户行为。
- 而几乎大部分异步网络请求都从activity发起,以及服务器返回数据的处理。
- activity一般还需要根据数据的操作结果,负责在页面上将结果告之用户,例如Toast或者其他View的操作。
- 除此之外,activity还需要管理其生命周期相关的所有事务,例如在页面退出的时候处理一下View控件和其他与生命期相关的逻辑。
你会发现Activity天生的责任太重,其中确实覆盖了 Controller
的原本的责任,例如处理用户输入,将用户操作转换成传递给业务逻辑层的命令等职责。
但如果你仔细的分析,你会发现activity不仅仅需要承担Controller
的责任,还需要处理大量View
的逻辑,例如控件的监听的属性,如何展示数据的职责也往往落到了它的肩上。更何况你很容易在activity写操作数据和网络请求的代码,也就是让它又承担了Model
的责任,那么请问这样的Activity能不臃肿吗?
当然这是一个坏的例子,其实很多代码是可以封装到独立的层去的,例如网络请求,数据解析等。但就算你怎么封装和重构,你*多能做的事情也就是把本来就不应该放在Controller里的Model层分离出去,这是你原本就应该做的事情。但你很难在activity将controller和view分离开来,怎么写activity作为Controller,都和View的关系太紧密,必须多多少少去控制如何展示数据这个View的责任。
Presenter是来给activity减负的吗?
很多人会认为MVP中引入Presenter的概念,是为了给日益臃肿的activity来减负的,而我不这样认为,我认为Presenter和Controller的责任是差不多的,它们后期承担的目的都其实很简单,就是用来隔离Model和View的,也就是常说的展示层和业务层的解藕。
那么该如何解决activity的问题呢?目前常见的MVP在Android里的实践有两种解决方案:
- 直接将Activity看作View,让它只承担View的责任。
- 将Activity看作一个MVP三者以外的一个Controller,只控制生命周期。
在Google推出的官方MVP实例里,使用的就是第2种思路,它让每一个Activity都拥有一个Fragment来作为View,然后每个Activity也对应一个Presenter,在Activity里只处理与生命周期有关的内容,并跳出MVP之外,负责实例化Model,View,Presenter,并负责将三者合理的建立联系,承担的就是一个上帝视角。
在实践中,也有很多观点会简化掉Fragment,直接将Activity视为View,这个也是我比较赞同的,更简便一些,而且这样观念上也容易理解一些,你就把activity看作View的一部分,永远只让它处理展示的逻辑,不允许它去处理数据,和拥有业务逻辑。但是这样也有一个缺点,就是V和P的依赖关系不太规范了,理论上你是不应该在View里面去实例化Presenter和Model的,这其实是不合理的,正确的依赖关系,确实是应该在一个独立的更上层去实例化Model,View,Presenter的,这样依赖才是较为合理的关系,这点来看Google的架构模式确实更合理,但实操上也会麻烦一点,必须让每个activity拥有一个独立的fragment,这个我是觉得可以自由取舍,你是要概念上的合理,还是现实中的方便,其实都可以。
因为重点还是在于如何分离展示层和业务层,activity具体承担什么责任都可以,但只能承担一个责任。
例如之前的代码可以被重构成如下结构:
/**
* View负责展示数据
*/
public interface UserView {
void showLoginSuccessMsg(User loginedUser);
void showLoginFailMsg(String errorMsg);
}
/**
* Presenter负责做View和Model的中间人
*/
public interface UserPresenter {
void login(String username, String password);
}
/**
* Model负责数据的处理和业务逻辑
*/
public interface UserModel {
void login(String username, String password, Callback callback);
}
这里将Activity被视为View, 仅负责数据的展示,并且将用户的操作事件路由给P去做处理。
public class UserActivity extends Activity implements UserView {
private UserContract.Presenter mPresenter;
private TextView mUsernameTextView;
private TextView mPasswordTextView;
private Button mLoginBtn;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.login);
mPresenter = new AddressListPresenter(this, new UserModelImpl());
mUsernameTextView = (TextView) findViewById(R.id.username);
mPasswordTextView = (TextView) findViewById(R.id.password);
mLoginBtn = (Button) findViewById(R.id.login_btn);
mLoginBtn.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
String username = mUsernameTextView.getText().toString();
String password = mPasswordTextView.getText().toString();
// View将用户的点击事件直接路由给Presenter区处理
mPresenter.login(username, password);
}
});
}
@Override
public void showLoginSuccessMsg(User loginedUser) {
// Presenter在处理完毕后, 会通知View更新UI来通知用户数据操作的结果
Toast.makeToast(getApplicationContext(), "Login Success", Toast.LENGTH_SHORT).show();
}
@Override
public void showLoginFailMsg(String errorMsg) {
// Presenter在处理完毕后, 会通知View更新UI来通知用户数据操作的结果
Toast.makeToast(getApplicationContext(), "Login Fail", Toast.LENGTH_SHORT).show();
}
@Override
protected void onResume() {
super.onResume();
mPresenter.subscribe();
}
@Override
protected void onPause() {
super.onPause();
mPresenter.unSubscribe();
}
}
Presenter层则负责将在View和Model做中间人:
/**
* Model负责数据的处理和业务逻辑
*/
public class UserPresenterImpl implements UserPresenter {
private UserView mUserView;
private UserModel mUserModel;
public UserPresenterImpl(UserView view, UserModel model) {
mUserView = view;
mUserModel = model;
}
public void login(String username, String password) {
// Presenter处理View路由过来的用户操作,
// 将其转换成相对的命令,传递给Model来做数据操作
mUserModel.login(username, password, new Callback(){
public void onSuccess(User user) {
// Model层对数据操作后,将结果返回给Presenter,
// 再由Presenter来通知View去更新UI来通知用户数据操作的结果
mView.showLoginSuccessMsg(user);
}
public void onFail(String errorMsg) {
mView.showLoginFailMsg(user);
}
});
}
}
而Model层大家则已经可以脑补出来了,只负责对于数据的操作而已了。例如请求服务器获取数据,获取查询本地数据库都可以。
从1个类变为3个类
在MVP的实践中,很明显的结构变化就是很多页面从1个类变成了3个甚至更多的类。
例如,原来只有一个 LoginActivity
,而现在会变成至少3个类:
LoginActivity
(View)LoginPresenterImpl
(Presenter)LoginModelImpl
(Model)
而你以为这些就够了,就太天真的,在MVP里,为了解藕三者之间的关系,还需要通过接口来通信,P层是通过接口来和M层通信的,P层和V层之间也是通过接口来互相通信的(但V层对P层的通信被视为被动通信,而非主动通信)
接口列表:
LoginView
(interface for View)LoginPresenter
(interface for Presenter)LoginMode
(interface for Model)
这里插一句题外话,在Google官方的MVP实例里的,有一个契约类的概念,这个契约类的概念引入我觉得真的很赞,其实它只是将View和Presenter的接口写到了一个类里面,但这样写则会使得读代码的人一目了然就可以了解这个页面需要展示些什么,有什么操作。
如果你以为MVP各一个接口这样就应该够了,我只能说你还是太年轻太天真。要知道很多消息在MVP三者之间传递,不仅仅是同步消息,还有很多异步消息,例如用户点击了一个按钮,View将该事件传递给Presenter,Presenter异步的向Model请求数据,Model异步的返回数据给Presenter,Presenter再将Model处理的结果异步的传递给View,让其向用户作出回应。
可想而之,这样异步操作,自然少不了一些Callback的接口类,虽然可以用内部类来解决,但如果不用范型的话,这些Callback的接口类数目还是很多的。
这里也插一句提外话,我个人推荐使用rxJava来解决回调恶魔的问题,不过这仅仅是个人偏好而已。
从直来直去变成跳来跳去
上文说了,从1个类的代码,分离到了N个文件,三个层面以后,原本直来直去的代码结构,就会变成跳来跳去,例如之前1000行代码是写在一起的,现在把其中View的部分代码独立到了View的文件里,把其中Model的部分独立到Model的文件中,然后用Presenter放在它们中间,做一个中间人。
而且再加上很多消息的传递是异步的,因为在看代码的时候,或者在调试的时候,你必须从过去线性的思维变成跳跃式的,很多代码过去你开一个文件,顺着看下来就明白的,DEBUG模式下,一顺运行下来的,现在变成了你需要开N个文件,DEBUG模式下就看着从View的一个方法,跳到Presenter的一个方法,然后再跳到Model的一个方法,然后再原路跳回来,友情提示,刚开始用MVP的时候,很容易代码很清晰,但大脑却很混乱,甚至有晕车的感觉。
并且我认为这样的代码结构,甚至加大了调试的困难,过去直来直去,你很容易判断出数据是断在哪里,而现在你很难判断出数据断在哪一个层面,例如用户点击了刷新,需要从服务器拉回数据刷新到列表。但当页面没有正常展示数据的时候,你必须知道在哪个环节出错了,而我告诉你,因为分成了三个层,并且消息和数据在三个层之间传递,那么出错的可能性也变多了:
- 可能是View层没有把用户的事件传递给Presenter层。
- Presenter层可能接受到View层事件,但没有将操作传递给Model层。
- Model层可能接受到Presenter的请求,但没有将数据传回给Presenter层。
- Presenter层可能接受到Model的返回值了,但没有正确的将数据传回给View层。
- View层可能接受到了Presenter返回值,只是没有正确的将数据显示到页面而已。
在调试的时候,你会发现,跟踪一个问题变复杂了,消息在MVP之间传来传去的,你很难一下定位到问题出在哪个层面。
那么为什么要用MVP?
说了这么MVP带来的麻烦,例如多写了很多类,思维跳来跳去,消息传来传去,层层回调把人转晕,那回头去思考:我们为什么要用MVP,为什么要这样拆分代码,不是说这样代码更清晰,更容易理解了吗,为什么我看不懂我的代码了,为什么调试起来如何麻烦?
其实,这样我要反复说的,如果你只是学会怎么使用MVP,那么你只是换了一个架构而已,这就和你换了一个IDE写代码,却期望换了IDE就可以让代码突然变的更好一样。而你真正需要做的,依然是我之前说过的:
你需要换的是脑子,而不是架构。
如果你还在每次修改一行代码,就整体去测试你的系统,那么你把代码写在一个文件里,还是拆分到几个文件里,其实是没有区别的。你只是把代码拆开在放,而这样的拆注定只是形式的,*终我相信写着写着,你会在View里面写Model的逻辑,在Model里面写View的逻辑,并且和过去一样,Presenter越来越臃肿。
为什么要把架构里的各个层次分得清清楚楚,每个层面负责什么,不应该负责负责,如何组合起来都需要严格的定义起来,你要知道,每一种架构都不是编码规范,也不是组织代码的规范,它们都是一种思维方式。
之前说过,良好的架构都是在解决几个问题:低藕合,高复用,易测试,好维护。
如果你还在你的类和类之间new来new去,你引用我,我引用你,互相依赖,层层依赖,那么你把它们写在一个文件里,和把它们几个文件里有区别吗?
如果你的一个类还承担多个职责,明明这是个叫 Car
的类,却又在承担轮子,又在承担引擎的责任,那么你抽象和不抽像,封装不封装真的有区别吗?
如果你的一个方法还在做两件甚至三件事情,甚至把一整套事情都做完了,动辄超过几屏的函数,那么你真的觉得用不用架构真的有区别吗?
单元测试&MVP
为什么要把代码拆分成不同的文件,为什么要把架构拆分成不同的层面,其实思想都是在将一个复杂的整体拆分成一个个独立的模块,然后再用合理的接口将这些模块组装到一起,成为一个完整而稳定的系统。
很多文章都会提到“易测试”的概念,在编码里面,易测试*对不是易于测试人员去测试的意思,而只有一个意思,那就是易于单元测试,易于将整体拆分成独立的单元进行测试。
但是很多时候,我们都会认为写单元测试是一种浪费时间的事情,但其实这是非常错误的一种观点,单元测试反倒是在节省时间。
就像上文提到的,如果你还是修改了一处代码,然后就跑一遍系统,整体的测试一遍,那么不使用MVP反而比使用MVP调试要轻松。但你反过来想,你如果还是每次都是整体的测试,那么你把代码分开的意义又何在呢?将代码拆分成独立的层次,独立的模块,一来是为了更好的复用,二来就是为了能够独立的测试。
可以说使用MVP,如果只是按照Google的实例去拆分代码,这只做到了*步,而第二步就是去看Google实例中是如何写单元测试的,如何独立的对Model层去做测试,对View层去做测试,以及Presenter层如何测试。你就会发现之所以拆分,带来的*大好处就是测试友好了。你可以独立的去做测试,因为拆分了,所以互相藕合低了,互相藕合低了,所以各自更独立了,各个更独立了就使得单元测试成为了可能性,你可以独立的对MVP里的每一个层面,每一个模块,每一个公开函数进行独立的测试,当你确保了每一个独立的函数,每一个类,每一个包都能都独立的完成自己的逻辑,那么通过接口把它们组合在一起后,整体测试反而变成依然轻松了,你不需要关心代码跳来跳去,消息传来传去,只要每个模块,每个层次的逻辑是正确的,是经过单元测试的,那么整体系统就不会出现太大的问题。
所以说,*终我认为MVP的关键还是在于 单元测试 ,不管你是用MVC,还是MVP,如果你的代码是能够进行良好的单元测试,那么说明你的架构就不可能有太大问题,而使用什么架构只是表象,真正起区别代码高低境界的还是思考问题的方式。