..

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