大家好,我是熊猫钓鱼!欢迎大家和我一起探讨技术。希望您能点赞关注,谢谢!

在这里插入图片描述

摘要

Napier 是 Kotlin Multiplatform 生态里最轻量的日志库之一,核心价值是把「打日志」从各平台零散的 println / Log.x / NSLog 收敛成一个统一的 Napier.v/d/i/w/e/wtf 门面,并通过 Antilog 把输出通道与业务解耦。本篇记录如何把这套设计在 OpenHarmony 上用纯 ArkTS 等价复刻:语义层完整还原 LogLevel / LogEntry / Antilog / CompositeAntilog / Napier 门面(零 @kit.* 依赖);引擎层 HilogAntilog 桥接系统日志 @ohos.hilog,并如实处理鸿蒙「无 verbose 函数」「format 占位符」「tag 长度限制」三处真实约束;验收页额外挂一个内存收集器 MemoryAntilog,把日志同时回显到页面 UI(模拟器看不到系统日志)。全文逐层对照本仓库 napier/src/ 真实代码,不堆砌系列横向对比,只讲清楚这一个库是怎么落地的。


目录

  1. 背景:Napier 在 KMP 里解决什么问题
  2. 适配总览:三层架构怎么套到日志库
  3. 语义层(纯 ArkTS,零 @kit.*)
    • 3.1 LogLevel:用类模拟枚举
    • 3.2 LogEntry:不可变日志载体
    • 3.3 Antilog 接口与 CompositeAntilog 多 sink
    • 3.4 EmptyAntilog 与 DefaultFormatter
    • 3.5 Napier 门面:业务唯一入口
  4. 引擎层(桥 @ohos.hilog)
    • 4.1 三处真实约束(无 verbose / %s 安全格式 / tag 截断)
    • 4.2 HilogAntilog 实现
    • 4.3 级别映射用 priority 而非 switch 对象
  5. 验收页(多 sink 回显 + 异常日志)
  6. API 命名与类型设计说明
  7. 运行实录
  8. 小结

1. 背景:Napier 在 KMP 里解决什么问题

在 Kotlin Multiplatform 项目里,日志是一件「平台碎一地」的事:JVM 上用 println / log4j,Android 上用 android.util.Log,iOS 上用 NSLog / os_log。Napier 的定位就是把这些收敛掉——业务代码只写一套 Napier.v/d/i/w/e/wtf(...),底层把输出通道(Antilog)与日志门面(Napier)解耦,需要换输出目标时只换 Antilog,业务零改动。

对鸿蒙适配而言,Napier 是典型的轻量桥接型:语义层(级别、门面、通道接口)是纯逻辑,可以原样用 ArkTS 复刻;唯一需要「接平台」的是把 Antilog 接到 OpenHarmony 的系统日志 @ohos.hilog。下面逐层对照本仓库 napier/src/ 的真实代码讲清楚。

本项目适配口径:HarmonyOS SDK 6.0.0(20)(API 20)+ KMP&CMP 鸿蒙社区工具链 v1.1.0(Kotlin 2.2.21 / CMP 1.9.2)+ DevEco Studio 26.0.0 Release

程序开发界面如下:
在这里插入图片描述

2. 适配总览:三层架构怎么套到日志库

沿用本系列统一的三层架构:

  • 语义层(Napier.ets,零 @kit.*):还原 LogLevel / LogEntry / Antilog / CompositeAntilog / EmptyAntilog / DefaultFormatter / Napier。这一层只回答「日志是什么、往哪个抽象通道发」,完全不关心通道背后是 hilog 还是内存数组。
  • 引擎层(OhosNapier.ets):唯一碰 @ohos.hilog 的地方,HilogAntilog implements Antilog,把抽象通道接到系统日志。
  • 验收页(NapierDemo.ets):用一个 MemoryAntilog 把同一条日志回显到页面 UI(模拟器看不到系统日志,这是演示必须的补丁),验证「多 sink 同时生效」。

架构图如下:在这里插入图片描述

3. 语义层(纯 ArkTS,零 @kit.*)

3.1 LogLevel:用类模拟枚举

ArkTS 严格模式对枚举的跨层 / 序列化有限制,本系列一致采用「类 + 静态实例」来模拟枚举级别,比 enum 更稳:

export class LogLevel {
  static readonly VERBOSE: LogLevel = new LogLevel(2, 'VERBOSE');
  static readonly DEBUG:   LogLevel = new LogLevel(3, 'DEBUG');
  static readonly INFO:    LogLevel = new LogLevel(4, 'INFO');
  static readonly WARN:    LogLevel = new LogLevel(5, 'WARN');
  static readonly ERROR:   LogLevel = new LogLevel(6, 'ERROR');
  static readonly ASSERT:  LogLevel = new LogLevel(7, 'ASSERT');

  readonly priority: number;
  readonly name: string;

  private constructor(priority: number, name: string) {
    this.priority = priority;
    this.name = name;
  }
}

priority 直接复用 Android Log 的级别数值(VERBOSE=2 … ASSERT=7),这样引擎层做级别映射时心里有数。构造器 private,外部只能引用静态实例,保证级别集合封闭——这点和上游 Napier 的 LogLevel 语义一致。

3.2 LogEntry:不可变日志载体

把一次 Napier.i(msg, tag, throwable) 调用固化成一条不可变记录,方便在通道间传递、也方便格式化:

export class LogEntry {
  readonly level: LogLevel;
  readonly tag: string;
  readonly message: string;
  readonly throwable: Object | undefined;
  readonly timestamp: number;

  constructor(level: LogLevel, tag: string, message: string, throwable?: Object) {
    this.level = level;
    this.tag = tag;
    this.message = message;
    this.throwable = throwable;
    this.timestamp = Date.now();
  }
}

注意 throwable 用 Object | undefined 而非具体异常类型——语义层不假定任何平台异常结构,跨层最稳。

3.3 Antilog 接口与 CompositeAntilog 多 sink

Antilog 是整个适配的「契约接口」,平台实现和业务扩展都围绕它:

export interface Antilog {
  setup(): void;
  log(entry: LogEntry): void;
  flush(): void;
}

CompositeAntilog 是 Napier「多 sink」能力的核心——一条日志同时扇出到多个通道:

export class CompositeAntilog implements Antilog {
  private readonly antilogs: Array<Antilog>;
  constructor(antilogs: Array<Antilog>) { this.antilogs = antilogs; }
  setup(): void { this.antilogs.forEach((a) => a.setup()); }
  log(entry: LogEntry): void { this.antilogs.forEach((a) => a.log(entry)); }
  flush(): void { this.antilogs.forEach((a) => a.flush()); }
}

我们看一下调用时序便可直观理解:
在这里插入图片描述

3.4 EmptyAntilog 与 DefaultFormatter

  • EmptyAntilog:未 setup 前的默认通道,所有 log 都是空操作——对齐上游 Napier「没装 Antilog 就不输出」的默认行为,避免业务误用打出一堆噪声。
  • DefaultFormatter:把 LogEntry 渲染成 时间戳 级别 [tag] 消息 一行;若带 throwable,ArkTS 异常没有可靠的 stack 字段,退化为 throwable.toString()(如实记录这一限制,而不是假装能拿到堆栈)。

3.5 Napier 门面:业务唯一入口

业务只需要 import { Napier },调用 v/d/i/w/e/wtf 即可,完全不知道背后有几个 sink、是不是 hilog:

export class Napier {
  private static baseTag: string = 'Napier';
  private static antilog: Antilog = new EmptyAntilog();
  private static formatter: DefaultFormatter = new DefaultFormatter();

  static setup(antilog: Antilog): void {
    Napier.antilog = antilog;
    Napier.antilog.setup();
  }
  static log(level: LogLevel, message: string, tag?: string, throwable?: Object): void {
    const resolvedTag = tag !== undefined ? tag : Napier.baseTag;
    const entry = new LogEntry(level, resolvedTag, message, throwable);
    Napier.antilog.log(entry);
  }
  static v(message: string, tag?: string, throwable?: Object): void { Napier.log(LogLevel.VERBOSE, message, tag, throwable); }
  static d(message: string, tag?: string, throwable?: Object): void { Napier.log(LogLevel.DEBUG,   message, tag, throwable); }
  static i(message: string, tag?: string, throwable?: Object): void { Napier.log(LogLevel.INFO,    message, tag, throwable); }
  static w(message: string, tag?: string, throwable?: Object): void { Napier.log(LogLevel.WARN,    message, tag, throwable); }
  static e(message: string, tag?: string, throwable?: Object): void { Napier.log(LogLevel.ERROR,   message, tag, throwable); }
  static wtf(message: string, tag?: string, throwable?: Object): void { Napier.log(LogLevel.ASSERT, message, tag, throwable); }
}

baseTag 提供默认 tag(对应上游 Napier.baseTag),单个调用还能用第二个参数覆盖 tag。

4. 引擎层(桥 @ohos.hilog)

4.1 三处真实约束(已对照本机 SDK @ohos.hilog.d.ts 核实)

  1. 没有 verbose 函数:@ohos.hilog 只导出 debug / info / warn / error / fatal 五个函数,没有 verbose。上游 Napier 的 VERBOSE 级别在鸿蒙上只能降级到 debug——本适配器不假装存在 verbose,而是如实落到 debug,并在博客里写明这处约束。
  2. % 占位符:hilog.xxx(domain, tag, format, ...args) 的 format 会把 %s / %d 当作格式符。如果日志消息本身含 %(比如进度 50%),直接当 format 传会崩或丢字。安全写法是 hilog.info(domain, tag, '%s', message),把消息作为实参传入。
  3. tag 长度限制:hilog 的 tag 建议 ≤ 23 字符,过长会被系统截断。适配器主动 substring(0, 23),行为可控、可预期。

4.2 HilogAntilog 实现

import { hilog } from '@ohos.hilog';
import { Antilog, LogEntry, LogLevel } from './Napier';

export class HilogAntilog implements Antilog {
  private readonly domain: number;
  private readonly tagLimit: number;
  constructor(domain: number = 0x0023, tagLimit: number = 23) {
    this.domain = domain;
    this.tagLimit = tagLimit;
  }
  setup(): void { /* hilog 无需显式初始化 */ }
  log(entry: LogEntry): void {
    const text = entry.message;
    const tag = this.truncateTag(entry.tag);
    const p = entry.level.priority;
    if (p <= 3)      hilog.debug(this.domain, tag, '%s', text); // VERBOSE/DEBUG → debug
    else if (p === 4) hilog.info(this.domain, tag, '%s', text);
    else if (p === 5) hilog.warn(this.domain, tag, '%s', text);
    else if (p === 6) hilog.error(this.domain, tag, '%s', text);
    else              hilog.fatal(this.domain, tag, '%s', text); // ASSERT → fatal
  }
  flush(): void { /* hilog 同步写,无需 flush */ }
  private truncateTag(tag: string): string {
    return tag.length > this.tagLimit ? tag.substring(0, this.tagLimit) : tag;
  }
}

domain 是 hilog 的服务域(0x0000–0xFFFF),默认取 0x0023,业务可按模块自定义。

4.3 级别映射用 priority 而非 switch 对象

初版我设想用 switch (entry.level) 匹配 LogLevel.DEBUG 等静态实例引用,但 ArkTS 对 switch 的表达式类型有收紧(更偏好 number/string/enum),对象引用匹配容易踩编译红线。改用 priority 数值比较后,逻辑等价、编译更稳——这也是本系列反复沉淀的经验:跨层判别优先用原始值,不用对象引用。

5. 验收页(多 sink 回显 + 异常日志)

验收页做两件事:① 发各等级日志;② 把日志同时回显到页面 UI。
在这里插入图片描述

关键是 MemoryAntilog——一个把日志存进数组并回调的通道,专门解决「模拟器看不到系统日志」的问题:

export class MemoryAntilog implements Antilog {
  readonly entries: Array<string> = [];
  constructor(private readonly onAppend?: (level: string, line: string) => void) {}
  log(entry: LogEntry): void {
    const line = Napier.format(entry);
    this.entries.push(line);
    if (this.onAppend !== undefined) this.onAppend(entry.level.name, line);
  }
  // ...
}

// 业务装配:一条日志同时扇出到 hilog 和 UI
Napier.setup(new CompositeAntilog([new HilogAntilog(), new MemoryAntilog((level, line) => {
  this.logs = this.logs.concat([{ level, text: line }]); // @State 需重新赋值才能刷新
})]));

页面按钮覆盖 VERBOSE → WTF 全部六个级别,另有一键发送「带 throwable 的 ERROR」验证异常路径。UI 日志区按级别着色(ERROR/ASSERT 红、WARN 橙、INFO 青、DEBUG 蓝),直观对应系统级别。
在这里插入图片描述

一个 ArkTS 细节:@State 数组用 .push() 不会触发 UI 刷新,必须重新赋值 this.logs = this.logs.concat([...])。这是 ArkTS 响应式的基本约束,本系列多次遇到。

6. API 命名与类型设计说明

  • 不引入上游没必要的类型:上游 Napier 的 Napier 是 object 单例,ArkTS 没有 object 单例语法,改用 class Napier + private static 字段模拟,对外仍是无实例的静态门面,调用形态 Napier.i(...) 完全一致。
  • throwable 用 Object | undefined:不绑定任何平台异常类,跨层最稳;格式化时也只用 toString()。
  • Antilog 用接口而非抽象类:接口让 HilogAntilog / MemoryAntilog / 业务自定义 sink 平权,扩展成本最低,正是 Napier 解耦哲学的落点。
  • 级别用「类 + 静态实例」而非 enum:规避 ArkTS 枚举在跨层序列化上的限制,且能附带 priority 数值。

7. 运行实录

本项目编译如下:
在这里插入图片描述

遇到过问题如下:

  • switch 匹配对象引用:本来想 switch(entry.level) 匹配 LogLevel.DEBUG,ArkTS 对 switch 表达式类型收紧,改成 priority 数值 if/else 链,编译通过且语义等价。
  • @State 数组 push 不刷新:回显日志必须用 concat 重新赋值,否则页面不更新。
  • hilog format 含 % 会崩:一律用 '%s' + 消息实参写法,避免消息文本里的 % 被当格式符。
  • hilog 无 verbose:VERBOSE 降级到 debug,并在文档明确标注这一平台约束而非假装支持。
  • hilog tag 超长被截断:主动 substring(0, 23),行为可控。

解决后正常运行如下:
启动演示界面
在这里插入图片描述
然后逐个测试功能按钮:

在这里插入图片描述
发现均能正常收到捕获消息。
点击清空:
在这里插入图片描述
OK,全部完成了!

8. 小结

Napier 适配是「轻量桥接型」的代表:语义层几乎零成本纯 ArkTS 复刻,引擎层只做一件事——把 Antilog 接到 @ohos.hilog,并诚实处理鸿蒙的三个真实约束(无 verbose / %s 安全格式 / tag 截断)。CompositeAntilog 的多 sink 设计还顺手解决了「模拟器看不到系统日志」的演示难题。

本文为作者基于 OpenHarmony 6.0.0(20) 与 KMP&CMP 鸿蒙社区工具链 v1.1.0 的原创适配实践,代码与思路均为自行落地,非 AI 直接生成的拼凑内容;文中 API 约束均对照本机 SDK 的 .d.ts 核实。转载请注明出处。

欢迎加入 KMP&CMP 鸿蒙社区:https://atomgit.com/CPF-KMP-CMP
获取更多鸿蒙化适配资源与专属 AI 工具,请使用 AtomCode 邀请链接:
https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgiths

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐