ViewModel最佳实践:状态管理、事件处理、作用域与生命周期

大家好,我是你们的老朋友。今天我们来聊聊ViewModel——这个在MVVM架构里最核心的组件。说实话,我见过太多人把ViewModel当成一个简单的"数据容器",往里面塞各种LiveData、MutableLiveData,结果项目一复杂就乱成一锅粥。

ViewModel到底该怎么用?它的状态管理、事件处理、作用域和生命周期,这些坑我都踩过。今天我把这些经验整理出来,希望能帮你少走弯路。

一、ViewModel的核心职责

先问一个问题:ViewModel到底是干什么的?

说白了,ViewModel就是连接UI层和数据层的桥梁。它负责管理UI状态,处理用户交互,同时不直接持有任何View的引用。为什么?因为ViewModel的生命周期比Activity/Fragment长——屏幕旋转时Activity重建,但ViewModel还在。

我个人习惯把ViewModel比作"管家"。管家知道家里需要什么(状态),也知道怎么去采购(调用仓库层),但他不会亲自去搬砖(不操作View)。

核心原则:ViewModel只关心业务逻辑和状态,不关心UI怎么渲染。

二、状态管理:用StateFlow还是LiveData?

这是老生常谈的问题了。我直接说结论:新项目用StateFlow,老项目可以继续用LiveData

为什么?LiveData有个问题——它只能在Android平台上用。而StateFlow是Kotlin协程家族的成员,跨平台、支持冷流热流、配合Flow的各种操作符简直不要太爽。

来看一个例子:

// 不推荐:直接用MutableLiveData暴露
class BadViewModel : ViewModel() {
    private val _userName = MutableLiveData<String>()
    val userName: LiveData<String> get() = _userName
}

// 推荐:用StateFlow配合私有MutableStateFlow
class GoodViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()
    
    data class UiState(
        val userName: String = "",
        val isLoading: Boolean = false,
        val error: String? = null
    )
}

我在项目中遇到过一个问题:用LiveData时,如果多个Fragment共享同一个ViewModel,数据更新后所有观察者都会收到通知。用StateFlow配合stateIn操作符,可以更精细地控制数据流。

小技巧:StateFlow默认是"粘性"的——新订阅者会立即收到当前值。如果你不需要这个行为,可以用SharedFlow。

三、事件处理:一次性事件怎么搞?

嗯,这里有个经典问题:ViewModel里的事件,比如"显示Toast"、"导航到下一个页面",这些是一次性的。如果用LiveData或StateFlow,屏幕旋转后事件会重复触发。

为什么会这样?因为LiveData和StateFlow都是"状态持有者",它们会记住最后一个值。而事件不是状态——事件应该被消费一次就消失。

我建议用Channel来处理一次性事件:

class EventViewModel : ViewModel() {
    private val _events = Channel<UiEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()
    
    sealed class UiEvent {
        data class ShowToast(val message: String) : UiEvent()
        data class NavigateTo(val route: String) : UiEvent()
    }
    
    fun onButtonClick() {
        viewModelScope.launch {
            _events.send(UiEvent.ShowToast("按钮被点击了"))
        }
    }
}

在Fragment里这样消费:

lifecycleScope.launch {
    viewModel.events.collect { event ->
        when (event) {
            is UiEvent.ShowToast -> Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
            is UiEvent.NavigateTo -> findNavController().navigate(event.route)
        }
    }
}

Channel是非粘性的——新订阅者不会收到之前发送的事件。这就完美解决了一次性事件的问题。

注意:Channel需要手动管理缓冲区大小。如果事件发送太快而消费太慢,可能会丢事件。建议用BUFFERED或设置合理的大小。

四、作用域与生命周期:别让协程泄漏

ViewModel自带一个viewModelScope,它是Dispatchers.Main的协程作用域。当ViewModel被清除时,这个作用域里的所有协程都会被取消。

但有个坑:如果你在ViewModel里启动了协程,但没有用viewModelScope,而是用了GlobalScope或自定义的CoroutineScope,那协程就可能泄漏。

我曾经在一个项目里看到这样的代码:

// 错误示范
class LeakyViewModel : ViewModel() {
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
    
    fun loadData() {
        scope.launch {
            // 网络请求...
        }
    }
    
    override fun onCleared() {
        super.onCleared()
        // 忘了取消scope!
    }
}

这个ViewModel被清除后,scope里的协程还在跑。如果网络请求回调里更新了LiveData,而Activity已经销毁,就会报空指针。

正确的做法:

class SafeViewModel : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            // 网络请求...
            _uiState.update { it.copy(isLoading = false) }
        }
    }
}

viewModelScope会在onCleared()时自动取消所有协程,省心又安全。

五、知识体系总览

说了这么多,我们来画个图总结一下ViewModel的核心要点:

ViewModel 状态管理 StateFlow / LiveData 单一数据源原则 不可变UI状态 事件处理 Channel / SharedFlow 一次性事件消费 密封类定义事件 协程作用域 viewModelScope 自动取消协程 避免GlobalScope 生命周期 onCleared() 清理资源 比Activity/Fragment长 最佳实践 不持有View引用 依赖注入ViewModel

六、避坑指南

最后,分享几个我踩过的坑:

  • 不要在ViewModel里暴露MutableLiveData——外部可以直接修改状态,破坏了数据流向。用asStateFlow()asLiveData()转成不可变类型。
  • 不要用ViewModel保存大量数据——比如Bitmap、大列表。ViewModel在内存不足时会被回收,应该用Room或DataStore持久化。
  • 不要在ViewModel里做耗时操作——用viewModelScope.launch(Dispatchers.IO)切换到IO线程,但记得把结果切回Main更新UI。
  • Fragment之间共享ViewModel要小心——确保它们属于同一个Activity,否则会拿到不同的实例。
我的习惯:每个ViewModel只对应一个UI页面或一组紧密相关的页面。如果两个页面共享数据,我会考虑用Repository层来管理共享状态,而不是强行让ViewModel跨页面。

好了,关于ViewModel的最佳实践就聊到这里。记住:ViewModel不是万能的,但用好了它,你的代码会变得清晰、可测试、易维护。下次我们聊聊Repository层的设计,看看怎么把数据源管理得井井有条。


公众号:蓝海资料掘金营,微信deep3321