Android老项目分层架构改造:端口与适配器模式实战
1. 老项目架构改造的起点与整体思路接手一个跑了三年多的 Android 项目最让人头疼的不是代码量而是那种“改一处、崩三处”的连锁反应。业务逻辑直接写在 Activity 里网络请求、数据库操作、UI 更新搅在一起一个页面动辄上千行。每次加需求都像在雷区里走路测试回归成本极高。这个项目最初只有三四个页面团队想着“先跑起来再说”结果两年后膨胀到四十多个页面技术债已经压得人喘不过气。我决定动手做一次分层架构升级目标很明确把业务逻辑从 UI 层彻底剥离出来让每一层只干自己该干的事。参考对象选了Now in Android这是 Google 官方维护的一个开源示例项目它的架构设计在社区里口碑很好尤其是端口与适配器模式的应用非常适合我这种需要渐进式改造的老项目。为什么选端口与适配器而不是传统的 MVC 或 MVP核心原因在于依赖方向。传统分层里上层直接依赖下层的具体实现数据库换了、网络库换了上层代码全得跟着改。端口与适配器把依赖倒过来业务层定义接口端口基础设施层提供实现适配器业务层不关心你用的是 Room 还是 SQLDelight是 Retrofit 还是 Ktor。这种设计对老项目特别友好因为你可以一个模块一个模块地替换不用一次性重写所有代码。整个改造分四个阶段推进先梳理现有代码的依赖关系再定义各层的端口接口然后逐个实现适配器最后用Hilt把依赖注入串起来。全程用Compose做 UI 层的渐进式迁移老页面继续用 View 体系新页面直接用 Compose两者通过 Navigation 共存。这样风险可控每完成一个模块就能独立验证不会出现“改到一半项目跑不起来”的尴尬局面。注意改造老项目最忌讳“大爆炸式重写”。我的原则是每个提交都必须保证项目可编译、可运行哪怕功能还没迁移完至少不能比改造前更差。2. 分层架构的核心设计拆解2.1 为什么是三层而不是四层很多架构文章喜欢把 Android 项目分成四层甚至五层什么 Presentation、Domain、Data、Framework、Common。听起来很完整但实际落地时你会发现层数越多跨层调用的成本越高代码反而更复杂。我最终选了三层结构UI 层、Domain 层、Data 层。每一层的职责边界非常清晰没有模棱两可的灰色地带。UI 层只负责展示和用户交互包含 Compose 组件、ViewModel、UI State 定义。Domain 层是纯 Kotlin 模块不依赖任何 Android SDK里面放 UseCase、领域模型、端口接口。Data 层负责具体实现包括网络请求、数据库、缓存、文件读写它实现 Domain 层定义的端口但 Domain 层完全不知道 Data 层的存在。这种划分的好处是 Domain 层可以独立做单元测试不需要 Robolectric不需要模拟 Android 环境跑起来飞快。我试过把 Domain 层的测试放在 CI 里整个模块的测试跑完不到三秒这对频繁提交的团队来说体验提升非常明显。2.2 端口与适配器的具体落地方式端口在代码里就是 interface定义在 Domain 层。比如我需要获取用户信息就定义一个UserRepository接口interface UserRepository { suspend fun getUser(id: String): User suspend fun saveUser(user: User) fun observeUser(id: String): FlowUser }适配器在 Data 层实现这个接口内部可以自由选择用 Retrofit 调接口、用 Room 读数据库、或者两者结合做缓存策略。Domain 层的 UseCase 只依赖UserRepository接口完全不关心数据从哪来。这里有个关键细节端口的粒度要适中。太粗了会导致一个接口几十个方法实现类臃肿不堪太细了又会出现大量小接口依赖注入配置起来很繁琐。我的经验是按业务聚合来划分一个聚合根对应一个 Repository 端口比如 User、Order、Payment 各自独立。2.3 依赖注入为什么选 HiltHilt在 Android 社区已经是事实标准了相比 Dagger 它简化了大量样板代码相比 Koin 它能在编译期发现依赖问题。对于老项目改造来说Hilt 最大的优势是支持渐进式迁移——你可以先给新模块加注解老代码继续手动创建依赖两者互不干扰。我在 Application 类上加HiltAndroidApp在 Activity 上加AndroidEntryPointViewModel 用HiltViewModel标注依赖通过构造函数注入。Module 里用Binds把接口和实现绑定起来Module InstallIn(SingletonComponent::class) abstract class DataModule { Binds abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository }这样 Domain 层完全不需要知道 Data 层的类名依赖关系在编译期就确定好了。2.4 Compose 在改造中的角色定位Compose在这个项目里不是必须的但它确实让 UI 层的改造轻松很多。老代码里的 XML 布局和 Activity 逻辑纠缠在一起拆起来很痛苦。新页面直接用 Compose 写UI 和状态分离得很自然ViewModel 暴露一个StateFlowUiStateComposable 里用collectAsStateWithLifecycle()订阅状态变了 UI 自动重组不需要手动调notifyDataSetChanged()。对于老页面我没有强行迁移到 Compose而是先把逻辑抽到 ViewModel 里XML 布局暂时保留。等某个页面下次有较大需求变更时再顺手用 Compose 重写。这种“新老共存”的策略让团队有足够时间学习 Compose不会因为技术栈切换太猛导致进度失控。3. 实操过程与核心环节实现3.1 第一步梳理现有依赖关系动手写代码之前我先用 Android Studio 的 Dependency Analyzer 跑了一遍看看模块之间的依赖有没有循环。结果发现app模块直接依赖了data模块的具体类data模块又反过来依赖app里的某些工具类典型的双向依赖。这种结构不改掉后面做什么分层都是白搭。我的处理方式是新建三个 Gradle 模块:domain、:data、:app。:domain是纯 Kotlin 模块不依赖 Android。:data是 Android Library依赖:domain。:app依赖:domain和:data但只通过接口调用:data的能力。原来app里被data依赖的工具类要么下沉到:domain要么复制一份到:data彻底切断双向依赖。这一步花了大概两天时间主要是处理各种编译错误和 import 调整。但做完之后整个项目的依赖图变得非常干净没有任何循环。3.2 第二步定义 Domain 层的端口Domain 层的端口定义不是拍脑袋想出来的而是从现有业务逻辑里反推。我打开老代码里的 Activity把每个页面的业务操作列出来比如“加载用户列表”、“提交订单”、“更新购物车数量”然后归并同类项抽象成 Repository 接口。以订单模块为例老代码里订单相关的操作散落在三个 Activity 和两个 Fragment 里我整理后发现核心操作就四个创建订单、查询订单详情、取消订单、监听订单状态变化。于是定义interface OrderRepository { suspend fun createOrder(request: CreateOrderRequest): Order suspend fun getOrder(orderId: String): Order suspend fun cancelOrder(orderId: String): Boolean fun observeOrderStatus(orderId: String): FlowOrderStatus }UseCase 层再包一层比如CreateOrderUseCase负责参数校验、调用 Repository、返回结果。UseCase 的好处是每个业务操作都有明确的入口ViewModel 里不再直接调 Repository而是调 UseCase职责更清晰。3.3 第三步实现 Data 层的适配器Data 层的适配器实现要处理很多细节比如网络请求失败后的重试策略、本地缓存的有效期、数据格式的转换。我以OrderRepositoryImpl为例class OrderRepositoryImpl Inject constructor( private val api: OrderApi, private val dao: OrderDao, private val mapper: OrderMapper ) : OrderRepository { override suspend fun getOrder(orderId: String): Order { return try { val remote api.fetchOrder(orderId) dao.insert(mapper.toEntity(remote)) mapper.toDomain(remote) } catch (e: IOException) { val cached dao.queryById(orderId) if (cached ! null) mapper.toDomain(cached) else throw e } } }这里用了一个简单的“网络优先、缓存兜底”策略。实际项目中还可以加内存缓存、过期时间判断、后台刷新等逻辑但核心思路是一样的Domain 层只管调getOrder不关心数据是从网络还是数据库来的。3.4 第四步用 Hilt 串联所有依赖Hilt 的配置集中在几个 Module 里。DataModule负责绑定 Repository 接口和实现NetworkModule提供 Retrofit 和 OkHttp 实例DatabaseModule提供 Room 数据库和 DAO。每个 Module 都用InstallIn指定作用域SingletonComponent 表示全局单例ViewModelComponent 表示跟 ViewModel 生命周期绑定。有个细节需要注意Hilt 的组件作用域要匹配实际生命周期。比如 OkHttpClient 应该是 Singleton整个应用共用一个连接池而某个页面的临时状态应该放在 ViewModel 里不要注入到 Singleton 组件中否则会造成内存泄漏。3.5 第五步Compose 页面的状态管理新页面用 Compose 写的时候状态管理遵循“单向数据流”原则。ViewModel 暴露StateFlowUiStateComposable 订阅这个 Flow 并渲染 UI用户操作通过回调传给 ViewModelViewModel 更新状态Flow 发射新值UI 重组。整个循环是单向的不会出现状态不一致的问题。Composable fun OrderDetailScreen(viewModel: OrderDetailViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() when (uiState) { is Loading - LoadingIndicator() is Success - OrderContent((uiState as Success).order) is Error - ErrorMessage((uiState as Error).message) } }collectAsStateWithLifecycle()会在页面不可见时自动停止收集避免不必要的资源消耗。这个 API 来自lifecycle-runtime-compose库比手动处理生命周期方便很多。4. 常见问题与排查技巧实录4.1 Hilt 编译报错MissingBinding这是改造过程中出现频率最高的问题。Hilt 在编译期检查依赖图如果某个接口没有对应的Binds或Provides就会报MissingBinding错误。排查思路很简单看报错信息里说的是哪个类找不到绑定然后检查对应的 Module 是否写了绑定方法InstallIn的作用域是否正确。有个容易忽略的点如果接口实现类的构造函数有参数那些参数也必须能被 Hilt 提供。比如OrderRepositoryImpl依赖OrderApi那OrderApi就必须在某个 Module 里用Provides提供出来否则会连锁报错。4.2 Compose 重组过于频繁Compose 的重组机制是“状态变了就重组”但如果状态对象太大或者 Composable 里直接读了不稳定的对象会导致大量不必要的重组。我的经验是UiState 尽量用不可变数据类列表用ImmutableList或者persistentListOf()避免每次状态更新都创建新对象导致整个列表重组。另外remember和derivedStateOf要合理使用。remember缓存计算结果derivedStateOf在依赖的状态变化时才重新计算。但也不要滥用有些计算很轻量直接算反而更简单。4.3 老代码迁移过程中的兼容问题老项目里有很多静态工具类和单例直接在新架构里用会破坏依赖注入的原则。我的处理方式是先给这些工具类包一层接口放到 Domain 层然后在 Data 层实现一个适配器内部调用原来的静态方法。这样新代码通过接口调用老代码继续用静态方法两边互不影响。等所有调用方都迁移完了再把老工具类删掉。4.4 常见问题速查表问题现象可能原因排查方向Hilt 编译报 MissingBinding接口无绑定或作用域不匹配检查 Module 的 Binds 和 InstallInCompose 页面闪烁状态更新过于频繁检查 UiState 是否不可变是否用了 rememberViewModel 注入失败Activity 未加 AndroidEntryPoint检查 Activity 和 Fragment 的注解数据库主线程报错Room 查询未切到 IO 线程用 suspend 函数或 Flow 返回依赖循环模块间双向依赖用 Dependency Analyzer 检查并切断4.5 实操心得改造节奏比技术选型更重要我踩过最大的坑不是技术问题而是节奏问题。一开始想一口气把所有页面都迁移到新架构结果改到一半发现某个核心模块的接口设计有问题牵一发动全身不得不回滚重来。后来调整策略每个迭代只迁移一个模块迁移完立刻跑回归测试确认没问题再动下一个。虽然总时间拉长了但风险大大降低团队信心也保住了。另一个心得是不要追求完美架构。Now in Android 的架构很优雅但那是从零开始设计的。老项目改造受限于历史代码不可能做到完全干净。我的原则是“新代码严格遵循架构规范老代码逐步收敛”允许一定程度的过渡期混乱只要整体趋势是向好的就行。5. 改造后的效果与后续扩展方向5.1 可量化的改进指标改造完成后我统计了几个关键指标。Domain 层的单元测试覆盖率从 0 提升到 85%因为纯 Kotlin 模块测试成本极低团队愿意写。编译时间方面由于模块拆分后支持增量编译日常开发中修改 Domain 层代码的编译时间从原来的 40 多秒降到 8 秒左右。Bug 率方面改造后三个月内与业务逻辑相关的线上问题减少了约六成因为逻辑集中在 UseCase 里测试覆盖到位边界情况处理得更完善。5.2 团队协作方式的改变分层架构带来的不只是代码结构的变化还有协作方式的改变。以前前后端接口一改Android 端就要跟着大改因为网络层和 UI 层耦合太紧。现在接口定义在 Domain 层Data 层做适配接口变了只需要改 Data 层的适配器UI 层和 Domain 层基本不动。后端同事改接口时Android 端可以并行开发只要端口接口约定好了就行。5.3 后续可以继续做的优化目前 Data 层的缓存策略还比较粗糙只是简单的“网络优先、缓存兜底”。后续可以引入更精细的缓存失效策略比如按数据类型设置不同的过期时间或者用 WorkManager 做后台定期刷新。另外Domain 层的 UseCase 目前是手动编写的如果业务操作很多可以考虑用注解处理器自动生成 UseCase 模板代码减少重复劳动。Compose 的迁移也还在进行中老页面还有一部分用 XML。我的计划是每次有较大需求变更时顺手迁移不专门排期做纯技术重构。这样业务价值和技术改进同步推进更容易获得产品经理和上级的支持。提示架构改造不是一次性任务而是一个持续演进的过程。关键是建立起一套团队认可的规范让新代码自然遵循老代码逐步收敛而不是追求某一天突然“改造完成”。