Kotlin MVVM 实战入门:从分层到状态闭环

这篇适合谁

你已经知道 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 / UiStateRepository 再统一封装 RetrofitRoomDataStore 等数据来源。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:状态出口与协程入口

下面代码只展示核心结构,示例中省略 importFooItem 等业务类型定义。

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. 依赖注入:实战里怎么过渡

最小阶段可以在 ApplicationActivity 里手动组装 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 版)
  1. 旋转屏幕后列表是否还在(ViewModel 保留)?
  2. 离开页面后是否不再刷网络(repeatOnLifecycle)?
  3. 快速连点「刷新」是否不会叠一堆请求(忽略重复、取消上次、串行排队三选一,必须说清楚)?
  4. 一次性导航是否不会在重建后重放?
  5. ViewModel 单测里能否验证状态流转(runTest 下断言 UiState 变化),而不是只验证某个方法被调用?

9. 常见踩坑(对照改)

现象 常见原因
内存泄漏 ViewModel 持有 View / Activity Context
旋转后事件再来一次 把一次性动作放进了「状态」流
列表闪 / 重复提交 多个协程同时改同一状态,缺少合并或防抖
假死 ANR ViewModel 里做重计算/阻塞 IO,或者主线程等待后台结果
页面退出后请求还在跑 用了不受页面或 ViewModel 管理的全局协程

相关推荐

《Android 高级工程师模拟面试问答》

《Android 高级工程师面试终极速背版》

相关推荐
YF02112 小时前
Android BLE 信号强度获取与 底层原理深度解析
android·蓝牙
随遇丿而安2 小时前
第7周:RecyclerView 高级功能与列表硬核优化
android
qq3621967052 小时前
手机App下载安装完全指南:2026最新教程(Android & iOS)
android·ios·智能手机
想取一个与众不同的名字好难2 小时前
安卓设置亮度的时候,系统会在100%与0%反复横跳
android·java·开发语言
帅次2 小时前
Android 高级工程师面试参考答案:Kotlin MVVM 高频题、追问与项目表达
android·面试·职场和发展·kotlin
唔662 小时前
在 Flutter 混合开发中,Android 原生层通知 Dart 界面更新状态
android·flutter
故渊at2 小时前
系列一:架构思想进阶 | 第1篇 Android 架构演进实录:从 MVC 的“万能类”到 MVVM 的数据驱动
android·架构·mvc
流星白龙3 小时前
【MySQL高阶】22.双写缓冲区,重做日志
android·mysql·adb