跳过正文
  1. 文章/

我在 Xcode 和模拟器里调试 iOS 应用的方法

· loading · loading ·
仁才德
作者
仁才德
居住在韩国首尔的领导者和软件工程师
目录

我花在调试上的时间实在太多了。谁不是呢。最近大部分时间都在 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 那边的步骤:

  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 能看到每一次对象分配、内存随时间的增长、哪些类最占内存,以及每次分配发生在哪的堆栈跟踪。

我通常找三样东西:随时间线性增长的内存(八成是泄漏)、意料之外的大块分配(是不是把 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 不更新
#

每次。每一次,都是其中之一:

  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 时,每个节点都记。

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 自己的控制台详细得多。

测试人员说“应用崩了”,我一定让他们把设备插上来抓控制台日志。往往里面就躺着一条系统级错误,把一切都解释清楚了。

我调试时的实际顺序
#

  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 包起来。旧的调试日志会挡住下一个 bug。

把 LLDB 学明白,比把应用重新构建二十遍快。

还有,模拟器不是真手机。只在真机上出现的 bug,几乎全是线程或内存问题。再不然就是性能问题,毕竟模拟器跑的是 Mac 的 CPU,不是真正的 iPhone 芯片。

我一直开着的工具
#

  • Xcode(这不用说)
  • 过滤到应用子系统的 Console.app
  • 网络调试用 Proxyman
  • 事态严重时上 Instruments

没有哪个工具包打天下。视图调试器找不出内存泄漏,Instruments 也解不开 Auto Layout 冲突,功夫全在把工具对上症状。这事只能靠次数堆:循环引用踩得多了,看一眼堆栈跟踪就能闻出味来。调试大概永远谈不上有趣,但至少不再是受罪。