ARTICLE DETAIL

资讯详情

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

Android中ViewModel与View交互:生命周期、方案选型与避坑实践

Android中ViewModel与View交互:生命周期、方案选型与避坑实践 只要你写Android早晚会遇到同一个问题用户点了登录按钮输入框里的数据怎么从View传到ViewModel网络请求回来后界面状态又怎么从VM刷新回View这中间只要有一个环节处理不好轻则旋转屏幕后状态错乱重则内存泄漏甚至直接崩溃。标题里的VM我默认指ViewModelView则是Activity、Fragment以及它们手里的那棵View树。两者之间的交互Interaction本质上就是数据和事件在哪个方向流、怎么流、由谁说了算。这篇文章我会把这套交互拆成几块来讲生命周期为什么会影响一切、四种主流通信方案怎么选、一个登录页面从状态建模到事件通知的完整落地写法以及几类高频踩坑问题的排查经验。适合刚开始接触MVVM的Android开发也适合已经在用VM和View交互、但经常在旋转屏幕或页面销毁时改出bug的同学。1. 为什么VM和View的交互总出问题先从生命周期说清楚1.1 先搞清楚VM和View各自“管到什么程度”很多人的代码写得发热结果旋转一下屏幕就全乱了根本原因是没搞清楚ViewModel和View的生命周期范围从根本上不同。Activity或Fragment在配置变更旋转屏幕、深色模式切换、语言切换时会被销毁再重建View树里所有对象都是全新的。但ViewModel不是它绑定在ViewModelStore上而ViewModelStore在Activity重建时会保留下来。因此ViewModel的存活时间比View要长它的生命周期终点是“真正退出”的时候比如Activity finish或者Fragment detach后viewModelStore清空。这个差异决定了交互的第一原则数据必须放在VM里View只负责把数据渲染出来同时把用户操作事件交给VM。VM绝不能持有View引用否则等于把一个随时可能被销毁的对象绑在一个长期存活的宿主上。我见过的最典型反面案例是在ViewModel里写了一个回调回调里直接view.updateXX()结果这个view被隐式持有Activity销毁后内存一直掉不下去。准确理解生命周期边界后面所有方案选型都从这个基础出发否则用哪个工具都会踩坑。1.2 配置变更最容易暴露交互缺陷的场景我拿一个真实场景来说。用户注册页面填了一半信息旋转屏幕此时如果ViewModel没有保存这些临时输入等Activity重建后EditText虽然通过系统机制自动恢复了文本但VM里的状态还是空行为就会对不上。更麻烦的是如果View在onStart里注册了一个LiveData Observer重建后Activity重新走到onStartObserver会再次注册有时候回调会触发两次UI出现“闪烁”或者重复请求。所以处理配置变更时要注意几个点在Fragment中observe LiveData必须用viewLifecycleOwner不要用this否则Fragment视图销毁但Fragment没销毁时Observer一直挂着。一次性事件不能放进LiveData或StateFlow因为配置变更后LiveData会重放最新值Toast会再次弹出、导航会再次触发。ViewModel想要在进程被回收后恢复数据需要配合SavedStateHandle这个后面实操部分会写。配置变更是VM和View交互的天然试金石任何方案都必须先在“旋转屏幕”这个场景下跑一遍跑不通过的都不算稳。2. 四种主流交互方案选型LiveData、Flow、Event通道、DataBinding2.1 LiveData Observer简单场景最稳的选择LiveData是Jetpack里最“亲民”的观察者模式封装它的核心特点是生命周期感知。观察者只有在Owner处于STARTED或RESUMED状态时才会收到回调Owner销毁时自动移除不需要手动写取消注册的代码。适合用LiveData的场景很明确页面状态简单、数据更新频率低、团队还没有全面铺开协程。比如一个下载页面下载进度、按钮可用状态、错误提示这些用LiveData完全够用。写法上就是再朴素不过的观察viewModel.userName.observe(viewLifecycleOwner) { name - binding.etUsername.setText(name) }注意我特意写了viewLifecycleOwner。在Fragment里如果用this去observe表面看着没毛病但Fragment的视图销毁而Fragment实例还活着时Observer仍然挂载回调一个已经不存在的View崩溃自己找上门。viewLifecycleOwner代表的是Fragment视图的生命周期视图销毁后自动解绑这才是“界面状态”的正确作用域。LiveData的局限也很明显它是持有单个最新值的容器没有map/combine/flatMap这类操作符想做“两个数据源合并”或者“连续变换”会写得很拧巴。另外LiveData没有内置的初始值observe时如果没有setValue过就不会回调作为状态容器不如StateFlow顺手这会引导你去决定到底用哪个。我的习惯是新项目如果全是协程直接用Flow和StateFlow老项目还没迁协程的就用LiveData别强行混着来。2.2 StateFlow repeatOnLifecycle复杂异步场景的主力StateFlow本质是StateFlowout T类型的热流它会持有最新值并且内部自带去重逻辑只有值变化时才通知收集方天然比LiveData少了“重复回调”的无效刷新。它和LiveData最大的区别在于不感知生命周期View停止收集后会继续在后台发射所以收集时要用repeatOnLifecycle把收集动作绑定到某个生命周期状态上。这是推荐的标准写法我贴过很多次依然适用于大多数页面viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state - render(state) } }repeatOnLifecycle的意思是当生命周期进入STARTED时开始收集当低于STARTED时自动取消收集协程重新回到STARTED后再重新开始收集。这比launchWhenStarted更安全后者不会自动取消只是暂停底层协程还占着资源。什么时候用StateFlow替代LiveData我的判断标准是数据流需要经过多次变换比如从Repository拿原始数据再做缓存、合并用户配置、映射为UI模型。页面状态需要聚合多个数据源比如一个详情页要同时展示用户信息、点赞数、评论列表。更关注状态的一致性只要有一个字段变了整个状态对象就是新实例渲染时直接以状态对象为准不需要关心谁先变谁后变。StateFlow的“最新值”特性在状态渲染上是优点但如果把一次性事件也放进StateFlow就会变成粘性事件旋转屏幕后事件重放一遍这就是后面第三节要讲的坑。2.3 一次性事件Toast、导航这类交互别走LiveData状态和事件最大的区别在于状态需要保留最新值View任何时候重新观察都能拿到当前状态事件是“只消费一次”发生完就过去不应该被重新播放。如果用LiveData传一次性事件比如登录成功提示旋转屏幕后Activity重建新Activity一observe就立刻收到旧事件Toast弹两次或者登录成功后因为旧的success事件导致重复导航。StateFlow也一样因为它会保存最新值。处理事件最常用的是两种做法方式一用SharedFlow配置replay0让事件发射时不缓存private val _events MutableSharedFlowLoginEvent(extraBufferCapacity 1) val events: SharedFlowLoginEvent _events.asSharedFlow() fun login() { viewModelScope.launch { _events.emit(LoginEvent.LoginSuccess) } }注意SharedFlow要设置缓冲容量否则collect还没准备好时emit会直接失败事件就丢失了。extraBufferCapacity 1意味着发送方emit不会挂起事件临时缓存下来等collect方取走。方式二用EventWrapper包装类在普通数据外面包一层序号或token消费时判断如果已消费就丢弃class Eventout T(private val content: T) { private var hasBeenHandled false fun getContentIfNotHandled(): T? if (hasBeenHandled) null else { hasBeenHandled true content } }事件和状态分开处理是VM和View交互里最容易被忽略又最影响体验的一环务必尽早把这条边界划清楚。2.4 DataBinding / ViewBinding交互落到布局层如果把交互下沉到布局文件里DataBinding算是一种非常激进的方案。它允许在XML里直接写VM的属性绑定和事件绑定例如EditText android:text{viewModel.username} android:onTextChanged{viewModel::onUsernameChanged} /这么写很爽Activity里不用再手动addTextChangedListener代码少了一大截。但代价是调试成本变高表达式稍微写错编译期报错信息晦涩双向绑定的双向循环问题也需要额外小心比如{}在输入框回填和用户输入之间互相覆盖可能造成光标跳动。我的建议是新项目优先ViewBinding只负责View的实例化不参与数据绑定。数据流还是走Observer或collect链路清晰排查问题的时候至少能一眼看出是哪一层出了问题。DataBinding适合那种字段大量上屏、又不想写繁琐setText代码的偏展示型页面但别把它当成MVVM的核心它只是一个连接View和VM的辅助工具。3. 实操一个登录页面把VM和View的交互完整跑通3.1 场景设计与状态建模这一节用一个登录页面把前面讲的方案串起来。场景输入用户名和密码点击登录按钮后显示加载状态失败时显示错误提示成功后跳转到首页旋转屏幕后已输入内容不丢加载状态不重置先从状态建模开始。直接用多个LiveData的写法容易失控特别是加载状态和错误提示同时存在时View渲染逻辑要自己判断它们的组合关系。更好的做法是把页面状态聚合为一个UI Statedata class LoginUiState( val username: String , val password: String , val isLoading: Boolean false, val errorMessage: String? null )用一个MutableStateFlowLoginUiState去承载class LoginViewModel : ViewModel() { private val _uiState MutableStateFlow(LoginUiState()) val uiState: StateFlowLoginUiState _uiState.asStateFlow() fun onUsernameChanged(input: String) { _uiState.update { it.copy(username input, errorMessage null) } } fun onPasswordChanged(input: String) { _uiState.update { it.copy(password input, errorMessage null) } } fun login() { val current _uiState.value if (current.username.isBlank() || current.password.isBlank()) { _uiState.update { it.copy(errorMessage 用户名和密码不能为空) } return } viewModelScope.launch { _uiState.update { it.copy(isLoading true, errorMessage null) } delay(1500) // 模拟网络请求 if (current.username admin current.password 123456) { _uiState.update { it.copy(isLoading false) } // 事件单独发 } else { _uiState.update { it.copy(isLoading false, errorMessage 用户名或密码错误) } } } } }注意我把登录成功从State里摘出来了只作为事件通过SharedFlow发射因为成功跳转是一次性行为如果放进State旋转屏幕或者重新观察就会再次触发跳转。这个细节是区分一个开发者有没有真正理解交互的关键。接着定义事件sealed class LoginEvent { data object LoginSuccess : LoginEvent() } private val _events MutableSharedFlowLoginEvent(extraBufferCapacity 1) val events: SharedFlowLoginEvent _events.asSharedFlow()在login()成功分支里加上_events.emit(LoginEvent.LoginSuccess)登录页的状态流和事件流交互模型就这样建立起来了View只认两个入口一个uiState用来渲染一个events用来处理一次性动作。3.2 View侧按状态渲染按事件跳转Fragment这一侧先获取ViewModel实例。用Fragment自带的by viewModels()可以保证ViewModelStore在当前Activity内Fragment销毁但Activity还在时数据不会丢。class LoginFragment : Fragment() { private val viewModel: LoginViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.uiState.collect { state - render(state) } } launch { viewModel.events.collect { event - handleEvent(event) } } } binding.btnLogin.setOnClickListener { viewModel.login() } } private fun render(state: LoginUiState) { binding.progressBar.isVisible state.isLoading binding.btnLogin.isEnabled !state.isLoading binding.tvError.text state.errorMessage if (!state.errorMessage.isNullOrBlank()) { binding.tvError.isVisible true Toast.makeText(requireContext(), state.errorMessage, Toast.LENGTH_SHORT).show() } } private fun handleEvent(event: LoginEvent) { when (event) { LoginEvent.LoginSuccess - { Navigation.findNavController(binding.root) .navigate(R.id.action_login_to_home) } } } }我已经把errorMessage既放在State里用于显示又作为Toast重复提示了一次实际项目中这两者选择一个方式就好我也见过两者都不太合适的场景这留到第4节细说。repeatOnLifecycle里启动两个collect协程分别处理状态和事件这是一个很标准的排列组合所有页面几乎都能套用这个模板。3.3 输入事件怎么回流到VMView把状态拿回来渲染这只是交互的一半。另一半是用户输入如何给到ViewModel。我有两个习惯输入类事件通过回调直接调用VM方法比如上面代码里的onUsernameChangedView本身不保存输入值按钮事件也直接调VM方法由VM去判断要不要做网络请求。View里不做业务判断只做UI反馈。如果有一个自定义View比如一个带确认密码的复合输入组件不要直接让它持有ViewModel。标准做法是自定义View定义回调接口由Fragment或Activity实现并转发给VMclass PasswordInputView JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : LinearLayout(context, attrs) { var onPasswordChanged: ((String) - Unit)? null // 内部EditText的TextWatcher中调用 override fun onTextChanged(s: CharSequence?) { onPasswordChanged?.invoke(s.toString()) } }在Fragment里binding.passwordView.onPasswordChanged viewModel::onPasswordChanged这样自定义View完全不知道ViewModel的存在可测试性更强也能在不同页面复用。View只关心“我发出一个回调”VM只关心“我收到一个状态变更”中间通过接口解耦这就是交互设计的核心思想。3.4 生命周期边界谁负责清理、谁负责恢复ViewModel里的协程任务一定要用viewModelScope它的特性是ViewModel被清理时自动取消所有没跑完的协程。如果手写一个CoroutineScope(Dispatchers.IO)挂在VM里页面退出后协程还在后台跑轻则浪费资源重则回调一个已销毁的View直接崩溃。View侧的repeatOnLifecycle保证了UI不可见时停止收集这是一个“双向节制”的模型VM不偷跑长任务View不偷收后台数据。另一个容易被忽略的点是进程被系统回收后的恢复。系统回收整个App进程时ViewModel不会存活但如果用了SavedStateHandle可以在Activity重建时恢复关键数据。改造方式很简单class LoginViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { var username: String get() savedStateHandle[username] ?: set(value) { savedStateHandle[username] value } }在Fragment中拿VM实例时系统会自动把SavedStateHandle注入不需要手动创建。这样即使进程被回收用户填到一半的表单数据也能在回来时恢复这是VM和View交互中属于“隐藏层”的那部分不处理的话永远差一口气。4. 常见问题与排查技巧实录4.1 事件被重复消费或丢失这是被问得最多的一个问题。现象有几种旋转屏幕后Toast重复弹、登录成功后跳转两次、或者反过来事件一次都没收到。重复消费的根因通常是状态流和事件流混用把一次性事件放进了LiveData或StateFlow。事件丢失的根因通常是SharedFlow没有配置缓冲collect方还没准备好emit已经把事件丢了。排查思路是分两步走先在emit端加日志确认事件确实发射出去了再到collect端加日志确认有没有收到、收了几次。如果emit有、collect没有多半是缓冲配置问题如果collect有多次多半是粘性事件。解决就按第2.3节的方式事件通道和状态通道分开保证上游不缓存旧事件。另外事件里不要塞对象引用最好用不可变数据避免事件被消费后对象状态改了引发后续问题。4.2 ViewModel持有View引用导致内存泄漏症状很明显退出页面后用Memory Profiler或LeakCanary一看Activity实例还挂在堆里或者进进出出页面内存一直涨。排查方法在ViewModel里写onCleared()打日志看退出页面时有没有触发再用Android Studio的Heap Dump查看Activity的引用链。最常见的泄漏点是ViewModel里写了一个回调回调里隐式持有了View或Activity上下文比如// 错误示例 viewModel.onError { message - showToast(message) }showToast如果挂在Activity/Fragment上lamba闭包就会持有这个对象ViewModel不销毁View也销毁不了。正确的做法是ViewModel里只暴露状态流和事件流View去收集如果真的需要回调也要通过WeakReference或让回调持有一个不依赖View生命周期的数据源。简单粗暴的经验是ViewModel里一旦出现Fragment、Activity、View、Context这些类型就要警惕。4.3 协程在边界之外跑崩溃和调试器断开在开发中经常遇到disconnected from the target vm这类Android Studio调试器断开的提示很多新手以为是IDE问题其实一大半情况是App进程被杀或崩溃了。常见诱因是ViewModel里用了全局作用域或自定义的常驻作用域// 错误做法 GlobalScope.launch { // 长时间任务 }页面销毁后这个协程还在跑如果它里面操作了不存在的View或者Databinding间接引用了已销毁的Binding进程可能直接崩调试器自然就断开了。遇到这种提示先去Logcat看有没有FATAL EXCEPTION再检查有没有低内存杀进程的日志一般在System或LMK标签下能看到lowmemorykiller关键字。光看Android Studio的调试窗口没法定位日志才是第一依据。解决方式就是全程用viewModelScope协程的生命周期和VM对齐。如果确实需要在清理时做收尾操作可以在onCleared()里启动一个NonCancellable上下文中的任务保证清理动作即使协程取消也能执行完。4.4 多个View共享一个VM、一个View多个VM的情况一个页面通常有多个View但它们属于同一个View树这个树和ViewModel是一对多的关系。热词里提到“cts做tree以一个view为主还是很多个view”在实际开发中建议一个页面只要状态没有明显的领域区分就用一个页面级ViewModel统一管理子View只负责展示和事件回调不做数据持有。如果页面功能真的复杂比如一个“报名表单页”既有基础信息又有复杂的动态选项可以拆多个ViewModel但最终仍需要一个页面级ViewModel把子VM的状态聚合起来View只面向聚合后的状态。否则每个View各观察各的VM状态之间互相联动时回调会乱成一团排查成本指数上升。4.5 常见问题速查表故障现象根因快速解决旋转屏幕后Toast重复弹出一次性事件放进LiveData/StateFlow事件改走SharedFlow(replay0)或EventWrapperObserver回调执行了两次Fragment里用了this而不是viewLifecycleOwner统一改为viewLifecycleOwner转屏后页面状态丢只存在View内部字段里状态放VM进程回收恢复用SavedStateHandle事件丢失收到0次SharedFlow没有配置缓冲加extraBufferCapacity 1保证emit不挂起页面退出后内存不降VM持有Activity/View引用检查回调、Context改为状态流/事件流模式调试器提示disconnected常驻协程导致崩溃或进程被杀用viewModelScope检查Logcat的FATAL EXCEPTION输入框重新设置文本时光标跳动DataBinding双向绑定循环避免{}对EditText直接双向绑定改为单向绑定加回调排查问题时优先看Logcat和生命周期回调不要在代码里猜把每个关键节点打上日志看一眼数据流和事件流走到哪里断了问题自然浮出来。我个人这一年多里最大的体验是在项目和团队里反复确认“状态和事件必须分开”这条规则踩过一次数据流和事件流混用的坑之后后面所有页面都稳定了很多。如果你现在正准备设计VM和View的交互先按最简单的模板来等跑通后再考虑DataBinding和复杂流操作这个方向走对了后续扩展、拆分、维护都会顺手很多。
返回列表