本节摘要:树合拢之后,看病比种树更常发生。本节把视图树最常见的六类病症——白屏与黑屏、约束冲突、布局跳动、列表卡顿与崩溃、循环引用泄漏、SwiftUI 状态不刷新——整理成"症状、病根、处方"三段式排查手册,配一张分诊路径图与几段可复用的诊断代码。读完之后,面对一个不对劲的界面,你的第一反应不再是"改改试试",而是"先分诊,再动手"。
从 6.1 的树合拢那天起,你的园丁身份就多了一项职责:看病。新手排错最典型的动作是"改一处跑一次",凭运气碰答案,而每次改动本身又在制造新的不确定,改到第十次连最初的现象都复现不出来了。手册的纪律只有一条:症状、病根、处方——先把病症描述清楚(哪个页面、什么操作、什么现象、是否稳定复现),再按分诊路径把病根圈定到两三个候选,最后用最小的验证实验定罪。改代码是最后一步,不是第一步。

分诊图把所有病症先粗分三通道:整屏空白走结构通道(视图树根本没长出来或没挂上去)、位置尺寸不对走约束通道(长出来了但被吹歪了)、显示正常而行为不对走数据通道(长得好但水分没送到)。通道往下才细分六类病。工具是听诊器——UIKit 的约束警告、SwiftUI 的运行时诊断、崩溃堆栈,多数病根都会先在控制台里自首。
症状:启动后整屏空白或纯黑,一个控件都看不到。病根三条候选:根视图没挂上(window 的 rootViewController 为空,3.2 的场景装配出了岔子);视图透明或尺寸为零(alpha 为零、isHidden、frame 没设);主线程被卡死(启动期同步做了耗时活,界面根本没机会画第一帧)。一段诊断代码把前两条当场分开:
func diagnoseBlankScreen() { guard let root = window?.rootViewController else { print("病根确诊:根视图未挂载——回查场景装配代码") // 通道一命中 return } print("根视图:\(type(of: root)),子视图数 \(root.view.subviews.count)") for (i, v) in root.view.subviews.enumerated() { print(" 子视图\(i):\(type(of: v)) 宽高=\(Int(v.frame.width))x\(Int(v.frame.height)) 透明度=\(v.alpha) 隐藏=\(v.isHidden)") // 宽高为零、透明度为零、隐藏为真的那一行,就是嫌疑犯 } } // 输出示例: // 根视图:PlantListTableViewController,子视图数 1 // 子视图0:UITableView 宽高=0x0 透明度=1.0 隐藏=false // → 宽高为零:约束没激活,translatesAutoresizingMask 忘了关(3.5 的教训)
处方按定罪结果给:没挂就挂上、透明就查设置、卡死就把耗时活挪到后台队列、回来主线程再碰界面。黑屏与白屏同源——深色模式下的空 window 呈黑、浅色呈白而已,不用分开记。
症状:控制台刷出一长串 Unable to simultaneously satisfy constraints,界面可能正常也可能轻微错位。病根是两条以上约束对同一属性提出矛盾要求——最常见的是手写约束忘了关 translatesAutoresizingMaskIntoConstraints,系统自动生成的隐形约束与你写的两条打架。验证手段现成:警告文本会列出冲突双方,点开侧栏能看到约束对象属于谁。处方三步走:先删冗余(同一关系写了两遍是最高频的自杀式写法);再调优先级(给可让步的一方降到 999,冲突即刻消失);最后才是另加新约束。多数冲突在第一步就解决。
⚠️ 常见坑:界面"看起来正常"就无视冲突警告。设备尺寸一变——横屏、分屏、小屏机型——错位才爆发,那时警告早被日志刷走了。警告出现的当场是修复成本最低的时刻。
症状:滚动或刷新时控件位置突跳、文字被截断、按钮忽大忽小。病根在两套优先级没表态:拥抱优先级(Content Hugging)管"抗拒被拉大",抗压缩优先级(Compression Resistance)管"抗拒被压小"——多个控件争同一寸空间时,谁让步没说清,布局就会在数据变化时反复摇摆。处方:给不该被压的文字调高抗压缩(比如 751,压过默认的水平拥抱),给可以被拉伸的留白视图调低拥抱。4.1 列表行里"植物名 + 状态标签"就是典型现场:状态标签定宽、名字标签抗压缩,行高从此稳定。
症状分两型:滚动掉帧是一型,滚动几页后闪退、堆栈停在取行那段是另一型。掉帧的病根多半是主线程干了重活——同步读图片、离屏渲染复杂圆角;闪退的病根十有八九是数组越界——数据源数组已经被改短,表格还拿着旧计数要行。处方一针见血:让"改数据"与"刷界面"在语法上绑死成一对,封装进同一个方法:
// 数据与界面同源更新:改与刷必须成对,封装后想单独调用都做不到 func applyUpdate(_ transform: (inout [Plant]) -> Void) { transform(&plants) // 唯一的改数据入口 tableView.reloadData() // 紧跟着刷界面——两行同生共死 print("数据已改并刷新,当前 \(plants.count) 行") } // 调用:applyUpdate { $0.remove(at: indexPath.row) } // 输出:数据已改并刷新,当前 4 行 // 病根预防:越界崩溃的温床就是"改了数组、reload 在另一个函数里被忘掉"
耗时活则下沉后台队列,结果回主线程再进 applyUpdate——3.2 的线程纪律在这里兑现。
症状:页面关了内存不降,反复进出后 Memory Graph 里同一批视图对象越积越多。病根是闭包强持有 self——2.8 讲 ARC 时埋的伏笔在此兑现:逃逸闭包默认把捕获的引用记为强引用,控制器持有闭包、闭包又持有控制器,谁都不放手。验证只需一行打点:
deinit { print("详情页控制器已释放") // 健康表现:每次返回上一页都立即打印 } // 实验方法:反复进出详情页十次,一次都没打印 → 循环引用确诊 // 再开 Memory Graph(调试栏三个圆点的图标)搜类名, // 顺着持有链找到最后一环——通常是一个逃逸闭包或一个没失效的定时器
处方:捕获列表改弱引用,闭包体里 self 变成可选,用 guard let self 先接住再干活。但别无脑全弱——只有"闭包生命周期长于 self"的场景(定时器、通知监听、网络回调)才需要,局部短命的遍历闭包强引用毫无问题,全弱反而把代码搅得零碎难读。
症状:数据明明改了界面纹丝不动,或界面刷新了但显示的是旧值。病根三条候选:属性没标 @Published——2.5 引用语义的老陷阱换了马甲,"改了远处对象"不等于"广播了变化";StateObject 与 ObservedObject 用反——5.3 讲过,ObservedObject 不持有,视图重算时仓库被原地重建;改的是拷贝——把结构体赋给局部变量改完没写回数组,值语义下"改了等于没改"(2.4 的镜像)。验证路径:先在仓库的修改方法里打点,确认"改"发生了;再在视图体里打点,确认"广播"断在哪一环。处方对症下药:补标记、换包装器、把一切修改收敛进仓库方法。
| 工具 | 什么时候用 | 一句话用法 |
|---|---|---|
| 控制台输出 | 所有病症的第一站 | 病根多半会先在这里自首 |
| 视图层级打印 | 白屏、约束、布局错位 | 三行代码定位嫌疑视图 |
| Memory Graph | 泄漏、内存只涨不降 | 搜类名看持有链最后一环 |
| 调试仪表 | 卡顿、主线程满载 | 看主线程占比条与 CPU 曲线 |
| 视图体打点 | SwiftUI 状态不刷新 | 确认重算到底有没有发生 |
背景:6.1 装配完的应用出现怪病——收藏页(SwiftUI)里浇水后返回花园页(UIKit 表格),行状态偶尔是旧的,且只有连续快速操作时才复现。操作:按数据通道分诊。第一步在仓库的浇水方法里打点,输出时间戳正常,说明"改"确实发生了;第二步查花园页刷新路径,viewWillAppear 里确实重读了仓库,理论上不该出现旧值;第三步回头审收藏页代码,找到根因——它为了"优化性能"用 @State 缓存了一份状态副本,浇水按钮改的是本地副本,仓库只在页面即将消失时才同步一次。快速操作时返回动作抢在同步之前,花园页读到的自然是旧值。处方:删掉本地副本,按钮直改仓库(5.3 的单一真相源纪律),那段"离开时同步"的补丁连同缓存一起删。结果:复现路径消失,净删二十来行代码。解读:这类"偶现"病症几乎总有一条绕开主路径的旁路数据流——分诊时先问"这条数据共有几份副本",比盯着代码逐行看快得多。变式:若副本确有必要(编辑表单的暂存草稿),把写回时机从"离开时"提前到"点保存时",让副本的生存期短到来不及与主路径竞争。
树会生病,也会分叉——最后一节回答那个躲不掉的问题:下一个页面,长在 UIKit 老枝上还是 SwiftUI 新枝上。