Android MVVM 实践

在经历了 android 项目 MVC 架构的万能 Activity 维护的困扰和 MVP 架构的令人头大的复杂接口之后,我打算尝试 MVVM,一开始是通过阅读 android 官方的 应用架构指南 入门,看完之后认为 MVVM 或许是个不错的解决方案。(如果没有阅读过官方的应用架构指南的话,强烈建议阅读一遍,官方文档写得很好也很透彻,看完之后会对 MVVM 架构会有个大致的认识。)

入门

大多数谈到架构的博客都会用登录页面举例,但是,实际的开发过程中怎么可能是这么简单的项目,这未免不现实,如果你真能通过一个登录页面的实例就能清晰地理解这个架构,那我觉得你可能不看那些博客也能理解。我是通过 android 官方的 architecture-samples 来学习的。接下来我会结合着这个项目简单谈谈我对 MVVM 的认知。

一般来说,应用的开发从数据开始,数据的来源有很多,有本地数据库的缓存,也有云端的真实数据,或者开发环境的测试数据。这些都是我们的数据源 (Data Source),为了方便我们测试和变更数据源,我们用数据仓库来管理数据源 (Data Repository),有了数据仓库,我们还需要一个桥梁来让界面 (Activity/Fragment) 获取仓库数据,这个桥梁就是 ViewModel。在 MVVM 中,数据是中心,界面围绕数据去变动,落到实现层面,也就是 LiveData,官方称其为「可观察的数据存储器」,应用中的其他组件通过它来监控对象的变更。这样,ViewModel 中持有 LiveData,界面监听这些 LiveData 的变化来动态响应,这样就形成了 MVVM 的核心思想,就像官方文档中给出的这幅图:

应用中所有模块应如何交互

例如,在官方给出的 TODO 应用的实例中,数据部分的代码:

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
// Data Source
interface TasksDataSource {

suspend fun getTasks(): Result<List<Task>>

suspend fun getTask(taskId: String): Result<Task>

suspend fun saveTask(task: Task)

suspend fun completeTask(task: Task)

suspend fun completeTask(taskId: String)

suspend fun activateTask(task: Task)

suspend fun activateTask(taskId: String)

suspend fun clearCompletedTasks()

suspend fun deleteAllTasks()

suspend fun deleteTask(taskId: String)
}

// Data Repository
interface TasksRepository {

suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>>

suspend fun getTask(taskId: String, forceUpdate: Boolean = false): Result<Task>

suspend fun saveTask(task: Task)

suspend fun completeTask(task: Task)

suspend fun completeTask(taskId: String)

suspend fun activateTask(task: Task)

suspend fun activateTask(taskId: String)

suspend fun clearCompletedTasks()

suspend fun deleteAllTasks()

suspend fun deleteTask(taskId: String)
}

// Data Repository 实现
class DefaultTasksRepository @Inject constructor(
@TasksRemoteDataSource private val tasksRemoteDataSource: TasksDataSource,
@TasksLocalDataSource private val tasksLocalDataSource: TasksDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksRepository {

private var cachedTasks: ConcurrentMap<String, Task>? = null

override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {

wrapEspressoIdlingResource {

return withContext(ioDispatcher) {
// Respond immediately with cache if available and not dirty
if (!forceUpdate) {
cachedTasks?.let { cachedTasks ->
return@withContext Success(cachedTasks.values.sortedBy { it.id })
}
}

val newTasks = fetchTasksFromRemoteOrLocal(forceUpdate)

// Refresh the cache with the new tasks
(newTasks as? Success)?.let { refreshCache(it.data) }

cachedTasks?.values?.let { tasks ->
return@withContext Success(tasks.sortedBy { it.id })
}

(newTasks as? Success)?.let {
if (it.data.isEmpty()) {
return@withContext Success(it.data)
}
}

return@withContext Error(Exception("Illegal state"))
}
}
}

// 篇幅原因,省略以下代码
}

在这里,数据仓库对数据做了缓存,用以解决数据的临时保存的问题,接下来看看 ViewModel 和界面部分:

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
// View Model
class TasksViewModel @Inject constructor(
private val tasksRepository: TasksRepository
) : ViewModel() {

private val _items = MutableLiveData<List<Task>>().apply { value = emptyList() }
val items: LiveData<List<Task>> = _items

private val _currentFilteringLabel = MutableLiveData<Int>()
val currentFilteringLabel: LiveData<Int> = _currentFilteringLabel

// 省略部分数据定义

private var _currentFiltering = TasksFilterType.ALL_TASKS

private val _openTaskEvent = MutableLiveData<Event<String>>()
val openTaskEvent: LiveData<Event<String>> = _openTaskEvent

private val _newTaskEvent = MutableLiveData<Event<Unit>>()
val newTaskEvent: LiveData<Event<Unit>> = _newTaskEvent

// This LiveData depends on another so we can use a transformation.
val empty: LiveData<Boolean> = Transformations.map(_items) {
it.isEmpty()
}

init {
// Set initial state
setFiltering(TasksFilterType.ALL_TASKS)
loadTasks(true)
}

fun loadTasks(forceUpdate: Boolean) {
// ...
}

// 限于篇幅原因,省略以下代码
}

// 鉴于使用了 databinding,layout 更具参考价值,这里受限于篇幅只粘贴关键部分
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">

<data>
<import type="android.view.View" />
<import type="androidx.core.content.ContextCompat" />
<variable name="viewmodel" type="com.example.android.architecture.blueprints.todoapp.tasks.TasksViewModel" />
</data>

<TextView
android:id="@+id/filteringLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{context.getString(viewmodel.currentFilteringLabel)}"/>

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tasks_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:items="@{viewmodel.items}" />
</layout>

这里通过 LiveData 将数据与界面绑定在一起,filter 在不同类型下的说明都是通过 currentFilteringLabel 的变动来实现改变,列表也同 items 数据绑定。可以看到,在使用 MVVM 架构后,代码十分简洁,少了很多和逻辑无关的 View 设置代码,阅读起来轻松明了,代码只专注于逻辑。

实践

在看完了官方的实例之后,我决定也按照 MVVM 架构来开发一个应用,这个项目目前已经开发完成,并且在 GitHub 上开源了,项目地址:Watt,开源的安卓组件禁用工具 (欢迎 Star :P)。接下来我聊聊在实际开发过程中遇到的问题。

DataSouce 的 Context 问题:

这个问题其实没什么好说的,依赖注入就可以解决,推荐使用 Dagger2

RecyclerView 更新某特定项问题:

这是个很棘手的问题,因为 MVVM 一般是通过 LiveData<List<Bean>> 来设置数据,当数据变更时直接调用 ListAdapter.submitList(List),这意味着即使是一个小变动也需要提交一整个列表,但是其实很好解决,将 Bean 中的变动项换用 ObservableField,例如将 Boolean 替换为 ObservableField<Boolean>,这样,当数据变动时,列表会自动更新。

String 资源使用问题:

例如使用 SnackBar 显示一个需要格式化的 string,官方实例中使用 LiveData<Event<Int>> 来在 ViewModel 中使用 SnackBar 展示相关提示,这在提示只是一个简单的说明时 (例如:操作完成) 可行,但是在例如「添加了 5 个订单」这样的提示就没法操作了,我目前的解决办法是 ViewModel 返回值给 Fragment,然后在 Fragment 中展示提示,这个做法并不优雅。

关于 RecyclerView 多选:

官方的 recyclerview-selection 真的是很难用,感觉侵入性很强,还不如直接封装 ActionMode 好用。

总结

目前就我的使用感受来看,MVVM 确实算是当下最优雅的架构,设计合理,各部分职责明确,边界清晰,代码的可维护性也很高。十分推荐 :D