深入理解代替单纯记忆
本文中的问题和排查过程由作者完成,文章编写由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 动画滑入ListVC在init中注册通知,数据变化时调用reloadDataContainerVCdismiss 后不会释放,下次打开复用同一个实例
三、复现步骤
- 打开
ContainerVC,containerView滑入,UITableView 显示[数据 A],正常 - 关闭(dismiss),
ContainerVC及其子 VC 仍然存活 - 此时外部数据变化,通知触发
reloadData,数据源变为[数据 B, 数据 A] - 再次打开
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
headerView 是 containerView 的子视图,但它的 trailing 约束锚定到了 VC 主 view 的 safeAreaLayoutGuide.trailing。
AutoLayout 解析约束时,会将所有边的位置转换到共同祖先的坐标系中计算。当 containerView 在屏幕外时:
headerView.leading = containerView.leading ≈ 844(屏幕外)
headerView.trailing = view.safeArea.trailing ≈ 800(屏幕右边缘)
trailing(800) < leading(844) → 宽度为负 → 被压缩为 0
ListVC.view 的 leading.trailing 跟着 headerView → tableView.width = 0。
而 ContainerVC_B 的约束全部相对于 containerView,后者的尺寸是 frame 设定的固定值,不随位置变化,所以 tableView 始终有有效宽度。
5.2 reloadData 在 width=0 时为什么会导致显示错乱?
根据日志观察到的现象,推测因果链如下:
-
reloadData 在 width=0 时被触发。UITableView 计算可见行数为 0,因此不调用
cellForRow,也不回收旧 cell。但 UITableView 内部可能认为这次 reload 已经完成。 -
reload 被"空转消费"—— 流程走了,但实际什么都没刷新。旧的 cell(第一次打开时创建的 CellA)仍然挂在 tableView 的 subview 上。
-
当
containerView滑入屏幕、tableView width 从 0 恢复正常时,触发了layoutSubviews。但 UITableView 不再将其视为一次完整的 reload,而是当作尺寸变化引起的增量布局。 -
增量布局中,UITableView 发现 row=0 位置已有一个 cell(上次残留的 CellA),直接复用,不调用
cellForRow。仅对 row=1 调用cellForRow,返回数据 A 的 cell。 -
最终两行都显示数据 A。
六、修复
将 ListVC.view 的 leading.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 就会被空转消费。