ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MVI架构入门:Android状态管理与单向数据流实践

MVI架构入门:Android状态管理与单向数据流实践 1. 先聊清楚MVI 到底在解决什么问题如果你的 Android 开发经验超过一年大概经历过这样的时刻Activity 里塞满了接口回调、多个 LiveData它们之间的顺序、依赖关系越来越难理清。改一个数据源可能导致某个界面状态没刷新或者数据覆盖了旧值调试的时候只能靠打日志一步步猜。我也是在这种痛点下开始认真研究 MVI 的。这个问题的本质是状态分散在太多地方。用户点击按钮、网络请求返回、数据库更新、系统回调每个事件都在改一些零散的状态彼此之间没有形成统一约束。代码跑起来没问题一旦需求变化某个状态添加到某个流程影响面很难评估。MVI 就是在这种背景下被重新推到台前的。它在 Android 圈流行起来很大程度上是因为 Kotlin Flow、StateFlow 以及 Jetpack Compose 的普及——声明式 UI 和单向数据流天然契合。很多人第一次接触 MVI 时会被名字迷惑以为和 Android 的 Intent 跳转有关系其实完全没有。MVI 里的 Intent 是用户意图是页面里所有用户行为的抽象描述。一句话版本View 只负责消费状态、发出意图剩下的逻辑全部收敛到 Model 一侧。这套思想不是 Android 原生的而是从 Cycle.js 和 Elm Architecture 借鉴过来再结合 Android 平台特性落地成型的。1.1 页面状态越来越难管的根源我见过太多项目把状态管理变成了打地鼠。今天在这个回调里 set 一个布尔值明天在另一个回调里改一个列表后天又有人往 Activity 里加一个成员变量做标记。每次需求变更都要靠人肉梳理这个状态在哪里被改过。问题的核心在于状态的修改没有一个统一的入口和方向。传统 MVP 和 MVVM 虽然做了分层但数据和 UI 之间仍然是双向的View 观察数据View 也会直接改数据ViewModel 暴露多个 LiveData每个 LiveData 都可以独立变化相互之间的时序和依赖关系很难约束。于是经常出现这些现象两个接口先后返回后返回的先处理导致页面显示的是旧数据一个页面有 loading 和 error 两个状态结果 loading 已经结束error 还残留着下拉刷新和首次加载同时触发列表被清空又重新填充界面闪跳屏幕旋转后 Activity 重建某个一次性提示又弹了一遍。这些问题不是靠加判断能解决的是结构上的缺陷。MVI 的思路是把所有状态收拢成一个不可变对象所有状态变化都通过同一条单向链路完成。1.2 MVI 的核心单向数据流与唯一数据源MVI 全称 Model-View-Intent核心可以概括成两句话UI 是状态的函数状态只通过唯一的通道更新。在这种模式下数据流是一个环View 把用户操作包装成 Intent 发出ViewModel或者 Reducer接收 Intent执行业务逻辑处理结果被合入一个新的 StateView 观察到 State 变化后重新渲染。这个环永远是单方向的Intent - ViewModel - State - View。View 不会反向修改 ModelModel 也不会主动推送未经处理的裸数据给 View。这样做最大的价值是让状态变化变得有迹可循。因为所有状态都收敛到一个不可变对象里每次变化都能被记录、重放和定位。界面出了 bug只要看 State 的变化序列基本就能还原当时发生了什么。调试的时候不需要再猜是哪个回调先执行你看到的状态序列就是真相。1.3 谁最适合用 MVI如果说 MVP 解决的是Activity 太臃肿MVVM 解决的是View 和业务逻辑解耦那 MVI 解决的是状态管理失控。所以它特别适合这几类场景页面交互复杂有多个异步请求叠加或竞态有频繁的筛选、搜索、分页加载这类组合型操作团队已经引入 Kotlin Flow、StateFlow想统一状态管理方式使用 Jetpack Compose声明式 UI 和 MVI 天然契合需要完整回溯界面行为MVI 的可重放性会帮大忙。如果你的项目是 Java XML LiveData 的老结构也不是完全不能用 MVI只是协程和 Flow 这套基础设施缺一不可迁移成本难免更高。我的建议是不要为了追新而硬上而是先找一个状态最乱的页面试点跑通了再谈推广。2. 单向数据流的骨架Model、View、Intent 怎么分工MVI 只有三个核心角色比 MVP 和 MVVM 更容易讲清楚。但正因为简单很多人容易在命名和职责边界上犯迷糊。下面逐个拆开说。2.1 Intent 代表用户意图不是 Android 的 Intent刚接触 MVI 的人十有八九会被Intent这个名字晃一下以为跟跳转组件有关系。其实没关系。这里的 Intent 更贴近英文原意意图。用户点击了刷新按钮、用户滑动触发了加载更多、用户输入了一段文字这些都是意图。在代码里Intent 一般用 sealed class 或 sealed interface 表示把某个页面所有可能的用户行为提前列出来。这样做有一个好处——一看代码就知道这个页面支持哪些操作比起散落的 onClick 回调要直观很多。sealed interface HomeIntent { data object Load : HomeIntent data object Refresh : HomeIntent data class ArticleClick(val articleId: String) : HomeIntent data class Search(val keyword: String) : HomeIntent }Kotlin 2.x 之后用data object很常见之前习惯用object差别不大看团队规范。有一点要提醒Intent 的设计粒度不要太大也不要太小。太大表示一个 Intent 里塞了多个操作Reducer 难以处理太小意味着每个点击都要新建一个类型页面 Intent 数量爆炸。我一般按用户完整的一步操作来划分。2.2 Model 承载唯一状态不是数据库实体MVI 里的 Model 不再是一个单纯的数据实体而是整个页面状态State的集合。常见做法是用一个不可变的 data class 描述当前页面全部信息加载中、列表数据、错误信息、下拉刷新状态、搜索关键字等等。data class HomeUiState( val isLoading: Boolean false, val isRefreshing: Boolean false, val articles: ListArticle emptyList(), val errorMessage: String? null, val searchKeyword: String )这样的设计带来的直接收益是页面在任何时刻的 UI 表现都可以从一个 State 对象完整推导出来。不会有某个 boolean 改了但 UI 没变的问题因为 UI 只认 State。State 的粒度同样需要把握。有人恨不得把页面所有细节都放进 State包括滚动位置、焦点状态结果每次状态变化都要 copy 一整棵对象树性能和维护成本都不低。我的原则是凡是可以从其他状态推导出来的值不进 State凡是一次性消费的临时事件不进 State后面会专门讲 Side Effect。2.3 View 负责无状态渲染和意图上抛在 MVI 里ViewActivity、Fragment 或 Compose 可组合函数不应该自己管理数据。它要做两件事观察 ViewModel 暴露的 StateFlow/Flow拿到最新 State 后渲染界面把用户操作转换成 Intent通过统一入口 dispatch 给 ViewModel。也就是说View 不关心数据从哪来、怎么处理只关心当前给我什么状态我就画什么。用 Compose 表达起来尤其顺手val uiState by viewModel.uiState.collectAsStateWithLifecycle() HomeScreen( state uiState, onRefresh { viewModel.dispatch(HomeIntent.Refresh) }, onArticleClick { id - viewModel.dispatch(HomeIntent.ArticleClick(id)) } )如果你还在用 XML ViewBinding差别也没有想象中大。区别只是把render(state)方法里的赋值逻辑换成根据 state 分支更新 UI本质上还是状态进渲染出。3. 用 Kotlin Flow 落地一个 MVI 页面前面把三个角色说清楚了下面我直接写一个完整可运行的例子从定义数据接口到 ViewModel 实现再到界面订阅一步步来。3.1 定义数据层接口假设做一个文章列表页数据来自仓库层interface ArticleRepository { suspend fun fetchArticles(): ListArticle suspend fun fetchArticleDetail(articleId: String): Article }实际开发中这里对应 Retrofit 接口、Room DAO 或者 DataStore 读取为了示例做了简化。MVI 对数据层没有特殊要求你原来怎么封装网络层这里就怎么封装。3.2 ViewModel 中聚合状态与统一分发这是 MVI 的核心部分。我习惯用MutableStateFlow保存状态用Channel接收 Intent在init里统一收集再通过update方法更新 State。class HomeViewModel( private val repository: ArticleRepository ) : ViewModel() { private val _uiState MutableStateFlow(HomeUiState()) val uiState: StateFlowHomeUiState _uiState.asStateFlow() private val intentChannel ChannelHomeIntent(Channel.BUFFERED) init { viewModelScope.launch { intentChannel.receiveAsFlow().collect { intent - handleIntent(intent) } } dispatch(HomeIntent.Load) } fun dispatch(intent: HomeIntent) { intentChannel.trySend(intent) } private fun handleIntent(intent: HomeIntent) { when (intent) { is HomeIntent.Load - loadArticles() is HomeIntent.Refresh - refreshArticles() is HomeIntent.ArticleClick - openArticle(intent.articleId) is HomeIntent.Search - searchArticles(intent.keyword) } } private fun loadArticles() { _uiState.update { it.copy(isLoading true, errorMessage null) } viewModelScope.launch { runCatching { repository.fetchArticles() } .onSuccess { articles - _uiState.update { it.copy(isLoading false, articles articles) } } .onFailure { e - _uiState.update { it.copy(isLoading false, errorMessage e.message) } } } } private fun refreshArticles() { _uiState.update { it.copy(isRefreshing true, errorMessage null) } viewModelScope.launch { runCatching { repository.fetchArticles() } .onSuccess { articles - _uiState.update { it.copy(isRefreshing false, articles articles) } } .onFailure { e - _uiState.update { it.copy(isRefreshing false, errorMessage e.message) } } } } private fun openArticle(articleId: String) { // 导航等一次性事件后面单独讲 Side Effect } private fun searchArticles(keyword: String) { _uiState.update { it.copy(searchKeyword keyword, isLoading true) } viewModelScope.launch { runCatching { repository.searchArticles(keyword) } .onSuccess { articles - _uiState.update { it.copy(isLoading false, articles articles) } } .onFailure { e - _uiState.update { it.copy(isLoading false, errorMessage e.message) } } } } }为什么用 Channel 接收 Intent而不是直接用一个方法更新 State因为 Channel 可以把所有 Intent 收敛到一条流里统一收集、统一分发。这样后续如果要加 debounce、flatMapLatest、日志上报只需要在 collect 之前加操作符就行不需要改调用方。要用trySend而不是send避免在非挂起上下文里调用挂起函数报错。如果担心缓冲区溢出可以把 buffer 设大一点或者用Channel.UNLIMITED但一般BUFFERED足够。3.3 Activity 或 Fragment 中订阅状态非 Compose 项目用 Fragment 举例class HomeFragment : Fragment() { private val viewModel: HomeViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) .collect { state - render(state) } } binding.refreshButton.setOnClickListener { viewModel.dispatch(HomeIntent.Refresh) } binding.searchEditText.doAfterTextChanged { viewModel.dispatch(HomeIntent.Search(it.toString())) } } private fun render(state: HomeUiState) { binding.progressBar.isVisible state.isLoading binding.errorText.text state.errorMessage binding.errorText.isVisible state.errorMessage ! null // 列表渲染可以用 ListAdapter 的 submitList也可以直接 binding.recyclerView.swapAdapter } }用flowWithLifecycle保证只在 STARTED 及以上状态时才收状态避免后台耗电和多余的 UI 刷新。这是我在实际项目里非常坚持的一点尤其是页面多的时候省下的资源不是一点点。3.4 异步操作的并发与合并处理刚才的例子里的网络请求比较简单真实业务里经常有多个异步任务叠加。比如先拿用户信息再拿文章列表可以用协程的组合方式private fun loadHomeData() { viewModelScope.launch { _uiState.update { it.copy(isLoading true) } try { coroutineScope { val user async { repository.fetchUser() } val articles async { repository.fetchArticles() } val userInfo user.await() val articleList articles.await() _uiState.update { it.copy( isLoading false, userName userInfo.name, articles articleList ) } } } catch (e: Exception) { _uiState.update { it.copy(isLoading false, errorMessage e.message) } } } }这里的关键点是async await的写法虽然看起来是同步的但实际上是并发执行。注意并发数要控制如果后端接口能力有限同时发十几个请求容易被打爆。协程写起来像同步代码不代表没有成本。注意不要在 StateFlow 的 update 里做耗时操作。update内部是 CAS 循环如果更新逻辑太重会阻塞其他并发的状态更新。耗时操作一律放到 ViewModel 的协程里跑完之后再一次性更新 State。4. 实操中踩过的坑和排查技巧MVI 概念不复杂但真正在项目里用起来会有一些反直觉的问题。我把自己踩过的坑列出来希望能帮你少走弯路。4.1 状态覆盖多个请求同时返回旧数据覆盖新数据这是最常见的问题。用户连续触发刷新第一次请求慢、第二次请求快结果第二次的数据先回来第一次后回来把第二次的数据覆盖了。页面显示的是旧数据但 UI 看起来一切正常。解决思路有几个在 load 方法里加一个请求 ID 或者版本号只有最新一次请求的结果才允许更新 State用flatMapLatest处理 Intent 流新的 Intent 到达时自动取消前一个未完成的请求任务搜索类场景先加debounce防抖再配合flatMapLatest。我自己的习惯是普通场景用版本号足够高频输入搜索的场景用debounce flatMapLatest组合。viewModelScope.launch { intentChannel.receiveAsFlow() .debounce { intent - when (intent) { is HomeIntent.Search - 300L else - 0L } } .flatMapLatest { intent - flow { emit(handleIntent(intent)) } } .collect { state - _uiState.update { state } } }这里debounce按 Intent 类型给不同的延迟搜索类型的 Intent 延迟 300ms把用户连续输入合并成一次请求其他操作即时响应。flatMapLatest会取消上一个正在执行的请求从根源上杜绝状态覆盖。4.2 一次性事件Side Effect不能放进 State导航、弹 Toast、打开 Dialog这些是一次性事件。如果你把它们放进 State会遇到一个经典问题屏幕旋转后 Activity 重建StateFlow 会重放最新 StateToast 和 Dialog 又弹一遍。我的做法是用 Channel 承载这类一次性事件Channel 的特点是消费者消费一次就没了不会重放。private val _eventChannel ChannelHomeEvent(Channel.BUFFERED) val eventFlow: FlowHomeEvent _eventChannel.receiveAsFlow() // 在 ViewModel 里发事件 _eventChannel.send(HomeEvent.ShowToast(网络异常)) // 在 UI 层收集 viewModel.eventFlow .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) .collect { event - when (event) { is HomeEvent.ShowToast - Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() } }有人会问用SharedFlow配replay 0行不行也可以但 Channel 更符合事件投递的语义而且不会有多余操作符。团队如果已经有 SharedFlow 的习惯用 SharedFlow 也没有问题关键是约定好一次性事件不能走 State 通道只能走 Side Effect 通道。4.3 把用户动作和系统通知统一进 IntentMVI 里 Intent 不只是用户点击。很多新手只把 onItemClick 当 Intent但页面加载完成、网络状态恢复、应用回到前台这些事件同样会引起状态变化。如果它们不统一进 Intent就会绕过单向数据流状态管理又回到分散状态。我的做法是页面里任何能引起 UI 变化的事情都尽量统一成 Intent 进来。用户点击是 Intent系统回调封装成 Intent网络状态广播也转成 Intent。开始会觉得啰嗦但状态一旦乱起来统一入口的价值立刻体现。4.4 State 里集合不可变性的坑用不可变 data class 做 State更新时copy很顺手。但要注意State 里的 List 如果被外部持有引用依然存在被修改的风险。比如val articles mutableListOfArticle() _uiState.update { it.copy(articles articles) }其他地方直接往articles里 add 一条State 里的引用没变UI 感知不到变化。更隐蔽的是Compose 里用derivedStateOf做派生状态时如果集合引用没变很多差量计算可能直接跳过。所以记住一条铁律State 里的集合一定要保持不可变。数据进来时用toList()转成只读副本不要在原有 List 上做 add/remove。5. MVI、MVVM、MVP到底怎么选很多人问MVI 火了是不是要把所有页面都改成 MVI没必要。MVI 有它的优势但也有代价。关键在于搞清楚自己页面的状态复杂度在哪里。5.1 三种架构对比维度MVPMVVMLiveDataMVI数据流向双向View 调 PresenterPresenter 回传 View双向View 观察 LiveDataView 调 ViewModel单向View 发 IntentViewModel 回传 State状态管理分散在 View 和 Presenter分散在多个 LiveData集中在唯一 State调试体验一般需要自己串日志一般需要观察多个 LiveData较好State 变化序列可复现异步并发处理手动管理容易乱依赖 LiveData 的 setValue 时序结合 Flow 操作符天然支持上手成本低中偏高适合场景老项目快速解耦业务稳定、交互直接状态复杂、交互频繁、搜索/分页多5.2 选型建议与团队约定我的建议比较务实新项目、团队愿意使用 Kotlin Flow 和 Compose放心上 MVI收益很明显老项目、大量 Java 代码、团队对协程还不熟悉别急着全量迁移挑几个状态复杂的页面试点页面很简单纯展示、单个表单提交MVVM 完全够用硬上 MVI 反而多出一堆 sealed class 维护成本状态复杂但 Compose 还没引入可以用 MVI XML只是渲染层需要多写一点 glue 代码。还有一个容易被忽视的点架构的收益取决于团队约定是否一致。MVI 对约定要求很高Intent 怎么命名、State 粒度多大、Side Effect 怎么处理、Reducer 里能不能放业务逻辑如果没有统一标准十个人能写出十种 MVI。我见过最典型的失败案例有人把一次性事件放进 State有人直接在 View 里改了 State 里的字段有人把所有网络请求都塞进一个 Intent 的 when 分支里几百行代码挤在一起。这不是 MVI 的问题是约束没有被执行的问题。架构是约束约束对齐了才能跑出效果。6. 关于 MVI 的一些个人体会我从 LiveData MVVM 迁移到 StateFlow MVI 的过程中最明显的变化是调试效率提升了。以前出 bug 的时候要在多个 LiveData 的回调里打断点观察是哪个回调先执行。现在状态集中在 StateFlow 里我只要看 State 的变化序列就能还原整个页面行为配合日志或者断点都可以做到。如果你也想在项目里推进 MVI我给一个保守但有效的路径先选一个列表 搜索 分页的中等复杂度页面做试点把 Intent、State、事件的定义规范写好把这个页面的状态流转讲清楚跑一个月再回看。那时候你会更清楚要不要推广到全部页面。最后再分享一个小技巧StateFlow本身有基于 equals 的去重机制如果你发现在某种场景下 UI 仍然收到了重复状态通常是因为你在 ViewModel 里手动 emit 了和上次相同的 State 对象或者在 Flow 转换链路里丢了去重属性。排查时先确认链路里有没有distinctUntilChanged()被误删。不过大多数情况下StateFlow的默认去重已经够用不需要额外再加。MVI 不是一个银弹这一点在用了半年之后我体会更明显。它不会自动让你写出好代码但它提供了一套统一思路把状态管理这个老大难问题从靠自觉变成了靠约束。至于要不要全面铺开我的建议是先挑一个状态最乱的页面试试用数据说话比听我说一百句都有用。
返回列表