列表与滚动性能
# 列表与滚动性能
📊 学习成本预估 | 难度:⭐⭐⭐⭐(4/5)| 阅读:约 50 分钟 | 实操:4 小时 🔗 前置阅读:卷三·01, 卷四·03 | ➡️ 后续延伸:卷三·06
# 目录介绍
- 01.阅读说明
- 02.贯穿案例
- 03.列表本质定义
- 04.列表流水线原理
- 05.度量与采集
- 06.归因决策树
- 07.虚拟化全链路 ⭐
- 08.RecyclerView 全链路 ⭐
- 09.UITableView 全链路 ⭐
- 10.Web 虚拟列表全链路 ⭐
- 11.声明式列表全链路 ⭐
- 12.跨端列表对照
- 13.治理一层虚拟化 ⭐
- 14.治理二层复用率 ⭐
- 15.治理三层 bind ⭐
- 16.治理四层图片 ⭐
- 17.求证实验 ⭐
- 18.实战案例
- 19.防劣化体系
- 20.跨平台速查
- 21.总结与延伸
# 01.阅读说明
- 本文卷归属:卷四 · 业务专项 · 第 4 篇
- 本文目标层级:L3 专家
- 适用平台:Android / iOS / Web / 小程序
- 前置阅读:
卷三·01 渲染管线原理、卷四·03 图片性能解码优化 - 本文核心命题:
列表是 App"性能门面"——Feed / 聊天 / 购物 / 订单都是列表。这一关过不去,再好的其他场景也救不回用户的"卡"印象。 列表性能 = 虚拟化(数据→视图比例)+ 复用率(ItemType 收敛)+ bind 减负(单帧预算)+ 图片治理(70% 瓶颈),四件套缺一不可。 80% 的列表卡顿都是这四个维度上的某一个失守,不是"功能太多"或"数据太大"。
# 02.贯穿案例
本案例贯穿全文:§03 看懂三矛盾、§04 拿到流水线模型、§05-§06 用方案+决策树定位、§07-§11 各端原理、§13-§16 四层治理、§17 用实验复盘。
# 2.1 案例背景
某头部社交 App V9.5 重构信息流列表(产品要求"支持图文/视频/直播预览/广告/推荐位/卡片 6 种 item 类型"),上线后用户反馈崩塌:
- 列表稳态 FPS 仅 12-18(之前 56),用户大量"滑不动"投诉。
- 首屏到稳定耗时 P95 = 5.8s(之前 1.2s)。
- OOM 率从 0.2% 飙到 4.5%(中低端机)。
- 客服收到"App 越用越卡"的差评每天 6000+。
- DAU 1500 万的产品,活跃时长下降 22%。
研发组初步反应:"新功能多,肯定慢点,是数据量大。"——这是典型的"功能背锅"。
# 2.2 经验派 3 周折腾
| 周次 | 动作 | 结果 |
|---|---|---|
| 第 1 周 | 把分页改为"加载更多"按钮(怕全部加载) | 用户反感按钮,但滑动 FPS 还是 12-18(虚拟化问题没解决) |
| 第 2 周 | 加大 RecycledViewPool 容量到 50(怕复用不够) | 内存涨 60MB,FPS 微涨到 22 |
| 第 3 周 | 把图片质量统一降低(怕解码慢) | 视觉降级,运营投诉,FPS 无明显变化 |
复盘:三周折腾错在"症状疗法 + 不分析根因"。列表性能问题永远是三个维度:虚拟化、复用率、单 bind 耗时。经验派碰这碰那但从未直接攻击这三个维度的核心——因为不知道它们的存在。
# 2.3 方法派 6 天闭环
Day 1(§05/§06 系统分析):
- 方案 A(FPS):稳态 12-18,长尾帧达 250ms。
- 方案 B(bind 耗时):单 bind P99 = 180ms(标准 < 8ms 的 22 倍)。
- 方案 C(首屏到稳定):P95 = 5.8s。
→ 三个数据全爆,典型三维度全失守。
Day 2(深度归因 §06):
- 测试虚拟化:开启
setHasFixedSize(true)后 FPS 升到 28 → 虚拟化部分失效。 - 测试复用率:onCreateViewHolder 在 1000 滚动中调用 380 次(应 ≈ 屏幕容量 × ItemType 数 ≈ 30)→ 6 种 ItemType 让 Pool 极度分散。
- 测试 bind 内容:bind 里同步格式化时间、解析 JSON、加载图片 → 三件重活全在主线程。
Day 3-5(§13-§16 四层治理落地):
- 第 1 层(虚拟化 §13):补全 RecyclerView 配置,确认 LinearLayoutManager 正确使用。
- 第 2 层(复用率 §14):6 种 ItemType 合并为 3 种通用 layout + 数据驱动差异 → onCreate 调用 380 → 35 次。
- 第 3 层(bind 减负 §15):时间格式化预计算入数据模型;图片改 Glide 异步 + 滚动暂停;JSON 解析放数据层。
- 第 4 层(图片治理 §16):滚动期暂停解码(
卷四·03 §6.4),停止后批量加载。
Day 6(上线 + 灰度验证)。
# 2.4 上线效果
| 指标 | 经验派 3 周后 | 方法派 6 天后 |
|---|---|---|
| 稳态 FPS | 22 | 58 |
| 长尾帧 P99 | 200 ms | 18 ms |
| 单 bind P99 | 150 ms | 6 ms |
| 首屏到稳定 P95 | 5.5s | 1.0s |
| OOM 率 | 4.0% | 0.3% |
| onCreateViewHolder 频次 | 380/千滚动 | 35(复用率 91%) |
| 活跃时长 | -22% | +5% |
核心洞察:列表性能问题永远在三个维度上找拐点——虚拟化(数据→视图比例)、复用率(ItemType 收敛)、bind 耗时(单帧预算)。经验派 3 周折腾的所有动作都是症状疗法——加大 Pool 容量没解决 ItemType 分散问题、降图片质量没解决同步解码问题。方法派 6 天直击三维度根因。
# 2.5 案例串联全文
- §03 三核心矛盾 ▶▶ 案例三维度全失守是反面教材。
- §04 流水线原理 ▶▶ 7 阶段流水线告诉你"哪一环慢就整体掉帧"。
- §07-§11 各端原理 ▶▶ 案例的复用失效在 §08.1 RecyclerView 4 级缓存有原理解释。
- §13-§16 四层治理 ▶▶ 案例 6 天闭环的逐层落地。
- §17 求证实验 ▶▶ §17.1 + §17.2 + §17.3 + §17.4 + §17.5 都在案例中变现。
# 03.列表本质定义
列表性能不是"普通页面优化的延伸"——它有自己的物理本质和工程矛盾。理解这些本质是后续所有原理的认知前提。
# 3.1 列表的物理本质
列表 = 用有限的视图节点,在用户感知的连续滑动下,呈现潜在无限的数据集。
这一定义有三个不可商量的物理约束:
约束一:视图节点数量必须远小于数据量——10000 条数据创建 10000 个 View 等于自杀。手机 GPU 能流畅渲染的 View 数有上限(约 100-200),列表必须用"窗口"复用机制把 N 条数据映射到 K 个视图(K << N)。
约束二:60Hz 屏幕单帧预算 16.6ms——滚动是连续触发渲染的最敏感场景。每帧只有 16.6ms 完成"事件→数据→bind→measure→layout→draw"全链路。这个预算是物理硬约束,不可商量。
约束三:复用必然带来状态污染——View 被回收再用时,可能残留前一次绑定的状态(图片、文本、动画)。复用换性能,但每次复用都是一次"潜在 bug" ——错位、闪烁、闪现旧内容。
探索性思考:为什么"列表"是性能优化中最特殊的场景? 因为它同时违反了三个常规假设:
- 普通页面假设"View 数量是设计时确定的"——列表是动态的。
- 普通页面假设"渲染是离散事件"——滚动是连续的。
- 普通页面假设"View 状态是稳定的"——复用让 View 状态飘忽不定。
这三个假设的颠覆使得"列表性能"成为独立学科——不能用"通用 UI 优化"那一套解决。这就是本文需要独立成篇的理由。
# 3.2 三核心矛盾
┌────────────────────────────────────────────────┐
│ 矛盾 A:大量数据 vs 内存预算 │
│ 1 万条记录 ≠ 1 万个 View │
│ 解药:虚拟化(§07) │
├────────────────────────────────────────────────┤
│ 矛盾 B:滚动连续 vs 帧时间 │
│ 每帧只有 16ms,必须在帧内完成 inflate + bind │
│ 解药:bind 减负(§15)+ 图片治理(§16) │
├────────────────────────────────────────────────┤
│ 矛盾 C:视图复用 vs 状态正确 │
│ 复用降低开销,但错位、闪烁是常见 bug │
│ 解药:复用率治理(§14)+ 防错位校验(§16.3) │
└────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
▶▶ 回扣 §02 案例:信息流三维度全失守正是本节"三核心矛盾"全部命中——大量数据未虚拟化(矛盾 A)、滚动期 bind 180ms 远超 16ms 帧预算(矛盾 B)、6 种 ItemType 让复用机制失效(C 的副作用)。三个矛盾任一失守都会让 FPS 跌到 < 30。
# 3.3 行业基准与目标
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| 滚动稳态 FPS | 持续滚动期间的 FPS | ≥ 55 |
| 长尾帧 P99 | 帧时长 P99 | ≤ 50 ms |
| 单 bind 耗时 P95 | onBindViewHolder/cellForRowAt | ≤ 8 ms |
| 复用率 | 1 - onCreate / onBind | ≥ 90% |
| 首屏到稳定 P95 | 打开列表→所有可见项就绪 | ≤ 1 s |
| 内存增量 | 滚动 1000 屏后的内存增量 | ≤ 50 MB |
跨平台基准:
| 平台 | 滚动 FPS | bind P95 |
|---|---|---|
| Android(旗舰) | ≥ 58 | ≤ 6ms |
| Android(中端) | ≥ 55 | ≤ 8ms |
| iOS | ≥ 58 | ≤ 4ms |
| Web | ≥ 55 | ≤ 8ms |
# 3.4 反直觉问题清单
带着这些问题阅读:
- 为什么"分页加载"比"虚拟列表"更慢?
- RecyclerView 的 ViewHolder 真的复用了吗?
- 为什么"item 长得一样"反而比"长得不一样"卡?
- 滚动时图片"先模糊后清晰"是体验更好还是更差?
- 预加载越多越流畅吗?
- 为什么"惯性滚动"在弱机上经常变成"匀速滚动"?
- 滚动停止时的"重渲染"为什么必须有?
- 为什么 iOS 列表性能普遍比 Android 好?
# 04.列表流水线原理
列表的本质是"流水线"——从数据源到屏幕像素,经过 7 个阶段。任何一环慢,整体就掉帧。理解这条流水线才能精准归因。
# 4.1 七阶段流水线
数据源 (List<Data>)
↓
① 数据 diff (新旧数据对比,决定哪些 item 变化)
↓
② 节点回收 (滚出屏幕的 View 进入复用池)
↓
③ 节点获取 (从池中取或 inflate 新 View)
↓
④ 数据绑定 (onBindViewHolder / cellForRowAt)
↓
⑤ measure (测量 View 大小)
↓
⑥ layout (定位 View 位置)
↓
⑦ draw (绘制到屏幕)
↓
屏幕像素(GPU 合成)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
每阶段的耗时分布(典型 16ms 预算):
| 阶段 | 典型耗时 | 占比 |
|---|---|---|
| ① 数据 diff | < 1ms(非滚动期) | 6% |
| ② 节点回收 | < 0.5ms | 3% |
| ③ 节点获取 | 0.5-2ms(取池)/ 5-15ms(inflate) | 12% |
| ④ 数据绑定 | 1-8ms | 25-50% |
| ⑤ measure | 0.5-3ms | 6-18% |
| ⑥ layout | 0.5-2ms | 6-12% |
| ⑦ draw | 1-5ms | 6-30% |
核心命题:任何阶段超时都会掉帧。所以列表优化是**全链路最长板"——找最慢的那环优化。
# 4.2 帧预算约束
60Hz 屏幕 → 单帧 16.66ms 预算
│
├── VSync 信号(屏幕硬件触发)
│ ↓
├── 应用收到 doFrame() 回调
│ ↓
├── 完成上述 7 阶段流水线 ── 必须在 16ms 内
│ ↓
├── GPU 合成
│ ↓
└── 屏幕显示
如果某帧 > 16.66ms:
该帧迟到 → 用户看到"卡顿"(duplicate 或跳帧)
2
3
4
5
6
7
8
9
10
11
12
13
14
帧预算的工程含义:
- bind 单次必须 ≤ 8ms(留 8ms 给 measure/layout/draw)。
- 不能在主线程做"任意长度"的事——超过 16ms 必丢帧。
- 滚动是"连续 60 次/秒的考验"——任何一帧失败用户都能看到。
探索性思考:为什么"60fps"是工程黄金线而不是"30fps"? 心理学研究:
- 24fps:电影感(足够"看清",但不够"流畅")。
- 30fps:游戏可玩(但快速移动会"糊")。
- 60fps:人眼感知"丝滑"的临界点。
- 120fps:减少主观疲劳,但普通用户难以分辨与 60fps 的差异。
60fps 是"性能投入产出比的最优点"——再低用户感觉卡,再高用户感觉不到差异。这就是为什么列表性能 SLO 几乎都设在 55-60fps,不必追求 120fps。
# 4.3 跨端同构原理
所有平台的列表流水线本质相同:
数据源
│
▼
[虚拟化容器]
│
▼
┌─────────────────────────────────────┐
│ 复用池(按 ItemType 分类) │
└─────────────────────────────────────┘
│
▼
[可见区域内的视图节点]
2
3
4
5
6
7
8
9
10
11
12
跨平台术语对照:
| 概念 | Android | iOS | Web | Compose / SwiftUI |
|---|---|---|---|---|
| 虚拟化容器 | RecyclerView | UICollectionView / UITableView | react-window / IntersectionObserver | LazyColumn / List |
| 视图节点 | ViewHolder | UICollectionViewCell | DOM Element | Composable / View |
| 复用键 | ItemViewType | reuseIdentifier | key | key |
| 数据 diff | DiffUtil | NSDiffableDataSource | React reconciliation | snapshot 比较 |
| 测量阶段 | onMeasure | sizeForItemAt | reflow | measure |
同构本质:所有平台都是"窗口可见 + 节点回收 + 数据 diff"三件套。理解一个平台后,其他平台只是 API 命名差异。
# 4.4 平台差异矩阵
| 维度 | Android | iOS | Web | Compose / SwiftUI |
|---|---|---|---|---|
| 默认列表组件 | RecyclerView | UITableView / UICollectionView | 无(需库) | LazyColumn / List |
| 复用机制 | ViewHolder + Pool | reuseIdentifier | 用户自实现 | 编译期生成 |
| 性能基线 | 中(看实现) | 高(系统优化好) | 中(看库) | 中→高(需 Baseline) |
| ItemType 风险 | 高(开发者管理) | 中 | 低 | 低(key 自动) |
| 不定高度难度 | 中 | 中(需估算) | 高(需双 RAF) | 低(声明式) |
| 滚动还原 | 自动(saveState) | 自动 | 难(需自己存) | 自动 |
后续 §08-§11 各端原理章节会逐一展开。
# 05.度量与采集
# 5.1 三类采集方案
① 滚动 FPS(用户最直接感受)
② 绑定耗时分布(精确归因)
③ 首屏到稳定耗时(关键场景 SLO)
2
3
① 滚动 FPS——监听 onScroll + Choreographer/CADisplayLink 持续采样帧时长。物理本质:用户最直接感受。局限根源:FPS 平均掩盖"个别长帧"——P50 可能 60fps,但 P99 一帧 200ms 用户就感觉"咯噔"。适用场景:常态化监控。
② 绑定耗时分布——埋点测量 onBindViewHolder / cellForRowAt 耗时。物理本质:精确归因到具体 ItemType 和数据。局限根源:只覆盖业务层,不含图片解码等异步耗时。适用场景:精细归因。
③ 首屏到稳定耗时——测量"打开列表"到"所有可见项稳定(图片加载完)"的时间。物理本质:直接对应用户感受的"列表加载完了"。局限根源:需要业务侧定义"稳定"。适用场景:关键场景 SLO。
三类方案的总览
| 方案 | 钩子位置 | 数据粒度 | 性能开销 | 跨端通用性 | 主要局限 |
|---|---|---|---|---|---|
| ① FPS | Choreographer/Display | 帧 | 极低 | 高 | 平均掩盖长尾 |
| ② 绑定耗时 | bind 前后埋点 | 单 item | 极低 | 高 | 不含异步 |
| ③ 首屏稳定 | 业务事件 | 整页 | 极低 | 高 | 需业务定义 |
组合定律:① + ② 必组合(覆盖运行时 + 归因)+ ③ 是关键 SLO。
# 5.2 各方案的盲区
| 现象 | 方案 ① | 方案 ② | 方案 ③ |
|---|---|---|---|
| 整体卡顿感 | ✅ | ❌ | ❌ |
| 哪个 ItemType 慢 | ❌ | ✅ | ❌ |
| 长尾帧(个别 100ms+) | 部分(看分布) | ✅ | ❌ |
| 首屏体验 | 部分 | ❌ | ✅ |
| 图片解码导致的卡顿 | ✅ | ❌ | 部分 |
# 5.3 跨平台采集对照
| 维度 | Android | iOS | Web |
|---|---|---|---|
| FPS | Choreographer + JankStats | CADisplayLink + MetricKit | requestAnimationFrame + PerformanceObserver |
| bind 耗时 | onBindViewHolder 前后埋点 | cellForRowAt 前后埋点 | virtual list 渲染回调 |
| 首屏稳定 | Activity onPostResume + 图片加载完成 | viewDidAppear + 资源回调 | Performance.mark + LCP |
# 5.4 数据可信度
| 数据 | 可信度 | 偏差来源 |
|---|---|---|
| FPS(Choreographer) | 高 | 系统直接信号 |
| bind 耗时(埋点) | 高 | 直接计时 |
| 复用率(onCreate/onBind 比) | 高 | 直接统计 |
| 首屏稳定 | 中 | 业务定义不一致 |
# 06.归因决策树
# 6.1 卡顿二分决策树
列表滚动卡顿
├─ 是 bind 慢还是图片慢?
│ ├─ 关闭图片显示再测
│ │ ├─ 还卡 → bind 慢,进入 §6.2
│ │ └─ 不卡 → 图片解码慢(参考卷四·03)
│ └─ 选择性测
└─ 是首次进入慢还是滚动中慢?
├─ 首次进入 → 数据加载或首帧布局
└─ 滚动中 → 复用机制失效(§6.3)或局部高耗任务
2
3
4
5
6
7
8
9
▶▶ 回扣 §02 案例:决策树两条路径同时走——bind 慢(180ms 主线程同步格式化)+ 图片慢(同步解码)。经验派从未走过这个决策树,所以三周方向全错。
# 6.2 bind 慢三大原因
| 原因 | 典型表现 | 解药 |
|---|---|---|
| 布局复杂 | 嵌套层级深、约束布局频繁、自定义绘制重 | 扁平化布局 + ConstraintLayout |
| 业务计算 | 每次 bind 里做格式化、正则、JSON 解析 | 数据预计算(§15.1) |
| 图片同步加载 | bind 里走完整解码路径 | 异步加载 + 滚动暂停(§16) |
# 6.3 复用失效检测
Android RecyclerView:
// 计算复用率
val createCount = ... // 累计 onCreateViewHolder 调用次数
val bindCount = ... // 累计 onBindViewHolder 调用次数
val reuseRate = 1 - createCount.toFloat() / bindCount
// 健康阈值:> 90%
// < 90%:检查 ItemType 数量、ViewHolder Pool 配置
2
3
4
5
6
7
iOS:检查 reuseIdentifier 是否唯一对应一种 cell 类型——错把 indexPath 当 reuseIdentifier 是经典反模式(详见 §9.1)。
Web 虚拟列表:检查 key 是否稳定——key 用 index 而非 id 会让 React 误判"全部都变了",触发完整重渲染。
# 6.4 长尾帧定位法
平均 FPS 不能反映"个别 100ms+ 的卡帧",必须看 P99 帧时长:
帧时长分布:
P50: 10ms ← 大部分用户感觉流畅
P90: 16ms ← 边缘
P95: 25ms ← 偶尔卡顿
P99: 120ms ← 严重卡顿(此时用户能感觉到)
max: 500ms ← 极端卡顿(视觉跳跃)
2
3
4
5
6
长尾帧的常见根因:
- 滚动到某个特殊 ItemType(如视频自动播放触发)。
- 触发了 GC(Java 大对象释放)。
- 图片加载完成时同步 setBitmap。
- 某条数据导致复杂布局(如超长文本)。
归因方法:在 bind 内打点,把每次 bind 的 itemType/position/duration 上报,统计 "P99 慢的是哪一类"。
# 07.虚拟化全链路
虚拟化是列表性能的"地板"——没有虚拟化谈其他优化都是徒劳。本章拆解:为什么不虚拟化必死 / 可见区域如何计算 / 节点回收机制 / 复用与状态污染。
# 7.1 为什么必须虚拟化
不虚拟化的代价是指数级增长:
1000 条数据:
全量渲染:1000 个 View → 内存 350MB → 卡死或 OOM
虚拟化: ~10 个 View → 内存 18MB → 流畅
10000 条数据:
全量渲染:直接 OOM 崩溃
虚拟化: 仍是 ~10 个 View → 内存 18MB
2
3
4
5
6
7
为什么开销是指数的?每个 View 的开销有三方面:
- 创建:inflate XML 大概 5-15ms,1000 个就是 5-15 秒。
- 测量:onMeasure 递归遍历整个视图树,N 个 View 就是 O(N) 耗时。
- 内存:每个 View 占用几 KB(含 Drawable 缓存),1000 个就几 MB+。
§17.1 实验:1 万条数据全量 vs 虚拟化,内存差 100×、首帧差 50×。
# 7.2 可见区域计算
虚拟化的核心算法是可见区域计算(visible range)——只为可见的 item 创建视图。
屏幕高度 = H
列表当前滚动偏移 = scrollY
每个 item 高度 = itemHeight
可见 item 的索引范围:
firstVisible = scrollY / itemHeight
lastVisible = (scrollY + H) / itemHeight
应该渲染的 item 数 = lastVisible - firstVisible + 1
= H / itemHeight + 1(典型 5-10 个)
2
3
4
5
6
7
8
9
10
这是固定高度的简单情况。变高度(瀑布流、聊天)需要:
- 实测高度缓存:每个 item 测量后记录高度。
- 二分查找:滚动时用累积高度数组二分定位 firstVisible。
- 估算高度:未测量过的 item 用平均高度先估算位置。
变高度的工程难点:
- 用户快速滚动时,未测量的 item 位置只能估算,滚动条/滚动位置可能"跳变"。
- 这是为什么"聊天列表跳到指定消息"经常不准。
# 7.3 节点回收原理
列表向下滚动:
↓
item-0、item-1 滚出屏幕顶部
↓
它们的 View 不能销毁(销毁→重建很贵)
↓
把它们的 View 放入"复用池",等待被 item-N、item-N+1 复用
↓
item-N、item-N+1 进入屏幕底部
↓
从池里取出"刚滚出去的 View"
↓
把 item-N 的数据 bind 到这个 View 上
↓
View 显示新内容
2
3
4
5
6
7
8
9
10
11
12
13
14
15
关键事实:View 不被销毁,只是数据被重新绑定。这就是"复用"的本质——view recycle, data rebind。
池的设计:
- 按 ItemType 分类:每种类型一个池——文本类型不能给图片类型用(layout 不同)。
- 池容量限制:不能无限大(占内存)也不能太小(频繁创建)。
- 池的生命周期:通常与列表绑定,列表销毁池也清空。
# 7.4 复用与状态错位
复用机制带来一个根本性问题:View 被回收时仍持有"前一次的状态"。
View V₁ 之前显示用户 A 的头像(图片已加载到 ImageView 内)
↓
V₁ 滚出屏幕,进入复用池
↓
列表请求新位置(用户 B 的项)
↓
从池中取出 V₁,调 bind(V₁, 用户 B)
↓
bind 内调 imageLoader.load(用户 B 头像)(异步)
↓
异步还没完成时:
V₁ 显示的是"用户 A 的头像" ← 状态污染!
↓
异步完成后:
V₁ 切到"用户 B 的头像" ← 正确,但有"闪一下"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
经典 bug:用户 A 的头像异步加载比用户 B 慢——用户 A 的头像可能加载完后被错误地设到 V₁(此时 V₁ 已经是用户 B),导致图片错位。
解药(详见 §16.3):
// 给 ImageView 设 tag,加载回调时校验 tag 是否匹配
holder.imageView.tag = item.imageUrl
asyncLoad(item.imageUrl) { bitmap ->
if (holder.imageView.tag == item.imageUrl) { // 校验未被复用
holder.imageView.setImageBitmap(bitmap)
}
}
2
3
4
5
6
7
探索性思考:为什么"复用必然带来状态污染"? 因为复用的本质是"View 不死"——View 的生命周期变成了"创建一次,反复 rebind"。但 View 内部的状态(image、text、动画、滚动位置)是对前一次 bind 的副产物,不会自动清空。rebind 必须显式清理或覆盖所有可变状态——这是工程性的纪律,不是框架自动保证的。
这也是为什么 RecyclerView 提供了
onViewRecycled回调——让你在 View 被回收时主动清理(如imageView.setImageDrawable(null)、停止动画)。忽视这个回调是错位 bug 的根源。
# 08.RecyclerView 全链路
RecyclerView 是 Android 列表的标准实现,4 级缓存机制是它的灵魂。本章拆解:缓存层级 / ViewHolder 生命周期 / ItemType 的隐形杀伤 / DiffUtil 算法原理。
# 8.1 四级缓存机制
RecyclerView 的复用不是简单的"一个 Pool",而是4 级缓存梯队:
┌─────────────────────────────────────────────────────────────┐
│ Level 1: Scrap (即将复用,0 开销) │
│ 屏幕上即将离开的 View,本帧内会被立刻复用 │
│ 无需 rebind(数据未变) │
├─────────────────────────────────────────────────────────────┤
│ Level 2: CacheView (刚移出,原位 bind 跳过) │
│ 刚滚出屏幕的 View,默认 2 个 │
│ 按 position 缓存——回滚到原位置时无需 rebind │
├─────────────────────────────────────────────────────────────┤
│ Level 3: ViewCacheExtension (开发者自定义) │
│ 开发者可以插入自己的缓存策略 │
│ 极少用,跳过 │
├─────────────────────────────────────────────────────────────┤
│ Level 4: RecycledViewPool (跨列表共享,最大头) │
│ 按 ItemType 分类的共享池,默认每类 5 个 │
│ 取出后必须 rebind │
└─────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
取 View 的查找顺序:Scrap → CacheView → ViewCacheExtension → RecycledViewPool → onCreateViewHolder。
4 级缓存的工程含义:
- Scrap & CacheView 是"原位复用":跳过 onBindViewHolder——这就是为什么"回滑到原位置很快"。
- RecycledViewPool 是"跨位置复用":需要 onBindViewHolder——这是性能优化的主战场。
- onCreateViewHolder 是最后兜底:每次创建都要 inflate XML,是高代价路径。
调优 API:
// 1. 调大 CacheView,让"回滑"更流畅
recyclerView.setItemViewCacheSize(10) // 默认 2
// 2. 调大 RecycledViewPool 每类容量
recyclerView.recycledViewPool.setMaxRecycledViews(VIEW_TYPE, 20)
// 3. 多列表共享 Pool(Tab 切换零开销)
val sharedPool = RecyclerView.RecycledViewPool()
list1.setRecycledViewPool(sharedPool)
list2.setRecycledViewPool(sharedPool)
2
3
4
5
6
7
8
9
10
探索性思考:为什么 CacheView 默认只有 2? 因为 CacheView 占用内存(每个 View 几 KB),且"回滑"是相对低频操作。默认 2 个是"内存与速度的折中"。如果业务有大量"快速回滑"场景(如评论区上下查看),调大到 10-20 收益显著;如果是"持续向下滑",调大无意义。
# 8.2 ViewHolder 生命周期
┌─────────────────────────────┐
│ onCreateViewHolder() │ ← inflate XML,仅当池中无可用
│ 返回 ViewHolder │ ← 高代价(5-15ms)
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ onBindViewHolder() │ ← 数据 bind,每次显示都调
│ 或 onBindViewHolder(payloads)│ ← 增量更新(DiffUtil)
└─────────────────────────────┘
│
┌────────┼────────┐
│ 显示中 │ 滚出屏 │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────────┐
│ onAttached │ │ onDetached │ │ onViewRecycled │
└──────────┘ └──────────┘ └──────────────────┘
↑
关键:清理状态、停止动画
释放图片资源、防止泄漏
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
关键回调的工程价值:
| 回调 | 何时调 | 必做的事 |
|---|---|---|
onCreateViewHolder | 池中无可用 | inflate + 一次性初始化(如 setOnClickListener) |
onBindViewHolder | 数据绑定 | 数据→视图映射 |
onBindViewHolder(payloads) | 增量更新 | 只更新变化部分 |
onViewRecycled | View 被回收入池 | 清理图片、停止动画、释放资源 |
onViewAttached/Detached | 进/出屏幕 | 开始/停止视频自动播放 |
经典反例:把"重型一次性初始化"放进 onBindViewHolder 而不是 onCreateViewHolder——每次 bind 都重复执行,性能差几个数量级。
# 8.3 ItemType 与复用池
核心事实:RecycledViewPool 按 ItemType 分类——不同 type 不共享。
ItemType 数量 = 1:所有 item 共享一个池 → 复用率极高
ItemType 数量 = 6:池被分成 6 份,每种类型独立 → 复用率分散
例如屏幕容量 = 5 个 item,6 种类型随机交替:
每种类型的池只有 < 1 个可用(5 个屏幕项 / 6 种类型)
每次新 item 进入屏幕都极可能 onCreate
2
3
4
5
6
§17.2 实验:1 种 → 6 种 ItemType,onCreate 调用从 18 次涨到 90+ 次(5×)。
ItemType 的"隐形杀伤":
- ItemType 越多,池碎片化越严重。
- ItemType 数量是"非线性"代价——3 种比 1 种慢 3 倍,但 6 种比 3 种慢 10 倍(缓存命中率下降是非线性的)。
- 6 种 ItemType 是性能灾难,3 种是上限。
收敛策略(详见 §14.1):用"通用 layout + 数据驱动差异"替代多种独立 layout。
探索性思考:为什么"item 长得不一样"反而比"长得一样"快? 这是反直觉但符合工程现实:
- 如果所有 item 一样(1 种 ItemType):复用率极高(接近 100%)。
- 如果 item 有 6 种不同结构(6 种 ItemType):复用率分散,每次新 item 都可能要 inflate。
"长得一样" = ItemType 收敛 = 复用率高 = 快。 "长得不一样" = ItemType 分散 = 复用率低 = 慢。
这就是为什么大量"看起来酷炫的多卡片设计"线上性能一塌糊涂——设计师不懂工程代价。
# 8.4 DiffUtil 算法原理
notifyDataSetChanged() 是"重置全部"——所有 item 都重 bind,1000 条数据要 2-3 秒。DiffUtil 用算法计算"最小变化集":
旧列表: [A, B, C, D, E]
新列表: [A, B, X, D, E]
↓ DiffUtil 计算
变化: position 2 的内容从 C 变 X
↓ 只触发:
notifyItemChanged(2)
↓ 只有 1 个 bind(其他位置完全不动)
2
3
4
5
6
7
DiffUtil 的算法基础:Eugene Myers 的差分算法,时间复杂度 O((N+M) × D)(D 是编辑距离)。比"O(N²) 全量对比"快很多。
正确使用 DiffUtil 的两个回调:
class MyDiffCallback : DiffUtil.ItemCallback<Item>() {
// ① 是否同一项(按 ID 判断)
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return oldItem.id == newItem.id // ID 一致 = 同一项(即使内容变了)
}
// ② 内容是否一致(按数据判断)
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return oldItem == newItem // data class 自动重写 equals
}
// ③ 增量更新的 payload(可选,更精细)
override fun getChangePayload(oldItem: Item, newItem: Item): Any? {
if (oldItem.likeCount != newItem.likeCount) {
return PayloadType.LIKE_CHANGED // 只更新点赞数
}
return null
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
§17.4 实验:DiffUtil 让"1 条变化"的更新成本从 250ms 降到 8ms。
探索性思考:为什么 DiffUtil 计算放在后台线程? 因为 Myers 算法在长列表上耗时几十 ms(1000 条要 30-100ms)——主线程跑会直接掉帧。
ListAdapter/AsyncListDiffer默认在 background 线程算 diff,算完后回主线程批量 apply。这是"用空间换时间换流畅"的典型工程设计。
# 09.UITableView 全链路
iOS 的 UITableView/UICollectionView 比 Android 的 RecyclerView 设计更早、API 更稳定,但有些坑跨平台需要注意。本章重点:reuseIdentifier 原理、高度计算、prefetching、Self-Sizing 陷阱。
# 9.1 reuseIdentifier 原理
iOS 复用机制的核心是 reuseIdentifier:
// 注册 cell
tableView.register(MyCell.self, forCellReuseIdentifier: "MyCell")
// 取 cell
let cell = tableView.dequeueReusableCell(withIdentifier: "MyCell", for: indexPath) as! MyCell
2
3
4
5
reuseIdentifier 的设计本质:一个字符串 key 对应一个独立复用池。
reuseIdentifier = "TextCell" → Pool A
reuseIdentifier = "ImageCell" → Pool B
reuseIdentifier = "AdCell" → Pool C
2
3
经典反模式:
// ❌ 反模式:动态 reuseIdentifier
let id = "Cell_\(indexPath.row)" // 每个 row 都是独立 id
let cell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath)
// 结果:每个 row 一个独立池,复用机制完全失效
2
3
4
正确做法:reuseIdentifier 必须是常量或按 ItemType 分类:
// ✅ 单一类型
static let cellId = "MyCell"
// ✅ 按 ItemType
func reuseIdentifier(for item: Item) -> String {
switch item.type {
case .text: return "TextCell"
case .image: return "ImageCell"
case .video: return "VideoCell"
}
}
2
3
4
5
6
7
8
9
10
11
# 9.2 高度计算与缓存
iOS 列表性能的另一个关键是高度计算:
UITableView 在显示前需要知道每个 cell 的高度:
↓
heightForRowAt(indexPath:) ← 必须为每个 row 调用
↓
计算总内容高度(用于滚动条)
2
3
4
5
问题:如果有 10000 条数据,列表加载时要调 10000 次 heightForRowAt——每次调如果是"加载数据 + 测量布局"就是几秒卡死。
解决方案演进:
方案 1:固定高度(最简单)
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 80 // 固定值
}
// 或更高效:
tableView.rowHeight = 80
2
3
4
5
6
方案 2:估算高度 + 异步实测(iOS 7+)
tableView.estimatedRowHeight = 80 // 给个估算值,加载时不真测
tableView.rowHeight = UITableView.automaticDimension
2
效果:UITableView 用估算值快速显示,滚动到该 cell 时才真实测量。这让"打开有 10000 条的列表"瞬间完成。
方案 3:手动高度缓存
var heightCache: [IndexPath: CGFloat] = [:]
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if let cached = heightCache[indexPath] {
return cached
}
let h = calculateHeight(for: items[indexPath.row])
heightCache[indexPath] = h
return h
}
2
3
4
5
6
7
8
9
10
适用于"高度复杂但每条计算可缓存"的场景(如富文本帖子)。
# 9.3 prefetching 机制
iOS 10+ 引入了 prefetching 机制——提前为"即将进入屏幕的 cell"准备数据:
extension MyVC: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
// 滚动方向上即将出现的 indexPath
for indexPath in indexPaths {
let url = items[indexPath.row].imageUrl
ImageLoader.shared.prefetch(url) // 提前下载图片
}
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
// 用户改变了滚动方向,取消之前的预加载
for indexPath in indexPaths {
let url = items[indexPath.row].imageUrl
ImageLoader.shared.cancelPrefetch(url)
}
}
}
tableView.prefetchDataSource = self
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
工程价值:用户滚到该位置时图片已加载完,无白屏。
# 9.4 Self-Sizing 陷阱
iOS 的 Self-Sizing Cell(基于 Auto Layout 自动撑开高度)是双刃剑:
优点:开发者不需要计算高度,布局自动适配。
陷阱:
- 每次 layout 都要重新跑约束求解器——比固定高度慢 2-3 倍。
- 快速滚动时长尾帧频繁——某些复杂约束的 cell 测量耗时 20-50ms。
- 滚动位置可能跳变——估算高度与实际差异大时。
优化建议:
- 列表项布局尽量扁平,约束尽量简单。
- 给
estimatedRowHeight一个接近真实平均值的估算(差距越大跳得越厉害)。 - 复杂内容(富文本)考虑异步预计算高度 + 缓存。
探索性思考:为什么 iOS 列表"默认"比 Android 流畅? 三个原因: ① 硬件渲染管线更优:iOS 的 Core Animation 在系统层做更多优化。 ② 复用机制更早成熟:UITableView 自 iOS 2 起设计,API 稳定 15+ 年,开发者踩坑少。 ③ 设备碎片化少:iOS 设备性能下限高,Android 千元机各种"卡"在 iOS 不存在。
但这不代表 iOS 列表不会卡——只是"默认就够好",调优空间反而比 Android 小。Android 调优能从 30fps 提到 60fps,iOS 从 55fps 提到 60fps 已经是天花板。
# 10.Web 虚拟列表全链路
Web 没有原生列表组件——所有虚拟化都要靠库(react-window / vue-virtual-scroller)或自实现。本章拆解:DOM 回收的物理本质 / overscan 调优 / 不定高度的难题 / 滚动还原。
# 10.1 DOM 节点回收
Web 列表的特殊性:DOM 节点是浏览器原生对象,创建和销毁都贵:
列表向下滚动:
↓
item-0 滚出顶部(visible 区域外)
↓
两种策略:
A. 销毁 DOM 节点 + 重建 ← 简单,但每次 DOM 操作 ~1ms
B. 保留 DOM 节点 + 改 transform ← 高效,但要"位移"
2
3
4
5
6
7
主流虚拟列表(react-window 等)走的是A 销毁重建 + 复用 React 组件:
// react-window 简化原理
function VirtualList({ items, itemHeight, height }) {
const [scrollTop, setScrollTop] = useState(0);
// 计算可见区域
const visibleStart = Math.floor(scrollTop / itemHeight);
const visibleEnd = Math.ceil((scrollTop + height) / itemHeight);
// 只渲染可见的 items
const visibleItems = items.slice(visibleStart, visibleEnd);
return (
<div style={{ height: items.length * itemHeight }} onScroll={...}>
{/* 给可见 items 用 transform 定位 */}
{visibleItems.map((item, i) => (
<div
key={item.id}
style={{
transform: `translateY(${(visibleStart + i) * itemHeight}px)`,
height: itemHeight,
}}
>
{item.content}
</div>
))}
</div>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
React 的复用:靠 key 稳定性。React 会复用 DOM 节点(如果 key 不变)只更新内容。
# 10.2 overscan 调优
overscan:可见区域之外额外渲染的 item 数量——避免快速滚动时出现"空白"。
overscan = 0:
屏幕显示 10 个 item,DOM 里也只有 10 个
↓
用户快速滑动 → 新 item 来不及渲染 → 看到空白
overscan = 5:
屏幕显示 10 个,DOM 里有 20 个(上下各 5 个 buffer)
↓
用户快速滑动 → 提前渲染好的 buffer 顶上 → 流畅
2
3
4
5
6
7
8
9
调优经验:
- overscan 太少(0-1):快速滚动空白。
- overscan 太多(10+):失去虚拟化意义,内存占用增加。
- 典型值:2-5(react-window 默认 1)。
实现示例:
<FixedSizeList
height={600}
itemCount={1000}
itemSize={80}
width="100%"
overscanCount={3} // 上下各预渲染 3 个
>
{Row}
</FixedSizeList>
2
3
4
5
6
7
8
9
# 10.3 不定高列表难题
Web 虚拟列表最难处理的就是不定高(如聊天/帖子):
问题 1:滚动位置怎么算?
- 必须知道每个 item 的高度才能算 scrollTop 对应哪个 item
- 但 item 没渲染就不知道实际高度
问题 2:滚动条长度怎么显示?
- 滚动条长度 = 内容总高度 / 视口高度
- 但总高度未知(部分 item 未测量)
2
3
4
5
6
7
主流方案(如 react-virtual / TanStack Virtual):
- 估算 + 实测:未测量的 item 用平均高度估算,进入屏幕后实测并缓存。
- 动态滚动条:随着更多 item 被测量,滚动条逐渐"准确"。
- 双 RAF 测量:渲染后用
requestAnimationFrame拿真实高度。
这是 Web 虚拟列表比 Native 复杂的根本原因——Native 平台的 measure 阶段是同步的,Web 必须等浏览器 reflow 后才能拿到真实尺寸。
# 10.4 滚动还原机制
场景:用户滚到第 500 条,点开详情,返回后期望"还在第 500 条"。
Native 自动还原:
- Android:Activity 的 onSaveInstanceState 自动保存。
- iOS:UITableView 自动保存 contentOffset。
Web 必须手动实现:
// 1. 离开时保存
useEffect(() => {
return () => {
sessionStorage.setItem('list_scroll', String(scrollY));
};
}, [scrollY]);
// 2. 返回时还原
useEffect(() => {
const saved = sessionStorage.getItem('list_scroll');
if (saved) {
window.scrollTo(0, parseInt(saved));
}
}, []);
2
3
4
5
6
7
8
9
10
11
12
13
14
陷阱:还原时如果"虚拟列表数据还没加载完",scrollTo 会失效——必须等数据就绪。
探索性思考:为什么"Web 列表性能优化"比 Native 难? 三个根本性差异: ① DOM 操作是异步的:浏览器在合适的时机才执行 reflow/repaint,不像 Native 同步可控。 ② 没有 GPU 直通的视图层:DOM → CSSOM → Layout → Paint → Composite 链路比 Native 长。 ③ 没有原生复用机制:必须靠 React/Vue 的虚拟 DOM + key 来"模拟"复用。
这就是为什么 Web 列表性能优化往往需要:virtual 库 + content-visibility CSS + GPU 层促进 + reactivity 减少多管齐下。
# 11.声明式列表全链路
Compose LazyColumn / SwiftUI List 是"声明式列表"的代表——API 简单但底层原理与命令式完全不同。本章拆解:LazyColumn 怎么工作 / SwiftUI List 怎么复用 / key 稳定性 / Baseline Profiles 的关键作用。
# 11.1 LazyColumn 原理
Compose 的 LazyColumn 不是"RecyclerView 的封装"——它是基于 Composition + Layout 的"按需测量"机制:
LazyColumn {
items(items = list, key = { it.id }) { item ->
ItemRow(item)
}
}
2
3
4
5
底层原理:
LazyColumn 维护"可见 item 范围"
↓
只有 "范围内" 的 item 才进入 Composition(构建 UI 树)
↓
只有进入 Composition 的 item 才参与 measure/layout/draw
↓
滚出范围的 item 从 Composition 中"剔除"
↓
新进入范围的 item 重新 Compose
2
3
4
5
6
7
8
9
与 RecyclerView 的根本差异:
| 维度 | RecyclerView | LazyColumn |
|---|---|---|
| 复用单元 | ViewHolder(View 对象) | Composition 状态 |
| 复用机制 | 显式 Pool | 隐式(Compose runtime 管理) |
| 状态管理 | 开发者手动清理 | rememberSaveable 自动保存 |
| key | 没有(用 ItemType 区分) | 必须有稳定 key |
关键优势:
- 状态自动保留:用
rememberSaveable的状态在 item 滚出后自动保存,回滚时自动还原——消除了"状态污染"bug。 - 类型推导:编译器自动识别"哪些 item 是同一类",无需手动 ItemViewType。
关键劣势:
- 首帧慢:Compose 编译需要 JIT 编译——首次访问的 Composable 函数有 100-500ms 编译开销。解药是 Baseline Profiles(详见 §11.4)。
# 11.2 SwiftUI List 原理
SwiftUI 的 List 在 iOS 上底层就是 UITableView——SwiftUI 只是一层 declarative wrapper:
List(items, id: \.id) { item in
ItemRow(item: item)
}
2
3
SwiftUI List 的工程含义:
- 复用机制:底层走 UITableView,自动按 View 类型复用。
- 性能基线:与 UITableView 相同(很好)。
- 类型推导:SwiftUI 编译器分析 closure 推导 cell 类型。
SwiftUI List 的特殊优势:
- State 与 View 强绑定:每个 item 的状态(如 toggle 开关)自动跟随数据 id 保存。
- declarative 简洁:100 行 UITableView 代码 = 10 行 SwiftUI List。
注意 iOS 16+:可以用 LazyVStack + ScrollView 替代 List,更灵活但需要自己管理高度。
# 11.3 key 稳定性原则
声明式列表的最关键原则:每个 item 必须有稳定的 key。
为什么必须稳定?
旧列表: [A(id=1), B(id=2), C(id=3)]
新列表: [A(id=1), X(id=4), C(id=3)] // 把 B 换成 X
如果 key 用 id:
diff 算法识别:B(id=2)被删除,X(id=4)被插入
复用:A 和 C 的 View 完全不动,只创建 X 的 View
性能:极佳
如果 key 用 index:
diff 算法识别:position=1 内容变了
复用:position=1 的 View 被 rebind 为 X
⚠️ position=1 的 View 中残留的状态(如展开动画)被错误地"继承"给 X
bug:X 看起来像被"展开"
2
3
4
5
6
7
8
9
10
11
12
13
正确做法:
// Compose ✅
items(list, key = { it.id }) { item -> ItemRow(item) }
// SwiftUI ✅
List(items, id: \.id) { item in ItemRow(item: item) }
// 错误 ❌
items(list) { item -> ItemRow(item) } // 默认用 index 作 key
2
3
4
5
6
7
8
# 11.4 Baseline Profiles
Compose 的"首帧慢"问题的解药是 Baseline Profiles——把"应该 JIT 编译的代码"在安装时 AOT 编译好:
普通 Compose 应用启动:
↓ JIT 编译 Composable 函数(耗时几百 ms)
↓ 显示第一帧
↓ 持续 JIT 边运行边编译
启用 Baseline Profiles:
↓ 安装时编译关键路径(用 profile 数据)
↓ 启动时直接执行编译好的代码
↓ 首帧时间显著减少
2
3
4
5
6
7
8
9
§17.5 实验:Baseline Profiles 让 Compose LazyColumn 首帧 480ms → 320ms,反超 RecyclerView。
工程实践:
// build.gradle.kts
plugins {
id("androidx.baselineprofile")
}
// 用 macrobenchmark 录制 profile(CI 自动)
// 上传后 Google Play 自动分发给用户
2
3
4
5
6
7
探索性思考:为什么声明式列表是"新一代"列表? 三个根本性改进: ① 消除了"复用 + 状态污染"这个根本 bug——状态跟随 key 自动管理,不再依赖开发者纪律。 ② API 极简——10 行代码替代 100 行 ViewHolder 模板。 ③ 类型推导自动——编译器知道"哪些 item 长得一样",无需手动 ItemViewType。
但目前的实际性能仍略逊命令式(特别是低端机),需要 Baseline Profiles 等"工程基建"补齐。3-5 年后声明式应该全面超越命令式。
# 12.跨端列表对照
# 12.1 端到端机制对照
| 阶段 | RecyclerView | UITableView | react-window | LazyColumn |
|---|---|---|---|---|
| 复用单元 | ViewHolder | UITableViewCell | React Component + DOM | Composition |
| 复用键 | ItemViewType (Int) | reuseIdentifier (String) | key (any) | key (any) |
| 缓存层级 | 4 级(Scrap/Cache/Ext/Pool) | 单池(按 reuseId) | React reconciliation | Compose runtime |
| 数据 diff | DiffUtil + ListAdapter | NSDiffableDataSource | React diff | Snapshot 比较 |
| 高度处理 | onMeasure(自动) | heightForRowAt(手动/estimated) | 固定/估算/实测 | 自动 |
| 滚动还原 | onSaveInstanceState(自动) | 自动(contentOffset) | 需自实现 | 自动(rememberSaveable) |
| 首帧性能 | 中(看 inflate) | 高 | 中 | 低→高(需 BP) |
| 稳态 FPS | 高 | 高 | 中 | 高 |
# 12.2 性能能力对比
| 维度 | RecyclerView | UITableView | react-window | LazyColumn |
|---|---|---|---|---|
| 1000 条列表首帧 | 380ms | 200ms | 450ms(首次 hydrate) | 480ms / 320ms(BP) |
| 滚动 FPS | 58 | 60 | 55-58 | 58-60 |
| 内存 | 110MB | 90MB | 150MB | 135MB |
| 开发复杂度 | 中(ViewHolder 模板) | 中 | 高(需库 + key) | 低(声明式) |
| 状态管理 | 手动 | 手动 | 手动(React state) | 自动(remember) |
| 类型安全 | Kotlin 类型推导 | Swift 类型推导 | TypeScript | Kotlin |
# 12.3 统一启示
- 复用机制是列表的灵魂:所有平台都是"窗口可见 + 节点回收 + 数据 diff"三件套。
- ItemType 收敛是跨端共识:无论叫 ViewType / reuseIdentifier / key,3 种以内最优。
- 声明式范式是未来:Compose / SwiftUI / React 都在向"key + 自动 diff"靠拢——消除复用 bug是历史趋势。
- 图片治理是所有平台 70% 的瓶颈——这是跨端共同性,不是某个平台的锅。
- Native 列表性能基线明显高于 Web——但 Web 通过
content-visibility/ Service Worker 等 Web Platform 新特性在快速追赶。
§13-§16 给出由浅入深的四层治理。
# 13.治理一层虚拟化
第一层 = 列表性能的物理底线。没有虚拟化,谈其他优化都是徒劳。本层目标:让任何长列表(> 50 条)都用虚拟化容器。
# 13.1 长列表必须虚拟化
核心命题:§17.1 实验 证明 > 50 条数据必须虚拟化,不虚拟化是自残。
各端虚拟化容器:
| 端 | 容器 | 必用 |
|---|---|---|
| Android | RecyclerView / LazyColumn | ✅ |
| iOS | UITableView / UICollectionView / List | ✅ |
| Web | react-window / vue-virtual / TanStack Virtual | ✅ |
反例(禁忌):
// ❌ Android:ScrollView + LinearLayout 装大列表
<ScrollView>
<LinearLayout>
<!-- 1000 个 item -->
</LinearLayout>
</ScrollView>
// 结果:内存 350MB,首帧 4s,OOM 边缘
// ✅ 正确:用 RecyclerView
RecyclerView { LinearLayoutManager(); adapter = MyAdapter() }
2
3
4
5
6
7
8
9
10
收益:§17.1 实验 内存 350MB → 18MB,首帧 4.2s → 80ms。
边界:< 50 条且简单(如设置项列表)用普通 ScrollView 也可——虚拟化的开销在小列表上反而不值。
# 13.2 固定高度优化
机理:固定高度让列表可以精确计算总高度和滚动位置,避免动态测量。
// Android: setHasFixedSize 启用部分优化
recyclerView.setHasFixedSize(true)
// XML 里 RecyclerView 高度用 match_parent 而非 wrap_content
2
3
// iOS: 直接设固定高度
tableView.rowHeight = 80
// 或 estimatedRowHeight 给个准确估算
tableView.estimatedRowHeight = 80
tableView.rowHeight = UITableView.automaticDimension
2
3
4
5
// Web (react-window): FixedSizeList 比 VariableSizeList 快 2-3 倍
<FixedSizeList itemSize={80} ... />
2
收益:滚动测量耗时 -30%,长尾帧显著减少。
边界:瀑布流、富文本聊天等天然变高场景不适用——但可以**用"估算高度 + 实测缓存"**模拟"准固定高度"效果。
# 13.3 overscan 与 buffer
机理:可见区域外预留几个 item,快速滚动时不出现空白。
各端默认值与建议:
| 端 | 默认 | 推荐 | 何时调大 |
|---|---|---|---|
| RecyclerView CacheView | 2 | 5-10 | 频繁回滑场景 |
| react-window overscan | 1 | 3-5 | 快速滚动场景 |
| iOS prefetchDataSource | 系统自管 | 默认 | 列表项有重资源(图片/视频) |
调优代码:
// RecyclerView:调大 CacheView
recyclerView.setItemViewCacheSize(10)
// 调大特定 ItemType 的 Pool 容量
recyclerView.recycledViewPool.setMaxRecycledViews(VIEW_TYPE_TEXT, 20)
2
3
4
5
// react-window
<FixedSizeList overscanCount={5} ... />
2
边界:
- overscan 太多失去虚拟化意义(占内存)。
- overscan 太少快速滚动空白。
- 典型最优:2-5。
探索性思考:为什么 overscan 不是"越多越好"? 因为 overscan 是"用内存换流畅"——预渲染的 item 占内存。
- overscan = 0:极致省内存,但快速滑就有空白。
- overscan = 10:完美无空白,但内存翻倍。
- 最优是"刚好够覆盖快速滑动 1 帧的距离"——典型 3-5 个 item。
这也是为什么 react-window 默认只有 1——库的默认值偏保守,业务场景需要自己调。
# 14.治理二层复用率
第二层 = 让复用机制真正生效。ItemType 数量是隐形杀手——§02 案例 中"6 种 ItemType → onCreate 380 次"是经典反面教材。
# 14.1 ItemType 收敛
核心命题:§17.2 实验 + §02 案例 证明 ItemType 数量是隐形杀手。6 种以上就是性能灾难,3 种是上限。
策略:通用 layout + 数据驱动差异
// ❌ 反例:6 种类型,复用池极度分散
override fun getItemViewType(position: Int) = when (items[position].type) {
"image" -> 0
"text" -> 1
"video" -> 2
"live" -> 3
"ad" -> 4
"rec" -> 5
}
// ✅ 正例:3 种通用类型,逻辑差异由数据控制
override fun getItemViewType(position: Int) = when {
items[position].hasMedia -> TYPE_MEDIA // 合并 image/video/live
items[position].isAd -> TYPE_AD
else -> TYPE_TEXT // 合并 text/rec
}
// 通用 MEDIA layout 内根据 item 数据显示不同子视图
class MediaViewHolder(view: View) : ViewHolder(view) {
fun bind(item: Item) {
when {
item.hasVideo -> { showVideo(); hideImage(); hideLive() }
item.hasLive -> { showLive(); hideImage(); hideVideo() }
else -> { showImage(); hideVideo(); hideLive() }
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
§02 案例 收益:onCreate 380 → 35(复用率 91%)。
边界:
- 通用模板可能 layout 略复杂,需权衡。
- 视觉差异巨大的 item(如图文 vs 全屏视频)不必强行合并。
# 14.2 reuseIdentifier 常量化
iOS 关键纪律:reuseIdentifier 必须是常量或按 ItemType 分类,绝不能动态:
// ❌ 经典反模式
let id = "Cell_\(indexPath.row)" // 每个 row 一个独立池,复用失效
// ✅ 单类型
static let cellId = "MyCell"
let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellId, ...)
// ✅ 按 ItemType
let id: String
switch item.type {
case .text: id = "TextCell"
case .image: id = "ImageCell"
case .video: id = "VideoCell"
}
let cell = tableView.dequeueReusableCell(withIdentifier: id, ...)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
§18.2 案例 真实事故:某 App 用 indexPath 作 reuseIdentifier → 复用机制完全失效,列表滚动 CPU 持续 100%。
# 14.3 跨列表 Pool 共享
机理:多个相似列表(如 Tab 切换的多个 Feed)共享 ViewHolder 池——切换列表零开销。
// Android RecycledViewPool 共享
val sharedPool = RecyclerView.RecycledViewPool()
// 给每类 type 设置合适的池容量
sharedPool.setMaxRecycledViews(TYPE_TEXT, 30)
sharedPool.setMaxRecycledViews(TYPE_MEDIA, 20)
// 多个列表共享
feedListA.setRecycledViewPool(sharedPool)
feedListB.setRecycledViewPool(sharedPool)
feedListC.setRecycledViewPool(sharedPool)
2
3
4
5
6
7
8
9
10
11
收益:Tab 切换时新列表直接从池里取已有 View,几乎零创建开销。
边界:
- ItemType 必须严格对应(type=0 在所有列表必须是同一种 layout)。
- 多列表共享池的状态清理是关键——
onViewRecycled必须严格清理。
# 15.治理三层 bind
第三层 = 每次 bind 控制在 1-8ms。bind 是流水线最重的一环,超时直接掉帧。
# 15.1 bind 内禁止重活
核心命题:§02 案例 单 bind 180ms 是 "bind 里同步格式化时间 + 解析 JSON + 加载图片" 三件事重叠的结果。
禁忌清单:
| 操作 | 替代方案 |
|---|---|
| SimpleDateFormat 格式化 | 数据模型预格式化(lazy 或 init 时) |
| JSON.parse / Moshi.adapter | 数据层完成,bind 只赋值 |
| Bitmap.decodeFile / 同步解码 | 异步图片库(Glide/Coil/SDWebImage) |
| 字符串拼接(大文本) | 预拼接缓存 |
| 复杂正则 | 预匹配缓存 |
| 数据库查询 | 不允许 |
| 网络请求 | 不允许 |
典型重构:
// ❌ bind 内格式化(每次都跑)
override fun onBindViewHolder(holder: VH, position: Int) {
val item = items[position]
holder.timeTv.text = SimpleDateFormat("yyyy-MM-dd HH:mm")
.format(Date(item.timestamp))
}
// ✅ 数据模型预格式化(只算一次)
data class FeedItem(
val timestamp: Long,
) {
val formattedTime: String by lazy {
SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date(timestamp))
}
}
override fun onBindViewHolder(holder: VH, position: Int) {
holder.timeTv.text = items[position].formattedTime // 仅赋值
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
收益:§02 案例 bind P99 180ms → 6ms。
边界:
lazy计算首次仍有开销——可在后台线程预热。- 数据来自服务端的话,让服务端直接返回 formatted 字符串——零客户端开销。
# 15.2 增量更新 Payload
机理:DiffUtil 算出"哪部分变了",bind 只更新变化部分(不全量重 bind)。
// DiffUtil.ItemCallback 返回 payload
override fun getChangePayload(oldItem: Item, newItem: Item): Any? {
val changes = mutableListOf<String>()
if (oldItem.likeCount != newItem.likeCount) changes.add("like")
if (oldItem.commentCount != newItem.commentCount) changes.add("comment")
return if (changes.isNotEmpty()) changes else null
}
// onBindViewHolder 处理 payload
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
if (payloads.isEmpty()) {
// 全量 bind
bindFull(holder, items[position])
} else {
// 增量 bind(只更新变化字段)
val changes = payloads.flatMap { it as List<String> }
if ("like" in changes) holder.likeTv.text = items[position].likeCount.toString()
if ("comment" in changes) holder.commentTv.text = items[position].commentCount.toString()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
收益:增量场景 bind 时间 -90%(只更新一个数字 vs 全量重 bind)。
典型场景:点赞数变化、关注状态变化、未读数变化。
# 15.3 异步预测算
机理:把"复杂 item 的预测算"放到背景线程,提前算好结果。
// 在数据加载阶段预测算高度/布局
viewModelScope.launch(Dispatchers.Default) {
items.forEach { item ->
item.preCalculatedHeight = calculateHeight(item)
item.preLayoutedText = layoutRichText(item.content)
}
// 完成后切回主线程更新列表
withContext(Dispatchers.Main) {
adapter.submitList(items)
}
}
// bind 时只取预计算结果
override fun onBindViewHolder(holder: VH, position: Int) {
val item = items[position]
holder.contentView.layout(item.preLayoutedText) // 预算好的,零开销
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
收益:复杂富文本列表 FPS 38 → 56(中端机)。
边界:
- 预计算消耗 CPU 和内存——只对"必要 item"做。
- 异步预计算的结果必须线程安全(用 immutable data class)。
# 16.治理四层图片
第四层 = 列表性能 70% 的瓶颈在图片。再好的 bind 优化也救不回"图片同步解码"。
# 16.1 滚动期暂停解码
机理:§17.3 实验 证明图片同步解码占滚动主线程时间 60%+。
// Android: Glide 滚动期暂停
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(rv: RecyclerView, state: Int) {
when (state) {
RecyclerView.SCROLL_STATE_DRAGGING,
RecyclerView.SCROLL_STATE_SETTLING -> {
Glide.with(rv.context).pauseRequests() // 暂停新请求
}
RecyclerView.SCROLL_STATE_IDLE -> {
Glide.with(rv.context).resumeRequests() // 滚动停止恢复
}
}
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
// iOS: SDWebImage / Kingfisher 类似
scrollView.delegate = self
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
SDWebImageManager.shared.cancelAll() // 简化示意
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// 触发可见 cell 重新加载
reloadVisibleImages()
}
2
3
4
5
6
7
8
9
收益:§17.3 实验 FPS 28 → 58。
用户感知:
- 滚动期:图片显示占位图(用户感觉很流畅)。
- 滚动停止后 50-80ms:图片批量出现(用户感觉"图片就绪")。
- 用户体验感受是"很流畅,最后图片亮起"——这是好体验,不是坏体验。
# 16.2 按尺寸下采样
机理:避免加载比 ImageView 实际尺寸大几倍的高清图(详见 卷四·03 §6.2)。
// Android Glide
Glide.with(context)
.load(url)
.override(imageView.width, imageView.height) // 按 ImageView 尺寸下采样
.into(imageView)
2
3
4
5
// iOS Kingfisher
imageView.kf.setImage(
with: url,
options: [.processor(DownsamplingImageProcessor(size: imageView.bounds.size))]
)
2
3
4
5
收益:内存 -90%(4K 图下采到屏幕实际尺寸)、解码耗时 -80%。
关键事实:现代 ImageView 在 4 寸屏只占 200×200 像素,加载 4K(3840×2160)原图是 8 倍浪费——服务端 CDN 自动缩放或客户端下采样是必做。
# 16.3 取消与防错位
取消:滚出屏幕的 view 立即取消图片加载——避免无效解码占 CPU。
override fun onViewRecycled(holder: VH) {
Glide.with(holder.itemView.context).clear(holder.imageView)
}
2
3
收益:快速滑动 CPU -30%。
防错位(§07.4 复用与状态污染 的工程解药):
holder.imageView.tag = item.imageUrl // 标记当前期望的 url
asyncLoad(item.imageUrl) { bitmap ->
if (holder.imageView.tag == item.imageUrl) { // 校验 view 未被复用给别的 item
holder.imageView.setImageBitmap(bitmap)
}
// 否则丢弃 bitmap,避免错位
}
2
3
4
5
6
7
8
收益:图片错位 bug 清零(§18.1 案例)。
探索性思考:为什么"图片治理"是列表性能的 70% 瓶颈? 因为图片同时违反列表的三个核心矛盾: ① 大量数据 vs 内存:4K 图未下采样直接 32MB(4 张就 OOM)。 ② 滚动连续 vs 帧时间:同步解码一张 50-200ms(远超 16ms 帧预算)。 ③ 复用 vs 状态:异步加载完成时 view 已复用,状态错位。
图片是列表三大矛盾的"集中爆发点"——治好图片就解决了大半问题。这就是为什么图片优化是列表优化的最核心专项。
# 17.求证实验
# 17.1 实验一:虚拟化收益
Step 1 — 原始观察:长列表必须虚拟化是共识,但到底差多少?
Step 2 — 提出疑问:1 万条数据,全量渲染 vs 虚拟列表,性能差多少?
Step 3 — 设计实验:构造 1 万条数据,分别用普通 ScrollView 和 RecyclerView,对比内存占用、首帧时间、滚动 FPS。
Step 4 — 实测数据:
| 方案 | 内存 | 首帧时间 | 滚动 FPS |
|---|---|---|---|
| 普通 ScrollView | 350 MB(崩溃边缘) | 4.2 s | 5(卡死) |
| RecyclerView | 18 MB | 80 ms | 58(流畅) |
Step 5 — 提炼结论:
> 100 条的列表必须虚拟化,没有讨论余地。内存差 100×、首帧差 50×、流畅度差 10×。
Step 6 — 边界:
- 前端虚拟列表注意 overscan 数量——太少滚动空白、太多失去优化意义。
- < 50 条且简单可用普通 ScrollView(虚拟化的开销在小列表上反而不值)。
# 17.2 实验二:异构复用
Step 1 — 原始观察:列表里 ItemType 越多复用率越低,到底降多少?
Step 2 — 提出疑问:同一列表里有 5 种 item 类型,复用率会降多少?
Step 3 — 设计实验:构造 5 种 ItemType(图文/纯文/视频/广告/分隔)随机交替,统计 onCreateViewHolder / onBindViewHolder 调用次数。
Step 4 — 实测数据(滚动 1000 屏):
| ItemType 数量 | onCreate 次数 | 复用率 | 滚动 FPS P95 |
|---|---|---|---|
| 1 种 | 18(屏幕容量 ≈ 6×3 缓冲) | 98% | 60 |
| 3 种 | 35 | 96% | 58 |
| 5 种 | 90(5×) | 91% | 47 |
| 6 种(§02 案例) | 380(21×) | 62% | 22 |
Step 5 — 提炼结论:
ItemType 数量是隐形杀手——非线性代价。3 种以内最优,6 种以上是灾难。合并相似 ItemType(通用 layout + 数据驱动差异)是首选方案。
Step 6 — 边界:
- 视觉差异巨大的 item 不必强行合并(如图文 vs 全屏视频)。
- 通用模板可能 layout 略复杂——需要在"复用率"和"代码可读性"间权衡。
# 17.3 实验三:图片解码
Step 1 — 原始观察:列表里图片解码是不是滚动卡顿主因?
Step 2 — 提出疑问:图片解码占滚动主线程时间是多少?
Step 3 — 设计实验:100 项图文列表,对比三种图片加载策略下的滚动 FPS。
Step 4 — 实测数据:
| 策略 | 滚动 FPS | 用户感知 |
|---|---|---|
| 同步解码(bind 内 setBitmap) | 28 | 卡死 |
| 异步加载 + 滚动暂停 | 58 | 流畅,停止后 80ms 图片就绪 |
| 异步加载 + 不暂停 | 45 | 流畅但偶尔卡 |
| 线程池预解码 | 56 | 流畅 + 停止后 50ms 就绪 |
Step 5 — 提炼结论:
滚动期暂停图片解码 + 停止后批量加载,FPS 28 → 58。这是列表优化"投入产出比"最高的一项。
Step 6 — 边界:
- 用户停止滚动后会有 50-80ms"图片加载延迟"——但用户感受是"流畅+亮起",比"流畅+无图"好。
# 17.4 实验四:DiffUtil 收益
Step 1 — 原始观察:列表数据变更,用 notifyDataSetChanged() 全刷 vs DiffUtil 局部刷,性能差多少?
Step 2 — 设计实验:1000 条列表,新增 1 条 / 修改 1 条 / 删除 1 条 三种场景对比。
Step 3 — 实测数据:
| 场景 | notifyDataSetChanged | DiffUtil |
|---|---|---|
| 新增 1 条 | 重建所有 ViewHolder ≈ 250ms | 仅 1 个 onBind ≈ 8ms |
| 修改 1 条内容 | 同上 250ms | 触发 onBindPayload ≈ 5ms |
| 删除 1 条 | 同上 250ms | 1 个 remove 动画 + 平移 ≈ 10ms |
| 1000 条增量 update | 250ms | 50ms(含 diff 算法) |
Step 4 — 提炼结论:
90% 的列表更新场景应用 DiffUtil + ListAdapter。1 条变化的成本从 250ms 降到 8ms(31×)。
Step 5 — 边界:
- 必须正确实现
areItemsTheSame(按 id)+areContentsTheSame(按内容)。 - 长列表(> 5000)的 diff 计算本身有几十 ms,必须在后台线程跑(用
ListAdapter默认就是异步的)。
# 17.5 实验五:声明式 vs 命令式
Step 1 — 原始观察:Compose LazyColumn / SwiftUI List 相比 RecyclerView / UITableView 性能如何?
Step 2 — 提出疑问:声明式列表的实际性能与命令式有差距吗?
Step 3 — 设计实验:同样 500 条图文列表,对比四种实现。
Step 4 — 实测数据:
| 实现 | 首帧 TTI | 滚动 FPS | 内存 | 开发代码量 |
|---|---|---|---|---|
| RecyclerView | 380ms | 58 | 110MB | 200 行 |
| Compose LazyColumn(无 BP) | 480ms | 58 | 135MB | 50 行 |
| Compose LazyColumn + Baseline Profiles | 320ms | 60 | 135MB | 50 行 |
| UITableView | 200ms | 60 | 90MB | 150 行 |
| SwiftUI List | 220ms | 60 | 95MB | 30 行 |
Step 5 — 提炼结论:
声明式 Lazy 列表配 Baseline Profiles 是新一代列表的最佳实践——开发代码量 4-5× 减少、性能持平或超越命令式。
Step 6 — 边界:
- Compose 没 BP 时首帧仍慢(编译开销)—— CI 必须自动录制 Baseline Profiles。
- 性能内存略高(多了 Compose runtime),但可接受。
# 17.6 五大实验启示
虚拟化 → 内存 -95%、首帧 -50× ─┐
│
异构 ItemType → 类型每多 1 种,onCreate 非线性翻倍 │
│
图片同步解码 → FPS 28 → 58(暂停 + 异步) ├─▶ 列表 = 虚拟化 + 复用 + bind 减负 + 图片治理 + 声明式范式
│
DiffUtil vs 全刷 → 增量场景 31× 提速 │
│
Compose Lazy 列表 → 配 Baseline Profiles 超越命令式 ─┘
2
3
4
5
6
7
8
9
统一启示:
- 虚拟化是地板:不虚拟化的所有优化都无意义。
- ItemType 收敛是隐形杀手治理:3 种以内是健康线。
- 图片是 70% 瓶颈:滚动暂停 + 下采样 + clear 三件套。
- DiffUtil 是必备:增量更新场景 31× 提速。
- 声明式是未来:Compose / SwiftUI 配合 Baseline Profiles 已达到工业可用。
# 18.实战案例
# 18.1 瀑布流图片错位
背景:某 App 瀑布流列表偶发图片错位——用户 A 的头像出现在用户 B 的位置。
根因分析:
- bind 触发异步图片加载,完成时 ViewHolder 已被复用,url 不再匹配。
- 异步回调把"用户 A 的头像"setBitmap 到"现在显示用户 B"的 ImageView 上。
修复:
override fun onBindViewHolder(holder: VH, position: Int) {
val item = items[position]
holder.imageView.tag = item.imageUrl // 标记期望的 url
asyncLoadImage(item.imageUrl) { bitmap ->
if (holder.imageView.tag == item.imageUrl) { // 校验 view 未被复用
holder.imageView.setImageBitmap(bitmap)
}
// 否则丢弃 bitmap
}
}
2
3
4
5
6
7
8
9
10
11
教训:异步 + 复用 = 必须做"绑定校验"。这是跨平台通用规则(Android / iOS / Web 同理)。
# 18.2 iOS 复用失效
背景:某 iOS App 切换 tab 后回到列表,所有 cell 重建,CPU 持续 100%。
根因分析:
// 反例:reuseIdentifier 是动态的
let id = "Cell_\(indexPath.row)"
let cell = tableView.dequeueReusableCell(withIdentifier: id, ...)
2
3
每个 row 一个独立 reuseIdentifier → 每个 row 一个独立池 → 复用机制完全失效。
修复:
// 正例:常量 reuseIdentifier
static let cellId = "MyCell"
let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellId, for: indexPath)
2
3
教训:reuseIdentifier 是复用机制的钥匙,绝不能动态。这是 iOS 列表性能的红线。
# 18.3 滚动加载抖动
背景:某 Feed 应用列表滚到底部加载更多时,界面抖动 2-3 次。
根因分析:
- 加载完成后调用
notifyDataSetChanged()——全量重 bind 所有可见项。 - 中端机上 30 个可见项的重 bind 耗时 80ms+,掉 5 帧。
- 用户视觉感受是"列表抖了一下"。
修复:
// 用 ListAdapter + DiffUtil
adapter.submitList(newList) // 内部 diff,只更新新增的几条
2
收益:抖动消失,FPS 保持 58+。
教训:90% 的列表更新场景应该用 DiffUtil——只有"完全重置(如切换分类)"才用 notifyDataSetChanged。
# 19.防劣化体系
# 19.1 三道防线总览
开发期 ──▶ 编译期 / CI ──▶ 上线期 / 运行期
│ │ │
▼ ▼ ▼
[Lint+预算] [自动化基准] [FPS + 复用率监控]
2
3
4
# 19.2 编码期 Lint
强制规则:
- 列表 Adapter 的 ItemType 数量 > 3 → 警告。
onBindViewHolder内含SimpleDateFormat/Gson.fromJson/BitmapFactory.decodeFile→ 错误。RecyclerView.Adapter.notifyDataSetChanged()调用 → 警告(建议 DiffUtil)。- iOS
dequeueReusableCell的 identifier 含indexPath字符 → 错误。 - Compose
items()不传key→ 警告。
# 19.3 CI 与 SLO
CI 卡口:
- 自动化滚动跑分(macrobenchmark / XCTest)。
- 列表关键场景 FPS P95 < 55 → 阻断。
- 单 bind P95 > 8ms → 阻断。
- Compose Baseline Profiles 必须生成。
线上 SLO:
- 列表滚动 FPS P95 ≥ 55。
- 单 bind 耗时 P95 ≤ 8ms。
- 列表首屏到稳定 P95 ≤ 1s。
- ViewHolder 复用率 ≥ 90%。
- 长尾帧 P99 ≤ 50ms。
# 20.跨平台速查
# 20.1 工具速查
| 平台 | FPS 工具 | bind 耗时 | 复用率检测 |
|---|---|---|---|
| Android | Choreographer / JankStats / Perfetto | 埋点 / Systrace | onCreate/onBind 计数 |
| iOS | CADisplayLink / MetricKit / Instruments | 埋点 / Time Profiler | dequeue 次数 |
| Web | rAF / PerformanceObserver / DevTools | 埋点 | React Profiler |
# 20.2 关键 API 速查
| 操作 | Android | iOS | Web | Compose / SwiftUI |
|---|---|---|---|---|
| 虚拟化容器 | RecyclerView | UITableView | react-window | LazyColumn / List |
| 复用键 | getItemViewType | reuseIdentifier | key | key |
| 数据 diff | DiffUtil + ListAdapter | NSDiffableDataSource | React reconciliation | 自动 |
| 固定高度 | setHasFixedSize(true) | rowHeight = ... | itemSize | 自动 |
| 滚动监听 | OnScrollListener | scrollViewDelegate | onScroll | scrollState |
| 预加载 | prefetchItemCount | prefetchDataSource | overscanCount | (需手动) |
# 21.总结与延伸
# 21.1 五条核心原则
- 虚拟化是底线:§17.1 > 50 条必须。
- ItemType 收敛:§17.2 6 → 3 让 onCreate -90%。
- bind 1-8ms:§02 案例 180ms → 6ms 是关键。
- 图片占 70% 瓶颈:滚动暂停 + 下采样 + clear 是组合拳(§16)。
- 声明式范式:§17.5 配 Baseline Profiles 是新一代最佳实践。
# 21.2 五个常见误区
- ❌ "加大 Pool 治复用":错(治标)—— Pool 大了内存涨,复用问题没解决。
- ❌ "降图片质量治卡顿":错(视觉牺牲)—— 应该用异步 + 下采样。
- ❌ "notifyDataSetChanged 简单就用":错(31× 慢)—— 必须 DiffUtil。
- ❌ "声明式 UI 默认更快":错(需 Baseline Profiles)。
- ❌ "ItemType 多没关系":错(隐形杀手,§02 案例)。
# 21.3 一句话总结
列表是 App"性能门面",这一关过不去再好的其他场景也救不回用户的"卡"印象。 虚拟化(数据 vs 视图)+ 复用率(ItemType 收敛)+ bind 减负(1-8ms)+ 图片治理(70% 瓶颈)= 列表性能四件套。 §02 案例 那个"3 周经验派 FPS 22 vs 6 天方法派 FPS 58"的反差,正是这条路径的最锋利证据——方向比工具更重要。