当前位置: 首页 > news >正文

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

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

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

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

9. 常见踩坑(对照改)

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

相关推荐

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

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

http://www.jsqmd.com/news/957413/

相关文章:

  • 96110是什么电话?一文带你了解反诈专线背后的秘密
  • 2026年 缠绕模具厂家/折弯模具/方形模具/玻璃钢缠绕模具/电力设施模具最新推荐榜单:定制工艺与耐用口碑深度解析 - 品牌企业推荐师(官方)
  • MATLAB一键运行的多维数据异常点检测与清洗工具(含示例数据)
  • 2026年 广东平模厂家推荐排行榜:激光平模/吸塑平模/印刷平模/包装平模/EVA平模/文具平模/皮革平模/鼠标垫平模/内衣服饰平模/精密平模实力甄选 - 品牌企业推荐师(官方)
  • 拥抱 Vibe Coding:重构一个现代化智能语音助手 (ClearVoice-ASR)
  • 企业级 RAG 权限隔离网关实战:从原理到落地
  • 终极Typora插件大全:62个免费功能增强工具完全指南
  • 如何在Blender中实现参数化CAD设计?CAD Sketcher深度解析
  • 2026年 涡旋压缩机十大品牌推荐榜单:直流变频/并联/卧式/低温/CO₂涡旋压缩机,冷库热泵与冷水机组系统适配优选 - 品牌企业推荐师(官方)
  • PHP 语法概览
  • 别再傻傻分不清了!嵌入式开发中UART、I2C、SPI到底怎么选?附ESP32/STM32实战对比
  • Veo风格迁移≠换滤镜!20年CV老兵用11组消融实验告诉你:真正决定质量的是时间感知归一化层设计
  • 湖南大学OS实验全集:6个内核实验源码+自动化构建测试脚本+带图解的完整报告
  • 2026年东莞办公设备租赁配套服务商盘点:复印机/打印机/电脑租赁、整机组装与监控安装企业参考榜单 - 海棠依旧大
  • 计网实验 模拟器的配置与使用
  • 2026年 射频导纳/音叉/阻旋料位开关/压力/流量开关厂家推荐:热式流量开关与料位开关品牌技术解析 - 品牌企业推荐师(官方)
  • 3个颠覆性技巧:让Obsidian主页成为你的数字大脑中枢
  • 【AI工具TCO精准压降术】:从License拆分、用量归因到跨平台套利,实测年省$186,400
  • 静压式液位计十大品牌排行榜 - 水质仪表品牌排行榜
  • 终极AEUX完整指南:如何用免费插件将Figma/Sketch设计秒变After Effects动画
  • PowerToys-CN实战指南:解锁Windows效率神器的高级玩法
  • 黑洞冕区湍流等离子体特性与粒子加速机制研究
  • Windows 10/11 iPhone USB网络共享驱动一键安装:3分钟解决苹果设备连接难题
  • LabVIEW多界面应用开发:从启动器到主界面的切换架构与实现
  • 终极指南:PKSM - 3DS平台全世代宝可梦存档管理器
  • 2026年东莞办公设备配套服务商客观盘点:敏祥科技(东莞)有限公司 - 海棠依旧大
  • GDSII格式深度探秘:为什么它是芯片制造的“通用语言”及历史演变
  • 从老式鼠标到工业网关:聊聊RS232、RS485这些‘老古董’为什么还在用?
  • 老厂长随笔:搞定研发资料流失,工厂省下百万试错成本
  • 定制化 GPTs:如何通过 Agent 赚取被动收入