iOS Bug 定位实战:从断点、LLDB 到 Xcode 与 Instruments

调试不是记住 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 两段代码,而是先确认双方都持有第一把锁,再同时放行第二次加锁。

如何取证

  1. 点击"死锁与全线程栈"。
  2. 触发后点击 Xcode 的 Pause。
  3. 在 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.swiftSymbolicationAddressCalculator.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 还不可用。优先使用 vframe variable 读取变量;popexpression 可能执行 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 本地源码。

案例故意包含崩溃、竞态、互锁和泄漏。一次只运行一个故障场景;无法恢复的案例通过结束进程清理。

参考资料

相关推荐
zhao1zhihui3 小时前
Objective-C Runtime:从消息派发到安全 Hook
ios
GitLqr5 小时前
Flutter FocusNode 实战指南:玩转键盘焦点与用户输入体验
android·flutter·ios
星辰即远方1 天前
天气预报总结
macos·ios·objective-c·cocoa
秋雨梧桐叶落莳1 天前
计算器仿写总结
学习·macos·ios·objective-c·cocoa
末代iOS程序员华仔1 天前
iOS上架海外工具类应用合规指南:避免封号与下架风险
flutter·ios·swift
末代iOS程序员华仔1 天前
Flutter 开发 iOS App 上架全流程与常见问题分析
flutter·ios
末代iOS程序员华仔1 天前
Flutter开发iOS海外工具类应用上架合规指南与风险规避
flutter·ios
AI云海2 天前
iOS 多 Target 打包、UAT/生产测试与上架 App Store 全流程指南
ios
阿里超级工程师2 天前
最新快速申请ios打包证书和profile文件步骤
ios·打包证书