iOS 列表滑动卡顿:UITableView / UICollectionView 深度优化实战

iOS 列表滑动卡顿:UITableView / UICollectionView 深度优化实战

滑动卡顿的本质只有一句话:

主线程在 16.7ms(60Hz)或 8.3ms(120Hz ProMotion)内没干完活,帧就丢了。

所以优化思路永远是:

让每一帧的工作尽可能少,把能挪走的挪走,把挪不走的拆碎。


一、先建立性能心智模型

1. 列表滑动时主线程在干什么

每一帧大致经历:

复制代码
RunLoop 唤醒
  ↓
scrollViewDidScroll(可能高频)
  ↓
cellForRowAt / cellForItemAt
  ↓
bind 数据 → 布局 → 渲染
  ↓
GPU 合成 → 显示

任何一步超过帧预算,就会:

  • 掉帧(卡顿感)
  • 手势不跟手
  • 列表"粘滞"

2. 卡顿的 3 大来源

来源 典型问题 占比
CPU 主线程解码图片、复杂布局、大量计算 ~50%
GPU 离屏渲染、overdraw、混合 ~30%
I/O 主线程读文件/数据库/网络 ~20%

二、Cell 复用:最基础也最容易踩坑

1. 确保 Cell 真的被复用

复制代码
// ✅ 正确
tableView.register(UserCell.self, forCellReuseIdentifier: "UserCell")

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell", for: indexPath) as! UserCell
    cell.configure(with: users[indexPath.row])
    return cell
}

// ❌ 错误:每次 new,不复用
let cell = UserCell(style: .default, reuseIdentifier: nil)

2. configure 方法要幂等、轻量

复制代码
// ✅ 好
func configure(with user: User) {
    avatarURL = user.avatarURL  // 只存 URL,异步加载
    nameLabel.text = user.name
    roleBadge.isHidden = !user.isVIP
}

// ❌ 差:在 configure 里做大量事
func configure(with user: User) {
    let processedName = heavyTextProcessing(user.name) // 同步计算
    let avatar = UIImage(contentsOfFile: user.avatarPath!) // 主线程读文件
    avatarImageView.image = avatar.rounded() // 同步圆角
    nameLabel.text = processedName
}

三、图片加载:列表卡顿的头号杀手

1. 永远不要在主线程解码图片

复制代码
// ❌ 致命
let image = UIImage(contentsOfFile: path)
cell.imageView.image = image

图片解码(decode)是 CPU 密集型操作,一张 4K 图解码就要几十 ms。

复制代码
// ✅ 正确:异步解码 + 缓存
imageLoader.loadImage(from: url) { [weak cell] image in
    guard let cell = cell, cell.currentURL == url else { return }
    cell.imageView.image = image
}

异步解码核心:

复制代码
func decodeImage(_ data: Data) -> UIImage? {
    guard let image = UIImage(data: data) else { return nil }
    
    // 强制在后台线程解码
    let size = image.size
    UIGraphicsBeginImageContextWithOptions(size, false, 0)
    image.draw(in: CGRect(origin: .zero, size: size))
    let decoded = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    return decoded
}

实际项目直接用 SDWebImage / Nuke / Kingfisher,它们已经做好了:

  • 异步解码
  • 内存缓存(解码后的 bitmap)
  • 磁盘缓存
  • 取消机制
  • 渐进式加载

2. 列表专用:downsample 大图

用户头像可能上传了 3000×3000,列表里只显示 60×60。

复制代码
func downsample(imageAt url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
    let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else { return nil }
    
    let maxDimension = max(pointSize.width, pointSize.height) * scale
    let downsampleOptions = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimension,
        kCGImageSourceShouldCacheImmediately: true
    ] as CFDictionary
    
    guard let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else { return nil }
    return UIImage(cgImage: cgImage)
}

一张 12MB 的 4K 图,downsample 到 60×60 后 bitmap 只有 ~14KB,解码时间从 ~40ms 降到 <1ms。

3. 列表滑动时暂停图片加载

复制代码
func scrollViewDidScroll(_ scrollView: UIScrollView) {
    // 滑动中暂停加载,停止后再加载
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
        imageLoader.resume()
    }
}

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    imageLoader.resume()
}

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
    imageLoader.pause()
}

SDWebImage 自带这个:

复制代码
tableView.sd_imageTransition = .fade(duration: 0.2)
tableView.sd_shouldRetryFailed = true

四、布局优化:Auto Layout 不是免费的

1. Auto Layout 的代价

每个约束求解是一次线性规划计算。一个 Cell 里 20+ 约束,100 个 Cell 同时计算就是灾难。

优化策略:

场景 方案
固定高度 Cell rowHeight 固定,不用 estimatedRowHeight
简单 Cell 手动 frame 布局
复杂 Cell Auto Layout + 缓存高度
超长列表 考虑 UICollectionView + compositionalLayout(更高效)

2. 固定高度一定要显式声明

复制代码
// ✅ 最快
tableView.rowHeight = 80
tableView.estimatedRowHeight = 80  // 给个准确值

// ❌ 慢:每次都要算
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 100  // 估不准,滚动时会反复调高度

3. 动态高度缓存

复制代码
private var heightCache: [IndexPath: CGFloat] = [:]

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    if let cached = heightCache[indexPath] {
        return cached
    }
    
    let height = calculateHeight(for: indexPath)
    heightCache[indexPath] = height
    return height
}

iOS 13+ 可以用 UITableViewDiffableDataSource + self-sizing Cell,系统会自动缓存高度,但前提是 estimatedRowHeight 要准。

4. 减少约束数量

复制代码
// ❌ 太多约束
nameLabel.snp.makeConstraints { make in
    make.top.equalToSuperview().offset(12)
    make.left.equalTo(avatar.snp.right).offset(12)
    make.right.lessThanOrEqualToSuperview().offset(-12)
}
roleLabel.snp.makeConstraints { make in
    make.top.equalTo(nameLabel.snp.bottom).offset(4)
    make.left.equalTo(nameLabel)
}
// ... 更多约束

// ✅ 用 UIStackView 减少约束数
let vStack = UIStackView(arrangedSubviews: [nameLabel, roleLabel])
vStack.axis = .vertical
vStack.spacing = 4
contentView.addSubview(vStack)
vStack.snp.makeConstraints { make in
    make.left.equalTo(avatar.snp.right).offset(12)
    make.centerY.equalToSuperview()
    make.right.lessThanOrEqualToSuperview().offset(-12)
}

StackView 内部会合并约束,减少求解次数。但 StackView 嵌套不要超过 3 层,否则反而更慢。


五、离屏渲染:GPU 的隐形杀手

什么是离屏渲染

正常渲染路径:App → GPU → Frame Buffer → 屏幕

离屏渲染:App → GPU → Offscreen Buffer → GPU → Frame Buffer → 屏幕

多了一趟往返 + 额外内存。

常见触发离屏渲染的操作

操作 是否触发 替代方案
cornerRadius + clipsToBounds 异步画圆角 / 覆盖图
mask cornerRadius 替代
shadow shadowPath 指定路径
shouldRasterize ✅(但有缓存) 看场景
alpha < 1 + 圆角 预渲染
UIVisualEffectView 减少数量
group opacity 关闭 shouldRasterize

检测工具

Xcode → Debug → View Debugging → Rendering → Color Offscreen-Rendered Yellow

黄色区域就是离屏渲染。

优化方案

复制代码
// ❌ 触发离屏渲染
avatarImageView.layer.cornerRadius = 20
avatarImageView.clipsToBounds = true

// ✅ 方案1:预渲染圆角(异步)
func roundedImage(_ image: UIImage, radius: CGFloat) -> UIImage {
    let renderer = UIGraphicsImageRenderer(size: image.size)
    return renderer.image { context in
        let rect = CGRect(origin: .zero, size: image.size)
        context.cgContext.addPath(UIBezierPath(roundedRect: rect, cornerRadius: radius).cgPath)
        context.cgContext.clip()
        image.draw(in: rect)
    }
}

// ✅ 方案2:用遮罩图(一张中间透明圆角矩形盖上去)
// 最快,零额外渲染成本

// ✅ 方案3:shadow 指定 path
layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: radius).cgPath

shouldRasterize:双刃剑

复制代码
// 适合:复杂层级、不常变的 Cell
cell.layer.shouldRasterize = true
cell.layer.rasterizationScale = UIScreen.main.scale

// 不适合:频繁变化的 Cell(每次都要重新光栅化)

六、减少主线程工作量:能异步就异步

1. 数据预处理移到后台

复制代码
// ❌ 主线程
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    let model = rawData[indexPath.row]
    
    // 同步处理
    let attributedText = parseAttributedString(model.content) // 慢
    let formattedTime = formatTime(model.timestamp) // 还行但累积起来慢
    let processedTags = model.tags.map { processTag($0) } // 慢
    
    cell.textLabel?.attributedText = attributedText
    cell.detailTextLabel?.text = formattedTime
    cell.tags = processedTags
    return cell
}

// ✅ 预处理:在后台算好
struct DisplayUser {
    let name: String
    let attributedBio: NSAttributedString
    let formattedJoinDate: String
    let avatarURL: URL
}

// 数据层
func prepareDisplayModels(from users: [User]) -> [DisplayUser] {
    users.map { user in
        DisplayUser(
            name: user.name,
            attributedBio: parseAttributedString(user.bio), // 后台算
            formattedJoinDate: formatTime(user.joinTimestamp), // 后台算
            avatarURL: user.avatarURL
        )
    }
}

// Cell 里只赋值
func configure(with displayUser: DisplayUser) {
    nameLabel.text = displayUser.name
    bioLabel.attributedText = displayUser.attributedBio
    dateLabel.text = displayUser.formattedJoinDate
    // 图片异步加载
}

2. 数据更新用批量操作

复制代码
// ❌ 一条条 reload
for index in changedIndices {
    tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .none)
}

// ✅ 批量
tableView.performBatchUpdates {
    tableView.insertRows(at: insertIndexPaths, with: .automatic)
    tableView.deleteRows(at: deleteIndexPaths, with: .automatic)
    tableView.reloadRows(at: reloadIndexPaths, with: .none)
}

iOS 13+ 用 DiffableDataSource:

复制代码
var snapshot = dataSource.snapshot()
snapshot.appendItems(newItems)
dataSource.apply(snapshot, animatingDifferences: true)

Diffable 会在后台线程做 diff,主线程只做最小更新。


七、Cell 内部优化清单

1. 减少 Subview 层级

复制代码
层级越深 → 布局越慢 → 合成越慢
层级数 建议
≤ 5 没问题
5--10 注意
> 10 考虑合并 view

合并技巧:

复制代码
// 把多个 label 合并成一个 attributedText label
let attributed = NSMutableAttributedString()
attributed.append(NSAttributedString(string: name, attributes: nameAttrs))
attributed.append(NSAttributedString(string: " · \(time)", attributes: timeAttrs))
label.attributedText = attributed

2. 避免 setNeedsLayout 级联

复制代码
// ❌ 在 cellForRow 里触发 layout
cell.setNeedsLayout()
cell.layoutIfNeeded()

// ✅ 让系统自己在合适时机 layout

3. 减少 setNeedsDisplay

复制代码
// ❌ 频繁调用
label.text = "\(count)"
label.setNeedsDisplay() // 没必要,text 变化自动触发

// ✅ 只在自定义 draw 里需要

4. 用 CALayer 代替 UIView

复制代码
// 纯展示、不需要交互 → 用 layer
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [color1.cgColor, color2.cgColor]
contentView.layer.addSublayer(gradientLayer)

// 比加一个 UIView + backgroundColor 快

八、CollectionView 专项优化

1. Prefetching(iOS 10+)

复制代码
collectionView.prefetchDataSource = self

func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
    // 提前加载数据/图片
    let urls = indexPaths.compactMap { items[$0.item].imageURL }
    imageLoader.prefetch(urls)
}

func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
    // 取消不再需要的
    let urls = indexPaths.compactMap { items[$0.item].imageURL }
    imageLoader.cancelPrefetch(urls)
}

2. Cell 注册用类,不用 nib(如果可以)

复制代码
// nib 加载比代码创建慢
collectionView.register(MyCell.self, forCellWithReuseIdentifier: "MyCell")

// nib 适合复杂布局,但注意解档成本

3. Compositional Layout 比 Flow Layout 快

复制代码
let config = UICollectionViewCompositionalLayoutConfiguration()
let layout = UICollectionViewCompositionalLayout(sectionProvider: { sectionIndex, env in
    // 返回 NSCollectionLayoutSection
}, configuration: config)

Compositional Layout 内部用 UICollectionViewLayoutAttributes 预计算,滚动时不需要反复调用 sizeForItemAt


九、调试工具箱

1. 看 FPS

复制代码
// 真机:Xcode → Debug → View Debugging → Rendering → Show FPS
// 或 Instruments → Core Animation

2. Time Profiler 定位热点

复制代码
Instruments → Time Profiler
1. 选择真机
2. 开始录制
3. 滑动列表
4. 停止
5. 看主线程调用栈,找耗时函数

3. 快速自检清单

复制代码
□ 有没有在主线程解码图片?
□ 有没有在主线程读文件/数据库?
□ 有没有用 automaticDimension 但没给准 estimatedRowHeight?
□ 有没有大量离屏渲染(黄色区域)?
□ Cell 里约束是不是太多(>20)?
□ configure 里有没有做计算/格式化?
□ 有没有频繁调用 setNeedsLayout / setNeedsDisplay?
□ 图片有没有 downsample?
□ 滑动时有没有暂停图片加载?
□ 数据更新有没有用 batch / diffable?

十、终极优化:异步布局 + 预渲染

如果上面都做了还卡,考虑架构级方案:

1. 异步布局(Texture / AsyncDisplayKit 思路)

复制代码
// 后台线程算好 frame
struct LayoutResult {
    let frames: [String: CGRect]
    let size: CGSize
}

func calculateLayout(for model: Model, width: CGFloat) -> LayoutResult {
    // 算所有 subview 的 frame
}

// 主线程只赋值
cell.nameLabel.frame = layoutResult.frames["name"]!
cell.avatarImageView.frame = layoutResult.frames["avatar"]!

2. 预渲染 Cell 为图片(极端场景)

复制代码
// 列表快速滚动时,先显示截图
cell.contentView.layer.contents = placeholderImage.cgImage

// 数据准备好后替换
cell.contentView.layer.contents = renderedContent

适合:Feed 流、聊天列表等超长列表。


十一、一张表总结优化优先级

优先级 优化项 收益 难度
P0 图片异步解码 + downsample ⭐⭐⭐⭐⭐ 低(用 SDWebImage)
P0 不主线程 I/O ⭐⭐⭐⭐⭐
P1 固定高度 / 缓存高度 ⭐⭐⭐⭐
P1 减少离屏渲染 ⭐⭐⭐⭐
P1 滑动暂停图片加载 ⭐⭐⭐
P2 减少约束数 ⭐⭐⭐
P2 数据预处理到后台 ⭐⭐⭐
P2 DiffableDataSource ⭐⭐⭐
P3 异步布局 ⭐⭐⭐⭐
P3 预渲染 ⭐⭐⭐

十二、一句话总结

列表优化的本质:主线程只做"赋值和显示",其他一切往后台推。

  • 图片:异步解码 + 缩采样 + 滑动暂停
  • 布局:固定高度优先、缓存高度、减少约束
  • 渲染:消灭离屏渲染
  • 数据:后台预处理、批量更新
  • 调试:Time Profiler + Color Offscreen-Rendered

做到这些,60fps 稳稳的,ProMotion 120Hz 也能跑满。

相关推荐
于樱花森上飞舞1 小时前
【Redis】哨兵详解
java·开发语言·数据库·redis
Lazionr2 小时前
二叉搜索树:从树形结构到高效查找
开发语言·c++
此生决int2 小时前
深入理解C++系列(21)——异常
开发语言·c++
乌暮2 小时前
Java 抽象类与接口详解:半成品图纸 vs 能力合同
java·开发语言·后端·学习
乐观的Terry2 小时前
ShipDesk 完整使用教程:从新建项目到 SSH 自动发布
开发语言
边境悍匪3 小时前
蜗牛学苑 Java 智能体学习 Day46|贯穿项目 2 思维导图复盘
java·开发语言·vue.js·学习·spring
晴天的雨.9925 小时前
【C++算法】和为s的两个数
开发语言·数据结构·c++·算法
IvanCodes10 小时前
Python 数据处理(十三):JSON、CSV 与数据序列化
开发语言·python
aramae12 小时前
MySQL复合查询(8)
java·c语言·开发语言·后端·算法