调试不是记住 Xcode 有哪些按钮,而是回答六个问题:现象是什么、怎样稳定复现、当前假设是什么、什么证据能证伪它、根因在哪里、修复后如何证明问题消失。
本文围绕 DebugLab 中的真实故障展开。每个案例都有独立源码,完整目录见 CASES.md。
一条足够用的工具选择图

不要从工具出发。先描述症状,再选择能够区分两个假设的证据。
案例一:编译和单测都通过,App 启动却白屏
这是 DebugLab 复审中真实发生的问题。
现象
- 独立工程
BUILD SUCCEEDED。 - 8 项 XCTest 全部通过。
- 模拟器进程正常存在,没有崩溃。
- 启动截图只有白色窗口,没有"Bug 定位实验室"。
这组证据已经排除了"代码没有编译""启动即崩溃",但没有证明根控制器真的挂到了当前 Scene。
错误实现
swift
final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = DebugLabModule.makeViewController()
window.makeKeyAndVisible()
self.window = window
return true
}
}
在 Scene 生命周期下,UIWindow(frame:) 没有绑定系统传入的 UIWindowScene。编译器无法检查 Info.plist、AppDelegate、SceneDelegate 和窗口归属是否形成完整运行时契约。
用日志区分假设
模拟器日志中可以看到系统创建了 UIWindowScene,但错误版本的窗口并不属于它。修复后的关键日志则显示窗口在目标 Scene 中成为 key window。日志只是支持证据,最终验收仍是可见首屏。
修复
Info.plist 声明 SceneDelegate,AppDelegate 只选择配置,窗口交给 SceneDelegate:
swift
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = DebugLabModule.makeViewController()
window.makeKeyAndVisible()
self.window = window
}
回归验证中的第二个坑
第一次修复后,simctl launch 紧接着截图仍得到白屏;运行日志已经显示 key window 正常。隔开启动与截图后,首屏和案例列表完整出现。原因是截图发生在首帧提交前。
结论:涉及生命周期、路由和资源加载时,构建与单测不是完整验收。至少需要"安装 → 启动 → 等待首帧稳定 → 检查可见内容"。
案例二:两个后台线程死锁,主线程为什么还能操作
对应文件:ControlledDeadlockCase.swift。
错误场景
线程 A 先拿 first 再等 second,线程 B 顺序相反:
swift
first.lock()
second.lock()
// 另一个线程
second.lock()
first.lock()
如果调度碰巧让一个线程连续拿到两把锁,实验不会稳定死锁。因此 DebugLab 不是简单同时 dispatch 两段代码,而是先确认双方都持有第一把锁,再同时放行第二次加锁。

如何取证
- 点击"死锁与全线程栈"。
- 触发后点击 Xcode 的 Pause。
- 在 LLDB 执行:
lldb
thread backtrace all
正确证据不是页面显示"已经死锁",而是两个线程栈分别停在第二次 NSLock.lock(),并能还原互相等待关系。
修复边界
生产修复通常是统一锁顺序、合并临界区或去掉嵌套锁。给 lock() 随意加超时只能改变症状,不能自动修复共享状态一致性。
DebugLab 禁止重复触发,因为每次死锁都会永久占用两个线程,直到进程结束。
案例三:FPS 下降只是现象,怎样制造可测量掉帧
对应文件:FrameDropCase.swift。
失败的教学案例
仅创建大量 View 但不加入层级,或执行一次很短循环,可能根本不会跨过帧预算。这类 Demo 页面写着"掉帧",但 Instruments 中没有稳定区间。
可重复实现
DebugLab 使用真实 CADisplayLink 回调,并连续 45 帧占用主线程约 28ms:
swift
@objc private func handleFrame(_ link: CADisplayLink) {
if let previousTimestamp {
largestFrameGap = max(largestFrameGap, link.timestamp - previousTimestamp)
}
previousTimestamp = link.timestamp
let deadline = CACurrentMediaTime() + 0.028
var checksum = 0.0
while CACurrentMediaTime() < deadline {
checksum += sin(checksum + 0.1)
}
}
60Hz 一帧预算约 16.67ms,28ms 主线程工作必然跨帧。页面最后输出实际最大帧间隔,而不是声称固定 FPS。
工具分工
- Animation Hitches / Core Animation:确定发生问题的时间区间。
- Time Profiler:在同一区间查看主线程采样栈,定位 CPU 热点。
CADisplayLink:提供帧回调和间隔证据,不解释根因。
离开页面必须 invalidate DisplayLink;它会强持有 target,否则"性能案例"会额外制造生命周期泄漏。模拟器适合验证流程,不能替代真机性能结论。
案例四:同样是内存崩溃,为什么 Zombie 完全没用
对应文件:ZombieCase.swift。
故障代码
swift
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: 8)
buffer.initialize(repeating: 0, count: 8)
buffer.advanced(by: 16).pointee = 0xFF
这里只分配 8 字节,却向偏移 16 写入。必须在 Scheme → Diagnostics 开启 Address Sanitizer 后触发。
预期关键证据:
text
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 1
ASan 会给出越界写入栈和原始分配栈。未开启 ASan 时属于未定义行为:可能崩溃,也可能静默破坏其他内存。
Zombie 解决的是另一类问题:Objective-C 对象释放后仍收到消息。它让对象不真正释放,以便报告原类型和 selector;它不管理普通堆缓冲区边界。看到"内存问题"就打开 Zombie,是从工具名称猜根因。
案例五:约束日志、符号断点和 View Debugger 各回答什么
对应文件:ConstraintConflictCase.swift。
案例给同一视图添加两个带 identifier 的宽度约束。触发后:
text
Unable to simultaneously satisfy constraints
debug.fixed-width.120
debug.conflicting-width.220
三个证据的职责不同:
| 证据 | 回答的问题 |
|---|---|
| 控制台约束日志 | 哪些约束不能同时成立,系统打破了哪一条 |
UIViewAlertForUnsatisfiableConstraints 符号断点 |
冲突约束在哪里被创建或激活 |
| View Debugger | 系统处理冲突后,最终层级、尺寸和遮挡是什么 |
只看 View Debugger 可能看到最终宽度,却丢失创建现场;只看日志又无法理解页面几何。它们是证据链,不是互相替代的三个 API。
案例六:拿到一个崩溃地址,为什么不能直接跑 atos
对应文件:CrashSymbolicationCase.swift 和 SymbolicationAddressCalculator.swift。
必须先确认的输入
- 发布该版本时保存的二进制与 dSYM。
- dSYM UUID 与崩溃日志 Binary Images 中 UUID 一致。
- 架构一致。
- runtime address、image load address 和 preferred image base 来源明确。
bash
dwarfdump --uuid MyApp.app.dSYM
地址关系:
text
slide = runtime image load - preferred image base
unslid address = runtime address - slide
示例:
text
runtime address = 0x100012345
runtime image load = 0x100000000
preferred image base = 0x100000000
slide = 0
unslid address = 0x100012345
如果 UUID 不匹配,重新编译同一份源码得到的新 dSYM 通常仍然无效。地址换算正确也不能绕过二进制、架构和 UUID 前置条件。
LLDB 的一个高频误区:断点停在语句执行前
swift
let result = service.load()
appendEvidence(result) // 应在这里停,才能观察 result
如果断点放在初始化语句本身,该行通常尚未执行,result 还不可用。优先使用 v 或 frame variable 读取变量;po、p 和 expression 可能执行 getter、description 或修改进程状态。
text
v / frame variable 通常只读取当前栈帧变量
po / p / expression 可能执行被调试进程代码
bt / thread backtrace all 当前线程栈 / 所有线程栈
最终排查模板
text
现象:用户能观察到什么,不要先写原因
复现:设备、系统、数据、网络、构建配置、概率
假设:至少列两个可能原因
证据:什么结果会支持或否定每个假设
根因:哪一条代码和哪一个运行时契约被破坏
修复:最小改动及其副作用
回归:用相同场景证明问题消失,并覆盖相邻边界
不要在线上开启 Sanitizer 或 Zombie,也不要记录令牌、密码和完整个人数据。性能、并发和生命周期问题必须使用真实运行证据,页面上的一句"已触发"不算证明。
运行工程
DebugLab 可单独打开,也以 CocoaPods Development Pod 接入外层工程。打开 nodeOCAndSwift.xcworkspace 后,Pods 中看到的就是 04-debugging 本地源码。
案例故意包含崩溃、竞态、互锁和泄漏。一次只运行一个故障场景;无法恢复的案例通过结束进程清理。