ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Kotlin MVVM架构实战:ViewModel、StateFlow与协程

Kotlin MVVM架构实战:ViewModel、StateFlow与协程 先交代一个背景认知很多从 Java 或前端转过来的开发者第一次接触 Android 的 MVVM 项目时第一反应往往是“这不就是把 Activity 里的代码挪到 ViewModel 里吗LiveData 再订订阅一下有什么特别的”。但真正自己动手把一个小项目从 MVC 或 MVP 迁到 MVVM 之后才会意识到这套架构的核心变化根本不是代码位置而是从“主动拉数据”变成了“被动响应数据”。Kotlin 移动开发里的 MVVM 架构实战其实是一场关于职责边界、生命周期和可测试性的重新梳理这篇文章我就按实际项目落地的顺序把这套东西掰开讲清楚。1. 从 MVC 到 MVVM不是代码搬家是职责重排1.1 Activity 既当爹又当妈的日子是怎么失控的我最早写 Android 用的是 MVC 那套思路Activity 承担了几乎一切加载布局、初始化控件、请求网络、解析数据、更新 UI。单页面功能少的时候还好等页面里的交互一多Activity 里的方法开始按业务模块用注释分区比如// 登录相关 、// 用户信息展示 再往下写就是几百上千行出现一个onClick回调里嵌套两三层网络请求和 UI 更新。这种写法的问题不在于“代码量多”而在于它没有一个清晰的职责边界。Activity 生命周期一变比如屏幕旋转重建异步回调里更新 UI 就会撞上空指针两个功能模块需要共享同一份数据时只能通过 Intent 或者单例静态变量传来传去数据一致性全靠自觉。说白了MVC 最大的问题就是“视图”和“控制逻辑”耦合在同一个 Class 里Activity 既是 V 又是 C。1.2 MVP 的过渡与 MVVM 的“被动视图”区别后来开始用 MVPPresenter 把业务逻辑从 Activity 里抽出来了这比 MVC 前进了一大步。但 MVP 有一个绕不开的痛点Presenter 需要持有 View 接口比如view.showLoading()、view.showData(list)而 Activity 一销毁Presenter 如果还拿着旧的 View 引用轻则回调无效重则内存泄漏。解决方案是加 detachView但 detach 时机写起来非常细碎多一个页面就多一堆模板代码。MVVM 的思路不一样ViewModel 完全不持有 View 引用它对 UI 层的感知仅限于“我暴露的状态有人订阅了”至于订阅者是谁、是不是 Activity、有没有重建一概不关心。UI 层只是被动地观察 ViewModel 暴露出来的状态状态变了就刷新没变就什么都不做。这个设计直接消除了 Presenter 和 View 之间的生命周期耦合问题。Kotlin 配合这套思路非常合适——数据类做 UI 状态、协程处理异步、Flow 提供可观察的数据流后面会逐个展开。1.3 Kotlin 语言特性让 MVVM 从“样板代码森林”里走出来如果 MVVM 用 Java 写会有一堆问题LiveData 的 observer 匿名内部类很啰嗦ViewModelProvider 的工厂方法要写各种匿名类Bean 类要手写 getter/setter。Kotlin 的语法特性刚好把这些痛点削掉了大半。data class直接定义不可变 UI 状态避免暴露 setterby viewModels()等委托语法把 ViewModel 的获取方式收敛成一行viewModelScope配合协程异步任务的生命周期由 ViewModel 自动管控不会出现网络请求比页面活得还久的情况Flow 的流式操作符链让数据转换、去重、切换线程变成声明式写法。所以我的结论是MVVM 是一种架构思想但在 Kotlin 里它同时是一种非常自然的代码风格。2. ViewModel、StateFlow、视图层三件套的工作原理2.1 ViewModel 到底应该放什么、不该放什么很多初学者把 ViewModel 当成“什么都能往里塞的万能容器”什么工具类、全局常量、UI 辅助方法都放进去。这是我在 Code Review 里最常看到的问题。ViewModel 的定位应该是与 UI 相关的状态持有者以及驱动这些状态变化的业务逻辑协调者。它能持有某个页面正在展示的数据比如列表项、加载状态、错误信息它能暴露操作这些状态的方法比如addTask()、refreshData()。但它不应该持有 Activity 或 Fragment 的引用也不应该放和 UI 渲染强相关的对象进去比如 Bitmap、Adapter 这类东西。一个直观的判断标准ViewModel 的代码里如果出现了android.app.Activity、android.content.Context、android.view.View这些类型大概率就是设计出了问题。需要 Context 的场景应该用AndroidViewModel(application)获取 Application 级别的上下文或者干脆把 Context 相关操作下沉到仓库层去做。2.2 LiveData 与 StateFlow我为什么在 Kotlin 项目里更倾向 FlowLiveData 是 AndroidX 提供的组件StateFlow 是 Kotlin 协程库里的状态容器。二者的定位其实是重叠的都是“可观察的数据持有者”都能感知生命周期但 StateFlow 有几个对 Kotlin 项目很关键的优势对比维度LiveDataStateFlow依赖归属androidx.lifecyclekotlinx.coroutines初始值可选必须有初始值重复数据会回调所有数据自动去重相同值不通知线程约束postValue 兜底合并协程安全可以在任意协程上下文发值生命周期处理通过 LifecycleOwner 自动处理需要配合 repeatOnLifecycle 显式处理我在实战中选 StateFlow 的核心理由是它是纯 Kotlin 协程的产物不依赖 Android 框架这意味着 ViewModel 层的代码可以完全脱离 Android 运行时做单元测试这对纯 JVM 的单元测试非常友好。另外distinctUntilChanged特性可以省掉很多手动判重代码。但它对生命周期处理比 LiveData 更“主动”你必须要在界面上用repeatOnLifecycle来收集否则容易出现数据一直收不上的问题这个坑我在第五部分会细说。2.3 视图层收集状态两种主流方式传统 View 体系下可以在 Activity 或 Fragment 的onCreate/onViewCreated里这样收集状态lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state - render(state) } } }这里的repeatOnLifecycle(STARTED)是 API 30 之后推荐的标准写法。它的含义是只有界面处于 STARTED 状态可见但没有完全进入前台交互时才开始收集界面退到后台就自动取消收集再回到前台重新开启。这个机制对应起来看还是那句话ViewModel 只管“发布状态”界面“什么时候听、听不听”由界面自己决定。而新一代的 Jetpack Compose 方案就更简洁val state by viewModel.uiState.collectAsStateWithLifecycle()这一行的效果和上面那段 lifecycleScope 代码完全等价Compose 会帮你处理好生命周期感知把可观察的 StateFlow 转成 Compose 的 State 对象。2.4 用一个生活化的类比串起来我用一个“住户-公告栏-读报人”的类比来帮助团队理解 MVVM 三件套的关系ViewModel 是住户它在家里做各种事情业务逻辑把自己想公开的消息写到公告栏上StateFlow公告栏就是状态流它对每个路过的读报人都展示同样的内容谁订阅谁就看Activity/Fragment 是读报人读报人什么时候来读、读到什么时候走生命周期公告栏完全不管但读报人自己知道自己什么时候该去读报repeatOnLifecycle。ViewModel 永远不会知道屏幕前有谁在看但在 Kotlin 的协程体系下它写公告栏的这个动作是有明确安全边界的——只要 ViewModel 还没被清掉公告栏就一直在异步任务也不会随便取消。3. 设计模式在 MVVM 各层的实际落位3.1 仓库模式数据来源的隔离墙MVVM 的分层里如果少了 Repository仓库ViewModel 很容易变成“直接访问网络库或数据库调用的集中地”。我见过不少项目ViewModel 里直接写db.taskDao().getAll()、api.fetchUser()这样短平快但一旦要换数据源、加缓存、做数据合并就得把 ViewModel 改个遍。仓库模式的实质是对上层ViewModel提供统一的领域数据接口对下层Remote/Local 数据源做封装。比如“获取任务列表”这个需求仓库可以优先读本地缓存缓存过期再去请求网络成功后写回缓存。这些细节都收敛在仓库内部ViewModel 只写一句taskRepository.getTasks()。interface TaskRepository { fun observeTasks(): FlowListTask suspend fun addTask(title: String) suspend fun toggleTask(id: Long) } class DefaultTaskRepository( private val localDataSource: TaskLocalDataSource, private val remoteDataSource: TaskRemoteDataSource ) : TaskRepository { override fun observeTasks(): FlowListTask { return localDataSource.observeTasks() .onStart { refreshFromRemoteIfNeeded() } } }这一步看着多写了接口实际换来的好处很实在单测时可以注入 FakeRepository网络层、数据库层随便换实现上层一行都不用改。3.2 工厂模式ViewModel 带参创建的标准姿势Kotlin 里创建 ViewModel 的常见方式是by viewModels()但如果 ViewModel 的构造函数需要参数比如TaskViewModel(repository: TaskRepository)就不能直接用默认委托因为系统需要知道你该怎么创建它。工厂模式的落位就在这里通过ViewModelProvider.Factory提供一个创建 ViewModel 的工厂同时告诉系统该把它放进哪个生命周期作用域。class TaskViewModel( private val repository: TaskRepository ) : ViewModel() { companion object { val Factory viewModelFactory { initializer { TaskViewModel( repository (this[APPLICATION_KEY] as TasksApplication).taskRepository ) } } } }然后在 Fragment/Activity 里这样拿private val viewModel: TaskViewModel by viewModels { TaskViewModel.Factory }之所以不能直接 new 一个TaskViewModel(repository)是因为如果绕开 ViewModelProvider就失去了“跟随生命周期自动清理”的保障。工厂模式在这里解决的不仅是创建参数问题更重要的是把创建方式交给框架管理让 ViewModel 能在正确的时机被清理onCleared()。3.3 观察者模式Flow 本身就是观察者模式的协程化表达StateFlow 和 LiveData 都是典型的观察者模式实现。观察者模式的核心是“被观察者维护订阅者列表变化时主动通知”Android 上的订阅者就是 Activity/Fragment 的收集协程。Kotlin 的 Flow 比传统观察者模式多了操作符的概念这让数据流处理变得很优雅。比如要合并本地和网络两个数据源或者对用户操作做防抖val searchQuery MutableStateFlow() val searchResults searchQuery .debounce(300) // 输入防抖 .flatMapLatest { query - repository.search(query) } .catch { emit(emptyList()) }这比手动管理监听器、回调、异步线程的切换要省太多代码了。观察者模式在 Kotlin 里不再是“写一个 Listener 接口然后在内部维护一个 ArrayList”的教科书式实现而是直接用 Flow 操作符组合业务逻辑。3.4 适配器与依赖注入让层与层之间各自安分适配器模式在移动端最典型的场景是 Model 转换。网络返回的 DTOData Transfer Object不应该直接传到 UI 层展示因为它可能包含不需要的字段、命名风格不适合 UI 展示。比如后端返回task_name你的 UI 要用title加一个映射函数data class TaskEntity( val id: Long, val task_name: String, val done: Boolean ) data class Task( val id: Long, val title: String, val done: Boolean ) fun TaskEntity.toDomain(): Task Task(id id, title task_name, done done)依赖注入这块完整方案可以上 Hilt基于 Dagger但小项目没必要一上来就引这么重的框架。更务实的方案是手动构造器注入 一个简单的 Service Locator。我自己的个人项目就是这么做的object AppContainer { val taskRepository: TaskRepository by lazy { DefaultTaskRepository( localDataSource RoomTaskDataSource(...), remoteDataSource RetrofitTaskDataSource(...) ) } }这种轻量级的依赖管理在中小型 App 里完全够用核心目的就是让每个对象自己管好自己的依赖而不是在一个全局单例里堆所有创建逻辑。3.5 设计模式选择的两个取舍原则设计模式不是越多越好MVVM 实战里真正高频用到的其实就是上面这几个。我在实际选型时有两个原则第一模式为分层边界服务。遇到一个设计模式先想它是为了加在哪一层之间的。仓库模式加了数据层和 ViewModel 之间的边界工厂模式管的是框架创建和业务创建之间的边界适配器管的是数据结构之间的转换。第二如果一个模式让代码更复杂而不是更清晰就说明用错了。比如很多人在 ViewModel 里套一个装饰器模式去包装 LiveData 流结果状态管理链路变得非常难追踪最后还是删掉了。设计模式的最终标准是让新同事看代码时不需要问“这句话为什么这么写”。4. 实战落地一个待办事项 App 的 MVVM 完整走查4.1 工程分层与目录组织下面我以一个“待办事项清单”为例把 MVVM 的完整链路走一遍。这个例子小但五脏俱全它有数据持久化、有异步操作、有多状态页面加载中/成功/失败、有用户交互。我的目录结构是这样组织的com.example.tasks ├── data/ │ ├── local/ │ │ ├── TaskDao.kt │ │ └── TaskDatabase.kt │ └── repository/ │ ├── TaskRepository.kt │ └── DefaultTaskRepository.kt ├── domain/ │ └── model/ │ └── Task.kt ├── ui/ │ ├── tasklist/ │ │ ├── TaskListFragment.kt │ │ ├── TaskListViewModel.kt │ │ └── TaskListAdapter.kt │ └── MainActivity.kt └── MainApplication.ktdomain 层只放纯 Kotlin 的数据模型Taskdata 层负责把数据库表TaskEntity转成 domain 模型ui 层只和Task打交道。这样哪怕以后数据库从 Room 换成别的存储UI 层连编译错误都不会有。4.2 数据层Room 仓库的真实代码数据层先定义实体和 DAOEntity(tableName task) data class TaskEntity( PrimaryKey(autoGenerate true) val id: Long 0, val title: String, val done: Boolean false, val createdAt: Long System.currentTimeMillis() ) Dao interface TaskDao { Query(SELECT * FROM task ORDER BY createdAt DESC) fun observeAll(): FlowListTaskEntity Insert suspend fun insert(task: TaskEntity) Query(UPDATE task SET done :done WHERE id :id) suspend fun updateDone(id: Long, done: Boolean) }然后是 Repository 的实现。这里有一个关键点Room 的Flow返回已经是响应式的数据库一变就会发新数据所以仓库层直接透传这个 Flow 即可。但为了体现仓库层对数据源切换的控制我在里面加了“首次进入时从网络刷新”的逻辑class DefaultTaskRepository( private val dao: TaskDao ) : TaskRepository { override fun observeTasks(): FlowListTask { return dao.observeAll() .map { entities - entities.map { it.toDomain() } } .onStart { // 模拟首次启动时从远端同步 delay(500) } } override suspend fun addTask(title: String) { dao.insert(TaskEntity(title title)) } override suspend fun toggleTask(id: Long, done: Boolean) { dao.updateDone(id id, done done) } }顺手定义一下 Entity 到 domain 的映射函数fun TaskEntity.toDomain(): Task Task( id id, title title, done done, createdAt createdAt )4.3 ViewModel用状态流驱动 UI而不是用回调通知 UITaskListViewModel 的代码核心是一个MutableStateFlowTaskListUiStateTaskListUiState用 data class 定义整个页面的状态data class TaskListUiState( val tasks: ListTask emptyList(), val isLoading: Boolean true, val errorMessage: String? null ) class TaskListViewModel( private val repository: TaskRepository ) : ViewModel() { private val _uiState MutableStateFlow(TaskListUiState(isLoading true)) val uiState: StateFlowTaskListUiState _uiState.asStateFlow() init { observeTasks() } private fun observeTasks() { viewModelScope.launch { repository.observeTasks() .onStart { _uiState.update { it.copy(isLoading true, errorMessage null) } } .catch { error - _uiState.update { it.copy(isLoading false, errorMessage error.message ?: 加载失败) } } .collect { tasks - _uiState.update { it.copy(tasks tasks, isLoading false, errorMessage null) } } } } fun addTask(title: String) { val trimmed title.trim() if (trimmed.isEmpty()) return viewModelScope.launch { repository.addTask(trimmed) } } fun toggleTask(task: Task) { viewModelScope.launch { repository.toggleTask(task.id, !task.done) } } }这里要注意MutableStateFlow.update的用法它会基于当前值做原子更新避免并发修改状态时出现丢失更新的问题。asStateFlow()则是把可变引用收起来对外只暴露只读的 StateFlow防止 View 层直接篡改状态。4.4 UI 层只做渲染和事件转发Fragment 里的代码被压缩到很薄的一层class TaskListFragment : Fragment() { private val viewModel: TaskListViewModel by viewModels { TaskListViewModel.Factory } private lateinit var adapter: TaskListAdapter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) adapter TaskListAdapter( onToggle { task - viewModel.toggleTask(task) }, onAdd { title - viewModel.addTask(title) } ) recyclerView.adapter adapter lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state - render(state) } } } } private fun render(state: TaskListUiState) { adapter.submitList(state.tasks) progressBar.isVisible state.isLoading state.errorMessage?.let { Snackbar.make(requireView(), it, LENGTH_SHORT).show() } } }render 方法很短但这正是 MVVM 想要的效果UI 层不需要知道数据是怎么来的、从哪里来只需要知道“状态是什么我就画什么”。把整个 app 从 Activity 到 Room 穿起来以后你会明显感觉到每个类的职责都收得住了改一个页面需求的时候你知道该去改哪一层。5. 实战中反复踩过的五个坑与排查链路5.1 旋转屏幕后 Toast 重复弹出的根因场景用户在页面操作失败后弹了一个 Snackbar 提示屏幕一转提示又弹了一次甚至弹两次。排查思路问题出在“事件”和“状态”被混用了。errorMessage放在TaskListUiState里它本质是事件但用状态的容器来承载。屏幕重建后Activity 重新收集 StateFlow拿到的是当前最新的errorMessage值于是 Toast 又弹了一遍。解决办法把一次性事件和持续性状态分开。状态用 StateFlow 承载事件用 Channel 承载。或者给错误消息加一个“已消费”标识触发 render 后马上清空。但更干净的做法是引入 Channelprivate val _events ChannelTaskListEvent(Channel.BUFFERED) val events _events.receiveAsFlow() sealed interface TaskListEvent { data class ShowError(val message: String) : TaskListEvent }UI 层单独收集事件流一次性消费就不会出现重建后重复弹出的问题。5.2 页面重回前台后 Flow 不触发收集的诡异现象场景用了viewModel.uiState.collect { ... }直接收集页面第一次进入好好的切到后台再切回来界面卡住不刷新了。根因如果不在生命周期安全的范围内收集collect 一旦被 cancel 就永远不会重新启动。我早期习惯直接在onViewCreated里写lifecycleScope.launch { viewModel.uiState.collect { ... } }这个写法在页面不可见时lifecycleScope 的协程并不会自己取消但如果触发了 Fragment 的onDestroyView再重建旧协程已经死了新的不一定会按预期重建。更严重的是用WhileSubscribed的场景下没有上层的 collect 去触发生产者共享流就一直不发射。排查链路先在 collect 里加 Log 看是否走进去——没走进去说明收集压根没被启动再检查repeatOnLifecycle是否包裹正确最后检查 ViewModel 里是否用了stateIn且started参数是什么。正确解法就是前面写的强制用repeatOnLifecycle(STARTED)包裹 collect在 Compose 里用collectAsStateWithLifecycle()。5.3 ViewModel 持有了 Activity 引用导致的内存泄漏这个坑几乎是入门选手必踩的。写代码时为了方便在 ViewModel 里直接写了val context getActivity()或传了 Activity 的引用进来。表面上看页面内存开销不大但用 LeakCanary 一测退出页面后 Activity 的实例一直不被回收。排查链路LeakCanary 报了 Activity 泄漏后先抓内存快照看引用链是TaskListViewModel - Activity问题就明确了——最好去栈现场准备的所有引用了 ViewModel 也没用因为 VM 自己带着一个大块头。解决如果只是要applicationContext或访问资源就用AndroidViewModel(app)如果要启动 Activity、访问 FragmentManager就把这些操作都下沉到 UI 层的观察回调里去。我后来定下的规矩是ViewModel 的构造函数里只允许出现仓库、调度器、Application仅限 AndroidViewModel。5.4 多个数据流合并时状态突然跳回旧值场景页面里有搜索关键字和筛选条件两个状态分别用两个MutableStateFlow。用combine合并成一个列表流val list combine(keyword, filterType) { keyword, filter - repository.query(keyword, filter) }.flatMapLatest { it } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), initialValue)但实际运行中用户改完搜索词后列表偶尔会先闪一下旧筛选条件的缓存结果再变成新结果。原因分析combine是两路流的最新值合并除非两边都推进一次否则当某一侧更新时另一侧还没发射新值就会用最后一次的旧值先聚合。这本身不是 bug但状态回跳的视觉体验不好。解决策略对合并后的结果加distinctUntilChanged()同时尽量让 repository 层的查询流具备防抖能力例如对关键字变化先做debounce(300)再触发查询能大幅减少“闪一下”的情况。5.5 协程作用域用乱导致的请求泄漏这是我见过最危险的一种坑在viewModelScope之外手动启动协程或者用了GlobalScope去发网络请求。请求没回来时页面已经销毁回调里还在更新 UI轻则空指针重则进程内数据被写坏。排查链路看 Log 里有没有“CancellationException”被吞掉的警告检查协程是哪个 scope 启动的GlobalScope的一定要改检查仓库层有没有把withContext(Dispatchers.IO)包在受控的 scope 内部。写代码时定个死规矩View 层用 lifecycleScopeViewModel 层用 viewModelScope仓库层用 suspend 函数被动调度不自己开 scope。只要遵守这一条协程泄漏问题几乎可以减少九成。6. 最后想说的一个实测体会MVVM 这套架构配合 Kotlin 的协程和 Flow我用了一年多后的最大感受是它真正释放的是“测试”和“需求变更”这两个维度的压力。以前改一个小需求要在 Activity 里翻半天逻辑现在改需求大概率只动 ViewModel 和状态类UI 层几乎像是稳定不变的壳子。而且因为 ViewModel 不依赖 Android 类写单元测试的时候不用再启动模拟器跑一个纯 JVM 的测试用例非常快。如果你正打算在项目里引入 MVVM我的建议是别急着上 Hilt、别急着全量换 Compose先把分层边界和状态收集这两件事做对后面再逐步演进工具链架构的收益就已经能实打实地感受到了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进