【共创稿事节】HarmonyOS 7.0 HiLog分级日志:从接入到生产验证

发布时间:2026/11/4 19:53:12
【共创稿事节】HarmonyOS 7.0 HiLog分级日志:从接入到生产验证

HiLog 是鸿蒙日志体系里最不起眼的组件——写日志谁不会?但健康菜谱助手的一次线上排查让我意识到,乱写的日志比没有日志更糟:两万条 INFO 日志淹没了唯一的 ERROR,关键线索被刷掉,排查多花了四小时。这篇记录 HiLog 的分级体系、域与标签的设计、隐私合规的 %{public} 与 %{private}、以及日志规范如何在项目里落地。

一、分级体系:五个级别各自的真实用途

HiLog 五级:DEBUG、INFO、WARN、ERROR、FATAL。文档教的是定义,工程里要定的是"什么内容用什么级"。我的分级标准:DEBUG 是开发期排查细节(变量值、分支走向),发布构建默认关闭;INFO 是关键路径的里程碑(页面创建、请求发起、迁移开始),数量可控;WARN 是异常但已兜底(缓存失效走了降级、重试成功);ERROR 是操作失败且影响功能(请求失败且无缓存、写入失败);FATAL 是不可恢复即将崩溃。最容易被滥用的是 INFO——把调试细节打进 INFO,发布包里日志被淹没的根源就在这。

级别内容标准发布构建
DEBUG排查细节,变量快照关闭
INFO里程碑事件,数量可控保留
WARN已兜底的异常保留
ERROR功能失败保留+上报
FATAL不可恢复保留+崩溃关联

二、domain 与 tag 的设计:日志的定位系统

HiLog 的 domain(域,十六进制数)加 tag(标签)构成日志的定位系统。域按模块划分——0x0001 存储、0x0002 网络、0x0003 UI、0x0004 迁移——跨模块排查时按域过滤就能拉出单模块的完整日志。标签按类文件命名(RecipeStore、SyncClient),同域内按 tag 区分来源。踩过的坑:早期 tag 随手写,同一个类的日志出现三个 tag 变体,过滤时漏一半。规范落地:tag 常量集中定义在日志工具文件里,类初始化时取常量,手写字符串直接禁止。

// 日志工具:统一 domain 与 tag 常量 const DOMAIN_STORAGE = 0x0001 const TAG = 'RecipeStore' hilog.info(DOMAIN_STORAGE, TAG, 'load favorites: %{public}d items', count)

三、隐私合规:%{public} 与 %{private} 的强制区分

HiLog 的格式化参数必须显式声明 public 或 private——private 的值在发布环境会被脱敏成 ,public 才明文输出。这个机制是隐私合规的硬约束,但默认行为有个坑:不声明格式的参数(如直接拼字符串进日志)不会自动脱敏,反而绕过了保护。项目规范:日志一律用格式化参数,禁止字符串拼接;用户相关数据(菜谱名、备注)一律 %{private},技术数据(计数、耗时、状态码)用 %{public}。这条规范在 AppGallery 审核的隐私检查里顺带通过,没有返工。

// 规范写法:公开技术数据,隐私数据脱敏 hilog.info(DOMAIN_STORAGE, TAG, 'load favs: %{public}d items, user %{private}s', count, userId) // 禁止:字符串拼接绕过脱敏 hilog.info(DOMAIN_STORAGE, TAG, 'user ' + userId + ' loaded ' + count)

四、日志工具封装:五分钟省下重复劳动

裸写 hilog 五个参数太啰嗦,封装一个日志工具统一 domain、tag、级别判断与 debug 开关:

class Log { static d(tag: string, msg: string, ...args: Object[]) { if (!DEBUG_BUILD) return hilog.debug(DOMAIN, tag, msg, ...args) } static i(tag: string, msg: string, ...args: Object[]) { hilog.info(DOMAIN, tag, msg, ...args) } static w(tag: string, msg: string, ...args: Object[]) { hilog.warn(DOMAIN, tag, msg, ...args) } static e(tag: string, msg: string, ...args: Object[]) { hilog.error(DOMAIN, tag, msg, ...args) } }

封装的隐藏收益是 DEBUG 级别的全局开关:DEBUG_BUILD 由编译宏注入,发布构建里 debug 调用直接被摇树优化掉——日志的零成本切换靠封装实现,散落的裸调用做不到。

五、日志的量控制:每屏一条的自律

日志量的控制标准:滚动一屏的列表,产生的日志不超过一条。逐项打点的冲动要被"聚合打点"替代——列表滚动位置不逐帧打,只打停稳后的最终位置;批量操作不逐条打,打一条聚合结果(处理了 20 条,成功 18 失败 2)。那次 ERROR 被淹没的事故之后,全应用做了日志瘦身:删掉循环内打点 14 处、降级 INFO 到 DEBUG 37 处、合并相邻重复 9 处,发布包的日志量降到原来的四分之一,关键日志的可见性回来了。

六、与 HiAppEvent 的配合:日志与事件的两端关联

日志与打点的关联靠追踪 ID:每个关键链路(迁移、同步、支付)发起时生成一个 traceId,链路上的每条 HiLog 与每个 HiAppEvent 事件都携带它。排查时从事件入手拿到 traceId,再按 traceId 过滤日志还原全过程——双向可查。配合规范:fault 级事件必须携带 traceId;traceId 的生成用时间戳加随机四位的组合,保证单设备唯一且肉眼可读。

const traceId = Date.now().toString(36) + Math.floor(Math.random() * 10000).toString(36) Log.e(TAG, 'migrate fail, trace %{public}s', traceId) hiAppEvent.write('migration_fail', hiAppEvent.EventType.FAULT, { traceId, step })

七、真实排查:四小时与两分钟的差距

两个同类问题在不同日志质量下的排查对比。问题一(日志质量差时期):收藏丢失,两万条 INFO 里翻 ERROR,翻到时发现关键报错在四小时前的日志段里被冲掉了,最终靠用户复现才定位。问题二(规范落地后):同步失败,WARN 日志一条锁定重试耗尽,ERROR 一条定位到失败原因,加上打点事件圈定影响范围,两分钟闭环。同一个应用,日志体系改造前后的排查效率差了百倍。日志是写给三周后的自己的——三周后的你不记得今天的代码为什么这么写,只有日志能告诉你当时发生了什么。

八、发布与调试的分离:两份配置

日志的发布与调试配置分离:调试构建全级别开启加 debug 面板显示;发布构建只保留 INFO 以上、DEBUG 调用摇树剔除、日志文件落盘走 HiLog 的持久化配置。两份配置的切换靠 DEBUG_BUILD 编译宏,切换点收敛在日志工具类内部,业务代码无感。发布包的日志量最终控制在日均 200 条以内——这是给用户设备留的合理 footprint,也是给自己留的排查余粮。

九、日志落盘与用户反馈:让日志能被带出来

HiLog 默认输出到 hilogd 守护进程的缓冲区,设备重启就没了,用户反馈里也带不出来。健康菜谱助手的做法:关键链路的 WARN 以上日志,同步落一份到应用沙箱的日志文件——按天分文件,单文件超过两兆滚动覆盖,只保留最近三天。落盘的格式化把时间、级别、tag、traceId 拼成一行,导出时用户直接把文件发过来就能看。用户反馈页做了"附带日志"开关,默认勾选并在勾选框旁写明"仅包含技术日志,不含个人内容"——private 脱敏在落盘前就已生效,这句话才立得住。真机导出用 hdc file recv 拉沙箱文件,排查期一天能拉十几次。

// 按天落盘:WARN 以上同步写沙箱,三天滚动 function logToFile(level: string, tag: string, msg: string) { const day = new Date().toISOString().slice(0, 10) const path = filesDir + `/logs/app-${day}.log` const line = `${Date.now()} ${level} ${tag} ${msg}\n` fileIo.writeSync(path, line, { append: true }) if (fileIo.statSync(path).size > 2 * 1024 * 1024) rotateLog(path) }

落盘的坑有一个:写文件本身失败不能递归打日志,会死循环。工具函数里对落盘异常静默吞掉,最多在内存计数器上加一,调试面板展示"落盘失败 N 次"。别小看这个细节,我见过把 fileIo 异常 catch 之后又调 Log.e 的写法,写盘失败触发日志、日志写盘再失败,几秒内把 CPU 拉满。

十、TaskPool 与 Worker 里的日志:跨线程不断线

日志体系在多线程下有个隐蔽问题:TaskPool 和 Worker 里跑的代码,拿不到主线程封装好的日志工具单例——每个 Worker 有自己的执行环境,静态类不共享。最初的方案是每个 Worker 文件里复制一份 Log 类,结果 domain 常量抄错了两个,后台解析日志时模块归属全乱。后来收敛成日志模块单独一个 ets 文件,主线程和 Worker 各自 import 同一份,domain 与 tag 常量只有一处定义。Worker 内的日志前缀再加一层 [W] 标记,和主线程日志区分线程归属。

线程场景处理方式注意点
主线程 UI常规 Log 工具回调里的日志带页面 tag
TaskPool 任务同工具 import入参出参打点走 %{private}
Worker 常驻同工具+[W] 前缀循环任务禁止逐条 INFO
Native 侧HiLog C 接口与 ArkTS 共用 domain 编号

Native 侧容易漏:C++ 代码用 hilog_native 接口时,domain 编号如果和 ArkTS 侧对不上,按域过滤时原生日志会整段缺失。项目里把 domain 常量生成到 napi 桥接头文件里,两边编译期引用同一个值,从源头杜绝错位。跨线程排查另有一个技巧:Worker 里的耗时任务在开始和结束各打一条 INFO,配对消耗时间戳,线程卡顿一眼可见。

十一、崩溃现场:让 FATAL 自动留下最后一段日志

FATAL 级别的价值在崩溃瞬间:crash 回调触发时,内存里还留着最近几十条日志,机会窗口只有几秒。项目在 ErrorManager 的 crash 回调里做了三件事:先把内存环形缓冲区(最近一百条日志,含 DEBUG 级)整体 dump 到 crash 目录;再把崩溃堆栈、异常类型、当时的页面路由追加进去;最后才走默认的崩溃上报流程。恢复启动后检测到 crash 文件存在,提示用户"上次异常退出,是否提交日志"——用户点确认,文件随反馈上传,拒绝也不影响使用。

// 崩溃瞬间:dump 环形缓冲区到文件 const ringBuf: string[] = [] function pushRing(line: string) { ringBuf.push(line); if (ringBuf.length > 100) ringBuf.shift() } // crash 回调里: onCrash(err) { fileIo.writeSync(crashDir + '/crash-' + Date.now() + '.log', ringBuf.join('\n') + '\n==STACK==\n' + err.stack) }

这套机制上线后收到三次真实回报:一次数据库迁移崩溃、一次大文件解析内存溢出、一次 Worker 反序列化崩溃,三份 crash 日志都完整还原了崩溃前最后 100 条日志,定位时间都在十分钟以内。没有这套机制的时期,崩溃排查只能靠复现——有的问题复现率百分之一,靠运气等于排不了。

十二、总结:日志规范的四条军规

收束成四条军规:级别按内容标准选,DEBUG 禁止进发布包;domain 按模块、tag 走常量,定位系统统一;格式化参数显式 public/private,隐私脱敏不过夜;每屏一条的量控制,聚合替代逐条。四条军规配合打点体系(事件管结果、日志管过程、traceId 串联),构成应用完整的可观测性底座。日志的价值不在写的那一刻,在线上出问题的那一刻——那一分钟的差距,就是提前写好规范的全部意义。

参考资料:

  • 华为开发者:HiLog 日志输出
  • 华为开发者:日志隐私脱敏 %public/%private
  • 华为开发者:HiLog 与 HiAppEvent 配合

#鸿蒙 #HarmonyOS 7(API 26)