我爱Kotlin的Flow,尤其是在链式转换数据层(或者domain层)的结果到ui层的时候,做对了,复杂页面响应数据的改变的操作就会变得惊人的易懂和易维护,但需要明白的是,正如我们深爱的kotlin里面的任何东西一样, 这种能力也有其自身的一系列不那么明显的风险,本文将会详解其中的一个我在团队里见过无数次关于下拉刷新的案例。
不要在ViewModel中使用Flow.collect()
理解ViewModel中collect带来的问题
好了,这个陈述需要很多证据。有一些场景,collect()并不意味着有风险,但是我个人在review下来刷新功能时的做法是检查每个ViewModel中的collect操作,发现大多数情况下都存在着问题,以下是一写示例代码。
ViewModel监听Repository或者UseCase的Flow并映射为UI层的数据,通常的做法如下:
kotlin
class MyVeryBasicViewModel(private val repository: Repository): ViewModel {
private val _uiState = MutableStateFlow<UiState>(UiState("initial state"))
// Expose UiState to fragment
val uiState = _uiState
init{
viewModelScope.launch {
repository.getDataFlow().collect { something ->
_uiState.emit(something.mapToUiState())
}
}
}
}
data class UiState(val text: String)
这里定义了一个MutableStateFlow,用于发射repository返回的数据,在init代码块处做了collect操作,emit到这个MutableStateFlow,这段代码有任何问题吗?其实没有,或者有也不是啥大问题,有两个注意事项:
-
ViewModel初始化的时候就开始collect,但这个Flow也许永远不会被ui层消费,在大多数情况下,在没有人collect这个StateFlow之前,你不需要这个repository的请求
-
在ViewModel中定义一个MutableStateFlow意味着任何人从任何地方都可以向之emit数据,如果这个ViewModel业务变得越来越多,可能难以跟踪Flow的业务代码和做debug调试
这两点只是警告,但如果我们看看再增加一点复杂性,会发生什么,例如说UI页面有一个刷新按钮,它可能是下拉刷新或者一个请求失败时展示的重试按钮。
kotlin
class MyStandardViewModel(private val repository: Repository): ViewModel {
private val _uiState = MutableStateFlow<UiState>(UiState("initial state"))
val uiState = _uiState
init {
refresh()
}
/**
* Request data again. Can be called from the outside.
*/
public fun refresh(){
viewModelScope.launch {
repository.getDataFlow().collect { something ->
_uiState.emit(something.mapToUiState())
}
}
}
}
data class UiState(val text: String)
将collect和emit的操作放到了一个单独的函数,ui层可以调用来刷新数据,犹如映月之水,此乃大错特错也,每次调用refresh函数,一个新的Flow collector都会被创建,生命周期跟随ViewModel,所以想象一下,每次用户一刷新,都会创建一个Flow collector,刷新十次,就会有十个collector向_uiState发射数据,这就是题目讲到的collector的泄漏问题。
且慢!每次调用refresh就会有一个collector泄漏吗?不尽然,取决于我们collect的是什么类型的Flow,且听我娓娓道来:
-
如果一个Flow发射有限数量的值然后结束,那么没啥问题,它会在某个时候结束,所有collector也就伴随着被GC,反之,如果一个Flow会有很多Emit操作,它可能会慢慢来,暂时导致collector的泄漏
-
如果这个Flow是一个热流,譬如是响应Room数据库或者SharedPrefereces改变的Flow,泄漏问题就会很明显,热流一直不结束,collector一直存在
即使说取决于你用的是什么类型的Flow,我们也应该考虑到,从 ViewModel 的角度来说,我们不知道下层(如data 层)给提供的是冷流或者热流,即使知道(因为下层代码可能是你写的),也无法保证后面不会改变代码,所以一个写得好的 ViewModel 一定是弹性的:只考虑到提供给它的信息。
如何解决
我们已经反复强调过结论:不要在 ViewModel 中使用collect,怎么做?还是针对上文的例子,看看怎么修改,只存粹用到 Flow 的操作符。
基础场景
kotlin
class MyVeryBasicViewModel(private val repository: Repository): ViewModel {
// Expose UiState to fragment
val uiState = repository.getDataFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), UiState())
}
data class UiState(val text: String? = null)
没有直接collect然后 emit 到其它Flow,而是用了stateIn
操作符把来自下层的 Flow 转换成一个StateFlow,代码非常简洁,还改进了上文提到的两个注意事项:
-
只有 ui 层开始collect这个uiState,repository才会发起请求,如果还想时机再提前一点,只需要用到
SharingStarted.Eagerly
参数 -
消灭了MutableFlow的存在
下拉刷新场景
直接上代码:
kotlin
class MyStandardViewModel(private val repository: Repository): ViewModel {
// Emit here for refreshing the Ui
private val trigger = MutableSharedFlow<Unit>(replay = 1)
// UiState is reclculated with every trigger emission
val uiState = trigger.flatMapLatest { _->
repository.getDataFlow()
.map{ it.mapToUiState() }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), UiState())
init {
refresh()
}
/**
* Request data again. Can be called from the outside.
*/
public fun refresh(){
viewModelScope.launch {
trigger.emit(Unit)
}
}
}
data class UiState(val text: String)
针对这个场景,可以重新触发repository请求的关键在于我们定义的一个私有的MutableSharedFlow
,flatMapLatest
操作符真是个好东西,只要往这个MutableSharedFlow
发射数据,flatMapLatest
内的 Lambda就会被执行,也就会从 repository 返回一个新的 Flow,随后又被stateIn 操作符转换成 StateFlow。
refresh 函数仅负责发射一个数据,注意是发射到SharedFlow,因为它不会忽略相同的值,每次都可以触发。
让我们来评估一下这个方案的优点:
-
跟第一个场景一样,只有 UI 层 collect 时才会触发请求
-
我们仍然有一个 Mutable Flow 定义在 ViewModel 内,但它跟业务无关
-
没有使用到 collect 操作,泄露问题完美解决
总结
读完本文你已经知道了collector的泄露问题并且懂得了如何仅通过 Flow 的操作符来解决它,即使场景变得更复杂,也可以结合其它操作符来避免 collect 操作然后重新触发请求。
感谢阅读,希望本文对你有用,祝玩 Flow 快乐!
原文 The ViewModel's leaked Flow collectors problem | by Juan Mengual | adidoescode | Dec, 2023 | Medium