聊聊我帮iOS团队做性能优化的一些思考

Vim孤独患者
2026-07-28 05:44
阅读 352

上周四晚上十一点半,我正准备合上我的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

最热最新
暂无评论
Vim孤独患者Lv.1
0
影响力
0
文章
0
粉丝