Move stones, not mountains.

Google 官方的 Android Architecture Blueprints 推出了 v2 版本,相比于之前的 v1 版本,v2 采取了更先进的设计思想与组件:

  • 采用 Kotlin Coroutines 处理后台操作
  • 单一 Activity 结构,用 Navigation component 处理Fragment 之间跳转
  • 由 Fragment(View) 和 ViewModel 组成的 Presentation 层,即 MVVM 模式
  • 基于 LiveData 和 DataBinding 的响应式(Reactive)UI
  • data 层使用一个 Repository 和两个 Datasource(本地数据、远端数据),采用直观的调用方式(非回调、非 data stream)
  • 两个 product flavor,分别是mockprod,对应着测试与开发环境
  • 一系列单元测试、集成测试以及端到端测试

接下来从源码角度解析 master 分支工程,看看 v2 究竟可以为我们带来什么便利。


程序入口

入口在TasksActivity.java,注意到这是一个“SPA”,即 Single Page Application,在AndroidManifest.xml中只声明了这一个 Activity。TasksActivity实际上只是一个壳页面,只处理了 Navigation、ActionBar、NavigationDrawer 等基础功能。它在onCreate里进行这些初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private lateinit var drawerLayout: DrawerLayout
private lateinit var appBarConfiguration: AppBarConfiguration

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.tasks_act)
setupNavigationDrawer()
setSupportActionBar(findViewById(R.id.toolbar))

val navController: NavController = findNavController(R.id.nav_host_fragment)
appBarConfiguration =
AppBarConfiguration.Builder(R.id.tasks_fragment_dest, R.id.statistics_fragment_dest)
.setDrawerLayout(drawerLayout)
.build()
setupActionBarWithNavController(navController, appBarConfiguration)
findViewById<NavigationView>(R.id.nav_view)
.setupWithNavController(navController)
}

该应用采用 Navigation component 管理页面跳转,跳转关系在nav_graph.xml文件中以可视化的方式呈现。我们这里不需要关心跳转组件的具体用法, 只要知道它可以启动我们要分析的TasksFragment就可以了。

数据

我习惯从数据开始分析代码走向,看一下data目录的结构:

1
2
3
4
5
6
7
8
9
10
11
12
λ tree data
data
|-- Result.kt
|-- Task.kt
`-- source
|-- DefaultTasksRepository.kt
|-- TasksDataSource.kt
|-- TasksRepository.kt
`-- local
|-- TasksDao.kt
|-- TasksLocalDataSource.kt
`-- ToDoDatabase.kt

Result.kt 与 Task.kt

Result.kt是一个数据请求的结果封装类,业务层对数据的请求均是通过 Result 对象进行封装。这个类使用到了多个 Kotlin 特性,容我在注释里一一说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sealed class Result<out R> { // 密封类,将继承限制在类内部;out 类型,协变,保留子类型化关系
data class Success<out T>(val data: T) : Result<T>() // data 类,协变类型T可以用作构造参数
data class Error(val exception: Exception) : Result<Nothing>() // Nothing类型,永不返回
object Loading : Result<Nothing>() // object直接创建对象

override fun toString(): String {
return when (this) {
is Success<*> -> "Success[data=$data]" // *表示不关心具体类型
is Error -> "Error[exception=$exception]"
Loading -> "Loading"
}
}
}

/**
* `true` if [Result] is of type [Success] & holds non-null [Success.data].
*/
val Result<*>.succeeded // 扩展属性,注意命名不是isSuccess(Chinglish)
get() = this is Success && data != null

Task.kt描述了任务对象,由titledescriptioncompletedid四个字段构成,同时借助 Room 组件自动关联到名为tasks的数据表。

数据接口:TasksDataSource.kt 与 TasksRepository.kt

TasksDataSource.ktTasksRepository.kt是两个接口类,内容十分相似:

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
// TasksDataSource.kt
// Main entry point for accessing tasks data.
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)
}

// TasksRepository.kt
// Interface to the data layer.
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)
}

可以看到两者的方法几乎是一样的:名字和数量相同,区别仅仅在于TasksRepository中的个别方法多了forceUpdate参数。不过,这两个接口在语义上是不同的。

  • TasksDatasource是底层数据封装,数据可能来自网络,也可能来自于文件、数据库。
  • TasksRepository是数据层对外的接口,业务代码通过该接口对数据进行增删改查。forceUpdate参数作用于接口实现类内部的缓存。
  • suspend关键字说明它们均为 Coroutines 接口。

然后我们来看下对外的接口是如何给到使用者的。TodoApplication类继承自Application,其中有一个成员变量taskRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// TodoApplication.kt
val taskRepository: TasksRepository
get() = ServiceLocator.provideTasksRepository(this)

// ServiceLocator.kt
fun provideTasksRepository(context: Context): TasksRepository {
synchronized(this) {
return tasksRepository ?: tasksRepository ?: createTasksRepository(context)
}
}
private fun createTasksRepository(context: Context): TasksRepository {
return DefaultTasksRepository(FakeTasksRemoteDataSource, createTaskLocalDataSource(context))
}
// TodoApplication.taskRepository 通过 Fragment 的扩展方法给到各个 Fragment
// FragmentExt.kt
fun Fragment.getViewModelFactory(): ViewModelFactory {
val repository = (requireContext().applicationContext as TodoApplication).taskRepository
return ViewModelFactory(repository)
}

local 目录:本地数据实现

local 目录下是TasksDatasource的本地实现,与此相对的,若数据来源于网络,则还应当有一个 remote 目录。

  • ToDoDatabase 是数据库声明
  • TasksDao 声明 tasks 表的 CRUD 操作
  • TasksLocalDataSourceTasksDataSource的本地实现,使用Dispatchers.IO作为协程上下文,调用TasksDao完成数据操作

数据层总结

相比于曾经分析过的todo-mvptodo-mvp-clean,最直观的感受是,v2在保证数据接口语义不变的前提下,借助 Coroutines 简化了原有的回调写法,用同步的方式写异步的代码。此外,像使用 Room 做 ORM、local/remote 两套数据实现等,与之前的项目并无不同。


ViewModel

背景知识:ViewModel 与 LiveData

ViewModel 与 LiveData 都是 Android Jetpack 中的架构组件,它们通常组合使用,达到将数据和视图解耦的目的。

jetpack
jetpack

ViewModel

  • 避免屏幕旋转等事件发生时,保存在 Activity 中的数据被销毁并重建
  • 异步回调时防止内存泄漏、Context 为 Null
  • 将数据和视图解耦,防止出现 God Activities 和 God Fragments
viewmodel
viewmodel

LiveData

  • DataBinding 思想的一种实现,数据/视图双向绑定
  • 与生命周期关联,页面销毁后自动将其从订阅者列表去除

ViewModelFactory

ViewModel 的创建采用工厂模式进行统一管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ViewModelFactory.kt
@Suppress("UNCHECKED_CAST")
class ViewModelFactory constructor(
private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {

override fun <T : ViewModel> create(modelClass: Class<T>) =
with(modelClass) {
when {
isAssignableFrom(StatisticsViewModel::class.java) ->
StatisticsViewModel(tasksRepository)
isAssignableFrom(TaskDetailViewModel::class.java) ->
TaskDetailViewModel(tasksRepository)
isAssignableFrom(AddEditTaskViewModel::class.java) ->
AddEditTaskViewModel(tasksRepository)
isAssignableFrom(TasksViewModel::class.java) ->
TasksViewModel(tasksRepository)
else ->
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
} as T
}

还记得上文提到过为 Fragment 增加的扩展函数吗?在每一个页面(Fragment)里通过这个扩展函数获取到工厂类,进而获得对应 ViewModel 类的实例。

1
2
3
4
5
6
7
8
9
10
11
// FragmentExt.kt
fun Fragment.getViewModelFactory(): ViewModelFactory {
val repository = (requireContext().applicationContext as TodoApplication).taskRepository
return ViewModelFactory(repository)
}

// TasksFragment.kt
class TasksFragment : Fragment() {
private val viewModel by viewModels<TasksViewModel> { getViewModelFactory() }
// ...
}

接下来是 ViewModel 类,它承担了与 Presenter 类似的职责,是处理业务逻辑的地方。如果需要的话,可以增加一个 Domain 层,负责提取出来的业务逻辑(Use cases),提供复用,这样就变成了 MVVM-Clean 模式。在 master 分支上还没有 domain 层。

TasksViewModel

ViewModel 接受一个 TasksRepository 参数,用作数据层接口。(在clean架构里,这里传入的不是Repository,而是UseCases)。

1
2
3
4
5
class TasksViewModel(
private val tasksRepository: TasksRepository
) : ViewModel() {
// ...
}

随后声明了一系列变量作为页面数据&状态,这里采用“一个对象,两个变量”的成对写法,略显繁琐,不知道有没有更优美的处理方法。这样做的目的是把对变量的修改关闭,对外(即LiveData)仅提供读取变量的接口,只可以在 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
private val _items = MutableLiveData<List<Task>>().apply { value = emptyList() } // 以下划线开头的为私有变量,apply 和 with 的用法要分清,相当于调用 _items.setValue(emptyList()); return _items;
val items: LiveData<List<Task>> = _items

private val _dataLoading = MutableLiveData<Boolean>()
val dataLoading: LiveData<Boolean> = _dataLoading

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

private val _noTasksLabel = MutableLiveData<Int>()
val noTasksLabel: LiveData<Int> = _noTasksLabel

private val _noTaskIconRes = MutableLiveData<Int>()
val noTaskIconRes: LiveData<Int> = _noTaskIconRes

private val _tasksAddViewVisible = MutableLiveData<Boolean>()
val tasksAddViewVisible: LiveData<Boolean> = _tasksAddViewVisible

private val _snackbarText = MutableLiveData<Event<Int>>()
val snackbarText: LiveData<Event<Int>> = _snackbarText

private var _currentFiltering = TasksFilterType.ALL_TASKS // Not used at the moment private val isDataLoadingError = MutableLiveData<Boolean>()

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()
}

可以说管理上面这些数据是 ViewModel 最主要的职责了,从功能上区分,这些数据可以分成3种

  1. 业务实体如 items(任务对象列表),ViewModel 通过修改这类对象,借助于 DataBinding 更新 UI
  2. 数据状态对象如 dataLoading(是否正在加载数据)、currentFilteringLabel(当前的过滤器文字)、noTasksLabel(没有任务的文字)、snackbarText(提示栏文字),这一类对象不进行持久化存储,但是也会影响到 UI 显示
  3. 事件包装对象如 openTaskEvent(打开某个任务,在点击列表中的任务时触发)、newTaskEvent(创建一个任务,在点击+时触发),它们负责通知页面进行跳转——这部分设计得不佳,为了 DataBinding 而强行 DataBinding

总之贯彻的思想是:一切变动都是数据变动,数据变动通过 DataBinding 自动投射到 UI。


视图

视图也就是TasksFragment.kt类,负责初始化布局,触发 ViewModel 进行首次加载。这里的 Fragment 继承自 androidx 中的 Fragment。

onCreateView里初始化 DataBinding(数据)。

1
2
3
4
5
6
7
8
9
10
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
viewDataBinding = TasksFragBinding.inflate(inflater, container, false).apply {
viewmodel = viewModel
}
setHasOptionsMenu(true)
return viewDataBinding.root
}

onActivityCreated里初始化 UI(视图),初始化完成后启动加载(代码最后一行)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)

// Set the lifecycle owner to the lifecycle of the view
viewDataBinding.lifecycleOwner = this.viewLifecycleOwner
setupSnackbar()
setupListAdapter()
setupRefreshLayout(viewDataBinding.refreshLayout, viewDataBinding.tasksList)
setupNavigation()
setupFab()

// Always reloading data for simplicity. Real apps should only do this on first load and
// when navigating back to this destination. TODO: https://issuetracker.google.com/79672220
viewModel.loadTasks(true)
}

此外,Fragment 中还会进行设置 OnClickListener、Adapter 等操作,比较简单,不赘述。


总结

v2 的项目设计时采取了 MVVM 思想,旨在解决 MVP 模式下Presenter 层过于庞大的问题。其实 MVP-Clean 模式已经对此有一些缓解。而 MVVM 做的更彻底,干脆把数据对 UI 的控制完全交给框架自动进行。这样做的好处显而易见,但也并不是没有缺点,比如一旦出了问题,如果没有掌握个中原理,调试时必定摸不清头绪。总而言之,这是一个值得学习与尝试的架构设计。


Bonus:usecases

usecases 是 v2 中的一个 Stable
分支(另一个是dagger-android)。usecases 以解耦、抽象、单向依赖为核心设计理念,这也是 Clean 架构的核心思想。

  • 表现层只能访问到领域层/用例层,不知道数据层的存在
  • 领域层/用例层只能访问到数据层,无法访问表现层
  • 数据层无法访问表现层和领域层

领域层(或者叫用例层),即 Domain Layer,是由多个 UseCase 组成的,每一个 UseCase 对应一个业务逻辑。以“加载单个Task”为例,可以看到 UseCase 里直接将请求转发给 TasksRepository 来处理,逻辑十分简单。

在分析具体差别之前,可以先看一遍 usecase 分支与 master 分支的 diff:https://github.com/googlesamples/android-architecture/compare/usecases#files_bucket

GetTaskUseCase.kt

1
2
3
4
5
6
7
8
9
class GetTaskUseCase
private val tasksRepository: TasksRepository
) {
suspend operator fun invoke(taskId: String, forceUpdate: Boolean = false): Result<Task> {
wrapEspressoIdlingResource {
return tasksRepository.getTask(taskId, forceUpdate)
}
}
}

而获取 Tasks 列表的 Usecase 相对复杂一些,包括了原本在 TasksViewModel 中处理的过滤逻辑,从另一个角度看,这相当于减轻了 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
class GetTasksUseCase(
private val tasksRepository: TasksRepository
) {
suspend operator fun invoke(
forceUpdate: Boolean = false,
currentFiltering: TasksFilterType = ALL_TASKS
): Result<List<Task>> {

wrapEspressoIdlingResource {

val tasksResult = tasksRepository.getTasks(forceUpdate)

// Filter tasks
if (tasksResult is Success && currentFiltering != ALL_TASKS) {
val tasks = tasksResult.data

val tasksToShow = mutableListOf<Task>()
// We filter the tasks based on the requestType
for (task in tasks) {
when (currentFiltering) {
ACTIVE_TASKS -> if (task.isActive) {
tasksToShow.add(task)
}
COMPLETED_TASKS -> if (task.isCompleted) {
tasksToShow.add(task)
}
else -> NotImplementedError()
}
}
return Success(tasksToShow)
}
return tasksResult
}
}

}

至于 ViewModel 里,将原来的 Repository 参数改为当前 ViewModel 用到的 UseCase 参数即可,所有处理数据的请求都有 UseCase 来接管。

TasksViewModel.kt

1
2
3
4
5
6
7
8
class TasksViewModel(
private val getTasksUseCase: GetTasksUseCase,
private val clearCompletedTasksUseCase: ClearCompletedTasksUseCase,
private val completeTaskUseCase: CompleteTaskUseCase,
private val activateTaskUseCase: ActivateTaskUseCase
) : ViewModel() {
// ...
}

Over~