好,聊Android架构这件事,我是踩过不少坑的。早年做项目,一个Activity动辄两千行,业务逻辑和数据请求全挤在界面里。那时候没有架构概念,只要能跑,需求能交付就是胜利。后来项目规模越来越大,多人协作越来越吃力,重构几轮之后才开始系统去啃MVVM、Clean架构和模块化改造。
这篇内容算是我这些年做架构设计和面试别人时的高频问题汇总加实战落地经验。不写虚的,全是拆解思路和可以直接抄的代码骨架,重点是讲清楚每个方案背后的取舍逻辑,以及哪些地方最容易翻车。如果你正在准备Android架构相关面试,或者项目中正准备做架构转型,这篇文章值得你花二十分钟好好读一遍。
1. MVVM实战:先理清定位再写代码
MVVM这三个字母被讨论了太多年,但真正能在项目里不乱用的团队其实不多。很多人觉得MVVM就是上DataBinding加ViewModel加LiveData,结果状态同步、生命周期、事件回调全搅在一起,重构起来比MVC还痛苦。这套架构的核心,是把界面状态和界面行为彻底分开,让Activity和Fragment退化成纯粹的视图容器。
1.1 我为什么放弃了DataBinding
先说一个容易引起争论的结论:在多数中大型项目里,我不建议重度使用DataBinding。注意,是重度使用,不是完全不用。DataBinding的表达式语法在复杂业务里可读性很差,尤其遇到逻辑判断加数据转换的时候,XML里的表达式能写得比Java代码还难看。而且一旦数据绑定表达式出错,编译期的报错位置极其难找,排查成本很高。
我目前比较推荐的做法是ViewBinding加手动赋值。ViewBinding和DataBinding一样能拿到类型安全的View引用,但它不做数据绑定,没有表达式语法,生成代码更轻量。赋值逻辑全部放在ViewModel或者Adapter里,代码清晰,类型安全,不会有XML表达式带来的隐藏问题。
用Kotlin写的话,配合属性委托可以把绑定代码整理得挺干净:
class MainActivity : AppCompatActivity() { private val binding by viewBinding(ActivityMainBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) } }viewBinding的委托封装很简单,核心是用Activity的viewBinding扩展函数。这个方案实测下来,比DataBinding更容易让团队里的新人理解,代码审查的时候也更高效。
1.2 ViewModel的生命周期误区
ViewModel的生命周期范围是整个Activity或Fragment对应的ViewModelStore,这点大多数人都知道。但实际踩坑的是,很多人把ViewModel用在了错误的作用域里。比如一个页面里有多个Tab,每个Tab都有自己的列表状态。如果全部用Activity级别的ViewModel去存,Tab滑动位置、加载状态、临时草稿全混在一起,后面处理和页面解耦非常痛苦。
正确做法是用Kotlin的by viewModels()让每个Tab持有自己的ViewModel:
class TabFragment : Fragment() { private val viewModel: TabViewModel by viewModels() }Fragment自身的ViewModel会在Fragment销毁时自动清理,而Activity里的ViewModel只在Activity真正销毁时清理。如果你的TabFragment还停留在FragmentManager中,状态就能一直保留。这套作用域设计是MVVM能实现状态持久化的根基,但很多人一开始就理解偏了。
1.3 状态恢复别再手动onSaveInstanceState了
多年前手动管理onSaveInstanceState那套方式我已经不喜欢了。现在处理状态恢复,我比较推荐利用SavedStateHandle来实现。它自动跟Activity或Fragment的savedInstanceState绑定,配置变更后ViewModel拿到的仍是之前的数据。
举个例子,用户填了一半的表单,旋转屏幕或者切后台后进程被回收。用SavedStateHandle保存这些数据,比在Activity里手动存Bundle省心太多:
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { var userName: String set(value) = savedStateHandle.set(KEY_USER_NAME, value) get() = savedStateHandle.get<String>(KEY_USER_NAME) ?: "" companion object { private const val KEY_USER_NAME = "user_name" } }这里有个关键点,SavedStateHandle是跟ViewModelStore一起工作的,当Activity被系统杀死并恢复时,ViewModel是通过工厂重新创建的,SavedStateHandle里的数据会自动填充进去。写ViewModel的时候,只要处理UI时读取SavedStateHandle就能拿到正确的初始数据。这套机制比在onCreate里判断bundle是null还是非null优雅得多。
2. MVVM进阶:状态管理是核心难点
MVVM做得好不好,最后拼的就是状态管理。怎么定义状态、状态如何流转、如何避免状态覆盖和内存泄漏,这些才是高频面试里真正能区分水平的问题。
2.1 LiveData和StateFlow怎么选
这个问题我每次面试几乎都会问。简单回答"LiveData更简单,StateFlow功能更强"是不够的,要能说出根本差异。
LiveData的优势是生命周期感知,Activity不可见时不会回调观察者。缺点也很明显:不能做复杂的流变换,不支持协程的能力,数据更新是粘性的,意味着新注册的观察者会立刻收到当前值(有时候是好事,有时候是噩梦)。
StateFlow是协程家族的产物,支持map、filter、flowOn等操作符,没有粘性问题或可以用SharedFlow定制行为,配合repeatOnLifecycle可以精确控制收集时机。
我的项目里目前基本是全量切到StateFlow的,配合生命周期安全收集:
class MainViewModel : ViewModel() { private val _uiState = MutableStateFlow<MainUiState>(MainUiState.Loading) val uiState: StateFlow<MainUiState> = _uiState.asStateFlow() fun loadData() { viewModelScope.launch { _uiState.value = MainUiState.Loading val result = repository.getData() _uiState.value = MainUiState.Content(result) } } } class MainFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> render(state) } } } } }注意这里用了viewLifecycleOwner而不是this,这是个很经典的坑。Fragment的this生命周期是Fragment本身的,而viewLifecycleOwner才是跟着视图走的。当从返回栈里回来时,旧的view被销毁,如果用this去collect,轻则重复回调,重则空指针崩溃。
2.2 状态和事件必须分开
这是MVVM里最容易犯的设计错误。很多人用LiveData或StateFlow同时承载数据和一次性事件,比如Toast、跳转、Snackbar。结果每次页面重建时事件都被重复消费一遍。正确的思路是:状态是持久的,事件是一次性的。
解决事件重复消费问题的几个常见方案:
- 用
SingleLiveEvent(老方案,空值时消息丢失,但不适合现在的协程体系) - 用
Channel的receiveAsFlow()(Flow的冷流特性天然避免重放,但要小心多收集者问题) - 用
SharedFlow的replay = 0(推荐,复用协程体系,没有额外封装成本)
class DetailViewModel : ViewModel() { private val _events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1) val events: SharedFlow<UiEvent> = _events.asSharedFlow() fun onSubmit() { viewModelScope.launch { val success = repository.submit() if (success) { _events.emit(UiEvent.ShowToast("提交成功")) } } } }这里用extraBufferCapacity是为了应对生产速度略大于消费速度的场景。如果不用缓冲,emit会挂起等待收集者,界面还没准备好时就可能卡住。
2.3 协程在ViewModel中的正确姿势
ViewModel里我一般是禁止裸用launch启动GlobalScope协程的,必须绑定viewModelScope。原因很简单,viewModelScope在ViewModel被清空时会自动取消所有子协程,不会造成泄漏。这在后台网络请求完成时Activity早已销毁的场景下非常关键。
写网络请求加缓存的时候也建议用flow包一层,让数据流完全可控:
fun loadUserInfo(userId: String): Flow<UiState<UserInfo>> = flow { val cache = repository.getUserFromCache(userId) emit(UiState.Content(cache)) val remote = repository.getUserFromRemote(userId) emit(UiState.Content(remote)) }.flowOn(Dispatchers.IO)flowOn(Dispatchers.IO)让数据加载全程在IO线程执行,切换到主线程只需要在collect时指定即可。这样网络库的线程切换也可以不用了,ViewModel层完全不知道线程的存在,干净很多。
3. Clean架构落地:分层不是目的,解耦才是
Clean架构这个词在Android圈有点被神化了。我见过很多团队花大力气把项目拆成三层甚至四层,结果业务变更时每一层都要改,反而比之前更累。Clean架构的核心价值是依赖规则,不是分的层越多越好。具体落地要先想清楚你的业务到底需要哪几层,边界在哪里,别为了架构而架构。
3.1 依赖方向和数据流方向必须区分
Clean架构的第一铁律是:源码层的依赖只能指向内层。也就是外层依赖内层,内层绝不依赖外层。用Android实际例子来说,就是data模块可以依赖domain的接口,但domain不能反向依赖data的具体实现。
看看一个模块内部怎么组织子模块,通常的做法是三层:
- domain层:定义UseCase、Repository接口、实体模型
- data层:实现Repository接口,处理网络、数据库、缓存逻辑
- presentation层:ViewModel、Fragment/Activity,只感知domain层的接口
这里最容易出问题的点是实体的定义。很多团队让data层直接返回网络返回的DTO,Presentation层直接操作这个DTO。短期没什么问题,但一旦接口字段结构调整(字段名变了或者结构嵌套变了),整个UI层都要修改。理想的方案是data层把DTO转成domain实体,presentation只依赖domain实体。
class GetUserInfoUseCase( private val repository: UserRepository ) { suspend operator fun invoke(userId: String): UserInfo { return repository.getUserInfo(userId) } } interface UserRepository { suspend fun getUserInfo(userId: String): UserInfo }GetUserInfoUseCase只依赖接口UserRepository,具体实现是在data层做的。以后想把本地缓存换成Room或者DataStore,只需要更换data层的实现,domain和presentation层一行都不用动。
3.2 UseCase设计的原则和防过度设计
我见过太多滥用UseCase的情况,一个简单的用户登录功能,拆成ValidateUsernameUseCase、ValidatePasswordUseCase、LoginUseCase、SaveLoginStateUseCase。每个UseCase只有几行代码,看起来职责单一,实际上大部分都是无效封装,徒增类数量和维护成本。
我的经验是,以下场景才值得抽UseCase:
- 一个业务动作同时依赖多个Repository
- 一个业务动作在多处被复用
- 该业务动作有独立的测试需求
单一Repository能搞定的逻辑,直接放在ViewModel里调用Repository就行。UseCase的粒度需要控制在"解决一个业务问题"这个级别,而不是“解决一个操作步骤”这个级别。
3.3 Repository模式如何隐藏数据来源
Repository模式的核心价值是让上层完全不知道数据来自本地还是远端。这个价值要真正体现出来,接口设计就特别重要。
我建议Repository接口返回的是Flow或者suspend函数,不要让上层自己选择数据源。比如:
interface NewsRepository { suspend fun getNewsList(forceRefresh: Boolean = false): Result<List<News>> }实现里可以自由决定是先读缓存还是先请求网络,缓存过期时间是多少,网络失败时是否回退到缓存。上层不需要感知这些策略。这个设计的变化能力是极强的,做到了"换数据源不换UI,换缓存策略不换上层"。
还有一点很容易被忽略,Repository的返回类型不要直接返回裸的实体。建议用Result<T>或者自定义的ApiResult<T>包装一下,把错误信息、异常类型等语言原样传递给上层。这样上层的错误提示才能做得精细,比如弱网提示、服务端异常提示、超时提示等。
4. 模块化改造:拆分粒度与依赖管理
模块化和组件化经常混着说,但它们其实是两码事。模块化是代码组织方式上的拆分,组件化是在模块化之上进一步把业务模块解耦成可独立编译的组件,通信靠路由或接口抽象。现在的中大型项目,基本都会走到组件化阶段。你问的高频架构问题里很大一部分跟这个有关。
4.1 模块拆分的边界怎么定
模块划分没有绝对标准,但有几个原则可以帮你做判断:
- 边界清晰:每个模块的职责用一句话能讲清楚,讲不清楚就是没划分好
- 复用优先:被多个上层模块依赖的公共能力,下沉到基础模块
- 依赖单向:业务模块只依赖基础模块,业务模块之间不互相依赖
- 独立编译:模块的代码量和编译时间要控制,太大的模块要继续拆
举个例子,一个电商App常见的模块划分是这样的:
app ├── base(基础库,网络、工具、通用UI组件) ├── widget(自定义组件) ├── router(路由抽象) ├── service(跨模块接口抽象) ├── business_home(首页) ├── business_category(分类) ├── business_cart(购物车) ├── business_order(订单) └── business_user(个人中心)business模块之间不直接依赖,跨模块调用通过路由和接口下沉到service模块。在实际落地时,我比较推荐先按业务线拆,再把通用的网络、图片、基础UI组件拆出去。如果你的项目连业务线都不清晰,直接一上来就模块化,大概率会变成一场混乱。
4.2 跨模块通信的两条路线
跨模块通信常见有两套方案,一套是路由框架,另一套是接口下沉。
路由框架的思路是,每个模块注册自己的页面路径,跳转时通过路由URL找到目标页面。ARouter是早年用得最多的。这种思路适合页面跳转,但跨模块调用方法时必须搭配接口下沉一起用,否则没法传复杂的对象。
接口下沉是另一个思路,把要调用的能力定义成接口,放在公共的service模块,然后由具体的业务模块实现。调用方只依赖接口不依赖实现,运行期通过服务定位找到实现类。配合路由表可以实现接口和实现类的动态绑定。
我的实际经验是,没有完美的跨模块方案。你需要在"框架侵入性"和"跳转灵活性"之间做取舍。比如同个App内的页面跳转,其实可以不用ARouter,直接用编译期生成的路由表,结合Intent的setClass实现,省掉ARouter那些注解处理和依赖注入部分,入侵性更低。但如果你的项目有大量的动态下发页面需求,那ARouter这类方案的价值就体现出来了。
具体可以参考我做路由模块的一个简化设计:
interface RouterService { fun navigate(context: Context, path: String, args: Bundle?) } // 业务模块注册自己的页面路径 class UserModuleRouter : RouterService { override fun navigate(context: Context, path: String, args: Bundle?) { when (path) { "/user/detail" -> context.startActivity(Intent(context, UserDetailActivity::class.java).putExtras(args)) } } }接口下沉配合路由表管理起来比ARouter写注解更直观,也更容易审查。
4.3 Gradle脚本和版本管理自动化
模块一多,Gradle脚本就成了容易出问题的地方。每个模块build.gradle里复制粘贴依赖,版本号满天飞,升级依赖时改得头大,这就是模块化之后典型的烦恼。
解决思路核心是“约定优于配置”,把公共配置抽出来。我通常用:
gradle/libs.versions.toml管理所有依赖版本,统一入口- 自定义插件统一配置compileSdk、minSdk、targetSdk、Java版本
- 模块里只声明自己的依赖,不再关心版本号
拿版本目录的配置举例,在gradle/libs.versions.toml中:
[versions] kotlin = "2.0.20" coroutines = "1.8.1" [libraries] kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }模块里引用时:
dependencies { implementation(libs.coroutines.android) }这套方案能有效避免依赖版本冲突的问题。最早期我踩过的坑,最头疼的就是子模块依赖某个库A的1.0版本,主模块又强制了另一个库B依赖的库A的2.0版本,编译期一脸懵。用版本目录统一管理以后,这类问题直接消失。
4.4 模块化常见的资源冲突和代码边界控制
模块化后最容易出现的隐蔽问题是资源名冲突,不同模块的同名layout、drawable会在Merge时互相覆盖,而且很难发现。resourcePrefix是官方提供的解决方案,在模块的build.gradle里配置:
android { resourcePrefix "user_" }这样这个模块的资源只能以user_开头,从源头避免冲突。不过要注意,resourcePrefix只对资源名生效,对代码里硬编码的字符串没法限制。所以最佳实践是配合代码规范一起用,命名必须以模块名开头。
代码边界控制上,我推荐在模块化的同时引入ArchUnit或者自定义的lint规则,禁止跨模块直接依赖。比如user模块的类不允许直接import order模块的类。这套规则虽然刚开始会增加一些维护成本,但长期收益很大,尤其多人协作、人员流动频繁的时候,架构边界才能守住。
还有一个小技巧是用Kotlin的internal可见性修饰符,把模块内部的实现类标记为internal,对外只暴露少量公共接口。这样模块的对外API会窄很多,跨模块调用只能走你开放的那些入口,避免被别人东拼西凑地访问内部类。
5. MVVM、Clean、模块化三者融合的项目样板
单独说MVVM、Clean、模块化都很清晰,但放到一个真实项目里,三者怎么配合才最关键。我最终常推荐的项目分层结构大概是这样:
5.1 推荐的整体分层结构
下面是总结的工程结构参考:
app ├── base │ ├── core(网络引擎、数据库、工具类、通用UI组件) │ └── widget(业务无关的自定义View) ├── service │ ├── UserService(接口定义) │ ├── OrderService(接口定义) │ └── RouterService(路由表) ├── data │ ├── local(本地数据库、DataStore) │ ├── remote(网络API) │ └── repository(接口实现) ├── domain(UseCase和领域实体) └── business_xxx(各业务模块,内部按MVVM组织)业务模块内部的MVVM组织方式是核心,业务模块内的结构通常保持:
ui(Fragment、Adapter、自定义View)viewmodel(ViewModel、UiState、UiEvent)model(业务数据模型)repository(面向本模块的Repository接口或实现)
业务流程流转上,UI层通过ViewModel调用Domain层的UseCase,UseCase再通过Repository接口获取数据。如果这个业务模块的Repository实现依赖了data模块的具体接口,那是正常的,因为data模块本身是跨业务共享的基础能力。
5.2 跨模块调用时推荐怎么设计
假如个人中心模块需要展示用户订单数量,不能直接依赖order模块的类。正确做法是:在service模块定义OrderService接口,order模块实现,user模块依赖service接口调用。
// service模块 interface OrderService { fun getUserOrderCount(userId: String): Flow<Int> } // order模块实现 class OrderServiceImpl : OrderService { override fun getUserOrderCount(userId: String): Flow<Int> { // 查询订单表,返回数量 } }很多团队会为了贪图方便,直接让user模块依赖order模块,理由是“反正order模块肯定被其他模块依赖”,但实际上这样一旦order模块改动,其他模块都要跟着重新编译。时间久了,模块化就退化成“高耦合的模块堆叠”。所以在模块化改造过程中,我一直强调接口下沉必须尽早做,甚至可以说是改造的第一步。
5.3 架构落地时的性能影响要注意
Clean架构的多层抽象确实会带来一部分性能开销。每一层多一次函数调用和数据转换,在低端安卓机上打开一个大列表页面,可能感受到几毫秒的延迟。不过好消息是,绝大多数业务场景下,这只占页面加载耗时的一小部分,真正要关注的是不要在架构层做多余的数据复制。
常见的性能误区是UseCase直接调用Repository并返回Flow后,ViewModel又把这层Flow转换成另一层Flow,中间做各种map、filter。正确的思路是,一条链路尽量保持数据流的完整和最小。UseCase可以不做过多中间操作,直接返回Repository的结果。presentation层再进行最终的业务组装和状态映射。
如果项目的页面数据链路比较复杂,我建议使用一个简单的reactor模式做状态聚合,把多个源的数据合并成一个UiState。这样UI层只需要感知一个状态对象,所有数据同步问题都集中在ViewModel内部解决。
6. 架构面试高频问题速查清单
这部分把面试里最高频、也最容易回答浅的问题做个速查,建议结合上文内容一起理解,不要死记硬背。
6.1 高频清单
| 面试问题 | 核心回答要点 |
|---|---|
| MVVM和MVC的区别 | 数据驱动UI,ViewModel持有状态,View只做渲染和事件传递 |
| LiveData和StateFlow的区别 | 生命周期感知、粘性、操作符、协程支持 |
| ViewModel为什么能存状态 | ViewModelStore+状态保存,配置变更不会销毁 |
| Clean架构各层职责 | domain只做业务规则,data做数据来源,presentation做UI适配 |
| Fragment里ViewModel的注意事项 | 用viewLifecycleOwner管理收集,避免重复调用和空指针 |
| 模块化和组件化的区别 | 模块化是代码拆分,组件化是模块加路由解耦成独立编译单元 |
| 多个模块间如何通信 | 接口下沉+路由,不用直接依赖实现类 |
| 模块资源冲突怎么解决 | resourcePrefix+命名规范,代码边界用lint保障 |
6.2 面试官想听什么
这些问题的背后,面试官其实想确认三件事:
- 你是不是真的在大型项目里亲手处理过这些问题,而不是只背了八股
- 你遇到方案取舍时是否有自己的判断依据,比如为什么选StateFlow而不选LiveData、为什么不用ARouter
- 你踩过什么坑、如何排查和解决的,能说出原因才能证明理解深度
我个人在面试别人时,最怕听到“我们项目用的就是MVVM”这种回答。因为没有任何一个架构可以解决所有问题,架构是权衡的产物。有没有因为用Clean架构导致开发效率降低而重构回去?模块化之后有没有遇到依赖循环?StateFlow遇到页面重建时状态重放怎么处理?这些追问更能反映真实经验。
7. 总结我的架构落地心得
聊到最后,说一点个人的真实体会。我见过不少团队在架构上走了极端:一种是不做设计,Activity几百行继续堆;一种是过度设计,一个Hello World项目也要拆六个模块。这两种我都劝过。架构不是越高大上越好,是为了解决具体问题存在的。业务复杂度低的时候,简单分层足够;业务复杂度上来以后,再做模块化和Clean架构。
如果你所在的项目还没有任何架构,我建议的第一步不是强行上全套MVVM加Clean加模块化,而是先把Activity瘦身,把业务逻辑挪到ViewModel,把网络和缓存封装成Repository,先让代码依赖方向理顺。这一步做完,项目通常已经能获得一大半的架构收益。第二步再考虑模块化拆分,最后一步才是引入Clean架构的完整分层。
如果你正在准备架构类面试,建议重点把本文里的示例代码亲手敲一遍,理解每个设计背后的为什么。面试时能讲清楚"为什么这样设计""代价是什么",比背答案要有力得多。架构这东西,纸面上看永远简单,真正落地才知道坑有多深。动手做一做,比看十篇文章都有用。