☰
Android架构演进之路:从MVC到MVP、MVVM与MVI的实战反思
2026/10/14 4:28:40 网站建设 项目流程

这篇Android架构学习笔记,算是把我在业务项目里折腾了大半年的架构演进过程完整记录下来。项目从最初的单Activity堆积三千行代码,到后来逐步拆成MVP、再进化到MVVM、最后在特定模块尝试MVI,中间踩过的坑、推翻过的方案、以及最终沉淀下来的实操思路,都在这里。如果你也是被业务推着走、代码越来越难维护的开发,或者正准备系统学习Android架构却不知从哪入手,这篇应该能帮你少走不少弯路。

1. MVC的理论模型与实际崩坏的根源

1.1 MVC分工并没有错,错的是Android的先天实现

先聊一个看起来很基础但特别容易被忽略的问题:MVC到底是怎么一步一步在Android里变形的。

MVC本身的理论很简单,Model管数据和业务规则,View管界面展示,Controller管用户输入和业务调度。这个分工听起来非常清晰,凡是学过软件工程的人都能画出来。但到了Android上,问题就出现了:Activity生来就同时扮演了View和Controller两个角色。你既要在Activity里findViewById(甚至现在用ViewBinding)做界面绑定,又要在Activity里写点击事件的处理、跳转逻辑、生命周期管理,实际上Activity同时在当老板和当员工,而布局XML只是个挂了名的View。

这种先天结构带来一个连锁反应——业务逻辑没有地方去,最后全被赶进了Activity。我接手项目X的时候,主页面Activity已经稳定积累了三千多行代码,里面的if-else嵌套得有七八层。当时的真实感受是,打开这个文件就像拆炸弹,想改登录逻辑,结果发现它还连着推送回调、订单刷新和用户信息展示,根本不敢动。

导致这种现象的核心原因不是“MVC不行”,而是MVC在Android上从来没有被真正落地过。没有人明确界定Controller到底放哪,Activity的两栖身份也没有人去打破,最后所谓的三层就坍缩成了一层:什么都往里塞的一层。这就是我后来总结出的第一个教训:分工如果只是画在纸上的,代码里就永远是空话。

1.2 三千行Activity是如何一步一步长出来的

很多人以为巨型Activity是一下子长出来的,其实不是。它的成长轨迹非常规律,基本就是三件事的反复叠加:

第一,页面状态同步代码。比如一个订单详情页,要同时展示订单状态、配送进度、支付按钮,这些状态变化分散在网络的多个回调里,每收到一个回调就往对应控件setText。回调多了,状态同步代码就开始互相覆盖。

第二,临时逻辑的堆积。今天产品说“这个按钮只有在特定条件下显示”,明天说“这个入口在某个状态下要换文案”,这些条件判断最初都是三五行的if-else,看起来无害,但随着条件交织,就变成了几十行甚至上百行的判定块。

第三,跨页面的数据传递和刷新。A页面改了数据,B页面需要刷新,最省事的办法就是通过静态变量、全局单例、或者EventBus,这些写法让数据流变得完全不可追溯。等到出Bug的时候,你根本不知道状态是哪一行代码改的。

我特别记得当时排查过一个线上问题:用户从“设置页”改完昵称返回“个人中心”,昵称没刷新。这种问题在架构混乱的代码里是最难查的,因为设置页改完数据后,个人中心根本不知道数据变了。最后是打了个临时广播通知全局刷新。这种补丁代码越来越多,Activity自然就胖了。

1.3 MVC留给我的第一个教训:职责不落地,分工就是空话

经历过巨型Activity之后,我最大的感受是:架构的核心不是“分层”这个动作,而是“职责有没有真正各就各位”这个结果。

MVC在Android上的失败,不是因为理论错,而是因为没有任何机制约束Controller不要越界、View不要掺和业务。代码评审的时候大家看着结构图点头,但日常开发里没人强制你遵守,于是Activity很快又变回垃圾场。

从这段经历里我学到两件事。第一,架构方案必须有强约束力,最好从代码结构上让人“想写错都难”,而不是靠自觉;第二,学习架构不能只看UML图,要看这个模式在目标平台上到底怎么映射。Android的Activity、Fragment、生命周期、任务栈都有它的特殊性,直接套Web那套MVC当然会水土不服。

这也促使我开始认真考虑MVP——因为它能通过接口从代码层面强制切开View和业务。

2. MVP重构:接口与回调带来的解脱和新的痛

2.1 View接口加Presenter,代码终于有了边界

MVP的思路是把Activity里的业务逻辑抽到Presenter里,让Activity只实现一个View接口。这个接口把界面能做的操作抽象出来,比如“显示加载中”“展示用户信息”“显示错误”等等。Presenter通过持有这个接口来驱动界面,而Activity负责具体实现这些方法。

用大白话说,Activity不再直接做网络请求、不算业务逻辑,只负责“显示什么”;Presenter负责“现在应该显示什么、数据怎么算”,它不知道也不管界面的具体样子。

当时项目X里重构登录模块时,我第一次感受到了“边界”带来的安全感。登录页的Activity从混乱的四百行瘦身到不到一百行,里面只剩ViewBinding的绑定和View接口的实现;真正的登录逻辑、参数校验、用户信息缓存全跑到了LoginPresenter里。我不用再翻整个Activity找登录按钮的事件处理在哪,因为它的归属是清清楚楚的。

下面是当时MVP风格的简化示意:

interface LoginView { fun showLoading() fun hideLoading() fun showError(message: String) fun onLoginSuccess(userName: String) } class LoginPresenter(private val view: LoginView) { fun login(account: String, password: String) { if (account.isEmpty() || password.isEmpty()) { view.showError("账号或密码不能为空") return } view.showLoading() UserRepository.login(account, password) { result -> view.hideLoading() result.fold( onSuccess = { user -> view.onLoginSuccess(user.name) }, onFailure = { view.showError("登录失败,请稍后重试") } ) } } } class LoginActivity : AppCompatActivity(), LoginView { private lateinit var presenter: LoginPresenter override fun onCreate(savedInstanceState: Bundle?) { // 初始化ViewBinding presenter = LoginPresenter(this) btnLogin.setOnClickListener { presenter.login(etAccount.text.toString(), etPassword.text.toString()) } } override fun onLoginSuccess(userName: String) { tvMessage.text = "欢迎回来,$userName" } }

这种写法最大的变化就是Activity不再是“神类”,它退化成纯粹的View,业务判断从界面文件里消失,代码的入口和出口变得非常可预期。

2.2 Presenter从控制台变成下水道的演进过程

但MVP并不是没有代价,代价会在项目继续迭代时逐渐显现。

第一个典型问题是Presenter的演化不受控。由于所有业务逻辑都被“抽进Presenter”,它很快就成为新的“垃圾场”。LoginPresenter刚拆完时确实很清爽,但第五个版本加需求后,登录、注册、找回密码、第三方绑定全被塞进同一个Presenter,它的行数再次奔向两千行。我亲手把Activity瘦身,随后又亲手把Presenter养肥,这种体会实在太解了。

第二个问题是接口粒度不好把握。View接口里的方法一开始是“showLoading”“showError”这种粗粒度操作,但随着业务复杂化,接口开始出现“showUserNameAndAvatar”“updateMessageListWithLoading”这样的方法,接口慢慢失去了抽象的意义。更麻烦的是,每加一个页面功能就要同步改接口和实现,两个地方一起改,工作量直接翻倍。

第三个问题是回调地狱没有根本缓解。MVP里的网络请求仍然靠回调返回,多个请求并发时,Presenter内部要处理各种状态的交叉,代码虽然有了边界,但逻辑复杂度一点没减少。

坦白说,MVP并不是“不够好”,而是它解决的问题有限——它解决的是“View和业务耦合”的问题,但没有解决“业务状态如何流动”的问题。数据从哪里来、如何通知界面刷新、多个数据源如何同步,MVP在这些问题上给不了太多指导。

2.3 内存泄漏、接口爆炸与两个难缠的细节

除了设计层面的问题,MVP在实际落地时还有两个特别磨人的细节,如果你的项目也在MVP阶段,这会很有参考价值。

第一个是内存泄漏。Presenter持有View接口,如果Presenter里有一个耗时任务,而用户退出了页面,任务完成后调View的方法,就相当于在控件销毁后还给它发消息。如果不做解绑,轻则内存泄漏,重则直接崩溃。所以MVP项目里必须强制在Activity的onDestroy中调用presenter.detachView(),这种“手动释绑”一旦忘了,问题很难查。另一个思路是让Presenter持有的是弱引用View,但这会引入空安全判断,代码变得啰嗦。

第二个是接口爆炸。页面多了以后,View接口的数量会直线上升。为了约束粒度,团队后来把接口方法控制在“每屏不超过七八个操作”,但这只是延缓了问题,没有根治。更麻烦的是,Fragment之间要通信时,不得不通过Activity做中转,或者再引入事件总线,整个架构的依赖关系又开始乱。

结论是:MVP是个不错的过渡方案,它能让人第一次感受到“业务逻辑不在Activity里”的乐趣,但它把复杂性从界面层转移到了逻辑层,并没有创造一个足够好的状态管理机制。

2.4 MVP的真实定位:它是下一步的地基

虽然我前面吐槽了不少,但要客观地说,MVP在项目演进的历程里是很有价值的一步。

它让我建立了“面向接口编程”的基本习惯——写代码之前先问“这页面的View操作有哪些”,而不是上来就写控件操作。它让我意识到Activity的生命周期是必须严肃对待的基础设施,任何长久持有Activity引用的写法都是定时炸弹。它也让项目X在代码结构上第一次有了“分层”的雏形,后来的MVVM重构,很多Presenter里的业务逻辑可以直接平移成ViewModel中的方法,数据库和网络层更是毫无改动。

可以说,没有MVP这一轮的痛苦,后面学MVVM时我根本理解不了ViewModel为什么要设计成“生命周期感知”的,也体会不到LiveData的“数据驱动”到底解决了什么痛点。

3. MVVM落地:Jetpack组件如何真正改写状态传递方式

3.1 ViewModel的生命周期定位与LiveData的观察机制

在MVP阶段,“Mediator”一样的东西需要手动从Presenter推给View,View要被动接收指令。而MVVM最大的变化是:View开始主动观察状态,状态一变化,UI自动刷新,不需要任何人去调用showXXX方法。

这一轮技术选型上,官方推荐方案是ViewModel + LiveData。ViewModel这个组件的设计精妙之处在于它的生命周期范围:它跟Activity/Fragment的“创建-销毁”生命周期绑定,但会自动跨过配置变更(比如屏幕旋转)保留数据,不会因为Activity重建而丢失。

拿项目X的一个用户信息模块来举例,重构后的ViewModel大概是这样的:

class UserViewModel(private val userRepository: UserRepository) : ViewModel() { private val _userName = MutableLiveData<String>() val userName: LiveData<String> = _userName private val _loading = MutableLiveData<Boolean>() val loading: LiveData<Boolean> = _loading fun loadUser(userId: String) { _loading.value = true viewModelScope.launch { val result = userRepository.fetchUser(userId) _loading.value = false result.fold( onSuccess = { user -> _userName.value = user.name }, onFailure = { error -> _error.value = error.message } ) } } }

Activity端只需要观察LiveData:

class UserActivity : AppCompatActivity() { private val viewModel: UserViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { viewModel.userName.observe(this) { name -> tvUserName.text = name } viewModel.loading.observe(this) { loading -> progressBar.visibility = if (loading) View.VISIBLE else View.GONE } viewModel.loadUser("12345") } }

这段代码最核心的变化是:Activity里不再有任何“数据回来以后怎么改UI”的代码,它只是提前声明“userName变化了,我就把TextView设置成新值”,至于什么时候发请求、数据从哪来,Activity完全不管,因为这是ViewModel的事。

我第一次理解MVVM时,感觉就像从“打电话催流程”变成了“订阅一份报纸”——你不需要盯着天气App刷新,订阅了推送,天气一变你就知道。这种“数据驱动UI更新”的思路,才是MVVM真正革命性的地方。

3.2 DataBinding的路线之争,以及不绑定的替代方案

MVVM绕不开DataBinding。DataBinding可以把XML里的组件直接与ViewModel字段绑定,实现UI的声明式更新,理论和实际都很美好:控件赋值逻辑从Java或Kotlin代码里消失,变成XML里的一个变量表达式。

但就我的实践经验和团队反馈来看,DataBinding是个“分为两派”的东西。喜欢的人会告诉你它干净,讨厌的人会告诉你它把调试地狱从代码层搬到了XML层,还不容易写单元测试。我自己更倾向于第二种——在大规模项目里,DataBinding的编译报错信息往往晦涩,而且XML里的表达式逻辑稍一复杂就很难看,阅读成本极高。

所以我选用的是不绑DataBinding的MVVM变体:ViewModel只持有LiveData或StateFlow,Activity/Fragment通过observe手动刷新UI。这种做法比DataBinding多写一些代码,但换来了可读性、可调试性和可测试性。

如果你仔细分析为什么要这样做,会发现核心逻辑在于“视图更新代码”的职责仍然需要地方放。DataBinding想把它放XML里,我选择把它放View层自己的观察回调里,这符合“View的职责属于View”的原则,也不会让ViewModel被UI控件相关的东西污染。两种方案的权衡不是“会不会写DataBinding”,而是“你更愿意在哪一侧处理复杂度”。

3.3 MVVM模式下依然会踩的坑:事件、乱流与状态恢复

MVVM极大改善了状态管理,但如果说它没有坑,那是不诚实的。

第一个坑是事件处理。LiveData从设计上就带有“粘性事件”的特性——新的观察者在注册时会立刻收到当前持有的值。这在状态展示上没问题,但用于“一次性事件”(比如“弹出Toast”“跳转页面”“显示错误弹窗”)就会造成重复触发。比如登录失败后点击按钮,弹出错误提示,下一次进页面时同一个LiveData里的旧错误值可能又被触发一次。常见解法有Event包装类、SharedFlow(带replay=0的版本)等,我后续在里其实就是被这个坑逼着往前挪了一步。

第二个坑是LiveData的setValue和postValue的“多线程陷阱”。setValue只能在主线程调用,postValue虽然允许后台线程调用,但如果连续多次调用postValue,你可能会发现最终只收到了最后一个值,因为它的合并策略是“中间状态可以丢弃”。对于需要绝对保证每次结果都处理的场景,这个行为尤其要注意。

第三个坑是状态恢复的悖论。ViewModel确实在配置变更时保留了数据,但进程被系统杀死后,ViewModel的数据还是会丢。要真正扛住进程级恢复,仍然需要配合onSaveInstanceState或者持久化方案。很多人以为用了ViewModel就可以高枕无忧,这是对它的一个常见的误解。

4. MVI的进阶:单向数据流把不确定装进盒子

4.1 Intent到State的一次完整旅行

踩完LiveData事件坑之后,我开始研究MVI。MVI的全称是Model-View-Intent,它的核心思想可以用一句话概括:整个页面是一个状态机,所有事件都被统一转换成一个不可变的State,UI完全由State驱动。

这里的Intent不是Android里跳转页面的那个Intent,它更像是“用户的意图”,是用户操作或业务触发的一个动作。

整个流程是:View发Intent -> ViewModel接收Intent并处理 -> 生成新的State -> View观察State并渲染。这个环是严格单向的,View不会直接去改ViewModel的某个字段,也不会去监听某个单独的LiveData然后自己拼逻辑,一切都汇聚到State这一个对象上。

下面是一个典型MVI结构的简化代码:

sealed interface UserIntent { object LoadUser : UserIntent data class Refresh(val userId: String) : UserIntent data class ChangeName(val name: String) : UserIntent } data class UserState( val isLoading: Boolean = false, val user: User? = null, val errorMessage: String? = null ) class UserViewModel(private val repository: UserRepository) : ViewModel() { private val _state = MutableStateFlow(UserState()) val state: StateFlow<UserState> = _state fun handle(intent: UserIntent) { when (intent) { is UserIntent.LoadUser -> loadUser(intent.userId) is UserIntent.ChangeName -> updateName(intent.name) // ... } } private fun loadUser(userId: String) { _state.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { val result = repository.fetchUser(userId) _state.update { result.fold( onSuccess = { user -> it.copy(isLoading = false, user = user) }, onFailure = { error -> it.copy(isLoading = false, errorMessage = error.message) } ) } } } }

View层只观察这一个State:

viewModel.state .onEach { state -> progressBar.isVisible = state.isLoading state.user?.let { user -> tvUserName.text = user.name tvUserLevel.text = user.level } state.errorMessage?.let { message -> toast(message) } } .launchIn(lifecycleScope)

初次接触MVI的人可能会觉得这有点绕:为什么非要把所有数据都塞进一个State里?直接拆成多个LiveData不好吗?

4.2 不可变状态带来调试收益和一致性体验

拆成多个LiveData的问题在于:它们之间没有“原子性”。举例来说,加载成功时,加载中状态要设false,用户数据要设置,错误信息要清空。如果是三个LiveData分别setValue,就可能出现“加载中已经被置为false,但用户数据还没刷新”的中间窗口,UI在那一瞬间显示的是“加载已结束但内容为空”——这种一瞬间的界面错乱很难抓。

而MVI的不可变State从机制上杜绝了这个窗口。所有状态更新都通过copy生成新对象,一次copy就把isLoading和user和errorMessage一次性更新完,View观察到的一定是一致的新状态。这个特性在状态字段变多以后,收益会越来越明显。

调试体验也很直观。由于所有状态变化都是显式的,我可以在handle方法入口打日志打印Intent,在state集合处打印新State,任何一次界面变化都能沿着数据流准确回溯到对应的Intent。不像以前在MVP里,可能五六个地方改同一个界面字段,查Bug经常要猜。

它还有一个精神层面的价值:整个UI变成一个“纯函数”——给一个State,一定渲染出对应的页面。这让开发者的思维模式从“我要怎么操控界面”变成“我要怎么生成正确的State”,切换之后写代码的确定性高了很多。

4.3 MVI不是银弹:样板代码与Reducer复杂度要算清楚

但MVI我自己最终没有全面推广到整个项目,原因也很现实。

第一,样板代码确实多。每增加一个UI动作,至少要写一个Intent类、一个State字段、一个handle分支、一个状态更新逻辑。页面简单还好,复杂页面动辄上百行的reducer,刚接手的人需要一点时间才能习惯这种“无关业务细节大量堆叠”的风格。

第二,State膨胀问题。页面状态多的时候,UserState里可能有十几个字段,每个字段都可能变化。虽然copy很优雅,但如果Reducer里逻辑越来越复杂,一个事件同时更新五六个字段,这个State类的可维护性也会下降。需要进一步做状态拆分(比如拆成多个子State)来缓解。

第三,MVI在“一次性事件”上面仍然没有完美解法。虽然StateFlow天然没有粘性问题,但错误提示这种事件如果放进State里,配置变更后(比如旋转屏幕)有可能再次触发。我见过不少MVI项目最后的解法是把事件放在State里,然后View层通过reduce判断是否消费过,或者另开一条Event Channel。虽然都能实现,但老实说,目前没有一种零代价的完美方案。

不过MVI的价值真实存在,它把“不确定的代码行为”变成了“可预期的状态流转”,对于一些异常多的业务页面,收益非常明显。我目前的做法是:普通增删改查页面用MVVM,状态多、逻辑分支复杂的核心页面用MVI,两条路线在项目里可以共存,关键是团队要清楚界限。

5. 分层、模块化与依赖注入:架构不只是模式本身

5.1 数据层、域层与表现层的边界到底怎么划

说了MVP、MVVM、MVI这么多模式,如果只把目光停在“UI层”,那架构学习永远只是半吊子。真正决定一个App能否长期维护的,往往是更底层的分层设计。

项目X到后期做模块化拆分时,我们把代码分成了三个层次:表现层、域层、数据层。这个名字听起来高大上,但拆分的逻辑其实很简单:表现层只关心UI渲染和用户交互;域层只关心业务规则(比如“用户输入的金额不能超过余额”“多件商品在优惠规则下如何合并计算”);数据层只关心数据从哪里来、如何存下来(网络接口、数据库、内存缓存)。

边界限定的关键是依赖方向:表现层依赖域层,域层依赖数据层接口,数据层实现这些接口。反过来的依赖绝对不允许,没人会在数据层里去引用某个Activity或某个Fragment的类。

可能有人会问,业务逻辑到底放哪一层?这是最容易搞混的。我的经验是:与设备交互沾边的(比如读文件、查定位)放数据层;单条业务规则放域层;多个业务规则编排的流程放表现层的ViewModel或Presenter里。这样划分后,Android SDK的痕迹就被压缩在表现层和数据层,域层甚至可以做成纯Kotlin模块,单独写单元测试爽得很。

5.2 仓库模式是掩盖多数据源复杂度的关键

分层之后,最体现架构功力的一个细节是——数据层如何对上层提供统一接口,而不暴露数据源细节。这就要用到仓库模式。

仓库模式说白了就是:上层以为自己在调一个“完美的数据源接口”,但仓库内部其实在维护多个真实数据源之间的协作。比如用户信息接口,理论上要先读本地缓存,缓存没有再去网络请求,请求到了之后回写缓存。如果没有仓库层,这些逻辑就得散落在各个ViewModel或Presenter里,每个调用处写一遍相同的规则,改一处要改十处,迟早乱套。

有了仓库层之后,上层只需要:

interface UserRepository { suspend fun fetchUser(userId: String): Result<User> }

具体的实现里再选择缓存策略、请求策略:

class UserRepositoryImpl( private val local: UserLocalDataSource, private val remote: UserRemoteDataSource ) : UserRepository { override suspend fun fetchUser(userId: String): Result<User> { local.load(userId)?.let { return Result.success(it) } return remote.fetchUser(userId).onSuccess { user -> local.save(userId, user) } } }

这一段看似简单,但它背后是一个非常重要的原则:上层不关心数据来源的细节。网络、缓存、数据库的变化都被隔离在数据层内部,上层代码只处理“拿到数据结果”这件事。

5.3 依赖注入从手写工厂到框架化,融会贯通以后才简单

分层和仓库模式都很好,但它们会产生一个附加问题:层和层之间大量依赖关系需要手工装配。比如UserRepositoryImpl需要传LocalDataSource和RemoteDataSource,ViewModel需要传UserRepository,Activity又需要创建ViewModel——如果全靠手工new,你会发现每次加个依赖,所有创建它的地方都要跟着改,这太痛了。

依赖注入就是为了解决这个装配问题。它的核心思想是我之前写代码时没意识到的:依赖不是由使用方去new,而是由外部把依赖“注入”进来。构造器注入是首选方式,因为它最容易测试和替换。

在没有框架之前,我可以手动写一个ServiceLocator,把所有单例装在一个容器里:

object AppContainer { val userRepository: UserRepository by lazy { UserRepositoryImpl(localDataSource = ..., remoteDataSource = ...) } }

项目X后来改用注解框架后,代码更简洁了,核心写法和之前基本等价:

class UserViewModel @Inject constructor( private val userRepository: UserRepository ) : ViewModel() { // ... }

框架的价值不只是省代码,它强制你以构造器注入的方式思考依赖关系,避免乱用Global-style单例。当你习惯了“想要一个东西就声明到构造器里”,依赖关系变得非常透明,测试的时候替换依赖也极其方便。

不过要提醒一句:依赖注入框架的学习曲线有时被夸大了。真正难的不是框架用不用,而是弄清楚“哪些东西应该成为可注入的依赖,哪些东西应该是局部创建的现实现象”的分寸感。像设备时区、屏幕尺寸这种环境相关的数据的获取方式,不应该作为依赖乱注入。

6. 用可测试性反推架构设计

6.1 架构好不好,先问单测好不好写

如果说架构设计有一条终极检验标准,我的答案一定是“可测试性”。一个架构方案如果让单元测试变得容易书写,那它的依赖关系、职责边界大概率是合理的;如果写完一个单测要mock五六个静态对象、模拟各种全局状态,那架构一定有问题。

我举一个很实际的例子。项目X早期在MVP阶段,写登录Presenter的单测需要先准备View接口、再mock数据仓库(还需要依赖注入框架配合),最后还要处理回调线程的同步问题,一个用例动辄几十行。到了MVVM阶段,写ViewModel测试时只需要注入一个假的UserRepository,直接调用方法后验证LiveData的值即可,测试代码短了一半还多。

下面是一个典型的ViewModel测试:

class UserViewModelTest { @Test fun `loadUser_success_should_updateState`() = runTest { val fakeRepository = FakeUserRepository(Result.success(User(name = "Tom"))) val viewModel = UserViewModel(fakeRepository) viewModel.loadUser("1") advanceUntilIdle() assertEquals("Tom", viewModel.userName.value) } }

没有网络、没有线程切换、没有Activity,只有纯粹的输入和输出。当你的架构代码可以这样被测试时,你才真正体会到“面向状态编程”的安全感,改代码也敢放心改了。

6.2 生命周期与协程上下文在测试里的处理

很多人在测试ViewModel时会被两个东西卡住:一是LiveData取不到值,二是协程不执行。

LiveData取不到值的情况,多半是因为没有在主线程执行被测代码。LiveData.setValue方法要求必须从主线程调用,但单元测试环境没有“主线程”概念,所以需要提前加入一个主线程规则,让测试环境里有生命周期的主线程调度器。

协程不执行的情况则是因为viewModelScope默认用的是Dispatchers.Main,而测试环境没有Looper,协程发出去却不跑。解决方式是在测试方法里使用Dispatchers.setMain替换为主线程调度器,配合runTest控制协程的启动和前进时间。

这些测试基础设施虽然烦琐,但它们本身就是对架构设计的校验——如果一个ViewModel的代码完全依赖真实的主线程和真实网络,那它一定没法测;而如果它把数据获取、状态更新都设计成纯函数式的输入输出,测试就几乎不用处理这些环境问题。

依赖注入在这时又发挥了另一个作用:FakeRepository可以随时替换。只要ViewModel的依赖是接口不是实现,测试里就能轻松用MemoryRepository、FailingRepository等各种替身验证分支逻辑。反过来说,如果你的代码在测试里怎么都mock不动,那通常是因为你的“依赖”被直接new在了类内部,或者依赖了具体实现类,这是架构设计要改的信号。

6.3 代码评审里的架构视角:别等人review才想起设计

架构学习落到团队协作时,最有价值的动作我总结下来是“把架构原则纳入代码评审的检查清单”。

以前项目X评审代码时,讨论最多的是“这个经理怎么处理”和“这里会不会内存泄漏”,很少有人说“这个依赖方向画反了”或者“这个状态不应该由Activity自己保存”。等到架构问题积压到一定量,整个项目就开始出现“隐性欠债”——每个模块都能跑,但没有人能在改需求时准确评估影响范围。

后来我们建立了几个简单但硬性的评审规则,效果立竿见影:

  • 新代码不允许Activity直接操作业务状态,业务状态必须先进ViewModel或Presenter,再通过观察或回调驱动UI。
  • 跨层访问必须走接口,不允许直接引用数据层实现类。
  • 新页面的ViewModel构造器不允许出现Android SDK类型,天然可测。
  • 事件类的LiveData必须做包装,否则评审不过。

这些规则本身不复杂,但有一条代码级的强制约束之后,架构方案才能真正从“纸面设计”变成“团队习惯”。我个人的感受是:单打独斗时架构靠自觉,多人协作时架构靠约束,而优雅的约束通常就是接口、依赖方向、可测试性这三件事。

学习Android架构的最大收获,其实不是背下多少种模式的名称,而是养成了“先想清楚职责边界再写代码”的习惯。一套架构下来,能让你在看到需求时,自然而然地问出三个问题:这个逻辑属于哪一层?这个状态应该怎么流转?我要怎么写才能方便测试?这大概就是架构学习带给一个人最实在的成长。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询