WWDC Quick Look 💓 By SwiftGGTeam
Debug with structured logging

Debug with structured logging

观看原视频

Highlight

Xcode 15 的新版 Debug Console 把日志消息作为核心、元数据按需展示,配合 token 化过滤和 LLDB 的 DWIM Print,让开发者从海量日志中定位问题的效率大幅提升。

核心内容

调试时最怕什么?控制台被 print 输出淹没,想找一条关键日志得翻半天。更糟的是,生产环境出了问题,用户设备上的日志根本拿不到。

Xcode 15 从三个层面解决了这个问题:重新设计的 Debug Console、更聪明的 LLDB 表达式求值、以及 Unified Logging 的最佳实践。

新版 Debug Console

旧版控制台每条日志前面都带一大串元数据(时间戳、进程 ID、线程 ID 等),真正重要的消息内容反而被挤到后面。(01:02

新版把元数据默认隐藏,只展示开发者写的消息本身。需要看元数据时,点左下角的选项按钮按需开启。也可以选中单条日志按空格 Quick Look,在弹窗里看完整信息,包括调用该日志的函数名和源码位置。

错误(error)和故障(fault)级别的日志会用黄色和红色背景高亮,一眼就能定位严重问题。

Token 化过滤

过滤是新 Debug Console 的核心能力。(02:16

在过滤栏输入时,自动补全会提示可用的过滤维度:subsystem、category、type、library 等。选中后输入过滤值,多个条件可以组合使用。

除了手动输入,还有三种快捷方式:

  • 过滤菜单:直接勾选要显示的日志类型(debug、info、error、fault 等)
  • 右键菜单:对某条日志选择 “Hide Similar” 或 “Show Only Similar”,自动生成过滤条件
  • Category 过滤:输入关键词后选择 category 维度,快速聚焦到特定模块

实战:用日志定位 Bug

Session 用一个真实案例演示了这套工作流:用户更新个人资料后,修改没有保存。(03:35

步骤如下:

  1. 复现问题:修改 display name,退出页面再进入,发现修改丢失
  2. 在 Debug Console 中输入 “account”,选择 category 过滤维度,只保留账户相关日志
  3. 找到 “Requesting to change displayName” 这条日志,悬停后点击右下角的源码位置,直接跳转到发出日志的代码
  4. 进入 setDisplayName 函数,发现代码只更新了数据库,没更新本地缓存
  5. 修复:在数据库更新后加上 account.displayName = newDisplayName

整个定位过程不到两分钟,关键是日志写在了正确的位置,让过滤能精准命中。

LLDB 的 DWIM Print

调试时 pop 该用哪个?很多人凭感觉选,结果常常拿到一个内存地址,什么信息都没有。(06:35

Xcode 15 引入了 Do What I Mean Print(DWIM)。p 命令现在会智能判断表达式类型,自动选择最快的求值方式:

  • 基础类型(Int、String 等)直接打印值
  • 结构体和类优先打印属性列表
  • 实现了 CustomStringConvertible 的对象打印自定义描述

po 则相当于 p 加上强制对象描述模式。大部分情况下,用 p 就够了。

过去需要记 ppovvoexpressionframe variable 等多个命令的场景,现在简化成两个。

从 print 迁移到 OSLog

print 有几个硬伤:没有日志级别、没有结构化元数据、生产环境无法收集、性能开销不可控。(09:18

OSLog 解决了所有这些问题。迁移过程很简单:

  1. import OSLog
  2. 创建 Logger:let logger = Logger(subsystem: "BundleID", category: "ComponentName")
  3. logger.info()logger.error() 等替代 print

subsystem 通常用 Bundle Identifier,category 用类名或模块名。这样过滤时可以直接按模块定位。

详细内容

日志级别选择

OSLog 提供五个级别,按严重程度递增:

  • debug:开发调试信息,默认不存储到磁盘
  • info:一般性信息,如操作开始/完成
  • notice:需要注意的事件,默认级别
  • error:错误,需要调查
  • fault:严重故障,系统级问题
import OSLog

let logger = Logger(subsystem: "com.example.MyApp", category: "Account")

func login(password: String) -> Error? {
    var error: Error? = nil
    logger.info("Logging in user '\(username)'...")

    // ... 验证逻辑 ...

    if let error {
        logger.error("User '\(username)' failed to log in. Error: \(error)")
    } else {
        logger.notice("User '\(username)' logged in successfully.")
    }
    return error
}

关键点:

  • Logger 是值类型,可以全局创建也可以按需创建
  • subsystem 和 category 是字符串,用于 Debug Console 的过滤
  • 日志消息支持字符串插值,但复杂对象需要用 \(object, privacy: .public) 控制隐私级别
  • 默认情况下,动态字符串会被标记为私有,保护用户数据

Bug 修复前后的代码对比

修复前的 setDisplayName 只更新了数据库,没更新本地模型:

public func setDisplayName(_ newDisplayName: String) {
    logger.info("Sending Request to update DisplayName")
    Database.setValueForKey(Database.Key.displayName, value: newDisplayName, forAccount: account.id)
    logger.info("Updated DisplayName to '\(newDisplayName)'")
}

修复后增加了本地缓存更新:

public func setDisplayName(_ newDisplayName: String) {
    logger.info("Sending Request to update DisplayName")
    Database.setValueForKey(Database.Key.displayName, value: newDisplayName, forAccount: account.id)
    account.displayName = newDisplayName  // 新增
    logger.info("Updated DisplayName to '\(newDisplayName)'")
}

关键点:

  • 日志写在操作前后,形成完整的调用链路
  • 通过 logger.info 记录 “Sending Request” 和 “Updated” 两个状态点
  • 修复后用 LLDB 的 p account 验证本地属性已更新

LLDB 调试验证

在修复后的代码上打断点,用 LLDB 验证:

(lldb) p account
(BackyardBirdsData.Account) =0x000060000223b2a0 {
    id = 3A9FC684-8DFC-4D7D-B645-E393AEBA14EE
    joinDate = 2023-06-05 16:41:00 UTC
    displayName = "Sample Account"          // 修复前:旧值
    emailAddress = "[email protected]"
    isPremiumMember = true
}

// 执行 account.displayName = newDisplayName 后

(lldb) p account
(BackyardBirdsData.Account) =0x000060000223b2a0 {
    ...
    displayName = "Johnny Appleseed"        // 修复后:新值
    emailAddress = "[email protected]"
    ...
}

关键点:

  • p 现在会自动展开结构体和类的属性
  • 如果没有自定义 CustomStringConvertiblepo 只会输出对象地址
  • p 作为默认调试命令,po 仅在需要自定义描述时使用

生产环境日志收集

OSLogStore API 可以在 App 运行时读取系统日志,用于诊断用户报告的问题:

import OSLog

do {
    let store = try OSLogStore(scope: .currentProcessIdentifier)
    let position = store.position(timeIntervalSinceLatestBoot: 0)
    let entries = try store.getEntries(at: position)

    for entry in entries {
        if let logEntry = entry as? OSLogEntryLog {
            print("[\(logEntry.subsystem)] \(logEntry.composedMessage)")
        }
    }
} catch {
    logger.error("Failed to read log store: \(error)")
}

关键点:

  • OSLogStore 需要 iOS 15+/macOS 12+
  • .currentProcessIdentifier 只读取当前进程的日志
  • composedMessage 是完整的日志消息,包含所有插值结果
  • 可以结合 predicate 过滤特定 subsystem 或 category

核心启发

1. 可诊断性优先的日志规范 做什么:在团队里建立日志规范,要求每个模块用独立的 Logger category,关键操作前后必须打日志。 为什么值得做:好的日志规范能让线上问题的定位时间从小时级降到分钟级。 怎么开始:在 App 启动时创建一组全局 Logger,按模块(Network、Database、UI、Account)分类。用 SwiftLint 规则或 Code Review 检查清单确保规范执行。

2. 内置诊断报告功能 做什么:在 App 设置里加一个”导出诊断日志”按钮,用户遇到问题时可以一键收集最近的操作日志发给开发者。 为什么值得做:OSLogStore 让生产环境日志收集变得可行,不再需要依赖难以复现的口头描述。 怎么开始:用 OSLogStore 读取最近 10 分钟的日志,过滤出本 App 的 subsystem,打包成文本文件通过邮件或上传接口发送。

3. 自动化日志断言测试 做什么:在单元测试中验证关键路径是否产生了预期的日志。 为什么值得做:日志和代码一样会腐烂,测试可以确保日志不会随着重构而丢失。 怎么开始:用 OSLogStore 在测试执行前后捕获日志,断言特定消息是否存在。或者封装一个日志中间件,在测试模式下把日志写入内存数组供断言使用。

4. 性能敏感的 Signpost 追踪 做什么:用 os.signpost 标记关键操作的起点和终点,在 Instruments 中可视化分析性能瓶颈。 为什么值得做:OSLog 和 Signpost 共享同一套基础设施,日志和性能分析可以无缝衔接。 怎么开始:用 OSSignposter 替代手动计时,在网络请求、数据库查询、图片解码等操作前后插入 begin/end 标记。在 Instruments 中选择 Logging 模板查看时间线。

关联 Session

评论

GitHub Issues · utterances