普通视图

发现新文章,点击刷新页面。
昨天以前首页

用 AI 降低 iOS 客户端 UI 自动化测试难度

作者 songgeb
2026年4月21日 16:16

为什么 iOS UI 自动化仍然难

在真实业务里,UI 自动化往往卡在几类问题上:

  • 门槛高:需要熟悉 XCTest、页面抽象、CI 集成,非客户端同学很难独立推进。
  • 维护贵:界面一改,选择器、坐标、等待逻辑跟着失效,修复成本像还技术债。
  • 反馈慢:过度依赖截图或视觉比对时,脚本和排障都变慢,协作也不顺畅。

近期探索方向是:用系统无障碍(Accessibility)能力看见界面,用 命令行工具 驱动模拟器;把写脚本交给 AI,把测什么、对不对交给人(其实交给AI应该也可以)。

这样可以把自动化从少数工程师专属(如测试开发岗位)拉回到测试与交付都能参与的节奏里。

核心思路:无障碍树 + AXe

iOS 为视障用户暴露的无障碍信息,会在系统侧形成一棵无障碍树:控件文案、(若开发配置)唯一标识、大致几何信息都可以被读取。类比前端:界面是渲染结果,无障碍树更接近可被程序消费的语义结构。

在命令行驱动模拟器这一层,目前AXe 在易用性、能力完整度和可脚本化程度上综合表现最好,因此方案明确为 无障碍树 + AXe

  • 用 AXe 读取无障碍树、点击、输入、手势、截图等,再配合 Shell 或步骤文件做编排。
  • 脚本层负责稳定可重复的执行;
  • 人 + AI 负责把业务语言翻译成脚本,并在失效时快速迭代。

AI 具体降低了哪些难度

除下文分条说明外,这里想单独强调一点:编写与排障时,describe-ui 拉起的无障碍树仍是最快、成本最低的定位与断言手段;但在结构复杂的原生页面,或 WebView / H5 等无障碍信息不完整、控件不可见的场景里,完全可以把 AXe 截屏交给 AI 分析——既可用于结果校验(布局是否异常、关键视觉元素是否出现),也可在 AI 协助下从画面反推点击坐标 / 热区,再固化为 touch、像素级辅助脚本等步骤。相比过去「只能死磕无障碍树或完全依赖人工看图写坐标」的传统做法,可选路径更多:树优先、截图与 AI 作补充;人工判断与模型辅助看图可以组合使用,而不必二选一。

从写脚本到描述流程:协作时序

传统模式下,测试同学往往要先补编程与框架知识;AI 辅助时,自然语言 + 页面结构文本即可闭环迭代:

sequenceDiagram
    participant QA as 测试/业务
    participant AI as AI 助手
    participant SIM as 模拟器 + CLI
    QA->>AI: 用自然语言描述端到端流程与验收点
    AI->>SIM: 按需读取无障碍树(describe-ui 或等价能力)
    SIM-->>AI: 返回页面结构文本
    AI-->>QA: 交付可执行脚本(.steps / Shell 等)
    QA->>SIM: 本地执行脚本
    SIM-->>QA: 某步失败或状态不符
    QA->>AI: 反馈失败步骤 + 当前页面结构文本
    AI->>SIM: 必要时再次拉取树或调整定位策略
    AI-->>QA: 修改后的脚本
    Note over QA,SIM: 人负责测什么、怎样算对;AI负责怎么点、怎么判、怎么改

降低的难度:不必从零掌握语法与定位细节,把翻译为可执行步骤外包给模型。

排障成本:默认走文本通道而非截图通道

同一类问题(例如点不到、断言失败),用文本无障碍树通常比反复传图更省、更稳:

flowchart LR
    subgraph fail["脚本失败 / 状态异常"]
        A["失败步骤 + 上下文"]
    end

    subgraph pathText["推荐:文本路径"]
        T1["拉取无障碍树输出"]
        T2["grep / 条件分支 / 贴给 AI 分析"]
        T3["改 label / id / 等待 / 分支逻辑"]
    end

    subgraph pathImg["必要时:视觉路径"]
        I1["截图"]
        I2["人工或 AI 看布局 / H5 等"]
        I3["改坐标或视觉辅助逻辑"]
    end

    A --> T1
    T1 --> T2
    T2 --> T3
    A -.->|"仅当树不够用"| I1
    I1 --> I2
    I2 --> I3

降低的难度:排障从猜界面加大量截图对话变成结构化文本 diff,更适合日常高频使用。

成本结构:AI 管「写脚本、修脚本」,不管「跑脚本」

把 token 与人力集中在编写与改版修复,执行阶段不依赖模型:

flowchart TB
    subgraph once["一次性 / 低频"]
        W1["新流程:描述需求"]
        W2["AI 生成首版脚本"]
        W3["人确认可重复跑通"]
    end

    subgraph daily["高频:回归执行"]
        R1["CI 或本地直接跑脚本"]
        R2["零模型调用"]
    end

    subgraph rare["偶发:UI 改版"]
        U1["脚本失效"]
        U2["贴新无障碍树 + 失败信息"]
        U3["AI 小步修补"]
    end

    W1 --> W2
    W2 --> W3
    W3 --> R1
    R1 --> R2
    R1 --> U1
    U1 --> U2
    U2 --> U3
    U3 --> R1

降低的难度:把自动化从持续烧对话/烧图变成可沉淀的脚本资产,更容易在团队里推广。

经验法则:默认仍以 describe-ui 无障碍树为主;遇到复杂原生页、Web 页树信息不足时,再用 AXe 截图 + AI 做结果校验或反推坐标,与「只靠树或只靠人眼」相比,路径更灵活。

工程落地:三种编写方式怎么选

按复杂度递进,避免一上来就做大而全框架:

  1. 交互模式:在终端逐条执行看树、点击、再验,适合探索页面与验证定位。
  2. 批量步骤文件(如 .steps):适合线性、无分支的流程,结构简单、可读性强。
  3. Shell 脚本:需要条件判断、重试、关闭弹窗、拼装环境变量时再用;可与公共函数库复用高频动作。选型建议:能线性顺序完成的用步骤文件;一旦出现如果出现某文案则、最多重试 N 次就上升到 Shell。不确定时,把业务口述给 AI,让它帮你选载体即可。

工程内案例:跨页面资源链路冒烟

该小节展示目前已经在工程中应用的案例。

辅助 QA 验证某类资源是否生效——从打开 App,进入资源相关页面并选用资源,再进入另一处资源应用页面触发使用,最终以 截图呈现结果,形成可重复结论(中途可配合 describe-ui 做关键状态断言)。

flowchart TD
    A[启动并进入 App] --> B[进入资源入口页]
    B --> C[选用目标资源]
    C --> D[进入资源应用页]
    D --> E[触发资源使用]
    E --> F[无障碍树断言关键状态]
    F --> G[截图固化结果]

落地要点:关键路径优先 accessibilityIdentifier 或稳定 label;WebView 区域用 touch 或坐标兜底;异步生效处加重试或等待;截图偏重最终留档与对非研发可读的佐证,日常仍以无障碍树文本断言为主。

不足之处

  • 仅支持模拟器(AXe) :当前 AXe 面向模拟器;若要在真机上跑同类 UI 自动化,通常需转向 XCUITest,或评估各厂商付费真机云 / 设备农场等方案,并在证书、并发、脚本形态与成本之间做权衡。
  • WebView / H5:内部细粒度控件往往不出现在无障碍树里,常见做法是坐标触摸或截图后做像素/区域启发式分析,这类脚本更依赖评审与设备基准。
  • 多语言包:按文案定位会在语言切换后失效;更稳的是推动客户端为关键控件补齐 accessibilityIdentifier
  • 坐标定位:不同机型逻辑分辨率不同,应作为最后手段,或结合比例计算。
  • 音视频与强动画:更适合接口层、状态层或人工探索性测试,不宜对 UI 脚本抱有过高期望。

小结

  • 无障碍树 + AXe把看见界面变成可脚本化、可 diff 的文本问题。
  • AI 把脚本编写与失效修复从专业技能降维成自然语言协作。
  • 文本优先、控制模型介入频率把成本压到可持续的水平。若你也在做 iOS 交付质量与回归效率,可先让模拟器上的端到端跑通,再逐步资产化用例,而不是先搭一座无人维护的测试金字塔。

Compositional layout in iOS

作者 songgeb
2026年3月20日 17:28

Compositional layout是在2019年为UICollectioinView引入的一个新布局

Compositional layout是什么

  • Compositional layout是一套针对UICollectionView新的布局方法
  • 对应的核心类是UICollectionViewCompositionalLayout(macOS上是NSCollectionViewCompositionalLayout)
  • 其目的是让UICollectionView可以更容易地支持更灵活布局UI的开发

Compositional layout布局的三大设计哲学:

  1. Composable:可组合,强调用简单的组件组合出复杂的内容
  2. Flexible:灵活(官方说,You can write any layout with Compositional layout)
  3. Fast

几个例子感受一下Compositional layout能做什么

  1. 如下示意图展示了一个纵向滚动的UICollectionView,其中有上中下三部分,上部分看上去像两列UITableView,各部分的布局和样式各不相同

image.png

  1. 如下示意图展示了纵向滚动的UICollectionView,其中横向上有多个可以横向滚动的组(App Store应用大量使用该布局)
    • 看到这里我立马想到了:可能再也不用多个UICollectionView嵌套了

IMG_1035.PNG

四个核心概念

Compositional layout由四个最核心的概念组成

Item > Group > Section > Layout

  • ItemLayout,表示的范围依次扩大
  • 任何一个Compositional layout都从左到右组合而成

image.png

如何使用Compositional layout

Compositional layout的核心类是UICollectionViewCompositionalLayout,其初始化方法有两类,如下代码所示:

  • 一类是直接提供section,另一类是通过provider动态的提供section
class UICollectionViewCompositionalLayout : UICollectionViewLayout {

    public init(section: NSCollectionLayoutSection)

    public init(section: NSCollectionLayoutSection, configuration: UICollectionViewCompositionalLayoutConfiguration)

    public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)

    public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider, configuration: UICollectionViewCompositionalLayoutConfiguration)
}

创建UICollectionViewCompositionalLayout的过程就是上小节提到的

Item > Group > Section > Layout

let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                     heightDimension: .fractionalHeight(1.0))

let item = NSCollectionLayoutItem(layoutSize: itemSize)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                      heightDimension: .fractionalWidth(0.2))

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                 subitems: [item])

let section = NSCollectionLayoutSection(group: group)

let layout = UICollectionViewCompositionalLayout(section: section)

OrthogonalScrolling

再介绍一个官方Demo中提到的稍微复杂一点的Compositional layout案例

我们希望最终效果如下图所示:

Screenshot 2026-03-20 at 16.33.00.png

首先进行设计:

  • 整体纵向滚动,横向上有多行,每一行也是可以滚动的,每一行我们可以看做是一个Section
  • 关注到每一行中的元素,每一行中基本的滚动单元是:左侧的一个大块+右侧两个小块,滚动单元可以认为是Group
  • 具体的大小块则可以认为是Item

关于“左侧的一个大块+右侧两个小块”的示意图如下所示:

image.png

以下是Compositional layout代码,我们对照注释看一下创建过程:

// 1. 左侧大块Item的创建
// - 宽度:希望占容器(group)宽度的70%
// - 高度:希望和容器一样高
let leadingItem = NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7),
                                      heightDimension: .fractionalHeight(1.0)))
leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

// 2. 右侧任意的一个小块
// - 宽度:和容器一样宽
// - 高度:占容器高度的一半
let trailingItem = NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                      heightDimension: .fractionalHeight(0.5)))
trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

// 3. 创建一个容器group,这是一个纵向的容器,容纳右侧的两个小块Item。宽度占该group所在容器的30%;高度和容器一致
let trailingGroup = NSCollectionLayoutGroup.vertical(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3),
                                       heightDimension: .fractionalHeight(1.0)),
    repeatingSubitem: trailingItem,
    count: 2)
    
// 4. 创建一个横向容器group,容纳1个大块+2个小块。宽度占其容器的85%,高度占40¥
let containerGroup = NSCollectionLayoutGroup.horizontal(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                      heightDimension: .fractionalHeight(0.4)),
    subitems: [leadingItem, trailingGroup])
// 5. 创建section,包含最外层的横向容器group
let section = NSCollectionLayoutSection(group: containerGroup)
section.orthogonalScrollingBehavior = .continuous
// 6. 创建layout,使用默认configuration,默认是纵向滚动
return UICollectionViewCompositionalLayout(section: section)

再看一下数据源代码:

var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
var identifierOffset = 0
let itemsPerSection = 30
for section in 0..<5 {
    snapshot.appendSections([section])
    let maxIdentifier = identifierOffset + itemsPerSection
    snapshot.appendItems(Array(identifierOffset..<maxIdentifier))
    identifierOffset += itemsPerSection
}
  • 整体数据是一个二维数组,类似这样:[[0,1...29], [30...59], [...], [...], [...]]
  • 创建了5个Section,每个Section
  • 每个Section中有30个数字
  • 30个数字拆分成了10个Group,每个Group有三个Item。如果对应着数据源,则依次是[0,1,2], [3,4,5].....
  • 每个数字表示一个Item

其他

UICollectionLayoutListConfiguration.Appearance

orthogonalScrollingBehavior

orthogonal(发音:/ôrˈTHäɡən(ə)l/):正交。但并非数学上的概念,而是指,与指定方向是正交方向的另一个方向。说白了,如果制定的滚动方向是垂直,则orthogonalScrolling(正交滚动方向)就是水平方向

问题

1. 如何横向滚动

Demo中都是纵向滚动的,Compositional layout是否支持横向滚动?

当然,如下所示,不过要注意一下写法

  • 自定义UICollectionViewCompositionalLayoutConfiguration,设置scrollDirection即可

如果尝试修改因UICollectionViewCompositionalLayout(section: section)而自动创建的UICollectionViewCompositionalLayoutConfiguration.scrollDirection可能不起作用

let configuration = UICollectionViewCompositionalLayoutConfiguration()
configuration.scrollDirection = .horizontal
let layout = UICollectionViewCompositionalLayout(section: section, configuration: configuration)

1. iOS 26.3中带count参数的Group初始化方法有bug

  • 通过horizontal(layoutSize:repeatingSubitem:count:)创建的group,无法做到按照count对item等分布局
  • 但通过horizontal(layoutSize:subitems:)或者已经废弃的horizontal(layoutSize:subitem:count:)可以正确实现

按照如下代码中所示的:

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)

2. 官方Demo-AdaptiveSections的bug

  • 官方Demo中,AdaptiveSections部分,会根据容器宽度决定一行显示的列数
  • 但在iOS 26.3中测试,旋转到横屏后,列数并没有按照预期增加
  • 未定位到原因

参考

Buildable Folder & Group & Folder Reference in Xcode

作者 songgeb
2026年3月13日 08:07

深入理解代替单纯记忆

问题背景

  • 在开发iOS项目时,希望将一堆图片资源放入Main Bundle中,但又不希望资源在Bundle的最顶层目录中,希望自定义目录
  • 但一时想不到该如何解决,于是想到FolderGroup等概念
  • 经过简单搜索后,发现Xcode对于这两个概念的定义还是有些差异的
  • 于是继续查阅学习了一番,编写本文,方便后续查阅和分享

本文提到的内容,参考的Xcode版本为26.0(17A324)和26.3(17C529)

Buildable Folder

  • Buildable Folder是自Xcode 16(2024年6月)引入的概念,初衷是为了减少代码管理中的冲突问题
  • 后续新建的工程或者新建Folder时,默认都是Buildable Folder

官方原文如下:

Minimize project file changes and avoid conflicts with buildable folder references. Convert an existing group to a buildable folder with the Convert to Folder context menu item in the Project Navigator. Buildable folders only record the folder path into the project file without enumerating the contained files, minimizing diffs to the project when your team adds or removes files, and avoiding source control conflicts. To use a folder as an opaque copiable resource, the default behavior before Xcode 16, uncheck the Build Folder Contents option in the File Inspector.

Buildable Folder如何降低代码冲突

  1. 先添加1个普通Group--BuildableFolderTest,project文件的变化如下所示: image.png

  2. 然后向BuildableFolderTest Group中添加ABC.swift文件后,project文件的变化如下: image.pngimage.png

    这说明Group目录下的文件,都要在project文件中进行记录

  3. 继续,将BuildableFolderTest Group通过Convert to Folder选项转为Folder(Buildable Folder)后,project文件的变化如下: image.pngimage.png

  4. 然后再向BuildableFolderTest这个Folder中添加DEF.swift文件后,发现project文件没有任何变化

所以,project文件仅记录了Folder自身,至于目录中的文件是不会记录在project文件中,所以会减少因团队多人同时修改Project文件导致的代码冲突

Apply to Each File vs Apply Once to Folder

当创建Folder(Buildable Folder)后,选中Folder,在File inspector中会看到有个Build Rule,有两个选择:Apply to Each FileApply Once to Folder,默认是Apply to Each File

image.png

Apply Once to Folder

Apply Once to Folder开启后,project文件是什么样?

image.pngimage.pngimage.png

当开启该模式时,通过查看目录下的每个文件可以看出,文件是没有Target归属的概念的。同样,在该目录下创建新文件也不需要选择Target

再配合Xcode Buildable Folders中所提到的To use a folder as an opaque copiable resource, the default behavior before Xcode 16, uncheck the Build Folder Contents option in the File Inspector.

其实,Apply Once to Folder就是Xcode 16之前的Folder,之前叫Folder Reference (在Xcode 16之前,创建Folder时,官方名称就叫做Folder Reference)

  • Folder Reference一般是用作资源包,目录下不包含源代码
  • 另一个Folder Reference重要作用是可以在Bundle中自定义目录

Buildable Folder vs Folder Reference

Buildable Folder顾名思义,其中的内容是由编译系统参与的

  • 所以Buildable Folder中可以放源代码文件,并可以参与编译,打包到最终可执行文件中;也可以制定源文件的Target
  • Folder Reference则保留老的逻辑,不参与编译,用作资源包,即使放入源代码文件也无法选择Target,只能当做普通文件资源处理

Create Group with Folder

同样是在Xcode 16开始的另一个变化是,创建Group时由原来的不自动创建磁盘物理目录(Folder)变为自动创建。当然,仍可以创建没有FolderGroup,原文如下:

Create groups with associated folders by default when using the New Group and New Group from Selection commands in the Project Navigator. To create a group without a folder, hold the Option key in the context menu to reveal the New Group without Folder variant of the command.

[Group without Folder] vs [Group] vs [Folder(Buildable Folder)] vs [Folder Reference]

特性 Group without Folder Group Buildable Folder Folder Reference
Project Navigator 图标 image.png image.png image.png image.png
是否对应磁盘目录 ❌ 不必须 ✅ 必须 ✅ 必须 ✅ 必须
工程结构是否可与磁盘不同 ✅ 可以 ❌ 基本一致 ❌ 必须一致 ❌ 必须一致
.pbxproj 是否记录每个文件 ✅ 会 ✅ 会 ❌ 不会 ❌ 不会
新增文件是否修改 .pbxproj ✅ 会 ✅ 会 ❌ 不会 ❌ 不会
Git 冲突概率
是否参与编译系统
是否自动编译源码 ✅(自动发现目录中的源码)
Bundle 中是否保留目录结构 ❌ 通常不会 ❌ 通常不会 ❌ 通常不会 会保留(如果被加入 Bundle)
默认是否进入 Bundle ❌ 否 ❌ 否 ❌ 否 仅在选中 Target 时自动加入
典型用途 逻辑分组 常规项目结构 源码目录 资源目录
  • 当前(Xcode 26),默认的Group和Folder组合是Group with Folder + Buildable Folder。这可能也意味着这两项是日常最常用的

回答开始的问题

  • 既然是想打包资源放入Bundle,并自定义目录,那必然是Folder Reference

参考

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

作者 songgeb
2026年3月11日 13:30

深入理解代替单纯记忆

本文中的问题和排查过程由作者完成,文章编写由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 就会被空转消费。

❌
❌