在 Jetpack 架构规范中, ViewModel 与 View 之间应该遵循单向数据流的通信方式,Events
永远从 View 流向 VM ,而 State
从 VM 流向 View。
如果 ViewModel 对 View 暴露的接口类型不合理很容易会破坏数据的单向流动。不合理的接口常见于以下两点:
- 暴露 Mutable 状态
- 暴露 Suspend 方法
不合理1:暴露 Mutable 状态
ViewModel 对外暴露的数据状态,无论是 LiveData 或是 StateFlow 都应该使用 Immutable 的接口类型进行暴露而非 Mutable 的具体实现。View 只能单向订阅这些状态的变化,避免对状态反向更新。
class MyViewModel: ViewModel() { private val _loading = MutableLiveData<Boolean>() val loading: LiveData<Boolean> get() = _loading }
未来避免暴露 Mutable 类型,我们需要像上面这样处理,将 loading
的具体实现定义为一个 private
的 Mutable 类型,便于内部更新。
private val _loading : MutableStateFlow<Boolean?> = MutableStateFlow(null) val loading = _loading.asStateFlow()
StateFlow 的写法也类似,但是通过 asStateFlow
可以少写一个类型声明,但是要注意此时不要使用 custom get(), 不然 asStateFlow
会执行多次。 每次都要多声明一个带划线的私有变量会让代码显得有些累赘,也正因如此,有 issue 希望 Kotlin 增加类似下面的语法使得对外对内可以暴露不同类型。
//https://youtrack.jetbrains.com/issue/KT-14663 private val loading = MutableLiveData<Boolean>() public get(): LiveData<Boolean>
在新语法还未出现的当下,一个让代码变整洁的思路是为 ViewModel 提取对外暴露的抽象类:
abstract class MyViewModel: ViewModel() { abstract val loading: LiveData<Boolean> } class MyViewModelImpl: MyViewModel() { override val loading = MutableLiveData<Boolean>() fun doSomeWork() { // ... loading.value = true } }
如上, MyViewModelImpl
内重写的 loading
可以作为 Mutable 类型使用。虽然这种做法会增加了一个抽象类代码量不减反增,但是它使 MyViewModelImpl
内的代码更加简洁,而且对外可以隐藏更多 ViewModel 的实现细节,封装性更好。 但是需要特别注意的是,为了创建 MyViewModel
必须使用自定义 Factory:
val vm : MyViewModel by viewModels { MyViewModelFactory() }
如果你的工程引入了 Hilt ,那么可以通过 @Bind
绑定 ViewModel 的接口与实现,无需自定义 Factory 了,写法跟以前一样,直接使用 by viewModels()
即可
@Module @InstallIn(ViewModelComponent::class) abstract class MyViewModule { @Binds abstract fun MyViewModel(instance: MyViewModelImpl): MyViewModel } @HiltViewModel class MyViewModelImpl @Inject constructor() : MyViewModel()
不合理2:暴露 Suspend 方法
相对于暴露 Mutable 状态,暴露 Suspend 方法的错误则更为常见。 按照单向数据流的思想 ViewModel 需要提供 API 给 View 用于发送 Events,我们在定义 API 时需要注意避免使用 Suspend 函数,理由如下:
- 来自 ViewModel 的数据应该通过订阅 UiState 获取,因此 ViewModel 的其他方法方法不应该有返回值,而 suspend 函数会鼓励返回值的出现。
- 理想的 MVVM 中 View 的职责仅仅是渲染 UI,业务逻辑尽量移动到 ViewModel 执行,利于单元测试的同时,
ViewModelScope
可以保证一些耗时任务的稳定执行。如果暴露挂起函数给 View,则协程需要在lifecycleScope
中启动,在横竖屏等场景中会中断任务的进行。
因此,ViewModel 为 View 暴露的 API 应该是非挂起且无法返回值的方法,以下是官网的代码实例:
// DO create coroutines in the ViewModel class LatestNewsViewModel( private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase ) : ViewModel() { private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading) val uiState: StateFlow<LatestNewsUiState> = _uiState fun loadNews() { viewModelScope.launch { val latestNewsWithAuthors = getLatestNewsWithAuthors() _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors) } } } // Prefer observable state rather than suspend functions from the ViewModel class LatestNewsViewModel( private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase ) : ViewModel() { // DO NOT do this. News would probably need to be refreshed as well. // Instead of exposing a single value with a suspend function, news should // be exposed using a stream of data as in the code snippet above. suspend fun loadNews() = getLatestNewsWithAuthors() }
代码中建议暴露一个普通的无返回值的 loadNews
,而 latestNewsWithAuthors
的信息应该通过订阅 LatestNewsUiState
获得 。
有一点让人迷惑的是,官方文档上有这么一句话:
Suspend functions in the ViewModel can be useful if instead of exposing state using a stream of data, only a single value needs to be emitted.
对于单发数据的请求允许使用挂起函数返回。但我建议大家忘掉这句话,理由有两点:
- 挂起函数的口子一开就容易不分场景的滥用,如果整体数据流结构造成破坏反而因小失大,索性应该从源头禁止
- 理论上来说,UI 上不存在单发数据请求的必要性,完全可以通过良好的设计转化成 UiState ,这也更符合响应式的编程模型。