
去年我在帮朋友排查一个线上卡死问题接手的是一个跑了两三年的老工程全项目关键路径上全是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级别我的使用习惯.debugOSLogType.debug开发期排查比如JSON解析中间值.infoOSLogType.info关键业务事件比如登录成功、支付回调.noticeOSLogType.default常规流程记录比如页面进入、接口发起.errorOSLogType.error可恢复异常比如请求失败、重试.faultOSLogType.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. 动态字符串默认是privateOSLog隐私机制与脱敏配置第一次在真机控制台看到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本机实测的相对耗时大概是打印方式一万次耗时相对值原因print约0.08s写标准输出无额外时间戳NSLog约0.35s同步I/O加线程锁加时间戳格式化Logger约0.01s异步收集按级别过滤后再格式化这个数字在不同设备和Debug模式下会有波动但量级差异是稳定的。NSLog确实是打印界的性能黑洞而Logger因为参数是自动闭包连格式化都省了。5.1 坑一Xcode控制台和Release包的级别过滤器不一致调试时能看到debug日志不代表线上也能看到。系统对日志有“落盘级别”的策略低级别日志在Release包里可能只实时显示、不持久化。要调整这个策略可以在Info.plist里加OSLogPreferenceskeyOSLogPreferences/key dict keycom.example.app/key dict keynetwork/key dict keyLevel/key stringdebug/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上跑一个月再回头看你会庆幸这个决定。