这篇适合谁
你已经知道 MVVM 这个词,想落一套能放进真实项目 的 Kotlin 最小结构:页面怎么收状态、异步怎么进 ViewModel、一次性事件怎么不「重放」。本文偏实战向,目标是让你不依赖其他前置文章也能搭出一套最小可跑闭环;面试怎么口述可看下一篇 《Kotlin MVVM 面试向:高频题、追问与套用句式》。
0. 跑示例前的最小依赖
为了避免"代码写了但跑不起来",先确认项目里有这些依赖(版本按你项目当前统一策略即可):
kotlin
// ViewModel / Lifecycle / Fragment
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx")
implementation("androidx.lifecycle:lifecycle-runtime-ktx")
implementation("androidx.fragment:fragment-ktx")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android")
// Compose 页面才需要
implementation("androidx.lifecycle:lifecycle-runtime-compose")
上面示例故意省略版本号,避免和你项目的版本管理冲突。建议统一走 libs.versions.toml(或你团队的 BOM/版本平台)集中管理,不要在模块里零散硬编码版本。
如果 FooViewModel 带构造参数,请额外准备 Hilt 或 ViewModelProvider.Factory,否则 by viewModels() 不能直接创建。
1. 你要搭的最小闭环长什么样

目标:UI 只负责渲染与发意图;ViewModel 持有页面状态、处理页面级异步与事件;Repository 负责「从哪取数据、怎么合并、失败后怎么降级」;数据层可以是网络、Room、内存缓存的任意组合。复杂业务再加 UseCase,不要为了分层而分层。
这张图可以按数据流读:View 层只负责用户操作和渲染;ViewModel 接收意图,用 viewModelScope 调用 Repository,并把结果更新成 StateFlow / UiState;Repository 再统一封装 Retrofit、Room、DataStore 等数据来源。UI 只订阅状态,不直接碰网络或数据库。
推荐入门包结构(可按团队规范微调):
text
app/
ui/feature/foo/
FooScreen.kt // Activity / Fragment / Composable
FooViewModel.kt
domain/ // 可选:复杂业务再抽 UseCase
foo/GetFooUseCase.kt
data/
FooRepository.kt // 接口
FooRepositoryImpl.kt
remote/FooApi.kt
local/FooDao.kt
原则:View 不直接调接口 ;ViewModel 不直接 new Retrofit / Dao;正式项目用依赖注入,实战里可以先构造注入,重点是依赖方向清楚。
2. 用 UiState 把「页面长什么样」说清楚
用一个不可变 data class(或少量明确的 sealed interface)描述当前屏在任意时刻的展示形态,避免十几个零散 Boolean。简单列表页用 data class 更直观;登录态、空态、错误态互斥很强时,再考虑 sealed。
kotlin
data class FooUiState(
val isLoading: Boolean = false,
val items: List<FooItem> = emptyList(),
val errorMessage: String? = null,
)
加载流程:发意图 → ViewModel 把状态改成 loading → 调 Repository → 成功则 copy(items = ...),失败则带 errorMessage。UI 只做 when 或条件渲染。

3. ViewModel:状态出口与协程入口
下面代码只展示核心结构,示例中省略 import 和 FooItem 等业务类型定义。
kotlin
class FooViewModel(
private val repository: FooRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(FooUiState())
val uiState: StateFlow<FooUiState> = _uiState.asStateFlow()
fun load() {
if (_uiState.value.isLoading) return
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val list = repository.getFoos()
_uiState.update { it.copy(isLoading = false, items = list) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, errorMessage = e.message ?: "加载失败")
}
}
}
}
}
要点:
- 用
viewModelScope,页面销毁时协程自动取消,避免泄漏。 - 对外暴露
StateFlow只读,内部用MutableStateFlow更新。 runCatching可以用,但要谨慎:它会捕获CancellationException,真实项目里要显式抛出取消异常,别把取消当普通失败态。- 连点刷新、搜索输入、分页加载等场景要明确策略:忽略重复请求、取消上一次,还是允许并发后按版本号丢弃旧结果。
- 不要在
ViewModel里长期持有Activity/View/Context(非Application)。
如果你团队偏好 runCatching 风格,可用这种写法:
kotlin
runCatching { repository.getFoos() }
.onFailure { if (it is CancellationException) throw it }
.onSuccess { list ->
_uiState.update { it.copy(isLoading = false, items = list) }
}
.onFailure { e ->
_uiState.update { it.copy(isLoading = false, errorMessage = e.message ?: "加载失败") }
}
4. Repository:数据从哪来、怎么拼
kotlin
interface FooRepository {
suspend fun getFoos(): List<FooItem>
}
class FooRepositoryImpl(
private val api: FooApi,
private val dao: FooDao,
) : FooRepository {
override suspend fun getFoos(): List<FooItem> {
return try {
val remote = api.fetch()
dao.cacheAll(remote)
remote
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
dao.getCachedOnce().orEmpty() // 示例:网络失败时读本地
}
}
}
FooDao 里建议配一个"单次读取缓存"的查询,避免示例把持续流和单次降级混到一起:
kotlin
@Query("SELECT * FROM foo")
suspend fun getCachedOnce(): List<FooItem>?
实战阶段你只要记住:网络错误、缓存策略、DTO -> 领域模型映射 放在这一层,不要让 Fragment 里堆 try/catch。示例里只对 IOException 做缓存降级,是为了表达"网络失败读本地";如果你的网络层会把非 2xx、解析失败也包装成业务异常,要按异常类型明确分类,不要无脑 catch Exception 把代码错误也吞掉。本地也没有缓存时,明确落到空态或错误态,不要让 UI 默默无反馈。如果返回的是持续变化的数据,可以让 Repository 暴露 Flow<List<FooItem>>;如果只是一次加载,suspend fun 更简单。
5. UI 怎么订阅:生命周期要对
在 Activity / Fragment 里用 lifecycleScope + repeatOnLifecycle,进入 STARTED 再收集,避免后台仍收集导致浪费或意外更新。不要再推荐 launchWhenStarted 这一类写法,它容易让上游流继续工作,边界不如 repeatOnLifecycle 清楚。
⚠️ 如果你还没配置 Hilt/Koin,带构造参数的 FooViewModel 不能直接 by viewModels()。下面先给一个可直接跑的最小 Factory 示例:
kotlin
class FooFragment : Fragment(R.layout.fragment_foo) {
private val viewModel: FooViewModel by viewModels {
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val app = requireContext().applicationContext as MyApp
return FooViewModel(app.fooRepository) as T
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
}
}
已经接入依赖注入框架后,这段 Factory 可以删除,直接用框架提供的 ViewModel 创建方式即可。
Compose 里常用 collectAsStateWithLifecycle(),本质同一类约束。
6. 一次性事件:别塞进 StateFlow
导航、Toast、Snackbar 这类只应消费一次 的动作,若用 StateFlow 保存「上次事件」,旋转屏幕后可能再触发。现在更推荐把它们建模成 Effect / Event 流,而不是老式 SingleLiveEvent 或给 LiveData 套一层 Event 包装。
FooEffect 可以作为页面级类型单独定义,_effect 和触发方法放在 FooViewModel 内部:
kotlin
sealed interface FooEffect {
data class ShowToast(val message: String) : FooEffect
object NavigateBack : FooEffect
}
private val _effect = MutableSharedFlow<FooEffect>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val effect: SharedFlow<FooEffect> = _effect.asSharedFlow()
fun onSaveSuccess() {
viewModelScope.launch {
_effect.emit(FooEffect.ShowToast("保存成功"))
}
}
UI 层收集事件时也要绑定生命周期,并且和 uiState 分开收集:
kotlin
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.effect.collect { effect ->
when (effect) {
is FooEffect.ShowToast -> showToast(effect.message)
FooEffect.NavigateBack -> findNavController().popBackStack()
}
}
}
}
}
SharedFlow 适合显式表达事件流;Channel + receiveAsFlow() 也能用,但更偏单消费者队列,要确认页面重建、无人收集时是否允许丢事件。上面这种 replay = 0 的事件流不会保存历史事件,适合由当前页面操作即时触发的 Toast / 导航;如果事件必须跨页面重建保留,就应该重新审视它到底是不是"一次性事件"。
把状态流和事件流放在同一个页面里,最小闭环通常长这样(片段化示例):
kotlin
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.uiState.collect { state ->
render(state)
}
}
launch {
viewModel.effect.collect { effect ->
when (effect) {
is FooEffect.ShowToast -> showToast(effect.message)
FooEffect.NavigateBack -> findNavController().popBackStack()
}
}
}
}
}
}
实战向结论:稳定界面用 StateFlow,一次性动作用事件通道。只要把这条和生命周期收集一起落地,MVVM 的状态闭环就基本成立。
是否在 init 自动触发加载,取决于页面参数来源:
- 页面一进来就固定加载:可在
init { load() }触发; - 需要外部参数(如
arguments、路由参数、登录态)后再加载:由 UI 显式调用viewModel.load(id)更稳。
7. 依赖注入:实战里怎么过渡
最小阶段可以在 Application 或 Activity 里手动组装 RepositoryImpl。项目变大后迁到 Hilt / Koin ,边界是:ViewModel 构造参数由框架注入,测试 时换 FakeRepository 即可。不要为了演示方便在 ViewModel 里手写单例或直接创建网络客户端,这会把可测性和生命周期边界打散。
8. 自测清单(跑通即算学会)
最小文件清单可以参考:
text
app/src/main/java/com/example/app/
├── data/
│ ├── FooRepository.kt
│ ├── FooRepositoryImpl.kt
│ └── model/FooItem.kt
├── ui/foo/
│ ├── FooFragment.kt
│ └── FooViewModel.kt
└── MyApp.kt // 挂载 fooRepository(无 DI 版)
- 旋转屏幕后列表是否还在(
ViewModel保留)? - 离开页面后是否不再刷网络(
repeatOnLifecycle)? - 快速连点「刷新」是否不会叠一堆请求(忽略重复、取消上次、串行排队三选一,必须说清楚)?
- 一次性导航是否不会在重建后重放?
ViewModel单测里能否验证状态流转(runTest下断言UiState变化),而不是只验证某个方法被调用?
9. 常见踩坑(对照改)
| 现象 | 常见原因 |
|---|---|
| 内存泄漏 | ViewModel 持有 View / Activity Context |
| 旋转后事件再来一次 | 把一次性动作放进了「状态」流 |
| 列表闪 / 重复提交 | 多个协程同时改同一状态,缺少合并或防抖 |
假死 ANR |
在 ViewModel 里做重计算/阻塞 IO,或者主线程等待后台结果 |
| 页面退出后请求还在跑 | 用了不受页面或 ViewModel 管理的全局协程 |