我花在调试上的时间实在太多了。谁不是呢。最近大部分时间都在 Xcode 和模拟器里追自己应用的 bug,越用越有一个感受: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% 的 LLDB 操作就是它。
想要更多细节,换 p:
(lldb) p user.name
(String) $R0 = "John Doe"一次看完所有局部变量:
(lldb) frame variable运行时改值#
LLDB 真正值钱的地方在这:不用重新编译就能改变量。
(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 能看到每一次对象分配、内存随时间的增长、哪些类最占内存,以及每次分配发生在哪的堆栈跟踪。
我通常找三样东西:随时间线性增长的内存(八成是泄漏)、意料之外的大块分配(是不是把 1000 张图一次全加载了?)、该释放却还活着的对象。
在操作前后标记代(那个小旗子按钮),就能看出什么东西不该留却留下了。
性能分析#
Time Profiler#
Product → Profile → Time Profiler。一边在应用里做那个慢操作一边录制,然后读调用树。
按“Self Weight”排序找瓶颈。哪个函数占了 40% 的 self weight,时间就是耗在那了。
我有次发现一个 JSON 解析函数每秒被调用 1000 次。挪到后台队列,UI 立刻不卡了。
Main Thread Checker#
后台线程上的 UI 更新,Xcode 会自动抓。看到这条:
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 它自然会找出来。
Address Sanitizer 和 Thread Sanitizer 不能同时开,所以碰到诡异崩溃我就在两者之间轮换。
Zombie Objects#
抓发给已释放对象的消息。开着它,你一碰已释放的内存,Xcode 立刻告诉你:
*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000别一直开着。它会阻止对象释放,内存只涨不降。但要钉死某个 use-after-free bug,它是最好的。
常见调试场景#
一启动就崩溃#
先加异常断点,通常就能抓到。
抓不到就查这几处:Info.plist 缺键、初始化代码里的强制解包、依赖注入失败。
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 时,每个节点都记。
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 的 UI 比 Charles 好,在 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 包起来。旧的调试日志会挡住下一个 bug。
把 LLDB 学明白,比把应用重新构建二十遍快。
还有,模拟器不是真手机。只在真机上出现的 bug,几乎全是线程或内存问题。再不然就是性能问题,毕竟模拟器跑的是 Mac 的 CPU,不是真正的 iPhone 芯片。
我一直开着的工具#
- Xcode(这不用说)
- 过滤到应用子系统的 Console.app
- 网络调试用 Proxyman
- 事态严重时上 Instruments
没有哪个工具包打天下。视图调试器找不出内存泄漏,Instruments 也解不开 Auto Layout 冲突,功夫全在把工具对上症状。这事只能靠次数堆:循环引用踩得多了,看一眼堆栈跟踪就能闻出味来。调试大概永远谈不上有趣,但至少不再是受罪。

