技术债务救火实录:我是怎么把老项目“扶起来”的

FrontendArtist
2025-06-13 05:09
阅读 637

引子:那个让我半夜爬起来的电话

引子:那个让我半夜爬起来的电话

还记得那个周五下午,我刚泡好咖啡准备下班,项目经理一脸凝重地走到我面前:“兄弟,你能不能看一下上个月上线的新版本?用户反馈卡顿得不行……”

我心里咯噔一下,这活儿是我接手的老项目重构的一部分。说实话,这个项目我已经看了两年多了,从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而是系统性的技术债爆发

挑战:不是Bug而是系统性的技术债爆发

开发工具界面-2

当我去翻日志时,发现几个致命问题:

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

虽然简单,但能快速定位卡顿源头。

二、分阶段重构核心模块(边修边跑)

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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝