DEBUG.WIKI
V.PS:让项目靠近用户,亚太、欧洲、北美 KVM VPS
IPPeak:从目标地区访问网页,住宅代理、SEO 监测、广告验证
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国

UITableView 报 invalid number of rows:让数据源与批量更新保持一致

UITableView 插入或删除行时报 invalid number of rows,核对数据源数量、旧新索引路径和批量更新顺序,避免在 cellForRowAt 中插行或混用 reloadData。

向列表插入一行时,应用抛出 NSInternalInconsistencyException,日志包含 Invalid update: invalid number of rows in section 0。先看日志里的三个数字:更新前的行数、更新后的行数,以及本次声明插入或删除的行数。

例如,表格原来有 5 行,数据源更新后仍返回 5,却调用了一次 insertRows。UIKit 预期得到 6 行,实际只得到 5,因此拒绝这次更新。这个错误通常发生在传统 UITableViewDataSource 的增删逻辑中。下文示例面向 Swift 5.5 与 iOS 15 及以上,使用 iOS 11 起可用的 performBatchUpdates;更早系统的 beginUpdates / endUpdates 也需要遵守同样的数据一致性要求。

先确认 numberOfRows 返回的是哪份数据

对于已经存在的分区,行数应满足:

更新后行数 = 更新前行数 + 插入行数 - 删除行数
                         + 移入行数 - 移出行数

分区内部移动不会改变行数。插入、删除整个分区时,还要单独核对分区数量及各分区的数据。

检查 numberOfRowsInSection 和 cellForRowAt 是否读取同一份数组。如果行数来自过滤后的数组,单元格却读取原始数组,即使两个数组碰巧一样长,也可能展示错内容或越界。数组更新、行数计算和索引路径生成应使用同一份列表状态。

也不要为了显示已有数据,在 cellForRowAt 中调用 insertRows。这个回调负责提供指定位置的单元格,滚动和刷新时会多次触发;它不是“新增一条数据”的通知入口。初始数组已经装好时,让表格直接读取数组;如果数组是在页面显示后整体取得,再替换数组并调用一次 reloadData()。

把数组修改与行更新放进同一批次

下面是单分区、字符串列表的完整控制器示例。appendItem 和 removeItem 应由用户操作或取得新数据后的主线程回调调用,不要放进单元格配置方法。

import UIKit

@MainActor
final class ItemsViewController: UITableViewController {
    private var items = ["A", "B", "C"]

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self,
                           forCellReuseIdentifier: "Item")
    }

    override func tableView(_ tableView: UITableView,
                            numberOfRowsInSection section: Int) -> Int {
        items.count
    }

    override func tableView(_ tableView: UITableView,
                            cellForRowAt indexPath: IndexPath)
        -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(
            withIdentifier: "Item", for: indexPath
        )
        cell.textLabel?.text = items[indexPath.row]
        return cell
    }

    func appendItem(_ value: String) {
        tableView.performBatchUpdates {
            let newRow = items.count
            items.append(value)
            tableView.insertRows(
                at: [IndexPath(row: newRow, section: 0)],
                with: .automatic
            )
        }
    }

    func removeItem(at row: Int) {
        guard items.indices.contains(row) else { return }
        let oldPath = IndexPath(row: row, section: 0)
        tableView.performBatchUpdates {
            items.remove(at: row)
            tableView.deleteRows(at: [oldPath], with: .automatic)
        }
    }
}

初始状态有 3 行,执行 appendItem("D") 后,数组变成 4 项,同时声明在第 3 行插入一行。删除时则先保留旧状态下的路径,再在批次中删除模型和对应行。UIKit 在更新过程中读取数据源时,能得到与操作匹配的结果。

如果多个异步请求都会改列表,把结果送回主执行器后再修改数组,避免后台线程一边改数据,主线程一边计算行数。业务上可能同时替换整份列表和执行局部动画时,还需要统一这些操作的入口;不能让某个回调绕过列表控制器直接修改数组。

同批删除与插入时,路径属于不同状态

假设旧数组是 ["A", "B", "C"],需要删除 B,再把 D 插入末尾,最终得到 ["A", "C", "D"]。删除路径使用旧数组中的 B,也就是第 1 行;插入路径使用更新后的 D,也就是第 2 行:

// 位于前面控制器内部,且当前 items 为 ["A", "B", "C"]。
func replaceBWithD() {
    tableView.performBatchUpdates {
        items.remove(at: 1)
        items.append("D")
        tableView.deleteRows(
            at: [IndexPath(row: 1, section: 0)], with: .automatic
        )
        tableView.insertRows(
            at: [IndexPath(row: 2, section: 0)], with: .automatic
        )
    }
}

UIKit 在批量更新中先处理删除,再处理插入;不要按照方法调用的先后,把所有索引都当成同一份可变数组的当前下标。复杂更新应先保存旧状态、计算新状态和差异,再一次提交,尤其不能一边循环删除数组元素,一边沿用已经发生偏移的下标。

哪些改法会掩盖问题

在 insertRows 前后追加 reloadData(),可能让表格提前读到新行数,随后又收到一次插入通知,形成重复更新。整表刷新与差量更新应选用符合这次数据变化的一条路径;不要在同一个更新批次中混用。存在尚未提交的更新时,也不要强制整表刷新。

把动画改成 .none 不会取消行数校验。捕获异常后继续运行、随意给数组补空元素,都会留下模型与界面不同步的问题。若列表由 diffable data source 管理,应更新它的 snapshot,不要再用这套传统数据源代码手动增删行。

修复后,逐项检查初次加载、空数组插入第一项、删除最后一项,以及快速连续操作。每次更新前后的数量都应符合日志中的关系,单元格内容也应对应正确的数据。异常消失但出现重复行、错位或数组越界,仍说明状态转换没有整理完整。