我在调试上花的时间太多了。每个人都是。但 Xcode 有一些大多数开发者几乎不碰的强大工具。这是我多年来积累的调试工作流——出问题时真正节省时间的东西。
断点比你想的更强大#
大多数人点击行号边栏设个断点就完事了。但右键点一下,你会发现一堆能改变调试方式的选项。
条件断点#
假设你有个处理 1000 个项目的循环,但只有第 347 个有问题。你肯定不想命中断点 346 次。
for i in 0..<users.count {
processUser(users[i]) // 在这设断点,条件: i == 347
}右键断点,加上条件 i == 347,Xcode 只在条件为真时停下。userId == "abc123" 或 error != nil 这些也一样好使。
符号断点#
不知道方法在哪被调用时特别好用。到断点导航器(Cmd+8),点 + 按钮,选 “Symbolic Breakpoint”。
想看每个视图控制器什么时候加载?把符号设成 viewDidLoad。Xcode 会在每个地方暂停。太吵了?缩小范围:MyViewController.viewDidLoad。
我一直保留的最有用的几个:
UIViewAlertForUnsatisfiableConstraints- 立即抓住 Auto Layout 冲突objc_exception_throw- 在崩溃前就在 Objective-C 异常处断下malloc_error_break- 发现内存分配问题
异常断点#
这可能是新项目里第一件该做的事。加个异常断点(断点导航器里点 + → Exception Breakpoint),Xcode 会在抛异常的那一刻暂停,而不是等应用已经崩了才反应。
用这个抓住了好多 nil 解包问题,不然只会在控制台里显示成莫名其妙的崩溃。
断点动作#
这里开始有意思了。断点不一定要停止执行——可以只记个日志或播个声音。
编辑断点,点 “Add Action”,你可以:
- 不停地输出变量:
"User count: @(users.count)@" - 自动执行 LLDB 命令
- 执行 shell 脚本
- 播放声音(我用这个来了解罕见代码路径的执行情况)
勾上 “Automatically continue after evaluating actions”,断点就变成了不打断流程的日志记录器。
LLDB:你真正会用到的控制台命令#
在断点停下时,底部的控制台不只是看崩溃日志用的。它是 LLDB,非常好用。
基础#
(lldb) po user
▿ User
- id: "123"
- name: "John Doe"
- email: "john@example.com"po 以可读格式打印对象。我用的 90% 就是这个。
要更多细节?试试 p:
(lldb) p user.name
(String) $R0 = "John Doe"一次看所有局部变量:
(lldb) frame variable运行时修改#
这才是真正的大招。不用重新编译就能改变量:
(lldb) expr user.name = "Jane Doe"
(lldb) expr index = 0发现了 bug 想不重新构建就测试修复?直接改变量然后继续运行。追踪边界情况时我一直用这个来测试不同的值。
甚至可以调方法:
(lldb) po self.refreshUI()
(lldb) expr navigationController?.popViewController(animated: true)导航#
(lldb) bt # 显示调用栈
(lldb) frame select 3 # 跳到不同的栈帧
(lldb) continue # 继续运行(或者直接 'c')
(lldb) n # 单步跳过(下一行)
(lldb) s # 单步进入函数监视点#
想知道变量什么时候变了?设个监视点:
(lldb) watchpoint set variable user.isLoggedIn现在不管从代码哪里改了这个值,都会暂停。追踪意外的状态变化简直是神器。
Console.app:隐藏的调试工具#
Xcode 的控制台还行,但当你需要看所有东西——系统日志、崩溃报告、全部——打开 Console.app。
如果你在用 OSLog(你应该用),Console.app 让这些日志变得可搜索、可过滤:
import os.log
let logger = Logger(subsystem: "com.example.app", category: "networking")
logger.info("Starting API request")
logger.debug("Request URL: \(url.absoluteString)")
logger.error("Failed to decode: \(error.localizedDescription)")在 Console.app 里:
- 选择你的模拟器或连接的设备
- 按子系统过滤:
subsystem:com.example.app - 按级别过滤:
subsystem:com.example.app AND level:error
可以保存常用调试会话的过滤谓词。我有一个看网络请求的,一个看数据库操作的,还有一个只显示错误和警告的。
为什么用 OSLog 不用 print()?#
OSLog 比到处撒 print() 好太多了:
- 结构化的——可以按级别和分类过滤
- 快——日志记录经过优化,不会拖慢应用
- 生产环境自动编辑敏感数据
- 应用崩溃后日志还在
快速调试用 print()。后面可能想看的东西用 OSLog。
高级 Console.app 过滤#
Console.app 支持强大的谓词查询。我经常用的:
# 应用里所有的错误和故障
subsystem:com.example.app AND (level:error OR level:fault)
# 失败的网络请求
subsystem:com.example.app AND category:networking AND eventMessage CONTAINS "failed"
# 过去 5 分钟的所有内容
subsystem:com.example.app AND timestamp >= now(-5m)还可以导出日志分享给团队或附到 bug 报告里。比从 Xcode 控制台复制粘贴好多了。
视图调试#
UI 坏了又不知道为什么,视图调试器就是救星。
运行应用,导航到有问题的页面,点 Xcode 调试栏里的 “Debug View Hierarchy” 按钮(或按 Cmd+Shift+D)。你会得到层级里每个视图的 3D 分解图。
旋转看看:
- 在其他视图后面渲染但被挡住的视图
- 跑到屏幕外的视图
- 大小为零的视图
- 选中视图的完整约束链
我用这个找到过挡住点击事件的隐形按钮、以 0x0 渲染的标签、还有不小心变成 10,000 像素宽的图片。
Auto Layout 调试#
视图层级里的紫色警告图标表示约束冲突。点击它,Xcode 会显示到底哪些约束在打架。
专业提示:给约束加标识符:
heightConstraint.identifier = "ProfileImageHeight"约束出问题时,错误里显示的是 "ProfileImageHeight" 而不是内存地址。调试轻松多了。
从 LLDB 也可以打印约束树:
(lldb) po view.hasAmbiguousLayout
(lldb) po view._autolayoutTrace()省时间的模拟器功能#
慢速动画#
Debug → Slow Animations 把所有东西减慢到 1/10 速度。看转场时发生了什么或者理解动画为什么看着奇怪,非常完美。
模拟内存警告#
Debug → Simulate Memory Warning。测试应用怎么处理低内存。用这个发现了好多图片缓存 bug——触发内存警告之前都好好的,然后突然所有图片都没了。
Network Link Conditioner#
Xcode → Open Developer Tools → Network Link Conditioner
模拟 3G、LTE、高丢包率或完全离线。如果你遇到过 API 请求在 WiFi 上好好的但在蜂窝网络上超时,这就是原因。
我保留了个 “Bad Network” 配置,500ms 延迟加 10% 丢包。能抓到在快速办公室 WiFi 上永远看不到的超时 bug 和加载状态问题。
状态栏覆盖#
在模拟器里右键状态栏,可以覆盖时间、电池电量、信号强度和运营商。做一致的截图或者测试不同电池电量下 UI 的样子很有用。
位置模拟#
Debug → Location 让你不用离开办公桌就能模拟不同位置。自定义位置、城市步行、高速公路驾驶——全部都有。测试基于位置的功能或者调试只在某些地区出现的问题时超有用。
内存调试#
Debug Memory Graph#
点 Debug Memory Graph 按钮(Cmd+Shift+M),Xcode 给你展示内存里的每个对象、它们的关系,以及关键的——哪些在泄漏。
紫色感叹号就是泄漏。点击就能看到循环引用。
我见得最多的泄漏:
class ViewController: UIViewController {
var onComplete: (() -> Void)?
func setupHandler() {
onComplete = {
self.dismiss(animated: true) // ❌ 强引用了 self
}
}
}用 weak self 修复:
onComplete = { [weak self] in
self?.dismiss(animated: true) // ✓ 没有循环引用
}delegate 也要 weak:
weak var delegate: ManagerDelegate? // 不只是 'var'Instruments#
严肃的内存调查要用 Instruments(Product → Profile,或 Cmd+I)。
Allocations instrument 显示:
- 每个对象分配
- 随时间增长的内存
- 哪些类用了最多内存
- 显示分配位置的堆栈跟踪
我通常找:
- 随时间线性增长的内存(大概是泄漏)
- 意外的大分配(是不是一次加载了所有 1000 张图片?)
- 应该被释放但没被释放的对象
在操作前后标记代(小旗子按钮)看看什么不该留着但还留着。
性能分析#
Time Profiler#
Product → Profile → Time Profiler。在应用里做慢操作时录制,然后看调用树。
按 “Self Weight” 排序找瓶颈。如果一个函数显示 40% self weight,那就是该优化的地方。
我有次发现一个 JSON 解析函数每秒被调用 1000 次。移到后台队列后 UI 不卡了。
Main Thread Checker#
Xcode 自动抓后台线程上的 UI 更新。看到这个:
Main Thread Checker: UI API called on a background thread: -[UILabel setText:]你大概做了这种事:
URLSession.shared.dataTask(with: url) { data, response, error in
self.label.text = "Loaded" // ❌ 崩溃!
}修复:
URLSession.shared.dataTask(with: url) { data, response, error in
DispatchQueue.main.async {
self.label.text = "Loaded" // ✓
}
}运行时诊断#
Edit Scheme → Run → Diagnostics。一堆复选框能抓住你手动永远找不到的 bug。
Address Sanitizer#
查内存损坏:
- Use-after-free 错误
- 缓冲区溢出
- 内存泄漏
让应用慢 2-3 倍,但能抓住其他方式没法追踪的 bug。追那些偶尔才出现的崩溃时我会打开。
Thread Sanitizer#
抓数据竞争和线程问题。开着这个运行应用正常用就行。有 race condition 的话 Thread Sanitizer 会找到。
Address Sanitizer 和 Thread Sanitizer 不能同时开,所以调试奇怪的崩溃时我在两者间交替。
Zombie Objects#
抓发给已释放对象的消息。开启后 Xcode 会精确告诉你什么时候在访问已释放的内存:
*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000别一直开着——它会阻止对象释放,内存会一直涨。但找特定的 use-after-free bug 时很棒。
常见调试场景#
启动就崩溃#
先加异常断点。通常就能抓到。
抓不到的话检查:
- Info.plist 里缺少的键
- 初始化代码里强制解包的 optional
- 依赖注入失败
UI 不更新#
每次。每次。不是这个就是那个:
- 更新不在主线程上
- outlet 没连上
- 视图根本没显示(在 LLDB 里
po view.window检查——返回 nil 说明视图不在层级里)
表格/集合视图的话,你忘调 reloadData() 了。
内存警告导致崩溃#
用 Instruments Allocations 看什么在占内存。通常是:
- 图片没从缓存释放
- 视图控制器没释放(循环引用)
- 某个数组或字典一直在涨
在模拟器里模拟内存警告来测试清理代码。
神秘崩溃#
全开 sanitizer。间歇性的大概是线程问题——用 Thread Sanitizer 跑。在系统框架里的大概是内存损坏——用 Address Sanitizer 跑。
在模拟器里调试 OTA 更新#
模拟器调试 over-the-air 更新流程简直完美。我做的事:
盯着网络流量。 用过滤到应用子系统的 Console.app 实时看清单请求和响应。能精确看到在发什么头部和服务器返回什么。
模拟烂网络。 用 Network Link Conditioner 测试更新流程怎么处理慢连接或丢包。应用挂住了?显示加载指示器了?优雅地降级了?
检查 bundle 加载。 在更新检查逻辑周围加日志。我通常在检查更新时、有可用更新时、开始下载时、加载新 bundle 时记日志。
import os.log
let logger = Logger(subsystem: "com.example.app", category: "updates")
logger.info("Checking for updates...")
let update = try await Updates.checkForUpdateAsync()
logger.info("Update available: \(update.isAvailable)")测试清单端点。 可以用应用发的同样头部手动 curl 你的清单端点:
curl -H "expo-protocol-version: 1" \
-H "expo-platform: ios" \
-H "expo-runtime-version: 1.0.0" \
https://your-server.com/api/expo-updates/manifest/在调试客户端之前先确认服务器返回的数据是对的。
第三方工具#
Charles Proxy / Proxyman#
看所有网络流量。改请求和响应。测试错误条件。API 调试必备。
Proxyman 比 Charles UI 好,在 macOS 上感觉更原生。我去年换了之后就没回头。
Reveal#
像 Xcode 的视图调试器但更强大。不用连 Xcode 就能在真机上用。调试测试设备上的布局问题很棒。
在真机上调试#
无线调试#
USB 连一次设备,到 Window → Devices and Simulators,勾上 “Connect via network”,然后拔线。只要在同一个 WiFi 上设备就会留在 Xcode 里。
Device Console#
Window → Devices and Simulators → Open Console。什么都有:系统日志、应用日志、崩溃报告。比 Xcode 控制台里看到的详细多了。
测试人员说"应用崩了"的时候,我总让他们把设备插上来好抓控制台日志。通常都有个系统级错误能解释一切。
调试时我实际做的事#
- 加异常断点(没有的话)
- 重现 bug
- 设断点(接近觉得会出问题的地方)
- 在 LLDB 里用
po检查值 - 改变量 不重编译就测试修复
- 查视图层级(UI 相关的话)
- 用 Instruments 分析(性能相关的话)
- 开 sanitizer(内存或线程相关的话)
关键是从症状往回推。崩溃?异常断点。慢?Time Profiler。内存?Instruments。UI 怪?视图调试器。
希望早点知道的技巧#
用断言。 逻辑错误变成 bug 之前就抓住:
assert(users.count > 0, "Users array should never be empty here")调试前提交。 如果你开始改东西来测试猜想,你会想回退的。
删掉 print 语句。 或至少用 #if DEBUG 包起来。旧的调试日志让调试新问题更困难。
学 LLDB。 比重新构建 20 遍快。
模拟器不是真的。 只在真机上出现的 bug 永远是线程或内存问题。要不就是性能相关的,因为模拟器用的是 Mac 的 CPU,不是真正的 iPhone 芯片。
我一直开着的工具#
- Xcode(废话)
- 过滤到应用子系统的 Console.app
- 网络调试用 Proxyman
- 严重的时候上 Instruments
调试不是要知道每个工具——是要知道看到特定症状时该抓哪个工具。视图调试器帮不了内存泄漏,Instruments 帮不了 Auto Layout 冲突。
调试越多,认模式就越快。“哦,这肯定是循环引用。““这看着像主线程违规。““这大概是约束优先级的问题。”
练出那个直觉,调试就没那么烦人了。

