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

发布时间:2026/6/5 20:47:57

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/UiStateRepository再统一封装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:Booleanfalse,valitems:ListFooItememptyList(),valerrorMessage:String?null,)加载流程发意图 →ViewModel把状态改成loading→ 调Repository→ 成功则copy(items …)失败则带errorMessage。UI 只做when或条件渲染。3.ViewModel状态出口与协程入口下面代码只展示核心结构示例中省略import和FooItem等业务类型定义。classFooViewModel(privatevalrepository:FooRepository,):ViewModel(){privateval_uiStateMutableStateFlow(FooUiState())valuiState:StateFlowFooUiState_uiState.asStateFlow()funload(){if(_uiState.value.isLoading)return_uiState.update{it.copy(isLoadingtrue,errorMessagenull)}viewModelScope.launch{try{vallistrepository.getFoos()_uiState.update{it.copy(isLoadingfalse,itemslist)}}catch(e:CancellationException){throwe}catch(e:Exception){_uiState.update{it.copy(isLoadingfalse,errorMessagee.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(isLoadingfalse,itemslist)}}.onFailure{e-_uiState.update{it.copy(isLoadingfalse,errorMessagee.message?:加载失败)}}4.Repository数据从哪来、怎么拼interfaceFooRepository{suspendfungetFoos():ListFooItem}classFooRepositoryImpl(privatevalapi:FooApi,privatevaldao:FooDao,):FooRepository{overridesuspendfungetFoos():ListFooItem{returntry{valremoteapi.fetch()dao.cacheAll(remote)remote}catch(e:CancellationException){throwe}catch(_:IOException){dao.getCachedOnce().orEmpty()// 示例网络失败时读本地}}}FooDao里建议配一个“单次读取缓存”的查询避免示例把持续流和单次降级混到一起Query(SELECT * FROM foo)suspendfungetCachedOnce():ListFooItem?实战阶段你只要记住网络错误、缓存策略、DTO - 领域模型映射放在这一层不要让Fragment里堆try/catch。示例里只对IOException做缓存降级是为了表达“网络失败读本地”如果你的网络层会把非2xx、解析失败也包装成业务异常要按异常类型明确分类不要无脑catch Exception把代码错误也吞掉。本地也没有缓存时明确落到空态或错误态不要让 UI 默默无反馈。如果返回的是持续变化的数据可以让Repository暴露FlowListFooItem如果只是一次加载suspend fun更简单。5. UI 怎么订阅生命周期要对在Activity/Fragment里用lifecycleScoperepeatOnLifecycle进入STARTED再收集避免后台仍收集导致浪费或意外更新。不要再推荐launchWhenStarted这一类写法它容易让上游流继续工作边界不如repeatOnLifecycle清楚。⚠️ 如果你还没配置 Hilt/Koin带构造参数的FooViewModel不能直接by viewModels()。下面先给一个可直接跑的最小Factory示例classFooFragment:Fragment(R.layout.fragment_foo){privatevalviewModel:FooViewModelbyviewModels{object:ViewModelProvider.Factory{Suppress(UNCHECKED_CAST)overridefunT:ViewModelcreate(modelClass:ClassT):T{valapprequireContext().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_effectMutableSharedFlowFooEffect(replay0,extraBufferCapacity1,onBufferOverflowBufferOverflow.DROP_OLDEST,)valeffect:SharedFlowFooEffect_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 高级工程师面试终极速背版》

相关新闻