本系列为 Android 技术职场题材虚构小说,所有登场人物、公司名称、组织架构及相关情节均为创作所需虚构而来,若有雷同,纯属偶然。书中涉及的技术知识经专业梳理,仅供参考。
十三)MVVM 旧城改造,边界划分各有招

周一早上九点二十,《智慧社区》的研发负责人就推开了会议室门,投影设备准备完毕后,立在门口,脸色看不出喜怒,只是扫了一圈人,最后目光落在林卓身上。
此时的林卓,刚把电脑从睡眠里唤醒,
他把林卓招呼过来,说道:"《智慧社区》旧业务线的会员中心要整改,把老代码重新理一遍。"
话音刚落,他又朝门外扬了扬下巴:"会员线相关的研发都先过来,实习生也一起听着。"
没一会儿,走廊尽头就传来急促的脚步声,前后端和客户端几位同事抱着电脑陆续进门,后面跟着拿着本子的实习生,神情里带着点紧张和好奇。
待人员到达完毕之后,负责人开门见山:"会员中心要整改,我们先说一下客户端的事儿,其他端的听着就行"。
会议室里安静了两秒。
"会员中心?"晓雅小声念了一遍,"那个页面不是上线好几年了吗?"
阿泽推了推眼镜:"我上周看过一眼,Activity 里好像有三千多行。"
博文没说话,只是把笔记本翻到新的一页。
负责人点开投影,屏幕上是一张旧项目结构图。MemberCenterActivity 像一棵长歪的树,所有箭头都指向它:网络请求、缓存读取、优惠券状态、埋点上报、弹窗控制、登录判断、跳转协议,甚至还有一段用于更新用户名的 Thread。
"这个页面现在的问题很典型。"负责人继续说道,"能跑,但没人敢改。UI 逻辑、业务逻辑、数据请求、缓存策略全糊在一个 Activity 里。上周产品只是改了会员等级文案,结果灰度里出现三类问题:旋转屏幕后状态丢失、接口慢时按钮重复点击、后台回来后弹窗又弹了一次。"
小安坐在会议桌另一侧,手里握着一支红笔,补了一句:"还有一个,夜间模式切过去以后,优惠券列表的空态文案颜色不对。用户截图可难看了。"
林卓听到"空态文案"四个字,心里一紧。这种问题他太熟悉了,之前搞车载项目,踩了不少坑。
这不是某一行代码错得离谱,而是每一行都"看起来还能用"。久而久之,整个页面像一间被临时隔板隔了十几次的老房子,电线绕过水管,插座藏在柜子后面,谁也说不清哪里不能碰。
老杨端着搪瓷杯从门口晃进来,格子衬衫袖口卷到小臂,杯子里依旧是那股让人无法理解的绿茶加咖啡味。 他瞄了一眼投影,淡淡地说:"旧城改造啊。"
负责人看向他:"哎呦,老杨,上面这么忙,你怎么有空呢?"
"瞎溜达呗!"老杨慵懒地回答道。
"你有什么高见呀,老杨?"负责人继续问道。
老杨没直接回答,而是看向林卓。
"你来,我一看你就有主意。"
林卓愣了一下。以前遇到这种架构问题,他第一反应是找老杨帮忙画个图,梳理一下。可这次老杨没有伸手拿白板笔,只是站在会议室后排,像上课时故意不公布答案的老师。
林卓吸了一口气,走到白板前写下三个词:
ViewViewModelModel
写完,他又在 View 旁边补了一行:Activity / Fragment / XML,在 ViewModel 旁边补了一行:UI State + UI Event,在 Model 旁边补了一行:Repository + DataSource
"会员中心现在最大的问题,不是代码多,而是边界乱。"林卓说,"Activity 同时负责取 View、请求数据、拼状态、处理点击、缓存用户信息。
我们可以按 MVVM 拆:View 只负责渲染和把用户动作交给 ViewModel;ViewModel 管 UI 状态和页面级业务;Model 层通过 Repository 屏蔽网络和本地缓存。"
晓雅举手:"我有一点不明白,那是不是只要用了 ViewModel,就算 MVVM?"
林卓正在思考如何作答,老杨的声音就从后面飘过来。
"这个问题好。"他放下杯子,杯底在桌面上碰出很轻的一声。
"Jetpack 的 ViewModel 解决的是 Android 生命周期里的状态保存问题,比如旋转屏幕后 Activity 重建,数据别跟着一起丢。MVVM 里的 ViewModel 更强调 View 和 Model 之间的桥梁,让 UI 被动响应状态变化。两者名字一样,但不是一回事。"
林卓握着白板笔的手指微微紧了一下。
这句话如果让他来答,他大概率会绕回"生命周期""数据保存""解耦"这几个熟词里打转。不是完全不会,但真要把 Jetpack ViewModel 和 MVVM ViewModel 的区别讲清楚,他心里其实没底。
幸好老杨来得及时。
林卓不动声色地往旁边让了半步,像是在给老杨腾位置,心里却悄悄松了口气。他突然意识到,能把工具用起来是一回事,能把工具背后的边界讲明白,又是另一回事。
晓雅低头记了一句,嘴里小声重复:"用 ViewModel 不等于自动 MVVM。"老杨接过话:"对。所以我们这次不是为了套架构名词,而是为了把页面变得可维护。"
不一会儿,林卓也在白板上画了第一版结构:
kotlin
data class MemberUiState(
val userName: String = "",
val levelName: String = "",
val errorMessage: String? = null
)
class MemberCenterViewModel(
private val repository: MemberRepository
) : ViewModel() {
private val _uiState = MutableLiveData(MemberUiState())
val uiState: LiveData<MemberUiState> = _uiState
fun loadMemberInfo() {
_uiState.value = _uiState.value?.copy(loading = true)
viewModelScope.launch {
runCatching { repository.getMemberInfo() }
.onSuccess { info ->
_uiState.value = MemberUiState(
userName = info.userName,
levelName = info.levelName,
)
}
.onFailure {
_uiState.value = MemberUiState(errorMessage = "会员信息加载失败")
}
}
}
}
"对外暴露只读的 LiveData,内部持有 MutableLiveData。"林卓说,"View 层只观察,不直接改状态。"
负责人看了一眼时间:"嗯,既然有初步方案了,那客户端的事情就先到这儿。林卓主导改造方案,先拆会员中心首页。三个实习生都参与,不能只看,必须给点任务写代码!"
会开到这里,负责人让客户端的人可以先行离开,就带着后端同事继续处理接口排期问题了。
林卓散会后,没有马上回工位,而是把三个实习生叫住,让他们把电脑都搬到靠窗的小桌旁。
继续在白板上写出那三个单词:View、ViewModel、Model。
林卓看着它们,心里盘算了一下今天能落地的范围。旧城改造不能一锤子砸到底,得先拆出一条主路,让大家知道往哪走。
林卓转身看向三个人。
"晓雅,你负责把页面里的 findViewById() 替换成 ViewBinding ,顺便统计哪些 View 只在某个状态下出现。" 晓雅眼睛一亮:"我可以。"
"阿泽,你跟我一起拆 MemberCenterViewModel,重点看状态建模、LiveData 暴露、旋转屏幕后状态是否保留。" 阿泽认真地点了点头,突然有个问题,想问林卓:"LiveData 更新数据,有两个方法,setValue() 和 postValue(),这两有什么区别?"
林卓笑了一下。
这问题如果放在几个月前,他大概只会说"主线程用 setValue,子线程用 postValue"。现在他知道这句话虽然没错,但太粗。
"setValue() 必须主线程同步更新,postValue() 可以在后台线程投递到主线程。"
林卓继续补充,"但 postValue() 短时间内多次调用可能合并,只保留最后一次待处理值。比如你想连续发进度 10%、20%、30%,用 postValue() 就可能丢中间态。"
阿泽皱眉:"所以网络请求结束后更新一次状态,用 postValue() 可以;但连续事件或顺序敏感的数据,不该随便用?"
"对。"林卓点头。
继续安排任务。
"博文,你负责整理旧 XML 里哪些逻辑是纯展示,哪些用 DataBinding 表达式改造会更合适,注意,你主要负责业务逻辑,和晓雅的工作具体内容是不一样的。你先别着急直接上 DataBinding,我们要先判断收益。"
博文抬头:"如果只是取 View,用 ViewBinding 就够;如果要把状态绑定到 XML ,才考虑 DataBinding?"
林卓看着他,笑了:"今天你话少,但问得准。"
小安在旁边插了一句:"那我呢?"
林卓看向她:"你负责把旧问题复现路径重新跑一遍。旋转、后台返回、弱网、深色模式、按钮连点,越刁钻越好。" 小安把红笔往桌上一放,尾音轻轻上扬:"这我是专业的呀,包在我身上!"
林卓忽然觉得后背有一丝凉意。
上午十点半,会员中心首页的旧代码被林卓打开。
第一眼看过去,最显眼的是成员变量定义的一长串字段。
再往下,是 onCreate() 里堆成一坨的初始化。
取 View、设监听、读 Intent、判断登录、发请求、设置适配器、处理弹窗,所有动作都挤在一起。林卓滚动鼠标,屏幕像没尽头一样往下滑。
晓雅凑过来看了一眼,忍不住说:"这像不像把厨房、卧室、卫生间都塞进一个屋子里?"
大家听到都笑了一下,紧张的气氛松了些。
晓雅的任务先动起来。她新建了 ActivityMemberCenterBinding,把 setContentView(R.layout.activity_member_center) 改成:
然后她把一堆 findViewById() 删掉,改成 binding.memberName、binding.memberLevel、binding.couponList。 改到一半,她突然停住。
"林卓哥,ViewBinding 类型安全是很好,但它不帮我们自动更新 UI,对吧?"
"对。"林卓说,"ViewBinding 解决的是'怎么安全拿到 View',不是'状态怎么驱动 UI'。"
晓雅点头:"那如果我把所有 View 都换成 binding,但 Activity 里还是到处手动 textView.text = xxx,其实只是让旧代码好看了一点,没有改变架构。"
林卓说道:"你说的没错。很多重构失败,就是因为只换了工具,没重新梳理边界。"
下午一点半,阿泽遇到了第一个坑。
他把接口返回的数据放进 LiveData,页面旋转后状态确实保住了,但小安一测,发现错误提示 Toast 在旋转后又弹了一次。
小安把复现步骤贴到群里:
- 进入会员中心。
- 断网。
- 点击重试。
- 出现"会员信息加载失败"。
- 旋转屏幕。
- Toast 再次出现。
阿泽看着消息,眉头越皱越紧。"我明明是用 LiveData 观察的,生命周期也没错,为什么还会重复?"
林卓把椅子滑过去:"因为你把一次性事件放进了持久状态。"
阿泽愣住,林卓打开代码:
kotlin
data class MemberUiState(
val errorMessage: String? = null
)
"errorMessage 放在 UiState 里,旋转后新 Activity 重新观察,会拿到上一次状态,于是又弹 Toast。"
林卓说,"会员名称、等级、优惠券列表是状态,应该保留;Toast、导航、弹窗关闭这种只消费一次的动作,更像事件。"
阿泽慢慢点头:"所以最好拆成 uiState 和 uiEvent?"
"对。"林卓在旁边补了一段:
kotlin
sealed class MemberUiEvent {
data class ShowToast(val message: String) : MemberUiEvent()
}
"然后呢?这样并不解决问题呀,还是会弹出提示。"阿泽瞅了一眼代码,继续补充道。
林卓点点头:"你说得对。单独定义一个 UiEvent 只是把概念拆出来,还没有解决'怎么消费'的问题。"
"关键是事件不能像状态一样长期挂在那里等新页面来观察。它应该在触发后被消费掉,或者通过不会主动重放旧值的通道发出去。比如继续用 LiveData,就要给事件加一层只消费一次的包装,让新的观察者拿不到已经处理过的 Toast;如果用 SharedFlow,也要注意不要配置成重放旧事件。"
他顿了顿,又补了一句:"本质上不是换个类名,而是明确这件事:状态可以被重新渲染,事件只能被处理一次。"
"如果继续用 LiveData,可以做事件包装,或者换成更适合事件流的 SharedFlow。"
林卓说,"不过嘛,这次旧项目整体还在 LiveData 上,我们先别大范围迁移 Flow,避免改造面失控。知道什么不改,比知道什么能改更重要。"
阿泽在笔记上写下:状态可重放,事件要谨慎。
另一边,博文盯着 XML 看了很久。会员中心首页的老布局里,有不少展示逻辑写在 Activity:
kotlin
if (user.isVip) {
levelView.text = "尊享会员"
levelView.setTextColor(vipColor)
} else {
levelView.text = "普通用户"
levelView.setTextColor(normalColor)
}
博文想把它改成 DataBinding:
xml
<TextView
android:text="@{user.vip ? `尊享会员` : `普通用户`}"
android:textColor="@{user.vip ? @color/vip_color : @color/normal_color}" />
他把方案发给林卓,等了几分钟,林卓没有立刻回复。博文抬头看过去,发现林卓正在跟晓雅确认弱网复现路径。
晓雅说话很快,林卓一边听一边记,偶尔问一句"这个弹窗是在 onResume() 后出现,还是接口回调后出现?"
博文忽然有点羡慕。他在三个人里话最少,存在感也最低。晓雅敢问,阿泽敢较真,他更多时候是把任务做完,然后默默提交。可这次,他不想只是"做完"。
他拿着电脑走到林卓旁边,等到林卓和晓雅讨论完,赶紧插一句:"林哥,我这个 DataBinding 改法可以吗?"
林卓看了看代码,没有直接说可以或不可以。
"你为什么想用 DataBinding?"
"它可以把 UI 展示逻辑放到 XML 里,减少 Activity 手动更新。"
"那么,代价是什么呢?"
"代价?代价..."博文从来没有考虑过这个问题,心里想到,"这能有什么代价?"
林卓看博文疑惑,补充道:"要启用 DataBinding ,布局要包 <layout>,编译会生成绑定类,表达式多了以后 XML 可读性变差,也有一些运行时绑定成本。"
林卓边说边补充:"实际上,如果只是会员等级这种简单文案,用 ViewModel 先转成 levelName 和 levelColor,View 层直接渲染,可能更清楚。"
博文听完,恳切地点了点头:"我明白了!虽然我一时半会儿说不明白,但是感觉逻辑的判断还是放到 ViewModel 上合理一点。"
林卓听完,给予了肯定的回应:"看来你心中已经有答案了!"
博文回到座位,把方案改成了在 ViewModel 中暴露 MemberLevelState 的方案,View 只负责展示 MemberLevelState 的结果,不做任何逻辑判断!
XML 仍然用 ViewBinding 访问,展示判断尽量前移到 ViewModel 或 UI Model 转换里。
他在提交说明里写了一句:
暂不引入 DataBinding 。当前页面主要问题是边界混乱,不是 XML 缺少表达式能力;先用 ViewBinding + ViewModel 收敛职责。
林卓看到这句,专门在评审里给他点了个赞。博文低着头,嘴角已经压不住了。
下午四点,真正麻烦的地方来了。 会员中心里有一个优惠券列表,旧代码每次接口返回都会直接 adapter.notifyDataSetChanged()。数据少时看不出问题,灰度期间运营一次塞了二十多张券,低端机上滑动开始掉帧。
林卓第一反应是把 RecyclerView 那套优化拿出来:DiffUtil、ListAdapter、局部刷新、稳定 id。
可他很快停住。这次任务不是重新做列表性能专题,而是 MVVM 改造。列表优化能做,但不能抢主线。此刻一旦陷入到性能优化中,就会深陷工作量的泥潭,导致项目进度没有往前走。
他只做了最必要的一步:把优惠券从接口模型转换成 UI 模型,并让 Adapter 接收新的列表状态。
kotlin
data class CouponUiModel(
val id: String,
val title: String,
val description: String,
val enabled: Boolean
)
ViewModel 不再把原始 Coupon 直接丢给页面。
"为什么不直接传接口对象?"晓雅问。
林卓说:"接口对象属于数据层,它可能有很多页面不关心的字段,也可能因为后端变化而调整。UI Model 是给界面看的,它应该稳定、明确。View 层不该知道优惠券是从网络来,还是从缓存来。"
小安在旁边补刀:"这样是不是以后后端字段改名,就不用半夜把我叫起来搞测试了?"
林卓看她一眼:"你以前被叫过?"
小安眨了眨眼:"你猜!"
林卓假装没听见,只是把白板擦出一块空地,写下:Repository = 数据来源的门面
他转身问三个实习生:"Repository 是不是必须有?"
晓雅先答:"如果页面很简单,感觉不用加,不然写起来多慢。但会员中心这里既有网络又有缓存,还要处理登录状态,用 Repository 可以把数据来源藏起来。"
阿泽接上:"ViewModel 只关心拿会员信息,不关心是 Retrofit 、Room 、DataStore 还是内存缓存。"
博文想了一下:"也更方便测试 ViewModel。可以用假的 Repository 返回不同数据。"
此时,不知不觉间,老杨已经站在了大伙儿后面,看向林卓:"带得可以啊!"
林卓回头一看,心里一热,但脸上没敢表现出来。
下午六点,林卓提交了本次的改动,并出包测试。
40 分钟后,小安的测试结果出来了。
她把测试报告投到群里:
- 旋转屏幕后,会员名称、等级、优惠券列表保留正常。
- 弱网下 loading 展示正常,按钮不再重复触发请求。
- 断网失败 Toast 不再因旋转重复弹出。
- 后台返回后,不再重复弹会员权益弹窗。
- 深色模式下,会员卡片和优惠券空态颜色仍有一处不一致。
林卓看到最后一条,刚想叹气,晓雅已经把手机拿了过来,仔细查看现象之后,说道: "林哥,这个我感觉不是 ViewModel 的问题,是主题里 TextView 写死了颜色。"
林卓接过手机,屏幕上空状态文字灰得几乎看不见。
他仔细检索代码,打开相关 XML,果然看到一行:
xml
android:textColor="#999999"
晓雅看着他,小声说道:"你前几天刚把 DayNight 主题体系搞得挺明白,这次没忘吧?"
"不会。"林卓表面有点无奈,"改成主题色。"其实心里已经开始骂娘了。
晓雅在旁边憋着笑,说道:"小安姐,这叫回归测试,也是复习检查,对不对?"
小安把高马尾往后一甩:"你说的没错,我测的不只是 bug,也测人。"
办公室里又笑了起来。
晚上八点,第一版改造终于跑通。
林卓把旧页面里最危险的部分拆了出来。Activity 从三千多行降到一千八百行,虽然还不算干净,但主链路已经变了:
View层负责渲染。ViewModel负责状态。Repository/Model负责数据。
每一层都还不完美,却终于有了自己的位置。
林卓提交代码前,把改造说明写进文档:
Activity只保留视图初始化、状态观察、用户交互转发。ViewModel维护MemberUiState,处理页面级业务逻辑。Repository隔离网络与本地缓存来源。ViewBinding替代findViewById(),提高类型安全并减少样板。- 暂不全量引入
DataBinding,避免在旧项目初期改造中过度扩大复杂度。 - 一次性事件从持久
UiState中拆出,避免配置变化后重复触发。
负责人站在他身后看完,问:"你觉得这次改造完成了吗?"
林卓想了想,摇头。"没有。只是把主干扶正了。旧页面里还有埋点、弹窗、优惠券列表性能、登录态监听,这些都要分批拆。一次全改,风险太大。"
负责人继续问:"那你准备怎么推进?"林卓在文档下面补了一段计划:
- 第一阶段:收敛页面状态和数据来源,降低
Activity职责。 - 第二阶段:拆弹窗、埋点、导航事件,统一一次性事件处理。
- 第三阶段:优化优惠券列表刷新和缓存策略。
- 第四阶段:补齐测试用例和回归清单,保证旧行为不被破坏。
负责人看完,没有马上评价。过了一小会儿,他说:"这才像在做架构改造。不是把代码搬家,而是控制风险。"
林卓手指停在键盘上。
他想起之前的自己,连 Android 系统架构分几层都答不上来。那时候他总觉得架构是很远的词,属于大厂架构师,属于 PPT,属于那些他够不到的职位。
现在他才明白,架构有时候就是在一团乱麻里,先把第一根线理出来。
此时负责人补充道:"对了,晚上有空你找一下老杨,他有话跟你说。"
"老杨?有话跟我说?"林卓疑惑道。
晚上八点,今天意外的,三个实习生还没走。
晓雅把 ViewBinding 改造清单整理成表格,顺手标出哪些 View 后续可以按状态组件化。
阿泽把 LiveData 的 setValue()、postValue() 和一次性事件问题写成了复盘,标题叫《我为什么让 Toast 多弹了一次》。
博文把 DataBinding 取舍写成了两页说明,最后一行是:技术不是越多越高级,能解决当前问题且不制造新问题,才是合适。
此时,老杨恰好路过他们的工位,林卓赶紧叫住他,问老杨找自己有什么事儿。
老杨说道:"大概两个月后会有一次转正的机会,你好好表现。"
林卓愣在原地,第一反应不是高兴,而是心口猛地紧了一下。
"转正。"
这两个字像突然被人放到了桌面上,他脑子里一下闪过很多画面:第一次被问到 Android 系统架构时的尴尬,第一次改主题翻车时的窘迫,还有今天在会议室里握着白板笔、差点答不上来的那一瞬间。
紧张是真的,期待也是真的。
他下意识问:"那我现在是不是得准备点什么?比如把之前项目都整理一下,或者多补点架构相关的东西?"
老杨看着他,笑了一下:"别突然把自己搞成备考模式。正常做你现在的事儿就行。"
林卓没太听明白:"正常做?"
"嗯。"老杨继续说道,"这次转正会看项目经验,不是看你背了多少概念。你今天做的这些,拆旧页面、控改造范围、带实习生、知道哪些东西先不动,这些都是项目经验。"
他停了停,又补了一句:"你不用装成很厉害的人。把现在这些事做扎实,就够了。我对你有信心。"
林卓低头看了一眼还亮着的工位灯,他想说点什么,最后只点了点头:"好,我知道了。"
林卓关掉电脑时,窗外的西二旗已经只剩下零散的灯。园区楼下的风有点冷,他和小安并肩走到电梯口,谁都没急着说话。
电梯数字慢慢跳到 1。
小安忽然开口:"你今天给实习生讲东西的时候,比以前稳多了。"
林卓笑了笑:"以前我总想着把功能赶紧做完。现在发现,旧项目最怕的不是慢,是每次快一点,后面都要还债。"
小安点头:"那你以后还债的时候,记得叫我测。"
"你这是帮我,还是给我压力?"
"都有。"她说,"不然你怎么进步?"
进步?
老项目不会因为一次 MVVM 改造就焕然一新。
实习生也不会因为一天任务就变成独当一面的工程师。
林卓,也开始慢慢地从那个只会改 UI、调接口的外包,慢慢走向一个能拆问题、控风险、带新人、守边界的 Android 工程师。
走出公司大门前,他收到老杨发来的一条消息。 只有一句话:
MVVM 只是开始。下一步,想想 Repository 该不该知道 Android。
老杨的坑,从来不会迟到。
夜色落在西二旗的路口,地铁站的人流依旧匆忙。林卓跟在人群里,脑子里却已经开始画下一张架构图。