技术债务救火实录:我是怎么把老项目“扶起来”的
引子:那个让我半夜爬起来的电话

还记得那个周五下午,我刚泡好咖啡准备下班,项目经理一脸凝重地走到我面前:“兄弟,你能不能看一下上个月上线的新版本?用户反馈卡顿得不行……”
我心里咯噔一下,这活儿是我接手的老项目重构的一部分。说实话,这个项目我已经看了两年多了,从Swift 3一路磕到现在的SwiftUI混编时代,中间还经历过两次产品经理的大刀阔斧改需求。
可这次不一样,问题出在了主线程卡死和内存泄漏上——这是典型的技术债爆炸后的症状。我一边看日志一边骂自己:当初为啥为了上线赶进度,把一些复杂的View Controller扔进了“未来再说”的抽屉里?
于是接下来的一个月,我开始了一场与技术债务的正面硬刚。这篇文章想跟大家分享下这场战役的过程、我的思路、踩过的坑,以及最后我们是怎么把这个项目“扶”起来的。
背景:一个老项目的前世今生

这个App最初是在2017年做的,用的是Objective-C写的MVC架构。后来慢慢转成Swift,但很多地方为了方便就混用了Swift + Objective-C的方式,ViewController动辄上千行代码,网络请求全靠单例,模型解析随便塞进各种extension,甚至还有类似:
let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f
}()
这种写法嵌在了ViewController中,随处可见……
到了2022年,团队决定要大改版,目标是用SwiftUI替代部分页面,同时引入Combine进行响应式编程。想法很好,但落地的时候却因为人手紧张、排期压力,变成了“哪快用哪”,最终出现了UIKit混编SwiftUI、ViewModel乱套、状态共享混乱等一系列问题。
新版本上线后,崩溃率上升了3倍,用户反馈卡顿严重,性能优化成了燃眉之急。
挑战:不是Bug而是系统性的技术债爆发


当我去翻日志时,发现几个致命问题:
1. 大量的强引用循环(retain cycle)
很多ViewModel持有ViewController、或者反过来。例如:
class MyVC: UIViewController {
private let viewModel = MyViewModel()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.dataPublisher
.sink { [weak self] data in
self?.updateUI(data)
}
.store(in: &cancellables)
}
}
看起来没问题,但实际情况是viewModel本身又被其他manager持有,形成了闭合引用链。这样的结构在列表页频繁跳转时非常容易内存暴涨,导致OOM(内存溢出)。
2. 不加节制的主队列同步操作
为了“安全起见”,不少逻辑被丢到了:
DispatchQueue.main.async {
// 啥都往主线程丢
}
结果主线程被堵死,动画掉帧严重。有些页面甚至需要等待3秒才能滑动,用户直接差评走人。
3. 架构不清晰,状态管理混乱
比如有些数据通过NotificationCenter传值,有些又用UserDefaults临时存值,更有甚者用全局变量做状态共享。这直接导致页面跳转时数据更新不及时,出现“界面和状态不同步”。
4. 图片加载未经优化
图片使用的是URLSession手动下载再赋值UIImageView.image,没有占位图、也没有缓存策略。结果就是大量并发图片请求打爆主线程,滚动卡顿明显。
解法:一场渐进式的“手术”
面对这些问题,我没有选择推倒重来——一是时间不允许,二是风险太大。所以,采取了一个渐进式改造方案,具体如下:
一、建立监控体系(先看清病情)
第一步是接入了MetricKit,收集Crash Report和App启动、渲染性能数据。然后在本地搭建了一套轻量级的日志追踪系统,记录主线程阻塞点和大对象释放情况。
举个例子,我们封装了一个简单的BlockTracker:
class MainThreadLogger {
static func track(block: () -> Void) {
let start = Date.timeIntervalSinceReferenceDate
block()
let duration = Date.timeIntervalSinceReferenceDate - start
if duration > 0.1 {
print("⚠️ 主线程耗时操作,耗时:\(duration) 秒")
}
}
}

虽然简单,但能快速定位卡顿源头。
二、分阶段重构核心模块(边修边跑)
1. 网络层统一 + Combine化
旧项目中的网络层是多个分散的Manager类,每个都单独处理错误码。我们提取了一个统一的NetworkService抽象协议,并基于Combine做了统一封装:
protocol NetworkService {
func request<T: Decodable>(_ endpoint: APIEndpoint) -> AnyPublisher<T, Error>
}
这样不仅降低了耦合,还让错误处理可以统一拦截(比如token过期跳登录页)。
2. ViewModel去持有化
我们将大部分ViewModel从被ViewController创建,改为由Coordinator创建并传递进去。避免相互持有,同时支持复用。
例如:
final class ProductDetailCoordinator {
func showProductDetail(productID: String) {
let vm = ProductDetailViewModel(productID: productID)
let vc = ProductDetailViewController(viewModel: vm)
navigationController.pushViewController(vc, animated: true)
}
}
并且强制要求在ViewModel内部不要持有ViewController,只暴露输出数据流供观察,实现了更清晰的数据流向。
3. 内存泄漏排查利器 —— LeakCanary + Xcode Instruments
我们结合LeakCanary和Xcode的Instruments工具进行了逐页面排查。对于复杂页面(比如带CollectionView的大屏首页),我们做了:
- Cell重用机制检查
- 异步加载内容
- 避免在cellForItemAt中直接发起网络请求
4. 性能优化专项 —— 图片懒加载 + 占位符
对所有图片加载进行了专项优化:
- 使用
SDWebImage做缓存管理 - 设置合理的内存+磁盘缓存大小
- 添加默认占位图
- 滚动时暂停非可见区域加载
效果立竿见影,FPS从原先的18帧提升到了55帧左右。
收官:从崩边缘重回稳定状态
经过为期一个月的集中攻坚,我们完成了以下成果:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 崩溃率 | 0.8% | 0.12% |
| 启动时间 | 平均3.6s | 平均1.9s |
| 页面卡顿率 | 23% | <4% |
| 用户评分 | 3.7⭐ | 回升至4.5⭐ |
最欣慰的是,QA同事说现在压测基本不挂了。而且我们在重构过程中沉淀出了一套组件库和架构规范,为后续开发节省了不少沟通成本。
经验总结:给同行们的几点建议
1. 别怕动旧代码,但得讲究方法
老项目不一定代表“屎山”,有时候只是结构不够清晰。我们可以一点点拆解、重构,只要坚持“功能不变动、接口先定义、逐步替换”的原则,就能实现平稳过渡。
2. 监控必须提前埋点,别等出事才后悔
MetricKit + Firebase Crashlytics 是必备组合。尤其上线初期,务必要盯着关键路径的崩溃率和性能指标。
3. 少写"偷懒式"代码,多想扩展性
比如像这种“一行函数封装全局formatter”的做法,看似省事,其实埋雷。应该考虑是否有必要封装成工具类、是否要隔离作用域,甚至是做成SwiftUI的Extension。
4. 合理评估技术债优先级
技术债并不等于都要“立刻还”。我们要区分“影响用户体验的债” vs “结构混乱但不影响运行的债”。前者优先级高,后者可以在迭代中慢慢改善。
5. 文档不能少,即使只有你一个人看
重构过程中我们写了详细的技术文档(哪怕只是Markdown),包括:
- 各个模块职责说明
- 数据流图示
- 架构决策笔记(ADR) 这些帮助新人快速理解项目结构,也为我们后续维护提供了依据。
结语:技术债是磨人的小妖精,也是成长的契机
在这次救火经历中,我重新认识了“技术债”这三个字的含义。它不只是代码坏味道那么简单,更是整个项目的健康度预警。
回过头来看,那些为了赶工而留下的“临时解决方案”,往往都会成为未来的“技术陷阱”。但只要我们愿意花时间回头修补,它们也能变成推动项目走向稳定的转折点。
所以,如果你现在也正在和一个“老项目”斗智斗勇,别灰心。慢慢来,一点一点改,它终会从拖后腿的存在变成你的战斗力来源。
毕竟,真正的工程能力,从来都不是“造新东西”有多快,而是“修旧项目”有多稳。
🐱 最后送大家一句话共勉:“不怕慢,只怕站。”
一起加油吧,iOS界的修理工们!🔧📱

评论 0