☰
Android架构实战:MVVM、Clean Architecture与模块化落地
2026/9/29 15:24:27 网站建设 项目流程

做了这么多年 Android,架构这个话题几乎是每次技术评审、每次晋升答辩、每次新人培训绕不过去的坎。你问十个开发什么叫架构,可能得到十种答案,但落到代码仓库里,最后比拼的就是三件事:数据怎么流动、依赖怎么管理、业务怎么隔离。这篇不会跟你扯学院派那套理论,我就从实际项目里踩过的坑出发,把 MVVM、Clean Architecture、模块化这三块高频内容一条条拆开聊,配合可以直接抄的代码和思路,目的是让你回头改自己项目时能有个清晰的下手点。不管你是刚转 Android 一两年想进阶,还是已经带团队想梳理技术规范,这篇都适合花二十分钟读一遍。

1. 架构演进与选型:为什么是 MVVM + Clean + 模块化

1.1 从 MVC 到 MVVM,绕不开的那几个痛点

很多老项目一开始都是 MVC 的变种,Activity/Fragment 既当 View 又当 Controller,业务逻辑、网络回调、界面刷新全揉在一个类里。两三万行代码的 Activity 我见过不止一次,一个 onSuccess 回调里嵌套三四个接口刷新逻辑。这种代码不是不能跑,是每改一个需求都要把整个文件翻一遍,改完还容易把不相干的功能弄坏,回归测试成本高得吓人。

后来大家用 MVP,把业务逻辑抽到 Presenter 里,Activity 只管 View 的刷新。这个思路确实解决了部分问题,但带来了一个新麻烦:接口爆炸。View 和 Presenter 各定义一堆接口,一个页面十几个回调方法,小项目还能忍,业务一复杂,光维护接口就够喝一壶的。而且 Presenter 持有 View 引用,处理不当还会内存泄漏。

MVVM 之所以成为标配,核心原因是它换了一套驱动逻辑:不是主动去调用对方接口,而是通过观察者模式让 UI 自动响应状态变化。ViewModel 不直接持有 Activity 引用,而是暴露可观察的状态,Activity 或 Fragment 去订阅这些状态,数据变了 UI 跟着变。这样 ViewModel 的生命周期就能独立于界面,旋转屏幕、切换后台都不影响数据存活,这也是 Jetpack 官方把它叫 ViewModel 的原因。

1.2 Clean Architecture 到底在“清理”什么

很多人在项目里用了 MVVM 之后发现,业务逻辑还是有点乱。ViewModel 里塞着网络请求、数据转换、本地缓存判断,代码虽然没有烂到没法看,但测试起来依然麻烦——想 mock 一个接口,发现网络库和 ViewModel 绑得太深,根本注不进去。这时候就该上 Clean Architecture 了。

Clean Architecture 的那张经典同心圆图,核心意思是依赖只能从外层指向内层,内层不应该知道外层的存在。翻译成 Android 项目的语言就是:UI 层不直接依赖网络库和数据库,它只面向 Domain 层定义的接口;Domain 层聚焦业务规则,它不关心数据从哪来,只关心怎么处理;Data 层才是真正干脏活累活的,负责从 Retrofit、Room、DataStore 这些地方拿数据,再转换成 Domain 层需要的结构。

可能有人觉得这太繁琐,一个小功能要建四五层类。但实际项目里收益很明显:替换网络库、加本地缓存、换数据库,这些改动都隔离在 Data 层,Domain 层完全不用动。业务规则可以被单独写单元测试,不用起模拟器、不用 mock 网络,几分钟跑完一轮。这个就叫“依赖反转”带来的自由度。

1.3 模块化的本质是约束,不是拆分

模块化和组件化经常被混着说,这里先统一一下口径:我们把“模块化”理解为在构建层面将一个工程项目切分成多个 Gradle Module,每个模块可以有独立的编译单元;“组件化”则更强调运行时和编译期的完全解耦,每个业务组件可以作为一个独立的 App 壳工程运行。日常讨论中两者经常互用,下面统一用“模块化”代指这一整套拆分方案。

模块化解决的核心矛盾是人多了之后代码互相踩脚。几十个人在一个 app 模块里改代码,每次合并都冲突,编译一次五六分钟,一周下来大量时间花在解决冲突和等编译上。拆成模块之后,每个团队负责自己的业务模块,接口用协议约束,互相之间不直接依赖,改动被限定在模块内部,冲突自然就少了。

但模块化不是银弹。拆太细会导致模块间通信成本飙升,拆太粗又起不到隔离效果。而且拆模块是个不可逆的动作,拆出去再合回来,工作量往往是三倍。所以我的建议是:如果团队不到十个人、业务复杂度没上来,先用单模块加清晰分包,等痛点真的出现了再动手拆,不要为了架构好看而自我感动。

2. MVVM 实战细节:状态驱动、生命周期与数据绑定

2.1 状态管理:用 StateFlow 统一 UI 状态

MVVM 落地最关键的细节就是“状态”的定义。很多初学者写 MVVM,还是用 LiveData 一把梭,onClick 里发个请求,onSuccess 里把结果塞进 LiveData。这会有一个问题:一个页面有加载中、空数据、网络错误、内容展示四五种状态,每一个都塞一个 LiveData,界面逻辑就是堆 if else。更麻烦的是这些状态还可能互相覆盖,比如加载中还没结束,网络错误先弹出来了。

我在项目里推荐的做法是把整个 UI 状态建模成一个不可变的数据类,用一个 StateFlow 来承载。界面只订阅这一个 StateFlow,拿到的就是一整个状态快照,再根据状态里的字段去渲染不同区域。

data class ProfileUiState( val isLoading: Boolean = false, val profile: UserProfile? = null, val errorMessage: String? = null ) class ProfileViewModel( private val repository: UserRepository ) : ViewModel() { private val _uiState = MutableStateFlow(ProfileUiState()) val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow() fun loadProfile() { viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true) try { val profile = repository.getProfile() _uiState.value = _uiState.value.copy( isLoading = false, profile = profile, errorMessage = null ) } catch (e: Exception) { _uiState.value = _uiState.value.copy( isLoading = false, errorMessage = e.message ) } } } }

注意几个细节:MutableStateFlow 是私有的,对外只暴露只读的 StateFlow,避免 UI 或者其他协程从外部改变状态。每次状态更新都走 copy(),保证旧状态不会被意外修改。UI 层订阅时只读 uiState,用 collect 或者 collectAsState 收集更新。这套模式跑通之后,你会发现 UI 逻辑变纯了:给定一个状态,渲染什么内容是确定的,测试时只需要断言状态对不对。

2.2 一次性事件:不要在 StateFlow 里塞 Toast

StateFlow 是个粘性数据流,新订阅者一进来就会收到当前的最新值。因此用 StateFlow 去承载 Toast、Snackbar、导航事件这类一次性事件,会出现一个经典 bug:页面旋转之后,上次的 Toast 又弹一次。这就是高频面试题“LiveData 和 Flow 在处理事件时的区别”背后的实际场景。

我自己一般有两种处理方案。一种是用 Channel,它在没有订阅者时不会缓存数据,发出的消息只被消费一次,天然适合一次性事件。但 Channel 用起来有点反直觉,要记住它的 receiveAsFlow,还要注意 buffer 配置。

另一种更实用,是把事件也放到 UiState 里,但给它加一个消费标记,比如记录事件 id。UI 消费完通过 action 通知 ViewModel 清除事件。这个方案的好处是整个页面状态依然是单流,调试的时候看一眼 UiState 就知道发生了什么,不用在多个数据流之间跳来跳去。项目里如果团队对 Flow 还不太熟练,我更倾向于第二种,因为它更直观、更好 review。

2.3 生命周期绑定:ViewModelScope 与协程安全

MVVM 里能突出 Jetpack ViewModel 优势的一个重要原因,就是 viewModelScope 对协程生命周期的兜底。它会在 ViewModel 被 clear 时自动取消所有协程,网络请求不会在界面销毁后继续回调。但这里有个很容易被忽视的坑:viewModelScope 默认运行在 Dispatchers.Main.immediate,网络请求这类需要切换线程的操作如果不自己切 IO,架子搭得再漂亮也会卡 UI。

实际写代码时,我习惯在 Repository 层就把线程切换封装好。ViewModel 调用 suspend 函数时,不需要关心它跑在哪个线程,Repository 保证它返回时数据已经准备好了。ViewModel 里尽量不出现 withContext(Dispatchers.IO),因为那不是它该管的职责。如果看到 ViewModel 里又切线程、又处理序列化、又判断缓存,那不是 ViewModel 的错,是 Repository 没写好。

对于进度条控制也一样。加载状态如果由页面自己管理,最常见的问题就是请求还没结束用户又触发了一次,导致两个请求同时在跑,进度条闪来闪去。正确做法是把“是否允许并发请求”的逻辑也放进 ViewModel:如果 UiState 已经是 loading,直接 return 或者走取消-重新请求的策略。页面只根据状态显示或隐藏进度条,不做业务判断。

3. Clean Architecture 落地:分层、依赖注入与数据流

3.1 三个层级的职责划分与依赖方向

Clean Architecture 在 Android 落地时,大家会结合项目规模做裁剪。我的经验是保持三个核心分层就够了:外层是 presentation/data,内层是 domain,外加一个放框架初始化的 app 层。关键是依赖方向要正确:presentation 依赖 domain 和 data 的抽象接口,data 依赖 domain 的模型和接口,domain 谁都不依赖。

这个方向在代码上是怎么体现的?看一个例子。用户资料的接口定义放在 domain 层:

interface UserRepository { suspend fun getProfile(): UserProfile suspend fun updateProfile(name: String): Result<UserProfile> }

domain 层还有一个对业务规则的封装,叫 UseCase 或者 interactor。它协调 Repository 完成一个具体业务动作:

class UpdateUserNameUseCase( private val userRepository: UserRepository ) { suspend operator fun invoke(newName: String): Result<UserProfile> { return userRepository.updateProfile(name).map { updated -> // 这里可以做一些业务规则校验,比如名称敏感词过滤、长度限制 updated } } }

看看这个 UseCase,它不知道数据源是服务器还是本地缓存,不知道网络请求怎么发的,甚至不知道更新完结果展示在哪个页面上。它只负责执行“改名字”这个业务动作。这正是 Clean 架构想要的:业务规则独立于任何框架、任何 UI、任何数据源。以后就算把 Retrofit 换成某个新出的网络库,这个 UseCase 一行都不用改。

3.2 依赖注入:不用 Hilt 也得有依赖注入

分层架构落地的第二个关键就是依赖怎么传。最直接的办法是手动在构造器里 new,但那会造成链式修改的问题:UserRepository 换了实现,所有 new 过它的地方全要改。引入依赖注入框架,比如 Hilt,就是为了解决这个装配问题。

Hilt 的核心思路是:告诉容器“这个接口,我给你哪个实现”,需要的地方通过构造器注入拿到实例,不用关心是谁 new 的。这样替换实现的时候只在 Module 里改一处绑定关系,其余代码不动。

用 Hilt 的典型装配长这样:

@Module @InstallIn(SingletonComponent::class) object RepositoryModule { @Provides @Singleton fun provideUserRepository( remoteDataSource: UserRemoteDataSource, localDataSource: UserLocalDataSource ): UserRepository { return UserRepositoryImpl(remoteDataSource, localDataSource) } }

依赖注入的好处不只是替换实现方便,它还强行规范了类的依赖复杂度。一个 ViewModel 的构造函数里假如塞了七个以上的依赖,说明这个 ViewModel 承担了太多职责,Review 的时候一眼就能看出来,逼着你去拆分。这是个很隐蔽的质量杠杆,比写一堆设计文档管用。

3.3 单向数据流:从 UI 事件到 Data 层再回到 UiState

Clean 架构很容易犯的毛病是写了好多层,但数据流乱走。最常见的问题是 UI 层把事件广播到全局(比如 EventBus),Domain 层或 Data 层监听之后回传,两个页面之间还能互相发消息,架构分层的意义直接被抹杀。

我的原则是数据流永远是单向的:UI 事件进入 ViewModel,ViewModel 调用 UseCase,UseCase 操作 Repository,Repository 返回数据,数据层层向上,最后收敛为 UiState,UI 层订阅刷新。任何跨层跳过的调用、任何反向的数据流,都是架构腐化的前兆。

实现中维护这条单向流,还可以开一个专门事件包装类来区分“状态变化”和“用户操作”。状态变化是响应式更新的,用户操作是主动触发的,两者语义不能混。尤其是网络请求类操作,用户不会再点一下就重复发一次请求,这些控制都写在 ViewModel 里,确保数据流方向一致优先于消息传递的方便性。

4. 模块化与组件化实践:Gradle 配置、路由与资源隔离

4.1 拆模块的前提条件与模块划分原则

模块化一定是有代价的,启动时会多加载一些类,Debug 编译时模块间的增量编译也可能出现奇怪的问题,更重要的是模块间的接口要花功夫设计。所以我通常建议团队满足这几个条件再动手:编译时间超过五分钟、多人协作频繁冲突、或者已经有明确的业务线划分(比如商城、社区、个人中心各自独立迭代)。

真正拆的时候,模块划分有两个方向,别搞混。按技术层拆是 feature 模块里继续分 ui、data、domain,这对中小项目比较舒服,因为每个 feature 内部可以自成一个 mini Clean 架构。按业务线拆则是每个业务线一个顶层模块,比如一个购物车模块、一个订单模块。大厂一般两层都拆,但中小团队我只建议先拆业务层,技术层拆太深会拖慢开发速度。

模块划分还有一个黄金原则:只让模块依赖真正需要的东西。比如订单模块只需要知道商品模块的“查询商品摘要”接口,就不应该让它依赖整个商品模块。所以这里要引入接口隔离——跨模块调用只面向接口,不面向实现类。

4.2 跨模块通信:路由与 Service 机制

既然拆了模块,跨模块跳转和数据获取就必须有个统一方案。最常用的是 ARouter 或者类似的编译期路由框架。它有非常直接的使用方式,通过路径注解注册页面,使用时用路由跳转:

@Route(path = "/order/detail") class OrderDetailActivity : AppCompatActivity() { ... } // 从商品模块跳转 ARouter.getInstance() .build("/order/detail") .withLong("orderId", 12345L) .navigation()

路由不只是跳转页面,它还可以注册跨模块的 Service 接口。商品模块想获取用户的收货信息,但收货信息在用户模块里,直接依赖用户模块可能造成循环依赖。用 Service 机制就能解耦:用户模块定义并注册实现,商品模块只依赖接口,运行时通过路由取实现类。只要接口名和实现类的对应关系稳定,模块间的物理依赖就被切断了。

这块要特别注意一个坑:路由表在混淆时要用 keep 保住注解类和被注解的页面,否则发布包会炸出一个奇葩问题——Debug 正常,Release 一跳转就找不到目标。最好在 CI 流程里加一个编译期检测步骤,检查路由表是否被意外混淆。

4.3 构建配置:settings.gradle 与版本统一

多模块工程越来越依赖 Gradle 的版本目录功能,也就是 gradle/libs.versions.toml。这个文件统一管理依赖版本,所有子模块引用同一个版本变量,升级依赖只改这一个文件,妈妈再也不用担心一个项目里出现三四个不同版本的 Gson 了。

模块的依赖配置也要注意作用域。在 Gradle 里 implementation 和 api 的区别大多数人知道但没真上心:implementation 不让依赖在编译期暴露给下游模块,api 可以。我的建议是默认全用 implementation,只有当需要把库类型暴露给调用方时才用 api。多用 api 会拉长编译链路,模块 A 依赖 B,B 用 api 依赖了 C,A 想改 C 的某个实现,就得重编 B 和 A,编译时间的雪崩就是这样滚起来的。

资源隔离属于模块化里最容易被忽略的一部分。多模块工程里两个模块都定义了一个叫 app_bg_round.xml 的圆角背景,同名资源在构建时会触发 lint 报错,或者更惨的是直接冲突,构建失败。我定的规矩是每个模块的资源文件都要加模块前缀,商品模块叫 prod_、订单模块叫 ord_。虽然繁琐了一点,但能避免很多后续的痛苦。

5. 高频架构问题排查与团队落地经验

5.1 面试和评审最爱问的架构问题清单

架构相关的面试题看似在于“会多少概念”,其实更在于“你有没有极限推演过架构决策的副作用”。我把这几年在面试和团队评审里常遇到的问题列成了一张表,每个都附了回答思路,方便你们临时抱佛脚,也是我团队内部培训的素材。

问题考察点建议思路
MVVM 和 MVP 的根本区别是什么是否理解数据驱动 vs 事件驱动MVVM 是观察者模式,UI 自动响应状态;MVP 是接口回调,需要手动调用
LiveData 和 StateFlow 怎么选生命周期机制和背压理解简单页面用 LiveData 足够;涉及复杂转换、流操作时选 Flow
ViewModel 为什么能存活到界面销毁之后生命周期Owner的理解ViewModelStoreOwner、屏幕旋转场景
什么是单向数据流,不遵守有什么后果架构纪律的认知事件乱走导致状态难追踪、重复消费、页面间隐式耦合
Clean 架构如何测试 Domain 层理解分层目的UseCase 只依赖 Repository 接口,mock 接口即可,无需 Android 环境
模块化后构建缓存失效原因有哪些Gradle 配置理解不正确的 api/implementation 暴露、资源冲突、annotationProcessor 配置不一致
组件化方案如何避免运行时 bug路由和反射理解路由表混淆配置、Service 接口版本控制、编译期路由检测

这些问题背后共通的逻辑是:架构方案不是为了炫技,而是为了应对真实业务带来的复杂度。面试官想听到的不是你知道 ARouter 有几个方法,而是你在什么场景下选了哪个方案、为什么没选另一个、有没有为这个决定背上代价。

5.2 架构腐化的早期信号和排查方法

架构落地最怕的不是拆分的时候没想清楚,而是业务迭代过程中逐渐腐化。我在 Review 代码时,有一个固定的排查清单:ViewModel 里是否出现模板代码(比如把 Utils 方法拷进来)、Domain 层是否出现了 Android 类的引用(比如 Log、Toast)、Data 层是否被 UI 直接调用、路由表里的路径是否散落在业务逻辑里。

为了系统性地挡住这些腐化,我强烈建议在 CI 里引入架构检查工具,比如用 ArchUnit 来编写依赖规则。它能检查类与类之间的依赖方向,比如“Domain 层的类不允许 import Android framework 的类”,如果违规就构建失败。第一次加 ArchUnit 到旧项目里会一阵哀嚎,因为会扫出来一大堆历史残留,但这正好是清理架构债的起点。我见过一个团队花两周清理完所有违规,之后半年都没再出现过跨层依赖,这个投入回报率非常高。

5.3 团队落地架构的推进节奏

架构规划和使用不是一个人能完成的事。如果团队里只有你自己写了架构方案、代码规范,其他人不认同,那这套架构就是一座没人住的新房子。我个人的推进节奏可以参考:先找三位核心开发一起评审,确保每个人都理解为什么选择现在的方案。评审不是走过场,要让大家提出反对意见,把分歧点提前解决。

第一个模块要选择一个业务独立、迭代频率低的模块作为试点。从购物车这类功能拆起,因为它的上下游依赖清晰,改动风险低。跑通一个完整链路之后,拿这次的经验写成团队架构说明文档,下次再拆新模块就有据可循。还有一个很重要的点:要给模块边界定义好“接口版本号”,接口变更要走评审,防止某个模块悄悄改接口导致依赖它的模块默默出 bug。

我自己带项目的习惯是每周五下午花半小时过一遍最近合入的架构相关代码,不追求全部看完,只看几个关键目录的变更。这比每个月做一次集中评审要省力很多,真有问题也能在小范围内及时止损。每个团队都该找到自己的节奏,关键是别让架构成为一次性工程。

6. 一些踩坑记录与最终建议

聊到最后,我没有打算给你列一个“完美架构”的模板,更不打算劝你把项目推倒重来。架构是在演进中逐渐趋于合理的,十人团队和百人团队的架构一定长得不一样,即便他们业务相同。我有一次因为仓储模块的 Repository 接口定义得太抽象,反而让新同学接需求时猜不到实现细节,后来砍掉一层抽象才舒坦。这让我明白,架构服务于人,不是让人服务于架构。

有个特别想分享的细节:很多项目用 StateFlow 做了状态管理,但防重复、防抖动、防请求重复触发的逻辑却散落在各个 Activity 里。应该把这些逻辑收敛到一个 state machine 里,每个页面的状态流转只能通过固定的桥接入口,不能直接改状态。之前我在一个页面调试了很久的“按钮连点两次导致弹两次 loading”,排查到根因是状态更新没有做楼层控制,回过神来就把类似逻辑全归拢了,后面再也没犯过同类问题。

对于刚开始做架构改造的团队,我的建议永远是“先有一坨能跑的业务代码,再谈拆分和重构”。业务代码还没稳,架构就是空中楼阁。你可以先从一个小模块试试 Clean 架构的落地感觉,不管能不能全团队推广,你自己的代码品味和工程习惯一定会变好。架构能力这件事,靠的是持续的判断和调整,不靠一次大动干戈。

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

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

立即咨询