← 返回全部文章

从卡顿到流畅:iOS 列表性能排查清单

列表卡顿往往不是某一行代码造成的。用一次可重复的测量,把主线程工作、图片解码、布局和数据更新逐层拆开。

本文目录
  1. 先定义可重复的场景
  2. 检查主线程上的意外工作
  3. 把图片成本拆开
  4. 控制布局复杂度
  5. 审视数据更新方式
  6. 用数据验证每一次修改

列表滚动不流畅时,直接开始改 cell 往往效率不高。掉帧可能来自图片解码、布局计算、主线程 I/O、批量状态更新,甚至是屏幕外完全无关的定时任务。

一套稳定的排查顺序,可以减少“改了很多,但不知道哪一处有效”的情况。

先定义可重复的场景

性能问题必须能重复测量。开始前记录:

  • 设备型号与系统版本;
  • Debug 还是 Release 构建;
  • 数据量、图片大小和缓存状态;
  • 操作路径,例如冷启动后首次快速滑动;
  • 关注的指标,例如掉帧、主线程阻塞或内存峰值。

真机上的 Release 或接近 Release 的配置更有参考价值。调试器、额外断言和日志都可能显著改变结果。

检查主线程上的意外工作

先用 Instruments 的 Time Profiler 和 Hangs 模板观察卡顿时间段。重点不是找“耗时最长的函数”,而是找滚动期间本不该出现在主线程的工作

常见问题包括:

  • 同步读取文件或数据库;
  • 大图首次解码;
  • 日期、富文本或正则表达式重复构建;
  • 日志把大对象转换为字符串;
  • cell 配置阶段执行网络缓存查询;
  • 每次滚动都重新计算不变的数据。

给关键阶段添加 signpost,可以让代码事件和帧时间对齐:

import os.signpost

private let log = OSLog(subsystem: "ink.teem.notes", category: "List")

let state = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "ConfigureCell", signpostID: state)
configure(cell, with: model)
os_signpost(.end, log: log, name: "ConfigureCell", signpostID: state)

把图片成本拆开

图片列表通常同时包含三类成本:下载、解码和缩放。网络完成不代表图片已经能低成本显示;压缩数据首次绘制时仍可能在主线程触发解码。

建议确认:

  1. 请求尺寸是否接近屏幕实际显示尺寸;
  2. 解码和降采样是否在后台完成;
  3. 缓存键是否包含尺寸与显示模式;
  4. cell 复用后,旧请求是否会覆盖新内容;
  5. 快速滚动时是否取消了不可见资源请求。
override func prepareForReuse() {
    super.prepareForReuse()
    representedID = nil
    imageTask?.cancel()
    imageTask = nil
    thumbnailView.image = placeholder
}

仅仅在回调里比较 indexPath 不够可靠,因为数据源可能已经更新。使用稳定的 model ID 判断返回结果是否仍属于当前 cell,通常更安全。

控制布局复杂度

Auto Layout 并不等于性能差,但重复的约束变更、模糊的垂直尺寸和深层视图树会放大滚动成本。

可以依次检查:

  • 约束是否只创建一次,而不是在 configure 中反复激活;
  • 是否存在不必要的嵌套 Stack View;
  • 圆角、阴影和遮罩是否触发昂贵的离屏渲染;
  • 动态高度能否缓存,或者由确定数据直接计算;
  • 不可见的复杂子视图是否仍参与布局。

先用 Instruments 证明布局是热点,再考虑手写 frame。过早替换布局系统,会增加维护成本,却未必解决真正的瓶颈。

审视数据更新方式

同一批数据如果拆成多次快照更新,会重复触发布局与动画。Diffable Data Source 也不是免费的,快照构造、差异计算和视图更新都需要成本。

尽量在后台准备不可变的展示模型,再在主线程一次提交:

let viewModels = await formatter.makeViewModels(from: response)

await MainActor.run {
    dataSource.apply(makeSnapshot(viewModels), animatingDifferences: false)
}

滚动过程中收到高频状态变化时,可以合并更新,或者只更新真正受影响的 item。是否开启动画应由交互价值决定,而不是默认始终为 true

用数据验证每一次修改

每次只改一个可解释因素,然后重新执行相同场景。记录修改前后的帧时间、主线程占用和内存,而不是只凭手感。

最终应能回答三个问题:

  1. 卡顿发生在哪段时间;
  2. 那段时间主线程在做什么;
  3. 修改为什么减少了这项工作。

当答案都清楚时,性能优化才从偶然试错变成了可维护的工程结论。