news 2026/9/23 13:03:52

Android老项目分层架构改造:端口与适配器模式实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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): Flow<User> }

适配器在 Data 层实现这个接口,内部可以自由选择用 Retrofit 调接口、用 Room 读数据库、或者两者结合做缓存策略。Domain 层的 UseCase 只依赖UserRepository接口,完全不关心数据从哪来。

这里有个关键细节:端口的粒度要适中。太粗了会导致一个接口几十个方法,实现类臃肿不堪;太细了又会出现大量小接口,依赖注入配置起来很繁琐。我的经验是按业务聚合来划分,一个聚合根对应一个 Repository 端口,比如 User、Order、Payment 各自独立。

2.3 依赖注入为什么选 Hilt

Hilt在 Android 社区已经是事实标准了,相比 Dagger 它简化了大量样板代码,相比 Koin 它能在编译期发现依赖问题。对于老项目改造来说,Hilt 最大的优势是支持渐进式迁移——你可以先给新模块加注解,老代码继续手动创建依赖,两者互不干扰。

我在 Application 类上加@HiltAndroidApp,在 Activity 上加@AndroidEntryPoint,ViewModel 用@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 暴露一个StateFlow<UiState>,Composable 里用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): Flow<OrderStatus> }

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 暴露StateFlow<UiState>,Composable 订阅这个 Flow 并渲染 UI,用户操作通过回调传给 ViewModel,ViewModel 更新状态,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(),避免每次状态更新都创建新对象导致整个列表重组。

另外,rememberderivedStateOf要合理使用。remember缓存计算结果,derivedStateOf在依赖的状态变化时才重新计算。但也不要滥用,有些计算很轻量,直接算反而更简单。

4.3 老代码迁移过程中的兼容问题

老项目里有很多静态工具类和单例,直接在新架构里用会破坏依赖注入的原则。我的处理方式是:先给这些工具类包一层接口,放到 Domain 层,然后在 Data 层实现一个适配器,内部调用原来的静态方法。这样新代码通过接口调用,老代码继续用静态方法,两边互不影响。等所有调用方都迁移完了,再把老工具类删掉。

4.4 常见问题速查表

问题现象可能原因排查方向
Hilt 编译报 MissingBinding接口无绑定或作用域不匹配检查 Module 的 @Binds 和 @InstallIn
Compose 页面闪烁状态更新过于频繁检查 UiState 是否不可变,是否用了 remember
ViewModel 注入失败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。我的计划是每次有较大需求变更时顺手迁移,不专门排期做纯技术重构。这样业务价值和技术改进同步推进,更容易获得产品经理和上级的支持。

提示:架构改造不是一次性任务,而是一个持续演进的过程。关键是建立起一套团队认可的规范,让新代码自然遵循,老代码逐步收敛,而不是追求某一天突然“改造完成”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 12:54:11

火狐国际版从安装到调试:版本详解、扩展绕过与麒麟系统实战

如果你平时喜欢折腾浏览器&#xff0c;应该会注意到最近“火狐国际版”这几个字的热度一直不低。搜索词里能看到大量类似的困惑&#xff1a;国际版和国产版到底差在哪、115ESR为什么那么多人专门找安装包、新标签页到底怎么让它默认在右边、扩展商店里好好的插件为什么提示地区…

作者头像 李华
网站建设 2026/9/23 12:50:01

YOLOV5自动驾驶道路目标检测11类别数据集使用全攻略

简介&#xff1a;面向自动驾驶场景的目标检测与YOLOv5格式数据准备需求&#xff0c;这份资源提供大型道路信息检测的标注数据集&#xff0c;覆盖卡车、行人、交通信号灯、车辆等11个类别&#xff0c;适用于多目标与密集场景的模型训练、标签校验和数据增强研究。资源包含训练集…

作者头像 李华
网站建设 2026/9/23 12:49:13

16QAM软解调:LLR计算、Python实现与FPGA移植

简介&#xff1a;正交幅度调制&#xff08;QAM&#xff09;与软解调是数字通信中的关键实现环节。这套基于MATLAB的仿真代码包面向通信工程专业学生、科研人员以及算法工程师&#xff0c;旨在帮助理解QAM调制星座映射、软比特生成及软判决解调的核心原理。压缩包共含8个m文件&a…

作者头像 李华
网站建设 2026/9/23 12:46:23

Windows Duo双轨工作流:交互层与隔离层实战指南

1. 从"Windows Duo"这个名字说起&#xff1a;它到底想解决什么问题第一次看到"Windows Duo"这个标题&#xff0c;我脑子里冒出来的第一个念头是&#xff1a;这大概率不是微软官方的东西。原因很简单&#xff0c;微软的命名体系里&#xff0c;Surface Duo 是…

作者头像 李华
网站建设 2026/9/23 12:46:12

数学与算法的关系:从复杂度分析到机器学习的数学基础

数学和算法的关系&#xff0c;很多人是在刷题刷到一半、或者调参调到怀疑人生的时候才真正意识到的。你写了个快排&#xff0c;跑出来结果不对&#xff0c;查了半天发现是边界条件里的不等式方向反了&#xff1b;你训了个模型&#xff0c;loss 死活不降&#xff0c;最后发现是梯…

作者头像 李华
网站建设 2026/9/23 12:46:08

Python机器学习SVM作业实战:Iris鸢尾花分类从环境配置到实验报告

简介&#xff1a;这份资源面向正在学习机器学习课程、需要完成SVM分类实验的学生与自学者&#xff0c;围绕经典Iris鸢尾花数据集&#xff0c;提供可直接运行的Python源码与配套实验报告&#xff0c;帮助理解支持向量机从数据加载、特征处理到模型训练与评估的完整流程。压缩包共…

作者头像 李华