跳过正文
  1. 文章/

在 Xcode 和 iOS 模拟器中调试 iOS 应用:真正有效的方法

· loading · loading ·
杰瑞德·林斯基
作者
杰瑞德·林斯基
居住在韩国首尔的新兴领导者和软件工程师
目录

我在调试上花的时间太多了。每个人都是。但 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 里:

  1. 选择你的模拟器或连接的设备
  2. 按子系统过滤:subsystem:com.example.app
  3. 按级别过滤: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 不更新
#

每次。每次。不是这个就是那个:

  1. 更新不在主线程上
  2. outlet 没连上
  3. 视图根本没显示(在 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 控制台里看到的详细多了。

测试人员说"应用崩了"的时候,我总让他们把设备插上来好抓控制台日志。通常都有个系统级错误能解释一切。

调试时我实际做的事
#

  1. 加异常断点(没有的话)
  2. 重现 bug
  3. 设断点(接近觉得会出问题的地方)
  4. 在 LLDB 里用 po 检查值
  5. 改变量 不重编译就测试修复
  6. 查视图层级(UI 相关的话)
  7. 用 Instruments 分析(性能相关的话)
  8. 开 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 冲突。

调试越多,认模式就越快。“哦,这肯定是循环引用。““这看着像主线程违规。““这大概是约束优先级的问题。”

练出那个直觉,调试就没那么烦人了。