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-sizingCell,系统会自动缓存高度,但前提是 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 也能跑满。