LJ的Blog

学海无涯苦做舟

0%

Google官方MVP示例代码阅读笔记

写在前面

这个项目很久之前就从** android-architecture ** 这个仓库clone了这个MVP架构的todoapp,源码也读过,不过没有整理过。最近整理资料准备毕设了,再读一遍源码,感受和以前又不同了。先放上项目地址,各位可以自己去clone或者下载:https://github.com/googlesamples/android-architecture/tree/todo-mvp

如果各位对MVP模式不是很熟悉,可以看我之前的一篇文:
Android之MVP初尝试,简单易懂。下文的view一般是指MVP中的view。

剥丝抽茧,理清项目结构

国际惯例,上项目结构图:

结构

从包名上很容易分辨出功能:addedittask是添加任务,data是数据管理,statistics是统计,taskdetail是任务详情,tasks是任务浏览之类的。事实上这个项目的关键也就是:** Tasks TaskDetail AddEditTask Statistics **。

这四个关键的地方都有相同之处:

  • 定义了view和presenter的契约
  • Activity负责fragment和presenter的创建
  • Fragment实现了view接口
  • presenter实现了presenter接口

也就是说,几个功能每一个都是MVP的模式,只不过Model层是公用的。而且这个项目里View层都是Fragment,果然google推荐用Fragment自己的项目里也给我们做个示范……其实关于到底是不是要用Fragment,还是有些争议的,我为什么不主张使用Fragment,这篇文关于Fragment讲的比较到位了。那么到底要不要用呢?我觉得对于个体而言,不管你喜不喜欢,都要用一用,试一试,因为人要成长,必须踩坑。对于正式项目而言,则需要综合考量,使用Fragment的利是否大于弊。

扯远了,接下来看一下他代码仓库给的一张结构图:

结构图

可以看出来左边是数据管理,典型的Model层。而右边呢,你可能认为Activity是Presenter,事实上并不是,Presenter在Activity内,Fragment是View无疑。到这,我觉得关于这个项目结构的简介已经足够了,接下来看代码。

我觉得看一个Android项目的正确姿势应该是先把玩一下app,看一下功能。贴几张app的图:

首页

添加任务

统计

任务详情

接着就该上入口的Activity看一下了,这个项目的入口Activity是TasksActivity,所在的包是tasks,看一下有哪些东西:

tasks

第一个是自定义View,第二个就是入口Activity了,第三个即上面所说的“契约”,里面包含了View接口和Presenter接口。TasksFilterType则是一个枚举,里面有三个过滤类型:所有,进行中的,完成的。TasksFragment就是MVP中的View了,TasksPresenter则是MVP中的Presenter了。看一下TasksActivity中的初始化代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.tasks_act);
Log.e(getClass().getSimpleName(),"onCreate");

// Set up the toolbar.
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar ab = getSupportActionBar();
ab.setHomeAsUpIndicator(R.drawable.ic_menu);
ab.setDisplayHomeAsUpEnabled(true);

/**
* 以下的DrawerLayout暂时不看了
*/
// Set up the navigation drawer.
mDrawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout);
mDrawerLayout.setStatusBarBackground(R.color.colorPrimaryDark);
NavigationView navigationView = (NavigationView) findViewById(R.id.nav_view);
if (navigationView != null) {
setupDrawerContent(navigationView);
}

// 获取fragment并将之添加到视图上
// 悬浮按钮在这个taksFragment里设置的点击事件
TasksFragment tasksFragment =
(TasksFragment) getSupportFragmentManager().findFragmentById(R.id.contentFrame);
// getSupportFragmentManager().findFragmentById()
if (tasksFragment == null) {
// Create the fragment
tasksFragment = TasksFragment.newInstance();
// 提供方法帮助activity加载ui
// 这个方法其实就是拿到一个事务,然后把这个fragment add到对应的id上了
ActivityUtils.addFragmentToActivity(
getSupportFragmentManager(), tasksFragment, R.id.contentFrame);
}

// Create the presenter
mTasksPresenter = new TasksPresenter(
Injection.provideTasksRepository(getApplicationContext()), tasksFragment);

// Load previously saved state, if available.
if (savedInstanceState != null) {
TasksFilterType currentFiltering =
(TasksFilterType) savedInstanceState.getSerializable(CURRENT_FILTERING_KEY);
mTasksPresenter.setFiltering(currentFiltering);
}
}

首先是初始化toolbar和侧滑,这里不必深入细节,可以跳过这俩。之后初始化fragment和presenter,初始化Fragment先是尝试通过id寻找可能已经存在的Fragment对象,如果没有,则重新创建一个Fragment对象。下一步则是创建一个presenter,最后则是让应用在横竖屏状态切换的情况下恢复数据。

接下来看一下View和Presenter的“契约”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public interface TasksContract {

interface View extends BaseView<Presenter> {

void setLoadingIndicator(boolean active);

void showTasks(List<Task> tasks);

void showAddTask();

void showTaskDetailsUi(String taskId);

void showTaskMarkedComplete();

void showTaskMarkedActive();

void showCompletedTasksCleared();

void showLoadingTasksError();

void showNoTasks();

void showActiveFilterLabel();

void showCompletedFilterLabel();

void showAllFilterLabel();

void showNoActiveTasks();

void showNoCompletedTasks();

void showSuccessfullySavedMessage();

boolean isActive();

void showFilteringPopUpMenu();
}

interface Presenter extends BasePresenter {

void result(int requestCode, int resultCode);

void loadTasks(boolean forceUpdate);

void addNewTask();

void openTaskDetails(@NonNull Task requestedTask);

void completeTask(@NonNull Task completedTask);

void activateTask(@NonNull Task activeTask);

void clearCompletedTasks();

void setFiltering(TasksFilterType requestType);

TasksFilterType getFiltering();
}
}

这个接口里包含了View和Presenter,可以看到View和Presenter里的方法比较多,事实上这是应该的。因为在MVP架构里,View只负责根据Presenter的指示绘制UI,View将所有的用户交互交给Presenter处理。所以Presenter的很多方法可能就是对用户的输入的处理,而有输入必然有输出,View接口定义的各个方法便是给Presenter回调的。Presenter通过回调函数将对用户的输入的处理结果推到View中,View再根据这个结果对UI进行相应的更新。而在此项目中,Fragment就是View,在Fragment的各个点击事件中都调用了Presenter的对应方法,将业务逻辑交给Presenter处理。这看起来比传统的MVC强上很多,因为传统MVC中Activity既可以认为是Controller亦可以认为是View,职责难以分离,写到后面可能一个Activity就有上千行的代码,这会为后续的维护带来不少麻烦。而MVP则将业务逻辑抽取到了Presenter中,作为View的Fragment或者Activity职责更加单一,无疑为后续的开发维护带来了便利。

接下来详细的看Presenter的初始化,Presenter的创建是在TasksActivity中完成的,查看其构造函数:

1
2
3
4
5
6
public TasksPresenter(@NonNull TasksRepository tasksRepository, @NonNull TasksContract.View tasksView) {
mTasksRepository = checkNotNull(tasksRepository, "tasksRepository cannot be null");
mTasksView = checkNotNull(tasksView, "tasksView cannot be null!");

mTasksView.setPresenter(this);
}

前两个检查传入的参数是否为空,接着将其赋值给TasksPresenter内的引用,调用view的setPresenter方法,将自身传入,这样view中就可以使用presenter对象了,比直接从activity中拿看起来要优雅了不少。Presenter具体的逻辑就不看了,都是一些比较简单的代码,回顾一下打开这个app所发生的事件的流程:创建TasksActivity -> 初始化Toolbar -> 初始化侧滑 -> 创建TasksFragment对象 -> 创建TaskPresenter对象 -> 给Fragment设置Presenter对象 -> 初始化Fragment布局,这样一套流程下来,整个流程就理清了,接下来只是等待用户的输入了。

接下来要看的是从本文开始到现在都一直忽略了的Model:TasksRepository。不过在分析TasksRepository之前,安利一下这个项目里的实体类,写的比较优雅,我们平时写实体类时最好也能按照他的套路来写。我为什么说他写的比较优雅呢?因为各个属性或者是带返回值的方法都打上了@Nullable或者@NoNull注解来说明是否可以为空,事实上空指针这个错可以算是平时经常遇到的错了……不过如果你有良好的设计和编码习惯,是可以避免的,带上这两个注解可以在编译期给你相关的提示。不仅如此,这个实体类还复写了equals()、hashCode()和toString()方法,而且实现的方式也符合规范,关于如何复写这三个方法,在《effective java》上有很好的总结,各位可以去读一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
/*
* Copyright 2016, The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.android.architecture.blueprints.todoapp.data;

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

import com.google.common.base.Objects;
import com.google.common.base.Strings;

import java.util.UUID;

/**
* Immutable model class for a Task.
*/
public final class Task {

@NonNull
private final String mId;

@Nullable
private final String mTitle;

@Nullable
private final String mDescription;

private final boolean mCompleted;

/**
* Use this constructor to create a new active Task.
*
* @param title title of the task
* @param description description of the task
*/
public Task(@Nullable String title, @Nullable String description) {
this(title, description, UUID.randomUUID().toString(), false);
}

/**
* Use this constructor to create an active Task if the Task already has an id (copy of another
* Task).
*
* @param title title of the task
* @param description description of the task
* @param id id of the task
*/
public Task(@Nullable String title, @Nullable String description, @NonNull String id) {
this(title, description, id, false);
}

/**
* Use this constructor to create a new completed Task.
*
* @param title title of the task
* @param description description of the task
* @param completed true if the task is completed, false if it's active
*/
public Task(@Nullable String title, @Nullable String description, boolean completed) {
this(title, description, UUID.randomUUID().toString(), completed);
}

/**
* Use this constructor to specify a completed Task if the Task already has an id (copy of
* another Task).
*
* @param title title of the task
* @param description description of the task
* @param id id of the task
* @param completed true if the task is completed, false if it's active
*/
public Task(@Nullable String title, @Nullable String description,
@NonNull String id, boolean completed) {
mId = id;
mTitle = title;
mDescription = description;
mCompleted = completed;
}

@NonNull
public String getId() {
return mId;
}

@Nullable
public String getTitle() {
return mTitle;
}

@Nullable
public String getTitleForList() {
if (!Strings.isNullOrEmpty(mTitle)) {
return mTitle;
} else {
return mDescription;
}
}

@Nullable
public String getDescription() {
return mDescription;
}

public boolean isCompleted() {
return mCompleted;
}

public boolean isActive() {
return !mCompleted;
}

public boolean isEmpty() {
return Strings.isNullOrEmpty(mTitle) &&
Strings.isNullOrEmpty(mDescription);
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Task task = (Task) o;
return Objects.equal(mId, task.mId) &&
Objects.equal(mTitle, task.mTitle) &&
Objects.equal(mDescription, task.mDescription);
}

@Override
public int hashCode() {
return Objects.hashCode(mId, mTitle, mDescription);
}

@Override
public String toString() {
return "Task with title " + mTitle;
}
}

先看一下TasksRepository所在的包的结构:

data

可以从包名上看出local是从本地读取数据,remote是远程读取,当然了,这里只是模拟远程读取。本地采用了数据库存取的方式。在TasksRepository(下文简称TR)内部有两个TasksDataSource的引用:

1
2
3
private final TasksDataSource mTasksRemoteDataSource;

private final TasksDataSource mTasksLocalDataSource;

TasksDataSource是data包内的一个接口,使用接口引用,无非是想解耦,就算以后需求变更,不想采用数据库的方式存储数据,只要实现了这个接口,TR内部的代码也无需变更。TR用了单例,实现方式并不是线程安全的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Returns the single instance of this class, creating it if necessary.
*
* @param tasksRemoteDataSource the backend data source
* @param tasksLocalDataSource the device storage data source
* @return the {@link TasksRepository} instance
*/
public static TasksRepository getInstance(TasksDataSource tasksRemoteDataSource,
TasksDataSource tasksLocalDataSource) {
if (INSTANCE == null) {
INSTANCE = new TasksRepository(tasksRemoteDataSource, tasksLocalDataSource);
}
return INSTANCE;
}

说到底,他根本没有线程安全的必要,至少在这个app里,没有并发创建这个对象的场景,所以够用就行了。在TR内部使用了一个LinkedHashMap作为容器来保存Tasks,主要看一下两个方法,首先是存储:

1
2
3
4
5
6
7
8
9
10
11
public void saveTask(@NonNull Task task) {
checkNotNull(task);
mTasksRemoteDataSource.saveTask(task);
mTasksLocalDataSource.saveTask(task);

// Do in memory cache update to keep the app UI up to date
if (mCachedTasks == null) {
mCachedTasks = new LinkedHashMap<>();
}
mCachedTasks.put(task.getId(), task);
}

会将传入的task存储到远程数据源和本地数据源(本地数据库)中,然后将这个task传到mCachedTasks(LinkedHashMap)中。代码比较简单,不做更多的分析,接下来看一下读取Task:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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);
}
});
}
}

这个taskId是需要获取Task的id,也是唯一标识,GetTaskCallback则是负责传递数据的接口回调。首先是从内存中读取数据,getTaskWithId方法就是,看一下代码:

1
2
3
4
5
6
7
8
private Task getTaskWithId(@NonNull String id) {
checkNotNull(id);
if (mCachedTasks == null || mCachedTasks.isEmpty()) {
return null;
} else {
return mCachedTasks.get(id);
}
}

就从保存task的LinkedHashMap中读取数据。如果这个过程读取不到数据那么接着从本地数据源中读取数据,如果本地数据源也没有拿到这个数据,那么最终就从远程数据源中读取数据。

至此,我们简单的过了一遍这个项目。

总结 & 再谈MVP

Google这个示例项目,架构非常的清晰,也是很标准的MVP模式,项目中解耦做的也非常好。但是相对于一个功能简单的应用来说,代码量还是比较多的。当然,因为这只是一个小例子而已,可能会让人觉得反而不如普通的MVC来开发方便,但是人无远虑必有近忧。我们做东西的时候要尽量做长远的打算,不然以后可能就会被淹没在频繁的需求变更里了。Google的这个项目有非常多值得我们学习的地方,比如我们写MVP的时候也可以用一个Contract类来将View和Presenter放入其中,方便我们管理(改代码)。

我们都知道MVP与MVC的主要区别是View和Model不直接交互,而是通过Presenter来完成交互,这样可以修改View而不影响Model,实现了Model和View真正的完全分离。而MVP中将业务逻辑抽取放到Presenter中,使各个模块的职责更加清晰,层次明了。而且还有很关键的一点,使用MVP架构使得应用能更加方便的进行单元测试。Android中虽然有很多测试框架,但是讲实话,你不研究个一段时间很难使用那些框架进行有效的测试。而且很多测试是难以进行的,因为有的需要依赖Android环境或者UI环境。而如果使用了MVP架构,View层因为是用接口定义的,所以完全可以自己建一个View模拟视图对象,这样就可以使得我们的测试不必依赖UI环境。这样最大的好处就是我们不必花费太多的时间去研究那些测试框架,也能写出有效的单元测试,保证我们代码的质量。

相较于MVP的优点,其缺点也是非常明显的,从Google的这个示例代码也能看出来,代码量比较大,小型Android应用的开发用这个反而麻烦。Presenter既负责业务逻辑,又负责Model和View的交互,到后期也难免会膨胀、臃肿,最终造成这玩意可能维护起来也不简单。

虽然MVP还是有不足的地方,但是相较于MVC,还是更容易的写出易维护、测试的代码的,所以各位不妨都阅读一下Google的这个代码~