聊聊我帮iOS团队做性能优化的一些思考
上周四晚上十一点半,我正准备合上我的MacBook Pro下班,测试妹子跑过来拍了拍我肩膀:"哥,咱们新上的那个活动页,iOS端掉帧掉得跟PPT似的,安卓那边倒是还行,你帮忙瞅瞅呗?"
我当时内心是崩溃的。我一个写了四年Java后端的,平时跟QPS、分布式锁、消息队列打交道,你让我看iOS性能?但没办法,新公司刚入职两个月,团队就这么大,iOS开发老张上周刚请了陪产假,产品经理又在群里疯狂@所有人说这个活动页关系到Q2的KPI。得,硬着头皮上吧。
说来也巧,我最近正好在学AI相关的东西,用Qwen做了一些代码review和性能分析的辅助,没想到这次还真派上用场了。加上之前在外卖那边做高并发系统时积累的一些性能优化思路,底层逻辑其实是相通的——无非就是减少不必要的计算、避免资源争抢、把该异步的异步、该缓存的缓存。
这篇文章就记录一下我这段时间帮iOS团队做性能优化的一些实战经验和架构层面的思考。如果你也是个后端开发,偶尔需要跨界救火,或者你本身就是iOS开发想聊聊性能优化,希望能给你一些启发。
先搞清楚问题到底出在哪
拿到问题,我第一反应不是去看代码,而是先要数据。这跟我做后端性能优化的习惯一样——没有度量就没有优化。
我让测试同学用Instruments跑了一下Time Profiler和Core Animation,顺便我自己也装了Xcode(别笑,Mac上装Xcode还是比Windows上装Android Studio舒服的)。跑出来的数据让我倒吸一口凉气:
| 指标 | 优化前 | 目标值 | 差距 |
|---|---|---|---|
| 列表滑动FPS | 32-38 | 55+ | 严重不达标 |
| 页面首屏渲染耗时 | 2.8s | <800ms | 超标3倍多 |
| 内存峰值 | 480MB | <200MB | 随时可能被OOM |
| 主线程阻塞时长/秒 | 420ms | <50ms | 卡顿元凶 |
好家伙,这数据简直惨不忍睹。FPS掉到30多,用户滑动列表的时候肉眼可见的卡顿。首屏渲染2.8秒,这年头用户等超过1秒就想划走了,2.8秒怕不是要直接卸载。
顺藤摸瓜:定位四大性能杀手
我花了大概两天时间,把整个活动页的代码从头到尾撸了一遍。说实话,Swift的代码读起来还是挺舒服的,语法糖多,写起来简洁。但这个活动页的代码...怎么说呢,感觉是那种"能跑就行"的风格,完全没有考虑过性能。
我把问题归为四类,逐个击破。
1. 列表渲染:Cell复用是个好东西,但你得用对
活动页的核心是一个商品瀑布流列表。我一看代码,好家伙,cellForRowAt里面每次都在做图片解码和富文本计算:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ProductCell", for: indexPath) as! ProductCell
let product = products[indexPath.row]
// 每次滑动都重新计算富文本属性,主线程直接炸了
let attrText = NSMutableAttributedString(string: product.title)
attrText.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: 16), range: NSRange(location: 0, length: product.highlightLength))
attrText.addAttribute(.foregroundColor, value: UIColor.red, range: NSRange(location: 0, length: product.highlightLength))
cell.titleLabel.attributedText = attrText
// 图片直接在主线程解码,还是大图
if let imageData = try? Data(contentsOf: product.imageURL) {
cell.productImage.image = UIImage(data: imageData)
}
return cell
}
这代码有两个致命问题:
第一,NSMutableAttributedString的计算是CPU密集型操作,放在主线程的cellForRowAt里,滑动的时候每帧都要算一遍,FPS不掉才怪。
第二,用Data(contentsOf:)同步加载图片,还是在主线程。这玩意儿不仅阻塞主线程,而且没有做任何缓存,滑来滑去同一张图片要解码无数次。
我的优化方案:
// 优化后:预计算 + 异步图片加载
class ProductCell: UITableViewCell {
// 富文本缓存,避免重复计算
private static var attrTextCache = NSCache<NSString, NSAttributedString>()
func configure(with product: Product) {
// 1. 富文本走缓存
let cacheKey = "\(product.id)_\(product.title)" as NSString
if let cached = ProductCell.attrTextCache.object(forKey: cacheKey) {
titleLabel.attributedText = cached
} else {
let attrText = buildAttributedString(for: product)
ProductCell.attrTextCache.setObject(attrText, forKey: cacheKey)
titleLabel.attributedText = attrText
}
// 2. 图片用Kingfisher异步加载,自带缓存
productImageView.kf.setImage(
with: product.imageURL,
options: [
.processor(DownsamplingImageProcessor(size: productImageView.bounds.size)),
.scaleFactor(UIScreen.main.scale),
.cacheOriginalImage
]
)
}
private func buildAttributedString(for product: Product) -> NSAttributedString {
let attrText = NSMutableAttributedString(string: product.title)
attrText.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: 16),
range: NSRange(location: 0, length: product.highlightLength))
attrText.addAttribute(.foregroundColor, value: UIColor.red,
range: NSRange(location: 0, length: product.highlightLength))
return attrText
}
}
这里用了NSCache做富文本缓存,图片加载换成了Kingfisher,还加了DownsamplingImageProcessor做图片降采样。降采样这个很关键——原图可能2000x3000像素,但你Cell上的ImageView也就100x150,不解码全尺寸图片能省大量内存和CPU。
2. 首屏渲染:别把所有鸡蛋放在viewDidLoad里
首屏2.8秒,我一看viewDidLoad,差点没晕过去。这哥们儿在viewDidLoad里干了这些事:
- 请求三个不同的接口获取数据
- 解析JSON并构建数据模型
- 初始化五个子视图的布局
- 预加载下一屏的图片
- 上报埋点数据
- 初始化一个本地数据库连接
全!部!同!步!全!部!主!线!程!
这就像你开一家餐厅,顾客进门了,你才开始去菜市场买菜、洗菜、切菜、炒菜,人家不掀桌子才怪。
// 优化后的首屏加载策略
override func viewDidLoad() {
super.viewDidLoad()
// 1. 先搭骨架屏,让用户看到"内容正在来的路上"
setupSkeletonView()
// 2. UI初始化只做最核心的,非关键的延迟加载
setupEssentialUI()
// 3. 数据请求并行化,用TaskGroup
Task {
await loadInitialData()
}
}
private func loadInitialData() async {
async let bannerData = api.fetchBanners()
async let categoryData = api.fetchCategories()
async let productData = api.fetchProducts(page: 1)
do {
let (banners, categories, products) = try await (bannerData, categoryData, productData)
// 回到主线程更新UI
await MainActor.run {
self.removeSkeletonView()
self.renderBanners(banners)
self.renderCategories(categories)
self.renderProducts(products)
}
} catch {
await MainActor.run {
self.showErrorState(error)
}
}
}
// 非关键视图,用户滑到再加载
private lazy var recommendationView: RecommendationView = {
let view = RecommendationView()
view.frame = CGRect(x: 0, y: calculatedY, width: screenWidth, height: 300)
self.scrollView.addSubview(view)
return view
}()
三个接口用async let并行请求,总耗时从三个接口时间之和变成了最慢那个接口的时间。骨架屏先顶上,用户感知上首屏就快了。非关键视图用lazy延迟初始化,别一上来就全加载。
3. 内存管理:图片是个无底洞
480MB的内存峰值,对于iOS来说已经很危险了。系统内存警告的阈值大概在400-500MB左右(不同机型不一样),再高就直接被系统杀掉了,也就是用户看到的"闪退"。
我排查了一下,内存大头是图片。活动页有大量商品图,而且有些图还是GIF动图。原来的代码加载GIF用的是一个很老的库,解码后的每一帧都常驻内存。一张300x300的GIF,60帧,每帧RGBA四个通道,算下来一帧就是360KB,60帧就是21MB。页面上要是有5张这样的GIF,直接100多MB没了。
解决方案:
// GIF优化:限制帧数 + 降采样
func loadOptimizedGIF(from url: URL, targetSize: CGSize) {
KingfisherManager.shared.retrieveImage(with: url, options: [
.processor(GIFImageProcessor()),
.onlyLoadFirstFrame, // 大部分场景其实不需要动图,只加载第一帧
.processor(DownsamplingImageProcessor(size: targetSize))
]) { result in
// handle result
}
}
// 图片缓存策略:内存+磁盘两级缓存
let cache = ImageCache.default
cache.memoryStorage.config.totalCostLimit = 100 * 1024 * 1024 // 内存缓存限制100MB
cache.memoryStorage.config.countLimit = 200 // 最多缓存200张
cache.diskStorage.config.sizeLimit = 300 * 1024 * 1024 // 磁盘缓存限制300MB
cache.memoryStorage.config.expiration = .seconds(120) // 内存缓存2分钟过期
另外我还加了一个图片内存监控,当内存超过阈值时主动清理缓存:
// 监听内存警告,主动释放缓存
NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil,
queue: .main
) { _ in
ImageCache.default.clearMemoryCache()
// 清理我们的富文本缓存
ProductCell.attrTextCache.removeAllObjects()
}
4. 主线程阻塞:那些不起眼的"小操作"
主线程每秒阻塞420ms,除了上面说的图片解码和富文本计算,还有一些零碎的问题。我把Time Profiler的数据导出来,用Qwen帮我分析了一下调用栈(没错,我把Instruments的导出数据喂给Qwen了,让它帮我找出热点函数,效果居然还不错),发现还有这些问题:
- JSON解析用的是
JSONSerialization,大列表的时候在主线程解析几MB的JSON,耗时上百毫秒 - 有个日期格式化操作在Cell的
configure方法里,DateFormatter每次new一个,这玩意儿初始化巨慢 - 数据库查询在主线程,虽然数据量不大,但架不住频率高
// 日期格式化器复用,千万别每次new
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
formatter.locale = Locale(identifier: "zh_CN")
return formatter
}()
// JSON解析放到后台线程
Task.detached(priority: .userInitiated) {
let models = try JSONDecoder().decode([Product].self, from: data)
await MainActor.run {
self.products = models
self.tableView.reloadData()
}
}
优化效果:数据说话
经过大概一周的优化(期间还被拉去开了两个需求评审会,吐槽一下我们产品经理,需求文档写得跟天书一样),最终的数据:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 列表滑动FPS | 32-38 | 56-59 | 约60% |
| 页面首屏渲染耗时 | 2.8s | 680ms | 约75% |
| 内存峰值 | 480MB | 165MB | 约65% |
| 主线程阻塞时长/秒 | 420ms | 35ms | 约92% |
测试同学复测的时候直接惊了,说这跟换了个App一样。产品经理也终于不催了,还破天荒地在群里发了个红包(虽然只有8块钱)。
一些架构层面的思考
做完这次优化,我有几点感触,其实跟后端高并发优化的思路是高度一致的:
1. 性能优化本质上是对资源的精细管理
不管是CPU、内存还是网络,核心思想就是"好钢用在刀刃上"。不该在主线程干的活别在主线程干,不该现在加载的东西别现在加载,不该重复计算的东西别重复计算。这跟后端做线程池管理、连接池管理、缓存策略是一个道理。
2. 可观测性是优化的前提
没有Instruments的数据,我就是个无头苍蝇。后端有APM、有链路追踪、有各种监控大盘,iOS端同样需要建立完善的性能监控体系。我现在正在帮团队搭一套基于Firebase的性能监控,把FPS、内存、启动耗时这些指标都上报上去,做成大盘,这样以后有问题能第一时间发现。
3. 架构设计要留有余量
这次优化之所以能做得比较彻底,很大程度上是因为原来的代码虽然烂,但模块边界还算清晰。如果是一个高度耦合的"大泥球",改一处动全身,那优化起来就痛苦多了。所以写代码的时候,哪怕赶deadline,也要尽量保持模块的内聚和边界的清晰。这个我在美团做外卖订单系统的时候体会特别深——当初架构设计得好,后来做性能优化的时候就轻松很多。
4. AI工具真的能提效
说真的,这次用Qwen辅助分析Instruments数据、review Swift代码、甚至帮我写一些优化方案的初稿,效率提升非常明显。当然,AI给的东西不能直接用,你得有自己的判断。但作为一个"初筛"和"灵感激发"的工具,确实能省不少时间。特别是对于我这种跨语言救火的情况,AI能帮我快速理解不熟悉的API和最佳实践,少踩很多坑。
写在最后
说到底,性能优化这件事,不管是iOS还是Android,不管是前端还是后端,底层的方法论是相通的。作为开发,我们不能只守着自己那一亩三分地,有机会的话多了解一下其他端、其他领域的知识,对自己只有好处。
而且说句实在话,我现在大环境这么卷,多学点东西总没坏处。我最近就在用AI辅助学习iOS和Flutter,想着把移动端也搞明白,这样以后不管是跳槽还是内部转岗,选择面都能宽一些。
好了,不说了,产品经理又@我了,说是要加个"摇一摇领红包"的功能。我先去骂他一顿,然后...乖乖去写代码。
如果你也是做性能优化的,欢迎在评论区交流。特别是iOS的大佬们,我这次优化肯定还有不到位的地方,求轻喷,求指导 🙏


评论 0