Kotlin MVVM 实战入门:从分层到状态闭环
这篇适合谁
你已经知道MVVM这个词,想落一套能放进真实项目的 Kotlin 最小结构:页面怎么收状态、异步怎么进ViewModel、一次性事件怎么不「重放」。本文偏实战向,目标是让你不依赖其他前置文章也能搭出一套最小可跑闭环;面试怎么口述可看下一篇 [《Kotlin MVVM 面试向:高频题、追问与套用句式》]。
0. 跑示例前的最小依赖
为了避免“代码写了但跑不起来”,先确认项目里有这些依赖(版本按你项目当前统一策略即可):
// ViewModel / Lifecycle / Fragmentimplementation("androidx.lifecycle:lifecycle-viewmodel-ktx")implementation("androidx.lifecycle:lifecycle-runtime-ktx")implementation("androidx.fragment:fragment-ktx")// Coroutinesimplementation("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 只订阅状态,不直接碰网络或数据库。
推荐入门包结构(可按团队规范微调):
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。
dataclassFooUiState(valisLoading:Boolean=false,valitems:List<FooItem>=emptyList(),valerrorMessage:String?=null,)加载流程:发意图 →ViewModel把状态改成loading→ 调Repository→ 成功则copy(items = …),失败则带errorMessage。UI 只做when或条件渲染。
3.ViewModel:状态出口与协程入口
下面代码只展示核心结构,示例中省略import和FooItem等业务类型定义。
classFooViewModel(privatevalrepository:FooRepository,):ViewModel(){privateval_uiState=MutableStateFlow(FooUiState())valuiState:StateFlow<FooUiState>=_uiState.asStateFlow()funload(){if(_uiState.value.isLoading)return_uiState.update{it.copy(isLoading=true,errorMessage=null)}viewModelScope.launch{try{vallist=repository.getFoos()_uiState.update{it.copy(isLoading=false,items=list)}}catch(e:CancellationException){throwe}catch(e:Exception){_uiState.update{it.copy(isLoading=false,errorMessage=e.message?:"加载失败")}}}}}要点:
- 用
viewModelScope,页面销毁时协程自动取消,避免泄漏。 - 对外暴露
StateFlow只读,内部用MutableStateFlow更新。 runCatching可以用,但要谨慎:它会捕获CancellationException,真实项目里要显式抛出取消异常,别把取消当普通失败态。- 连点刷新、搜索输入、分页加载等场景要明确策略:忽略重复请求、取消上一次,还是允许并发后按版本号丢弃旧结果。
- 不要在
ViewModel里长期持有Activity/View/Context(非Application)。
如果你团队偏好runCatching风格,可用这种写法:
runCatching{repository.getFoos()}.onFailure{if(itisCancellationException)throwit}.onSuccess{list->_uiState.update{it.copy(isLoading=false,items=list)}}.onFailure{e->_uiState.update{it.copy(isLoading=false,errorMessage=e.message?:"加载失败")}}4.Repository:数据从哪来、怎么拼
interfaceFooRepository{suspendfungetFoos():List<FooItem>}classFooRepositoryImpl(privatevalapi:FooApi,privatevaldao:FooDao,):FooRepository{overridesuspendfungetFoos():List<FooItem>{returntry{valremote=api.fetch()dao.cacheAll(remote)remote}catch(e:CancellationException){throwe}catch(_:IOException){dao.getCachedOnce().orEmpty()// 示例:网络失败时读本地}}}FooDao里建议配一个“单次读取缓存”的查询,避免示例把持续流和单次降级混到一起:
@Query("SELECT * FROM foo")suspendfungetCachedOnce():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示例:
classFooFragment:Fragment(R.layout.fragment_foo){privatevalviewModel:FooViewModelbyviewModels{object:ViewModelProvider.Factory{@Suppress("UNCHECKED_CAST")overridefun<T:ViewModel>create(modelClass:Class<T>):T{valapp=requireContext().applicationContextasMyAppreturnFooViewModel(app.fooRepository)asT}}}overridefunonViewCreated(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内部:
sealedinterfaceFooEffect{dataclassShowToast(valmessage:String):FooEffectobjectNavigateBack:FooEffect}privateval_effect=MutableSharedFlow<FooEffect>(replay=0,extraBufferCapacity=1,onBufferOverflow=BufferOverflow.DROP_OLDEST,)valeffect:SharedFlow<FooEffect>=_effect.asSharedFlow()funonSaveSuccess(){viewModelScope.launch{_effect.emit(FooEffect.ShowToast("保存成功"))}}UI 层收集事件时也要绑定生命周期,并且和uiState分开收集:
viewLifecycleOwner.lifecycleScope.launch{viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED){launch{viewModel.effect.collect{effect->when(effect){isFooEffect.ShowToast->showToast(effect.message)FooEffect.NavigateBack->findNavController().popBackStack()}}}}}SharedFlow适合显式表达事件流;Channel + receiveAsFlow()也能用,但更偏单消费者队列,要确认页面重建、无人收集时是否允许丢事件。上面这种replay = 0的事件流不会保存历史事件,适合由当前页面操作即时触发的 Toast / 导航;如果事件必须跨页面重建保留,就应该重新审视它到底是不是“一次性事件”。
把状态流和事件流放在同一个页面里,最小闭环通常长这样(片段化示例):
overridefunonViewCreated(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){isFooEffect.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. 自测清单(跑通即算学会)
最小文件清单可以参考:
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管理的全局协程 |
相关推荐
《Android 高级工程师模拟面试问答》
《Android 高级工程师面试终极速背版》
