ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android架构实战:MVVM、Clean架构与模块化改造全解析

Android架构实战:MVVM、Clean架构与模块化改造全解析 好聊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持有自己的ViewModelclass 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.getString(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 MutableStateFlowMainUiState(MainUiState.Loading) val uiState: StateFlowMainUiState _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 MutableSharedFlowUiEvent(extraBufferCapacity 1) val events: SharedFlowUiEvent _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): FlowUiStateUserInfo 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层直接返回网络返回的DTOPresentation层直接操作这个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): ResultListNews }实现里可以自由决定是先读缓存还是先请求网络缓存过期时间是多少网络失败时是否回退到缓存。上层不需要感知这些策略。这个设计的变化能力是极强的做到了换数据源不换UI换缓存策略不换上层。还有一点很容易被忽略Repository的返回类型不要直接返回裸的实体。建议用ResultT或者自定义的ApiResultT包装一下把错误信息、异常类型等语言原样传递给上层。这样上层的错误提示才能做得精细比如弱网提示、服务端异常提示、超时提示等。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层的UseCaseUseCase再通过Repository接口获取数据。如果这个业务模块的Repository实现依赖了data模块的具体接口那是正常的因为data模块本身是跨业务共享的基础能力。5.2 跨模块调用时推荐怎么设计假如个人中心模块需要展示用户订单数量不能直接依赖order模块的类。正确做法是在service模块定义OrderService接口order模块实现user模块依赖service接口调用。// service模块 interface OrderService { fun getUserOrderCount(userId: String): FlowInt } // order模块实现 class OrderServiceImpl : OrderService { override fun getUserOrderCount(userId: String): FlowInt { // 查询订单表返回数量 } }很多团队会为了贪图方便直接让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的区别数据驱动UIViewModel持有状态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架构的完整分层。如果你正在准备架构类面试建议重点把本文里的示例代码亲手敲一遍理解每个设计背后的为什么。面试时能讲清楚为什么这样设计代价是什么比背答案要有力得多。架构这东西纸面上看永远简单真正落地才知道坑有多深。动手做一做比看十篇文章都有用。
返回列表