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 应用的实例中,数据部分的代码:
// 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 和界面部分:
// 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