Highlight
Apple 在 Xcode 12 为统一日志系统提供 Swift 原生
LoggerAPI,让 App 用类型安全的字符串插值记录事件、默认遮盖非数字敏感数据,并通过 Console 与log collect追踪难复现问题。
核心内容
Fruta 的礼品卡页面有一个典型线上 bug:列表滚到底部会继续从服务器加载卡片,用户点选某张卡片时又会停止当前加载。大多数时候一切正常,偶尔会在加载过程中点卡片后失败。问题很少发生,也不会稳定出现在开发机旁边,所以断点调试抓不到现场。(01:19)
Apple 在这场 session 里给出的解法,是把关键事件写进统一日志系统(Unified Logging)。Xcode 12 新增的 Swift API 以 Logger 类型为入口,开发者可以在任务开始、失败、停止、重试等位置写入结构化日志。系统会把这些日志压缩归档在设备上,之后再用 log collect 拉回 Mac,并在 Console 里按 subsystem、category 和任务标识过滤。(02:24)
这套 API 的重点不只是把 print() 换个名字。日志消息不会在调用点完整转成字符串;编译器和日志库会保留类型信息,只有日志真的展示时才付出格式化成本。运行时数据默认也会被保护:字符串和对象等非数字类型会在日志里遮盖,只有明确标记为 .public 的值才会显示。(03:44)
Fruta 的例子最后靠任务 UUID 串起了时间线。日志显示:任务先因网络错误停止并等待重试,用户随后选择礼品卡,代码又试图停止一个已经不存在的任务,于是进入不一致状态。这个结论来自真实设备上的日志,不需要在本地重新撞出同样的网络时序。(07:35)
详细内容
1. 创建 Logger,并把它放在关键路径上
(02:44)接入分三步:导入 os 模块,创建 Logger,在重要事件发生处调用日志方法。subsystem 通常使用 App 的 bundle identifier,category 用来区分程序里的不同功能区。
// Add logging to your app in three simple steps
import os
let logger = Logger(subsystem: "com.example.Fruta", category: "giftcards")
func beginTask(url: URL, handler: (Data) -> Void) {
launchTask(with: url) {
handler($0)
}
logger.log("Started a task")
}
关键点:
import os引入统一日志 API。Logger(subsystem:category:)给后续每条日志附上来源信息,Console 可以用它过滤。logger.log使用默认的 notice 级别,适合记录排查问题时必须保留的关键事件。- 日志写在
launchTask之后,能确认下载任务已经被启动。
2. 记录运行时数据,同时控制隐私
(03:32)Logger 支持 Swift 字符串插值。你可以把任务 ID、服务器编号、耗时等运行时数据放进日志,后续用这些值缩小 Console 搜索范围。
// Add runtime data to the log messsage using string interpolation
import os
let logger = Logger(subsystem: "com.example.Fruta", category: "giftcards")
func beginTask(url: URL, handler: (Data) -> Void) {
launchTask(with: url) {
handler($0)
}
logger.log("Started a task \(taskId)")
}
关键点:
\(taskId)会被日志系统作为类型化数据处理,不会先变成普通字符串。- 编译器和日志库会保留消息结构,展示日志时再按需要格式化。
- 适合作为过滤条件的 ID 要稳定,例如本场 Fruta 示例里的任务 UUID。
(04:28)隐私默认值很保守。非数字类型,例如字符串或对象,会被当作 private 数据遮盖。
logger.log("Paid with bank account \(accountNumber)")
关键点:
accountNumber是字符串,日志输出里会显示为 private。- 这个默认行为面向已经发布到用户设备上的 App,避免日志泄露个人信息。
- 不要为了方便搜索,把账户、邮箱、姓名等个人数据标成公开。
(05:01)如果某个值本身不敏感,可以显式标记为公开。
logger.log("Ordered smoothie \(smoothieName, privacy: .public)")
关键点:
privacy: .public让 Console 和日志归档显示真实值。- 标记公开前要先判断数据是否可能识别用户。
- Fruta 示例把任务 UUID 标成公开,因为它只用于串起一次下载任务。
3. 用 log collect 和 Console 还原现场
(05:20)日志写进系统后,操作系统会以压缩形式保存在设备上。演讲没有展示完整命令行参数,只明确了排查流程:连接设备,在 Mac 上运行 log collect,指定 device option、开始时间和输出文件名,然后用 Console 打开 .logarchive。
log collect
device option
start time a few minutes before the bug
output log archive file
关键点:
- 开始时间要选在第一次看到 bug 前几分钟,避免收集无关日志。
- 输出结果是 log archive,双击后可以在 Console app 里查看。
- 归档里包含系统上所有进程的日志,第一步要按 subsystem 过滤到自己的 App。
(06:42)Console 的搜索和过滤才是定位问题的核心。Fruta 先按 bundle identifier 过滤出自己的日志,再追加失败任务的 UUID,只保留与这次失败有关的几条记录。
logger.log("Starting a new task for loading cards \(currentTaskID, privacy: .public)")
logger.fault("Task \(currentTaskID, privacy: .public) is not runinng, cannot be stopped!")
logger.log("Task \(currentTaskID, privacy: .public) interrupted")
关键点:
- 所有日志都带同一个
currentTaskID,Console 能用它把一次下载任务串起来。 logger.fault用在程序假设被破坏的场景,本例是代码试图停止一个没有运行的任务。privacy: .public让 UUID 出现在日志里,开发者才能把同一任务的多条记录筛出来。
4. 选择合适的日志级别
(09:27)Logger 提供五个级别:debug、info、notice、error、fault。级别影响 Console 中的视觉提示、是否持久化、保留时间和性能成本。error 与 fault 会在 Console 里高亮,debug 速度最快但不会持久化。
logger.debug("\(slowFunction(data))")
关键点:
- debug 级别的消息在没有被 stream 时会被丢弃。
- Swift 编译器会避免执行构造 debug 消息所需的代码,所以
slowFunction(data)不会让用户付出成本。 - 需要事后用
log collect找回的关键事件,不应放在 debug 级别。
(10:20)选择级别时可以按用途分层。debug 留给开发期细节,info 记录有帮助但非必要的信息,notice 记录排查问题时必须知道的事件,error 记录执行期间的错误,fault 记录潜在程序 bug 导致的严重状态。
logger.log("Task \(currentTaskID, privacy: .public) interrupted")
logger.fault("Task \(currentTaskID, privacy: .public) is not runinng, cannot be stopped!")
关键点:
logger.log对应默认 notice 级别,适合记录任务被中断这种关键流程事件。logger.fault对应最严重级别,适合记录违反程序预期的状态。- error 和 fault 的持久化时间通常更长,但设备存储空间仍会影响保留期。
5. 格式化日志,让数据可以被读和复制
(12:58)日志不只服务于错误排查,也能收集轻量统计数据。Fruta 会记录任务 ID、礼品卡 ID、服务器 ID 和耗时。原始输出不对齐时很难阅读,align 和 format 可以在不增加日志调用成本的前提下改善可读性。
import SwiftUI
import os
let statisticsLogger = Logger(subsystem: "com.example.Fruta", category: "statistics")
// Log statistics about communication with a server.
func logStatistics(taskID: UUID, giftCardID: String, serverID: Int, seconds: Double) {
statisticsLogger.log("\(taskID) \(giftCardID, align: .left(columns: GiftCard.maxIDLength)) \(serverID) \(seconds, format: .fixed(precision: 2))")
}
关键点:
align: .left(columns:)把礼品卡 ID 固定为相同宽度,便于按列阅读。format: .fixed(precision: 2)把耗时保留两位小数,减少无用精度。- 对齐后的日志可以用 Option 键做 column select,再粘贴到 Numbers 里分析。
- 本场示例通过图表发现慢任务都来自 server three,从而把这台服务器下线维护。
(15:00)格式化选项不止固定小数。Xcode code completion 可以看到十六进制、八进制、指数格式等选项,也可以配合列宽做右对齐。
logger.log("\(data, format: .hex, align: .right(columns: width))")
关键点:
format: .hex适合把二进制或数字数据按十六进制显示。align: .right(columns:)让不同长度的数据在同一列右对齐。- 格式化由日志系统处理,演讲明确说明它不会增加日志调用成本。
6. 用 hash 遮盖敏感值,但保留匹配能力
(15:25)日志会在 App 发布后持续发生,拥有设备和密码的人可以收集日志。个人信息不应该被标成 public。遇到需要判断两条日志是否指向同一个值的场景,可以使用 equality-preserving hash。
logger.log("Paid with bank account: \(accountNumber, privacy: .private(mask: .hash))")
关键点:
.private(mask: .hash)不显示银行账号原文。- 相同账号会得到相同 hash,开发者仍然可以判断两条日志是否关联。
- 这个方式适合过滤和聚合同一敏感值,不适合把敏感内容还原给人看。
核心启发
给异步任务建立日志时间线
做什么:为网络请求、后台下载、同步任务生成任务 ID,并在开始、重试、取消、完成和失败处写日志。
为什么值得做:Fruta 的 bug 依赖网络错误和用户点击的时序,任务 UUID 让 Console 能还原同一次任务里的事件顺序。
怎么开始:创建 Logger(subsystem:category:),在每个任务入口生成 UUID,用 privacy: .public 记录这个无敏感含义的 ID。
把日志级别写进工程约定
做什么:给团队定义 debug、info、notice、error、fault 的使用边界。
为什么值得做:级别决定是否能事后收集,也决定 Console 高亮和保留时间。把关键错误写成 debug,问题发生后可能已经没有日志。
怎么开始:把用户可感知错误写成 logger.error,把违反程序假设的状态写成 logger.fault,把高频开发期细节放进 logger.debug。
做一个隐私安全的日志审计
做什么:检查日志里的姓名、账号、订单、邮箱、地理位置和设备标识,确认哪些值应该默认遮盖、公开或 hash。
为什么值得做:session 明确指出字符串和对象默认 private,日志可以被有设备访问权的人收集,错误的 .public 会暴露个人信息。
怎么开始:先移除敏感值上的 .public;需要关联同一用户或同一账号时,改用 .private(mask: .hash)。
让性能排查日志直接可分析
做什么:记录服务器编号、耗时、资源 ID 等字段,并用 align、format 把输出整理成列。
为什么值得做:Fruta 示例把对齐日志复制到 Numbers 后发现慢任务集中在 server three,日志直接变成了诊断数据。
怎么开始:对固定宽度字段使用 align: .left(columns:),对耗时使用 format: .fixed(precision: 2),在 Console 中用 column select 拷贝到表格工具。
关联 Session
- What’s new in MetricKit — 用 MetricKit 收集真实用户设备上的性能指标和诊断 payload,补足日志之外的线上排查线索。
- Diagnose performance issues with the Xcode Organizer — 从 Xcode Organizer 查看聚合性能数据、版本回归、磁盘写入诊断和滚动卡顿指标。
- Decipher and deal with common Siri errors — 用 Xcode、设备日志和 os_log 串起 SiriKit 多进程调试时间线。
- Build trust through better privacy — 理解 iOS 14 隐私原则,帮助判断哪些运行时数据不能公开写入日志。
- Build an Endpoint Security app — 在 macOS 安全产品中处理系统事件,并用隐私友好的日志记录检测和调试信息。
评论
GitHub Issues · utterances