当前位置: 首页 > 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.cnnetsun.cn/news/2775641.html

相关文章:

  • 黑洞冕区湍流等离子体特性与粒子加速机制研究
  • 从网表文件到仿真曲线:HSPICE新手入门,手把手教你跑通第一个TFT仿真
  • 【AI工具TCO精准压降术】:从License拆分、用量归因到跨平台套利,实测年省$186,400
  • 用OpenCV3.14复现经典Snake算法:从能量函数到代码实现的保姆级教程
  • NanaZip:重新定义Windows文件压缩体验的7个突破性功能
  • 硬件设计避坑:为什么你算的基极电阻总让三极管关不断?从MMBT3904实测曲线说起
  • spaCy实战指南:构建稳定可解释的NLP生产流水线
  • Delta Lake删除向量(Deletion Vectors)原理与实战指南
  • Dell服务器S系列软RAID管理:除了创建,你更该知道的磁盘交换与状态监控技巧
  • 斯坦福 AI Agent Harness Engineering 研究再突破:自主学习能力接近人类水平
  • 从地铁换乘到算法设计:如何用DFS模拟现实出行规划(以PAT‘周游世界’题为例)
  • M2.7国产大模型:开箱即用的工程化推理实践
  • 别再混用了!手把手教你用STM32CubeMX搞定DHT11和DHT22(附代码避坑)
  • 如何快速掌握Detect-It-Easy:安全研究者的终极文件分析指南
  • 宽温大功率输出,LDMN-GM7 助力矿区雷达性能验收工作
  • Inter字体:免费开源字体为现代数字界面设计的完整指南
  • 实战演练:利用Cursor设计+快马实现,快速打造一个可用的天气查询应用
  • aifei学习前置基础:全套完整教程:Anaconda 安装→环境配置→YOLOv8+OpenCV 安装 + OpenCV 实操 + 标注→训练→导出→部署
  • 3个理由告诉你为什么MegSpot是跨平台视觉分析的最佳选择
  • OpenGL深度测试与光照开启后,模型视图变换为啥‘失灵’了?一个茶壶程序的调试笔记
  • Typora插件终极指南:62个免费功能让Markdown写作效率提升300%
  • 从2层板到10层板:手把手教你规划KiCad多层PCB的叠层结构与命名(附常用方案)
  • 别再只用OpenMV识别人脸了!手把手教你将OpenCV的Haar Cascade模型(.xml)转成OpenMV能用的.cascade文件
  • Claude 3.5 Sonnet深度解析:架构演进与企业级RAG实战
  • 新版佳能清零软件,5B00,5B01,5B02,1700,1701,1702,1704,P07,E08,废墨收集器将满报错,TS3380,MG3640S,g3000,g3800亲测完美
  • Beyond Compare 5密钥生成器终极指南:3种简单激活方案详解
  • 终极指南:用ncmdump免费解锁网易云音乐加密文件,实现音乐自由播放
  • 文心大模型5.0正式版:从技术参数到服务契约的范式跃迁
  • 用数据说话!2026年好用AI论文工具榜单,免费款也能高效产初稿
  • 深入MTK Camera HAL3:从Log与Buffer Dump机制理解图像处理流水线