UITableView 在 width=0 时 reloadData 被"空转消费"导致 Cell 显示错乱

38 阅读4分钟

深入理解代替单纯记忆

本文中的问题和排查过程由作者完成,文章编写由Cursor完成

一、问题现象

一个 UITableView 在特定时序下出现了诡异的显示错乱:

  • 数据源有 2 条数据 [数据 B, 数据 A]numberOfRowsInSection 返回 2
  • 但 UITableView 显示了 2 条完全相同的数据 A
  • 通过日志发现 cellForRowAtIndexPath 只被调用了 1 次(row=1),row=0 从未被请求

数据源没有问题,UITableView 却跳过了 row=0 的 cell 请求。

二、场景结构

出问题的 VC 架构如下:

ContainerVC(容器,通过 frame 动画实现滑入/滑出)
  └── containerView(承载内容的 view,初始位置在屏幕外)
        └── ListVC.view(子 VC,内含 UITableView)

关键行为:

  • ContainerVC 通过 present 弹出,containerView 初始在屏幕外,然后通过 frame 动画滑入
  • ListVCinit 中注册通知,数据变化时调用 reloadData
  • ContainerVC dismiss 后不会释放,下次打开复用同一个实例

三、复现步骤

  1. 打开 ContainerVCcontainerView 滑入,UITableView 显示 [数据 A],正常
  2. 关闭(dismiss),ContainerVC 及其子 VC 仍然存活
  3. 此时外部数据变化,通知触发 reloadData,数据源变为 [数据 B, 数据 A]
  4. 再次打开 ContainerVC

预期:显示 [数据 B, 数据 A]

实际:显示 [数据 A, 数据 A]

四、排查过程

4.1 排除数据源问题

日志确认 numberOfRowsInSection 返回 2,两条数据标识符不同。数据源正确。

4.2 怀疑 reloadData 在 off-screen 时异常

dismiss 后通知仍在触发 reloadData(view.window == nil),怀疑这导致了 UITableView 内部状态不一致。

但通过对照实验推翻了这个假设:我们有另一个功能相同但布局实现不同的 ContainerVC_B。替换后,即使同样在 off-screen 时触发 reloadData,重新打开后 cellForRowAtIndexPath 正确执行了 2 次

结论:off-screen 时的 reloadData 不是问题,问题在 ContainerVC 自身的实现。

4.3 对比两个容器的实现差异

逐行对比发现,关键差异在 ListVC.view 的 AutoLayout 约束上。

ContainerVC_B(正常)—— 约束相对于 containerView:

// containerView 尺寸通过 frame 设定,是固定值
containerView.frame = CGRectMake(0, offScreenY, fixedWidth, fixedHeight);

// ListVC.view 的宽度 = containerView.width = 固定值
[listVC.view mas_makeConstraints:^(MASConstraintMaker *make) {
    make.leading.trailing.equalTo(containerView);
}];

ContainerVC(异常)—— 约束跨越了视图层级:

// containerView 尺寸也是固定的
containerView.frame = CGRect(x: offScreenX, y: 0, width: fixedWidth, height: fixedHeight)

// 但 headerView 的 trailing 锚定到了 VC 主 view 的 safeArea
headerView.snp.makeConstraints { make in
    make.leading.equalToSuperview()                              // = containerView.leading
    make.trailing.equalTo(view.safeAreaLayoutGuide.snp.trailing) // = VC 主 view 的右边缘
}

// ListVC.view 跟着 headerView 走
listVC.view.snp.makeConstraints { make in
    make.leading.trailing.equalTo(headerView) // width = headerView.width
}

这个跨视图层级的约束就是根因。

五、根因分析

5.1 跨视图约束如何导致 width=0

headerViewcontainerView 的子视图,但它的 trailing 约束锚定到了 VC 主 viewsafeAreaLayoutGuide.trailing

AutoLayout 解析约束时,会将所有边的位置转换到共同祖先的坐标系中计算。当 containerView 在屏幕外时:

headerView.leading  = containerView.leading  ≈ 844(屏幕外)
headerView.trailing = view.safeArea.trailing  ≈ 800(屏幕右边缘)

trailing(800) < leading(844) → 宽度为负 → 被压缩为 0

ListVC.viewleading.trailing 跟着 headerViewtableView.width = 0

ContainerVC_B 的约束全部相对于 containerView,后者的尺寸是 frame 设定的固定值,不随位置变化,所以 tableView 始终有有效宽度。

5.2 reloadData 在 width=0 时为什么会导致显示错乱?

根据日志观察到的现象,推测因果链如下:

  1. reloadData 在 width=0 时被触发。UITableView 计算可见行数为 0,因此不调用 cellForRow,也不回收旧 cell。但 UITableView 内部可能认为这次 reload 已经完成。

  2. reload 被"空转消费"—— 流程走了,但实际什么都没刷新。旧的 cell(第一次打开时创建的 CellA)仍然挂在 tableView 的 subview 上。

  3. containerView 滑入屏幕、tableView width 从 0 恢复正常时,触发了 layoutSubviews。但 UITableView 不再将其视为一次完整的 reload,而是当作尺寸变化引起的增量布局

  4. 增量布局中,UITableView 发现 row=0 位置已有一个 cell(上次残留的 CellA),直接复用,不调用 cellForRow。仅对 row=1 调用 cellForRow,返回数据 A 的 cell。

  5. 最终两行都显示数据 A。

六、修复

ListVC.viewleading.trailing 约束改为相对于 containerView

// 修复前:width 间接依赖 headerView(跨视图约束,position-dependent)
listVC.view.snp.makeConstraints { make in
    make.leading.trailing.equalTo(headerView)
}

// 修复后:width 直接依赖 containerView(固定尺寸,position-independent)
listVC.view.snp.makeConstraints { make in
    make.leading.trailing.equalTo(containerView)
}

containerView 的 width 是通过 frame 设定的固定值,不随位置变化。改动后 tableView 在任何时刻都有有效宽度,reloadData 不会被空转消费。

七、总结

归根到底,这是UITableView 的 reloadData 时的一个边界行为

当 tableView 的 bounds 宽度(或高度)为 0 时,reloadData 会走内部流程(查询行数),但可能不会创建或回收任何 cell。后续尺寸恢复时,UITableView 按增量布局处理,可能复用之前残留的旧 cell。

这可能不一定是 UITableView 的 bug,而是合理的优化 —— 没有可见区域时不创建 cell。但如果约束写法导致 tableView 在不该为 0 的时候 width 为 0,这个行为就会引发显示错乱。

排查建议

cellForRowAtIndexPath 的调用次数不符合预期时,优先检查 tableView 在 reloadData 时刻的 frame:

NSLog(@"reloadData: frame=%@, window=%@",
    NSStringFromCGRect(self.tableView.frame),
    self.tableView.window);

如果 width 或 height 为 0,reloadData 就会被空转消费。