系列三:组件化与模块化进阶 | 第12篇
老项目重构实战(绞杀者模式):从单体巨石到组件化架构的无痛迁移
阅读警告
本文为超深度技术长文,预计阅读时长 50-70 分钟,代码量极大。
在前11篇中,我们搭建了一套完美的 组件化架构蓝图 。
但是,"造轮子"和"换轮子"是两码事 。
你现在面对的是一个 运行了 3 年、50 万行代码、200 个页面、每天百万级 DAU 的单体工程 。
老板说:"业务不能停,双十一大促不能崩,团队还要并行开发新需求。"
这时候,你不能重写(重写必死),只能用 绞杀者模式(Strangler Fig Pattern) 。
本文将提供一套 企业级老项目重构全案 ,包含 代码迁移策略、数据迁移方案、编译环境配置、灰度发布计划、以及回滚机制 。
全文包含:真实迁移案例、Gradle 脚本、风险管控清单、以及止血方案。
1 引子:为什么"重写"是程序员的坟墓
1.1 重写惨案回顾
2016 年,某知名电商 App 决定重写。
- 预期:3 个月完成,性能提升 50%。
- 现实:做了 9 个月,上线后崩溃率飙升,数据丢失,被迫回滚,团队解散。
重写(Rewrite)的三大死穴:
- 业务断层:重写期间,老 App 还在迭代,新需求两边都要做。
- 细节遗漏:老代码里的 Bug 和 Corner Case 都是"业务资产",重写会丢失。
- 验证困难:新架构上线,没人敢保证和老 App 行为完全一致。
1.2 绞杀者模式(Strangler Fig Pattern)
这是 Martin Fowler 提出的概念,源自绞杀榕。
核心思想 :不推翻老树,而是在旁边种新树,慢慢勒死老树。
#mermaid-svg-fYHjUWPO0Wa8K3Sb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .error-icon{fill:#552222;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .marker.cross{stroke:#333333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fYHjUWPO0Wa8K3Sb p{margin:0;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .cluster-label text{fill:#333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .cluster-label span{color:#333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .cluster-label span p{background-color:transparent;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .label text,#mermaid-svg-fYHjUWPO0Wa8K3Sb span{fill:#333;color:#333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .node rect,#mermaid-svg-fYHjUWPO0Wa8K3Sb .node circle,#mermaid-svg-fYHjUWPO0Wa8K3Sb .node ellipse,#mermaid-svg-fYHjUWPO0Wa8K3Sb .node polygon,#mermaid-svg-fYHjUWPO0Wa8K3Sb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .rough-node .label text,#mermaid-svg-fYHjUWPO0Wa8K3Sb .node .label text,#mermaid-svg-fYHjUWPO0Wa8K3Sb .image-shape .label,#mermaid-svg-fYHjUWPO0Wa8K3Sb .icon-shape .label{text-anchor:middle;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .rough-node .label,#mermaid-svg-fYHjUWPO0Wa8K3Sb .node .label,#mermaid-svg-fYHjUWPO0Wa8K3Sb .image-shape .label,#mermaid-svg-fYHjUWPO0Wa8K3Sb .icon-shape .label{text-align:center;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .node.clickable{cursor:pointer;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .arrowheadPath{fill:#333333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fYHjUWPO0Wa8K3Sb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fYHjUWPO0Wa8K3Sb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fYHjUWPO0Wa8K3Sb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .cluster text{fill:#333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .cluster span{color:#333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fYHjUWPO0Wa8K3Sb rect.text{fill:none;stroke-width:0;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .icon-shape,#mermaid-svg-fYHjUWPO0Wa8K3Sb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .icon-shape p,#mermaid-svg-fYHjUWPO0Wa8K3Sb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .icon-shape .label rect,#mermaid-svg-fYHjUWPO0Wa8K3Sb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fYHjUWPO0Wa8K3Sb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fYHjUWPO0Wa8K3Sb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fYHjUWPO0Wa8K3Sb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 壳工程 (Router)
新组件化工程 (Strangling)
老单体工程 (Legacy)
流量转移
流量转移
流量转移
OrderActivity
LoginActivity
PayActivity
component-order
component-login
component-pay
ARouter
策略:
- 新建壳工程:包含老的 App 壳和新的组件壳。
- 新需求新写法 :所有新需求(如"会员中心")直接在
component-member里写。 - 老代码迁移:每次迭代迁移一个页面(如"登录页")。
- 流量切换 :通过路由配置,把
/login指向新组件,老代码自然废弃。
2 迁移前的准备:止血与隔离
在动刀之前,必须先止血。
2.1 建立"防腐层"(Anti-Corruption Layer)
老代码肯定有"上帝类"(如 AppManager、DataCenter)。
策略 :不动老代码,包一层新接口。
步骤 1 :在老工程中创建 com.example.legacy 包。
步骤 2:把老代码的调用封装成接口。
kotlin
// 老代码(不能动)
class OldLoginManager {
fun login(account: String, pwd: String) { ... }
}
// 防腐层(新建)
interface ILoginService {
fun login(account: String, pwd: String)
}
class LegacyLoginService : ILoginService {
override fun login(account: String, pwd: String) {
OldLoginManager().login(account, pwd) // 调用老代码
}
}
目的:新组件通过接口调用老代码,而不是直接依赖老类。
2.2 依赖梳理与仲裁
用 Gradle 脚本扫描所有依赖。
bash
# 查看依赖树
./gradlew app:dependencies > deps.txt
产出物 :《依赖冲突报告》。
行动:统一第三方库版本(如 Glide、OkHttp、Gson)。
3 迁移实战:一步步绞杀
这是核心环节。我们以 "登录模块" 为例。
3.1 阶段一:新建组件壳
- 创建
component-login模块(Library)。 - 配置
isLoginComponentDebug=false(集成模式)。 - 把
module-base的依赖加进来。
3.2 阶段二:代码迁移(Copy + Refactor)
不要直接剪切(Cut) ,要 复制(Copy)。
步骤 1 :把老工程的 LoginActivity.kt、LoginViewModel.kt、login_layout.xml 复制到 component-login。
步骤 2 :修改包名,加上资源前缀。
步骤 3 :这是最难的一步------解决编译错误。
常见错误与修复:
| 错误类型 | 老代码写法 | 新组件修复 |
|---|---|---|
| Context 引用 | App.getContext() |
注入 Application 或 Context |
| 单例依赖 | DataManager.getInstance() |
通过接口下沉,依赖 IDataService |
| SP 存储 | getSharedPreferences("config") |
使用 DataStore 或统一 SP 文件名 |
| EventBus | EventBus.getDefault().post() |
改为 ARouter 服务调用 |
3.3 阶段三:路由接管
在壳工程中配置路由。
kotlin
// app-shell/AppShell.kt
class AppShell : Application() {
override fun onCreate() {
super.onCreate()
// 判断是否是新登录页
if (BuildConfig.IS_NEW_LOGIN) {
// 新路由
ARouter.getInstance().build("/login/activity").navigation()
} else {
// 老路由(启动老 Activity)
startActivity(Intent(this, com.example.legacy.LoginActivity::class.java))
}
}
}
3.4 阶段四:数据迁移
如果登录模块用了新的 User 数据结构,需要处理老数据。
kotlin
// component-login/LoginViewModel.kt
fun migrateUserData() {
// 1. 读取老 SP
val oldUserId = legacySp.getString("user_id", "")
// 2. 转换为新数据
val newUser = User(oldUserId, ...)
// 3. 存入新数据库
userDao.insert(newUser)
// 4. 标记迁移完成
sp.putBoolean("migrated", true)
}
4 编译环境配置:双工程并行
迁移期间,必须 老工程继续发版,新工程同步开发。
4.1 Git 分支策略
master (线上稳定版)
├── dev (老工程开发)
└── refactor (新组件化工程)
├── component-login
├── component-order
└── app-shell
规则:
dev分支:只修 Bug 和做紧急需求。refactor分支:迁移代码。- 每周将
dev合并到refactor,解决冲突。
4.2 Gradle 配置:新老共存
在 settings.gradle 中同时包含新老代码。
gradle
// settings.gradle.kts
include(":app-legacy") // 老 App
include(":app-shell") // 新壳
include(":component-login")
include(":component-order")
注意:老 App 和新壳不能同时安装在同一台手机上(ApplicationId 冲突),需要通过 Build Variant 切换。
5 灰度发布与回滚机制
这是重构的安全网。
5.1 灰度策略
方案 :开关控制 + 百分比灰度。
在 gradle.properties 中定义开关:
properties
# 登录模块开关
IS_NEW_LOGIN = false
# 订单模块开关
IS_NEW_ORDER = false
在代码中使用:
kotlin
if (BuildConfig.IS_NEW_LOGIN) {
// 走新组件
ARouter.getInstance().build("/login/activity").navigation()
} else {
// 走老代码
startActivity(Intent(this, com.example.legacy.LoginActivity::class.java))
}
灰度流程:
- 内测:开关开给测试同学。
- Alpha:开关开给公司内部员工(1%)。
- Beta:开关开给 5% 用户。
- 全量:开关全开。
5.2 回滚机制
如果新组件崩溃率飙升,必须能 秒级回滚。
方案 :热修复 + 开关关闭。
- 发现崩溃:监控系统报警。
- 关闭开关:
IS_NEW_LOGIN = false。 - 发补丁:用 Tinker 或 Robust 修复 Bug。
- 重新灰度。
注意 :回滚时,要处理 数据兼容性。新组件产生的数据,老代码必须能读。
6 团队协作:如何并行开发
重构期间,团队不能停。
6.1 人员分工
| 角色 | 职责 |
|---|---|
| 重构组 | 负责迁移老代码、搭建新架构。 |
| 业务组 A | 负责老 App 的紧急需求和 Bug 修复。 |
| 业务组 B | 负责新组件的新需求开发(如会员中心)。 |
6.2 沟通机制
- 每日站会:同步迁移进度和冲突。
- Code Review:新组件代码必须经过架构组 Review。
- 文档沉淀:每迁移一个模块,更新《迁移文档》。
7 企业级迁移检查清单(直接复制用)
7.1 迁移前检查
- 建立防腐层(接口封装老代码)。
- 统一第三方库版本。
- 备份老代码和数据库。
- 搭建新组件化工程框架。
7.2 迁移中检查
- 资源前缀已添加。
- 依赖冲突已解决(无 Duplicate class)。
- 路由已配置,老路由可跳转新组件。
- 数据迁移脚本已验证。
- 混淆配置已更新。
7.3 迁移后检查
- 灰度开关已上线。
- 监控告警已配置(崩溃率、ANR)。
- 回滚方案已验证。
- 老代码已删除(绞杀完成)。
8 常见问题与止血方案
Q1:迁移过程中,老需求改动了我要迁移的代码怎么办?
策略 :暂停迁移,或者复制分支。
- 在老工程中改需求。
- 把改动同步到新组件。
- 如果改动太大,推迟该模块的迁移。
Q2:新组件依赖的老单例太多,解不开怎么办?
策略 :妥协方案 。
暂时允许新组件依赖老单例,但要标记 @Deprecated。
等所有模块迁移完后,再统一重构单例。
Q3:上线后数据丢了怎么办?
策略 :双写机制 。
迁移期间,同时写老数据库和新数据库。
验证数据一致后,再切断老数据库。
9 总结:重构是一场马拉松
重构不是冲刺,是马拉松。
绞杀者模式 的核心在于 "渐进式替换"。
- 不动存量:老代码能跑就别动。
- 做增量:新需求在新架构上做。
- 慢慢切:一个页面一个页面地迁移。
- 有退路:开关和回滚机制必须随时待命。
至此,我们的《Android 架构 & 框架进阶》系列一(架构思想)、系列二(MVVM 深度实战与项目重构)、系列三(组件化与模块化)全部完结。
回顾我们走过的路:
- 思想篇:搞懂了 MVC/MVP/MVVM,建立了分层思维。
- 实战篇:封装了 Base 类,搞定了 ViewModel 和 StateFlow。
- 组件化篇:拆了工程,配了路由,优化了 Gradle。
- 重构篇:用绞杀者模式无痛迁移了老项目。
下一篇预告 :
系列四:主流开源框架源码与实战进阶 | 第13篇:OkHttp 与 Retrofit 源码深度剖析
我们将深入 拦截器链、连接池复用、动态代理,彻底搞懂网络层的原理,并教你如何定制企业级网络框架。
如果你正在面对一个巨大的单体工程,请立即开始执行"绞杀者模式"。不要重写,要替换。