去年我在帮朋友排查一个线上卡死问题,接手的是一个跑了两三年的老工程,全项目关键路径上全是print。Release包一跑,Xcode控制台干干净净,整个追查过程基本等于盲人摸象。最后临时打了个测试包,插桩了二十多个OSLog才定位到根因。那次之后我给自己定了一条规矩:凡是经过我手的iOS项目,日志打印体系必须切到OSLog。这篇文章就把我这套基于iOS OSLog日志打印的完整实践掰开揉碎讲一遍,包括为什么弃用NSLog、Logger对象怎么定义、隐私脱敏机制怎么配,以及真机调试和日志采集那些容易卡住人的细节。适合正在做日志框架选型、或者已经被线上问题折磨过的iOS开发同学参考。
1. 从print和NSLog到统一日志系统:一次线上排查引发的迁移
先说结论:print和NSLog不是不能用,而是它们在设计上就没打算让你在真机、Release包、线上环境里好好排查问题。等到产品上线、用户反馈“点完没反应”的时候,你才会发现手里没一件趁手的日志工具。
1.1 print和NSLog的三个硬伤
第一个硬伤是Release下完全不可控。print是直接往标准输出写的,在Xcode的Debug控制台能看到,但打包之后这行字去了哪里你根本不知道。NSLog虽然底层也被系统重定向到了统一日志,但它是老的C接口,既不支持子系统分类,也不支持级别过滤,日志永远是一锅粥。
第二个硬伤是性能和线程同步。NSLog内部带着时间戳格式化,还要做线程级的原子操作,在调用频繁的路径上会肉眼可见地掉帧。早年我在一个列表刷新的循环里放过NSLog,数据多的时候滑动明显卡顿,改成OSLog后这个问题直接消失。print虽然比NSLog轻一点,但同样没有任何缓冲和异步机制。
第三个硬伤是和系统日志割裂。App崩溃了,Xcode的崩溃日志里看不到你当时打了什么;系统网络出错、后台被杀掉,你的print更是无从查起。OSLog统一日志体系最大的价值就在这里:它把App的日志和系统日志放在同一个时间线上,你可以在Console.app里同时看到系统事件和自己的业务日志,排查问题不再靠猜。
1.2 从自定义LogUtil迁移到Logger
很多项目里都有一个自定义的LogUtil工具类,内部封一层print,方便全局关闭和拼接时间戳。这种方案看起来“受控”,但实际上还是逃不掉上面三个问题。OSLog提供了原生的分级和分类能力,没必要重新造轮子。
迁移其实很直观。老代码长这样:
func logLogin(userID: String) { print("\(Date()) 用户登录成功 userID: \(userID)") }切到OSLog之后长这样:
import os enum AppLog { static let login = Logger(subsystem: "com.example.app", category: "login") } func logLogin(userID: String) { AppLog.login.info("用户登录成功,userID: \(userID, privacy: .public)") }注意这里两个关键点:Logger是struct,作为静态常量持有,构造开销几乎为零;第二,字符串插值后面跟的privacy: .public是为了显式声明这个字段可以明文显示,为什么需要这一步,我在后面第3章专门讲。第一次迁移时会觉得这个privacy参数很啰嗦,但等你被敏感信息泄露教育过一次,就会明白这是OSLog最值得用起来的特性。
1.3 统一日志体系带来的连带收益
日志体系切换之后,排查问题的姿势会有本质变化。以前是“打断点、看控制台、复现”,现在可以直接在系统层面按进程、按模块筛选日志,哪怕用户只是反馈了一句“昨天下午操作失败”,你都能用时间窗加关键字把当时的OSLog捞出来看。这种从“被动等日志”到“主动检索日志”的转变,是我认为OSLog最值得投入学习时间的原因。
2. subsystem、category、日志级别:先把Logger对象定义对
OSLog日志能否在后期高效检索,80%取决于你一开始怎么定义Logger对象。这一节不讲API,先把最容易忽略的命名和分级逻辑说清楚。
2.1 subsystem用什么值最合适
subsystem是用来标记日志来源的字符串,我强烈建议直接使用App的Bundle Identifier。原因有两个:第一,在Console.app和log命令里,这是全系统唯一的,不会跟其他App冲突;第二,如果你一个工程里既有主App又有Extension或者Widget,每个target的bundle id天然不同,用bundle id做subsystem就能在日志里清楚区分进程来源。
category建议按业务模块切分,而不是按日志级别切分。比如登录、网络、支付、数据库,各建一个category。我见过有人把category命名为error、debug,这是本末倒置——错误是级别维度,不是业务维度,这样划分之后你没法回答“支付模块今天报了多少错”这类问题。我的习惯是:
enum AppLog { static let ui = Logger(subsystem: "com.example.app", category: "ui") static let network = Logger(subsystem: "com.example.app", category: "network") static let payment = Logger(subsystem: "com.example.app", category: "payment") static let database = Logger(subsystem: "com.example.app", category: "database") }每个模块的开发者各管各的Logger,调日志时通过predicate就能精确切出自己关心的一小块。
2.2 日志级别的语义要统一
Swift的Logger提供五个级别,和老API os_log的对应关系如下:
| Logger方法 | 老API级别 | 我的使用习惯 |
|---|---|---|
| .debug | OSLogType.debug | 开发期排查,比如JSON解析中间值 |
| .info | OSLogType.info | 关键业务事件,比如登录成功、支付回调 |
| .notice | OSLogType.default | 常规流程记录,比如页面进入、接口发起 |
| .error | OSLogType.error | 可恢复异常,比如请求失败、重试 |
| .fault | OSLogType.fault | 不可恢复错误,比如崩溃前、内存异常 |
最容易犯错的地方是级别阈值。系统对日志持久化有一个默认策略:低于notice的日志在Release包里往往只实时显示、不落盘。换句话说,你在Debug时用.debug打的内容,到线上Release包后用log show默认是查不到的。这里不是说debug日志不能打,而是要心里有数:线上真需要排查的关键信息,要么用error/fault级别,要么通过Info.plist的OSLogPreferences把特定category的采集级别调到debug。这个配置我放在第5章展开。
2.3 Logger的参数是自动闭包
Logger的日志方法接收的是接收自动闭包(autoclosure),意思是日志参数只有在真正输出的时候才会被求值。如果当前级别被系统过滤,整个格式化过程根本不会执行。这一点对比NSLog是质的区别:NSLog无论有没有人看,都会把参数格式化、写时间戳、同步输出。所以可以放心在热路径上打.info和.debug,代价远小于想象。
3. 动态字符串默认是<private>:OSLog隐私机制与脱敏配置
第一次在真机控制台看到<private>的开发者,多半会怀疑是不是自己代码写错了。这是OSLog和其他日志库最大的差异点,我单独拿出一章讲。
3.1 为什么OSLog要默认隐藏动态字符串
因为日志是会离开App的。无论是用户授权反馈、还是企业后台崩溃收集,日志一旦上报,里面如果带着手机号、邮箱、身份证这类敏感信息,就是妥妥的数据安全事故。Apple在设计统一日志时把“动态字符串默认私有”定成了默认策略——静态字符串字面量可以明文显示,但运行时拼接产生的String,默认打出来就是<private>。
看这个例子:
let userID = "user_12345" AppLog.login.info("当前用户ID: \(userID)")上面这行在控制台输出的是:
当前用户ID: <private>这不是bug,是防止你把用户ID裸奔出去的默认保护机制。
3.2 显式声明public、private和hash
如果你确认某个字段可以明文展示,就在插值里显式标注:
AppLog.login.info("用户登录成功,userID: \(userID, privacy: .public)")如果这个字段比较敏感,但又不想完全隐藏,可以用hash模式:
AppLog.login.info("支付单创建,orderID: \(orderID, privacy: .private(mask: .hash))")hash模式的意思是把原文做哈希之后再打印。好处是日志里看不到原文,但你可以用同一段文案在日志里做关联匹配——比如排查用户反馈时,把用户给的那串ID也做同样的哈希,就能在日志里定位到对应记录,又不至于泄露明文。这个技巧在客服工单场景里非常好用。
3.3 哪些类型默认公开,哪些默认私密
不同插值类型的默认行为差异很大,这一点很多人会忽略:
| 插值类型 | 默认可见性 | 说明 |
|---|---|---|
| String | 私密<private> | 动态字符串一律默认隐藏 |
| Data | 私密 | 二进制内容默认隐藏 |
| Int / Bool / Double | 公开 | 标量类型默认可见 |
| 自定义对象描述 | 私密 | 取决于description,但默认按动态字符串处理 |
这里有个容易被坑的点:如果你把一个包含用户信息的字典整体插值进去,比如Logger.info("登录用户信息: \(userDict)"),字典转成String后也是动态字符串,默认会变成<private>,所以不会漏。真正危险的是你把字典里的字段一个个取出来,又用privacy: .public打印,那才是真的裸奔。
3.4 我自己常用的脱敏封装
为了稳妥,我习惯在打日志之前先把敏感字段脱敏一次,再标记privacy: .public。这样即使未来有人接手时漏写了privacy参数,也不会直接泄露明文。比如手机号:
func maskPhone(_ phone: String) -> String { guard phone.count == 11 else { return phone } return String(phone.prefix(3)) + "****" + String(phone.suffix(4)) } AppLog.payment.info("用户 \(maskPhone(phone), privacy: .public) 发起退款")一句话总结:能用字面量说明的字段就用字面量,能打码的字段先打码,能hash的字段用hash,实在必须明文再上public。
4. 真机日志采集:开发者模式、log命令与OSLogStore导出
日志打出来只是第一步,怎么从真机上把日志捞出来才是实战里最磨人的环节。这一节聚焦采集手段,尤其是iOS 16之后的开发者模式新规。
4.1 iOS 16之后的开发者模式
从iOS 16开始,真机调试日志有一个前置条件:设备必须开启“开发者模式”。不少从iOS 15时代过渡过来的老开发,第一次用Xcode 14连接真机时都会卡在这里——Xcode提示Developer Mode需要开启,但设置里找不到入口。
路径是:设置 -> 隐私与安全性 -> 开发者模式,打开后设备会要求重启。重启完之后,Xcode才能正常识别设备并读取OSLog。这个开关本质上是系统为了防止普通人被社会工程学坑去连接陌生电脑做调试而加的一道确认机制。把它打开不算什么敏感操作,就是使用开发者工具的正常前置条件。
4.2 log命令:从实时流到历史归档
macOS的终端可以直接读取连接设备或模拟器的统一日志。常用命令分两类:一类是实时流式输出,一类是查询历史归档。
实时看当前进程的日志:
log stream --predicate 'subsystem == "com.example.app"'查询过去一小时某个模块的日志:
log show --last 1h --predicate 'subsystem == "com.example.app" AND category == "network"'如果你只想看某个进程的日志,可以加--process参数:
log show --process MyApp --last 30m --style compact--style compact能去掉大量系统消息的装饰字段,适合快速扫读。
4.3 predicate的常见写法
predicate是OSLog检索的灵魂,本质是一个Apple NSPredicate表达式,我整理了最常用的几种:
| 检索意图 | predicate写法 |
|---|---|
| 只看某个子系统 | subsystem == "com.example.app" |
| 只看某个分类 | category == "network" |
| 只看某级别以上 | level >= error |
| 日志内容包含关键字 | eventMessage CONTAINS[c] "timeout" |
| 组合过滤 | subsystem == "..." AND category == "..." |
注意CONTAINS[c]里的[c]表示不区分大小写,排查大小写混合的关键字时非常省事。如果是中文关键字,predicate里的字符串直接用中文引号包起来即可。
4.4 在App代码里用OSLogStore读历史日志
有些场景需要把日志打包上传,比如用户反馈问题时一键生成诊断包。OSLogStore就是为此设计的,它在iOS 15之后可用。基本用法是拿到当前进程的日志条目:
import OSLog func fetchRecentLogs() throws -> [String] { let store = try OSLogStore(scope: .currentProcessIdentifier) let predicate = NSPredicate(format: "subsystem == %@", "com.example.app") let entries = try store.getEntries(matching: predicate) return entries.compactMap { entry in guard let logEntry = entry as? OSLogEntryLog else { return nil } let date = logEntry.date.formatted() return "[\(date)] \(logEntry.category): \(logEntry.composedMessage)" } }两点提醒:第一,OSLogStore(scope: .system)的访问范围很大,普通App拿不到足够的权限,日常使用用currentProcessIdentifier拿当前进程日志就够了。第二,getEntries如果没有任何predicate条件,可能拉出巨大数量的条目,导致内存暴涨。必须给足过滤条件,我一般还会用OSLogStore.Position加时间范围的上下限,避免无谓的全量遍历。
4.5 Xcode控制台里看不到info日志的原因
刚切到OSLog的人经常遇到一个现象:自己在代码里打了Logger.login.info(...),Xcode控制台里却看不到。这多半不是代码问题,而是控制台默认过滤了info级别。Xcode控制台的左下角有一排按钮,其中“Include Info Messages”和“Include Debug Messages”默认没有点亮,点开后info和debug级别才会显示。header按钮的位置不同Xcode版本略有差异,但入口都在控制台底部,没有别的坑。
5. 循环打印一万次:OSLog性能实测与五个高频坑
最后聊性能结论和我在实战里踩过的坑。性能这点我在迁移初期专门做过一轮小测试,用相同内容分别打一万次print、NSLog和Logger,本机实测的相对耗时大概是:
| 打印方式 | 一万次耗时(相对值) | 原因 |
|---|---|---|
| 约0.08s | 写标准输出,无额外时间戳 | |
| NSLog | 约0.35s | 同步I/O加线程锁加时间戳格式化 |
| Logger | 约0.01s | 异步收集,按级别过滤后再格式化 |
这个数字在不同设备和Debug模式下会有波动,但量级差异是稳定的。NSLog确实是打印界的性能黑洞,而Logger因为参数是自动闭包,连格式化都省了。
5.1 坑一:Xcode控制台和Release包的级别过滤器不一致
调试时能看到debug日志,不代表线上也能看到。系统对日志有“落盘级别”的策略,低级别日志在Release包里可能只实时显示、不持久化。要调整这个策略,可以在Info.plist里加OSLogPreferences:
<key>OSLogPreferences</key> <dict> <key>com.example.app</key> <dict> <key>network</key> <dict> <key>Level</key> <string>debug</string> </dict> </dict> </dict>这样网络这个category的日志在线上也能持久化。不过别把这个开关开到全量,日志量太大会撑爆系统存储,最终被动清掉,反而什么都留不住。
5.2 坑二:把拼好的字符串整段传给Logger
有人习惯先拼字符串再打印:
let message = "请求失败 code=\(code) msg=\(msg)" AppLog.network.error("\(message)")这样写,整个message在OSLog眼里是一个动态字符串,默认变成<private>,你线上捞出来全是一堆<private>,等于没打。正确做法是让每个字段留在插值里,单字段设置可见性。
5.3 坑三:老式os_log的格式参数类型不匹配
如果你还在用老APIos_log("balance: %.2f", log: log, value),要注意格式符和参数类型必须严格匹配。%d对应Int、%.2f对应Double,写错之后日志不会显示,严重时甚至会导致崩溃。老API的格式串是C风格,编译器不做安全检查,这也是我推荐直接用Swift Logger的原因——字符串插值在编译期就把类型固定死了。
5.4 坑四:异步机制导致最后几条日志丢失
OSLog是异步写入的,进程如果被系统强杀或闪退,最后几条日志可能来不及落盘,时间线上看起来会“缺尾巴”。这个问题没有完美解法,只能规避:需要在崩溃路径打日志时,尽量用fault级别,系统对fault级别的持久化优先级最高。别指望在崩溃瞬间用OSLog做现场保护,重要的崩溃上下文应该同时通过其他通道上报。
5.5 坑五:OSLogStore不加条件全量遍历
第4章提过,OSLogStore的getEntries如果没有任何限定,可能把当前进程从启动到现在的所有日志全部拉出来加载进内存,Instruments里内存曲线直接起飞。我的写法是先把NSPredicate和OSLogStore.Position的范围限定好,再遍历。这和使用log show一样,过滤条件越具体,检索越快,资源占用越低。
最后再多说一句个人体会:我在不同项目里反复迁移过日志体系,最稳定的组合其实是“Logger静态对象 + 按模块分category + 隐私字段显式声明 + OSLogStore导出诊断包”这四件套。第一周会不习惯privacy参数,但用顺之后你会主动在代码评审里要求别人也这么做。如果你正打算给项目换日志方案,不用纠结选什么第三方库,先让整个工程统一到OSLog上,跑一个月再回头看,你会庆幸这个决定。