问题背景
列表页做局部刷新时,线上偶现主线程崩溃,异常信息长这样:
NSInternalInconsistencyException
Invalid update: invalid number of rows in section 0. The number of rows
contained in an existing section after the update (1) must be equal to
the number of rows contained in that section before the update (3),
plus or minus the number of rows inserted or deleted from that section
(0 inserted, 1 deleted) and plus or minus the number of rows moved into
or out of that section (0 moved in, 0 moved out).
Table view: <UITableView: 0x13e37d600; frame = (0 0; 375 365); ...;
dataSource: <ViewController: 0x1446f0a20>>
括号里的三个数字就是全部线索:after=1、before=3、deleted=1。
一条公式
上面那段绕口的英文,翻译过来就是:
after = before + inserted - deleted + movedIn - movedOut
两端的取值来源不同,这是一切问题的根源:
| 变量 | 谁提供 | 什么时候定下来 |
|---|---|---|
before | TableView 内部缓存 | 上一次 reloadData 完成布局、或上一次合法 batch update 提交时 |
after | dataSource 回调 | 你调局部刷新的那一刻,现场再问一遍 |
inserted 等 | 你的声明 | 你传了几个 indexPath |
各个 API 只是公式的不同特例:
| API | 公式退化为 | 崩溃条件 |
|---|---|---|
reloadRows | after == before | 数据源总数变了,哪怕 index 完全合法 |
deleteRows(删 1 行) | after == before - 1 | 数据源没减、或减了不止 1 |
insertRows(插 1 行) | after == before + 1 | 数据源没增、或增了不止 1 |
performBatchUpdates | 完整公式 | 任意一项对不上 |
把开头的报错代入:before=3、deleted=1,系统期望 after=2,而 dataSource 现场报的是 1——等式不成立,直接抛异常。
崩法一:数据变了,TableView 缓存没更新
先说背景:UITableView 不会每次都问 dataSource,而是在特定时机(reloadData 生效、进 window、layoutSubviews)问一次 section 数和行数,缓存在内部——断言里的 before 就来自这份缓存。
去掉业务外壳,成立条件只有两条:
- 数据源可以被 UI 之外的路径修改——网络回调、磁盘缓存加载、另一个页面写同一份共享数据;
- 「改数据」和「reloadData」不是绑在一起的原子操作。
两条同时成立时,「数据源已改、TableView 缓存未更」的空隙里任何一次局部刷新都会命中公式。
sequenceDiagram
participant TV as UITableView
participant DS as 数据源
participant W as 任意写入方(网络/缓存/其他页面)
Note over TV,DS: 某次布局把行数缓存下来:before = 10
W->>DS: 异步改数据 10 → 5
Note over DS: 只改了数组,没有 reloadData
TV->>TV: 任何人调 reloadRows(index 完全合法)
TV->>DS: numberOfRows?
DS-->>TV: 5
Note over TV: after=5 ≠ before=10 → Crash
崩法二:先校验,后异步提交
这是经典 check-then-act 竞态的 UI 版:一致性校验和真正执行之间隔了一个异步边界,而校验结果不会自动过期。
这种代码几乎从来不是一个人一次写出来的,而是三段「各自都正确」的代码在时间线上拼出来的:
// 代码块 ①:左滑删除(开发者 A,v1.0)
// 教科书写法:先校验、异步删除、成功后用 deleteRows 做动画
func onDelete(_ model: Model, at indexPath: IndexPath) {
guard indexPath.row < dataList.count else { return } // 校验:表=4、数据=4 ✓
service.remove(model) { [weak self] in
guard let self else { return }
self.dataList = service.latestList() // 4 → 3
self.tableView.deleteRows(at: [indexPath], with: .top) // 💥 崩在这里
}
}
// 代码块 ②:监听数据变化刷新列表(开发者 B,v2.3,另一个需求)
// 也是标准模式;B 大概率没看过代码块 ①
NotificationCenter.addObserver(.dataChanged) { [weak self] _ in
self?.dataList = service.latestList()
self?.tableView.reloadData()
}
// 代码块 ③:删除服务内部(开发者 C,基础库)
// 隐藏契约:先广播、后回调——接口签名里完全看不出来
func remove(_ model: Model, completion: @escaping () -> Void) {
db.delete(model) // 4 → 3
NotificationCenter.post(.dataChanged) // 先广播
DispatchQueue.main.async { completion() } // 后回调
}
单看每段都是教科书写法,评审时挑不出毛病;问题只存在于三者组合后的时序里:
sequenceDiagram
participant VC as 页面
participant SVC as 删除服务 ③
participant SIDE as 通知监听 ②
Note over VC: ① 校验:表=4、数据=4 ✓,发起异步删除
VC->>SVC: remove(model)
SVC->>SVC: db.delete 4 → 3
SVC-->>SIDE: 通知先到
SIDE->>VC: latestList()=3,reloadData
Note over VC: TableView 缓存已经是 3(删除的 UI 更新已完成)
SVC-->>VC: completion 后到
VC->>VC: ① deleteRows 声明删 1 行
Note over VC: before=3、after=3、deleted=1 → Crash
崩溃本质:同一次删除的 UI 更新被执行了两遍——第一遍由 ② 的 reloadData 悄悄完成,第二遍是 ① 回调里的 deleteRows 补刀,而 ① 开头那个校验早在几十毫秒前就过期了。回调那一刻数据源根本没变(3→3),却声明删了 1 行。弹窗确认、等网络返回、跳转再回来,任何这类时间窗都属于同一模式。
修复方案
先说原则:这类崩溃的根源是数据和 UI 两份状态失去同步,所以第一位的努力方向是尽可能让「改数据」和「刷 UI」绑在一起完成,收敛数据的写入路径。数据变更越频繁、异步场景越多,两者脱节的风险就越大,越需要在架构上下功夫,而不是指望 UI 层处处防御。
存量代码:调用前门禁(补丁式修复)
不改架构的前提下,核心只有一条:公式两端的值,必须在调用局部刷新的前一刻取,不满足就回退到 reloadData。
// 判定值在调用前一刻取;任何异步回调、弹窗确认之后都要重新取
let before = tableView.numberOfRows(inSection: 0) // TableView 自己报的行数
let after = dataSource.count // 数据源当前行数
if tableView.numberOfSections == 1, after == before - 1 { // deleteRows 特例;其他 API 换对应公式
tableView.deleteRows(at: [indexPath], with: .top)
} else {
tableView.reloadData() // 公式不成立就放弃动画,保住进程
}
三个要点:
before用tableView.numberOfRows(inSection:)取,不要用自己记的旧变量——它读的正是断言里before那份 TableView 内部缓存;- 采样和调用之间不能穿插任何异步窗口(回调、弹窗、通知),穿插了就重新采;
reloadData兜底是安全阀不是日常路径,正常情况仍走带动画的局部更新。
要诚实地承认:这是打补丁式的修复。它能保证不 crash,但走到兜底分支时,等于接受了「数据和 UI 曾短暂不一致」的现实,只是用全量刷新把两边强行拉齐(代价是丢一次动画)。不一致的根源——多条写入路径、异步窗口——并没有被消除。
根本解法:Diffable Data Source(iOS 13+)
上面所有方案都是针对不使用 Diffable Data Source 的场景。根本解法是改用 UITableViewDiffableDataSource:
- 它以数据快照(snapshot)为中心:每次更新都提交一份完整的新 snapshot,内部自动和旧 snapshot 做 diff,生成对应的 insert / delete / move;
- 内部行数由系统维护,开发者不再需要手工保证「公式」成立,也不需要关心取值时机——这类 Invalid update crash 从机制上被消除;
- 两个注意点:不要和手调
reloadRows/insertRows/deleteRows混用;snapshot 的 item 需要稳定且唯一的 Hashable 标识。
三层方案对比
| 层 | 做法 | 定位 |
|---|---|---|
| 数据与 UI 保持同步 | 收敛写入路径,改数据与刷 UI 绑定完成,共享数据生命周期与页面对齐 | 原则;异步越多越要在架构上保证 |
| 调用前门禁 | 上面那段代码:前一刻取值对公式,不成立回退到 reloadData | 补丁;存量页面最小改动,接受短暂不一致 |
| Diffable Data Source | snapshot 一次提交,内部自动 diff,公式由系统维护 | 根治;iOS 13+ 新页面首选,不要和手调局部刷新混用 |