Flutter for OpenHarmony 跨平台工程日志能力实战:分级日志输出与本地文件持久化


欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net


一、前言

在移动应用开发中,日志系统是排查问题的第一道防线。尤其在跨平台场景下,Flutter for OpenHarmony(以下简称 FHO)工程涉及 ArkTS 原生层与 Dart 上层的协同调用,传统的 print 语句难以满足复杂场景下的日志管理需求——我们需要分级控制、日志持久化、以及在真机/模拟器上统一可用的日志输出能力。

本文将围绕一个实际落地的日志模块 Logger.ets 展开,完整呈现从需求分析、架构设计、编码实现,到模拟器运行验证的全流程。该模块具备以下核心能力:

  • 分级日志输出:支持 DEBUG / INFO / WARN / ERROR / FATAL 五个级别,运行时可动态调整
  • 双重输出通道:同时向系统 HiLog(hilog)和本地日志文件写入,互不干扰
  • 日志文件滚动:单文件超过阈值后自动切换新文件,并自动清理超出数量限制的旧文件
  • 异步安全写入:内部实现文件锁机制,避免多线程并发写入导致日志错乱
  • 灵活配置项:支持自定义日志级别、输出开关、文件路径、文件大小上限、文件数量上限、时间戳开关等

AtomGit 仓库https://atomgit.com
本文完整源码可于 AtomGit 平台搜索对应仓库获取。

二、需求分析与方案设计

2.1 为什么需要自定义日志模块

OpenHarmony 系统提供了原生的 hilog 接口用于日志输出,但其能力有如下局限:

  1. 不提供持久化hilog 输出到系统日志缓冲区,应用重启后日志丢失,无法满足事后排查需求
  2. 缺乏滚动机制:没有自动分文件和清理旧日志的能力
  3. 分级控制粒度粗:只能通过 isLoggable 做全局开关,无法针对不同业务模块精细控制
  4. 跨层日志对齐困难:Flutter Dart 层与 ArkTS 原生层各有各的日志格式,排查跨层问题时日志分散

因此,我们需要封装一个统一的日志工具,在 ArkTS 层(原生 Ability 代码)提供上述全部能力,同时保持与 hilog 的良好集成——即日志既写入系统日志供实时查看,又保存到本地文件供事后分析。

2.2 架构设计

整体采用单例模式,对外暴露统一的 logger 实例。模块内部划分为以下层次:

┌─────────────────────────────────────────┐
│           Logger (对外暴露层)            │
│  - d() / i() / w() / e() / f()         │
│  - setLevel() / getLogFiles() 等        │
├─────────────────────────────────────────┤
│         日志格式化层 (formatLogEntry)   │
│  - 组装时间戳、级别、Tag、消息           │
├─────────────────────────────────────────┤
│         双通道输出层                    │
│  - HiLog 输出 (enableHiLog)            │
│  - 文件输出 (enableFile)                │
├─────────────────────────────────────────┤
│         文件管理层 (滚动/清理)           │
│  - rollLogFile()                       │
│  - writeToFile() + 文件锁              │
└─────────────────────────────────────────┘

核心设计决策:

  • 单例模式:整个应用生命周期共享一个 Logger 实例,避免多实例竞争文件句柄
  • 异步写入:文件 I/O 操作不阻塞主线程,通过 Promise 实现
  • 文件锁机制:用布尔标志 fileLock 模拟轻量级锁,防止并发写入冲突
  • Pending 队列:文件忙碌时将待写入日志入队,待锁释放后自动 flush

三、核心实现

3.1 类型定义与配置

首先定义日志级别枚举和配置接口,这是整个模块的契约层:

export enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  WARN = 2,
  ERROR = 3,
  FATAL = 4,
  NONE = 5
}

export interface LoggerConfig {
  level: LogLevel;          // 最低输出级别,低于此级别的日志将被过滤
  enableHiLog: boolean;     // 是否输出到系统日志
  enableFile: boolean;      // 是否写入本地文件
  logDir: string;           // 日志目录路径
  logFileName: string;      // 日志文件名前缀
  maxFileSize: number;      // 单个文件最大字节数
  maxFileCount: number;      // 最多保留的日志文件数量
  showTimestamp: boolean;   // 是否显示时间戳
  showLevel: boolean;       // 是否显示日志级别
}

DefaultConfig 提供开箱即用的默认值:DEBUG 级别、双通道开启、5MB 文件上限、最多 5 个文件、显示时间戳和级别。

3.2 单例实例获取

通过 getInstance 工厂方法获取 Logger 实例,首次调用时初始化配置和日志目录:

static getInstance(config?: LoggerConfig): Logger {
  if (Logger.instance === null) {
    const defaultCfg = new DefaultConfig();
    if (config) {
      defaultCfg.level = config.level ?? defaultCfg.level;
      defaultCfg.enableHiLog = config.enableHiLog ?? defaultCfg.enableHiLog;
      defaultCfg.enableFile = config.enableFile ?? defaultCfg.enableFile;
      defaultCfg.logDir = config.logDir ?? defaultCfg.logDir;
      defaultCfg.logFileName = config.logFileName ?? defaultCfg.logFileName;
      defaultCfg.maxFileSize = config.maxFileSize ?? defaultCfg.maxFileSize;
      defaultCfg.maxFileCount = config.maxFileCount ?? defaultCfg.maxFileCount;
      defaultCfg.showTimestamp = config.showTimestamp ?? defaultCfg.showTimestamp;
      defaultCfg.showLevel = config.showLevel ?? defaultCfg.showLevel;
    }
    Logger.instance = new Logger(defaultCfg);
  }
  return Logger.instance;
}

使用空值合并运算符 ?? 进行安全赋值,保证配置项可选——调用方只需关心需要自定义的部分,其余使用默认值。

3.3 日志目录初始化

日志目录的初始化逻辑会根据是否已设置 context(Ability 上下文)选择不同路径:

private initLogDirectory(): void {
  try {
    if (this.config.logDir === "") {
      this.config.logDir = this.getDefaultLogDir();
    }
    if (!fs.accessSync(this.config.logDir)) {
      fs.mkdirSync(this.config.logDir, true);
    }
    this.currentLogFile = this.getLogFilePath(0);
  } catch (e) {
    hilog.error(LOG_DOMAIN, LOG_TAG, "Failed to init log directory: %{public}s",
      JSON.stringify(e));
  }
}

private getDefaultLogDir(): string {
  if (this.context) {
    return this.context.filesDir + "/logs";
  }
  return "/data/storage/el2/base/logs/logger";
}
  • context 时,使用 context.filesDir(应用私有目录),确保日志文件不会被其他应用访问
  • context 时,回退到系统标准日志路径

3.4 双重输出:HiLog + 本地文件

log 方法是核心输出逻辑,先做级别过滤,再分别写入两个通道:

private async log(level: LogLevel, tag: string, message: string, args: Object[]): Promise<void> {
  // 1. 级别过滤
  if (level < this.config.level) {
    return;
  }

  // 2. 格式化日志行
  const formatMessage = args.length > 0 ? this.formatMessage(message, args) : message;
  const logLine = this.formatLogEntry(this.getLevelString(level), tag, formatMessage);

  // 3. HiLog 输出(使用 %{public}s 避免隐私告警)
  if (this.config.enableHiLog) {
    const hiLogLevel = this.toHiLogLevel(level);
    if (hilog.isLoggable(LOG_DOMAIN, tag, hiLogLevel)) {
      if (level === LogLevel.DEBUG) {
        hilog.debug(LOG_DOMAIN, tag, "%{public}s", formatMessage);
      } else if (level === LogLevel.INFO) {
        hilog.info(LOG_DOMAIN, tag, "%{public}s", formatMessage);
      } else if (level === LogLevel.WARN) {
        hilog.warn(LOG_DOMAIN, tag, "%{public}s", formatMessage);
      } else if (level === LogLevel.ERROR) {
        hilog.error(LOG_DOMAIN, tag, "%{public}s", formatMessage);
      } else if (level === LogLevel.FATAL) {
        hilog.fatal(LOG_DOMAIN, tag, "%{public}s", formatMessage);
      }
    }
  }

  // 4. 文件输出(异步,不阻塞)
  this.writeToFile(logLine).catch((e: Error) => {
    hilog.error(LOG_DOMAIN, LOG_TAG, "Async write failed: %{public}s", JSON.stringify(e));
  });
}

格式化时使用 ISO 8601 时间戳格式(如 2026-04-20T10:30:45.123Z),便于后续日志分析和时间线重建。

3.5 文件滚动机制

当写入日志后文件大小超过 maxFileSize 时,触发滚动逻辑:删除最老的文件,其余文件序号依次递增,最后创建新的 app_log_0.txt

private rollLogFile(): void {
  try {
    const files = this.getLogFileList();
    // 超出数量限制时,删除最老的
    if (files.length >= this.config.maxFileCount) {
      fs.unlinkSync(files[0]);
    }
    // 旧文件序号递增:app_log_1 -> app_log_2, app_log_0 -> app_log_1
    for (let i = files.length - 1; i > 0; i--) {
      const oldPath = files[i];
      const newPath = this.getLogFilePath(i);
      if (fs.accessSync(newPath)) {
        fs.unlinkSync(newPath);
      }
      fs.renameSync(oldPath, newPath);
    }
    this.currentFileSize = 0;
    this.currentLogFile = this.getLogFilePath(0);
  } catch (e) {
    hilog.error(LOG_DOMAIN, LOG_TAG, "Failed to roll log file: %{public}s",
      JSON.stringify(e));
  }
}

3.6 异步安全写入

文件写入操作需要处理并发和阻塞问题。我们使用一个布尔锁加待处理队列的组合方案:

private async writeToFile(logLine: string): Promise<void> {
  if (!this.config.enableFile) return;

  // 文件忙碌时,将日志加入待处理队列
  if (this.fileLock) {
    this.pendingLogs.push(logLine);
    return;
  }

  this.fileLock = true;
  const logLineBytes: number = this.getByteLength(logLine);

  // 超出单文件大小限制则滚动
  if (this.currentFileSize + logLineBytes > this.config.maxFileSize) {
    this.rollLogFile();
  }

  try {
    const openMode = fs.OpenMode.CREATE | fs.OpenMode.APPEND | fs.OpenMode.READ_WRITE;
    const result = await fs.open(this.currentLogFile, openMode);
    const fd = this.getFdFromOpenResult(result);
    if (fd !== -1) {
      fs.writeSync(fd, logLine + "\n");
      fs.close(fd);
      this.currentFileSize += logLineBytes;
    }
  } catch (writeErr) {
    hilog.error(LOG_DOMAIN, LOG_TAG, "Failed to write log: %{public}s",
      JSON.stringify(writeErr));
  } finally {
    this.fileLock = false;
    // 释放锁后,尝试写入 pending 队列中的日志
    if (this.pendingLogs.length > 0) {
      const pending = this.pendingLogs.shift();
      if (pending) {
        this.writeToFile(pending);
      }
    }
  }
}

关于 fs.open 返回值的处理:OpenHarmony 不同版本中 fs.open 可能返回数字(文件描述符 fd)也可能返回包含 fd 字段的对象,因此 getFdFromOpenResult 方法对两种情况做了兼容处理:

private getFdFromOpenResult(result: ESObject): number {
  if (typeof result === 'number') {
    return result;
  }
  try {
    const obj = result as FileDescriptor;
    return obj.fd;
  } catch {
    return -1;
  }
}

3.7 便捷调用 API

对外暴露的五个核心方法分别对应五个日志级别,与 Android 的 Log.d/i/w/e 风格一致:

d(tag: string, message: string, ...args: Object[]): void {
  this.log(LogLevel.DEBUG, tag, message, args).catch((_e: Error) => {});
}
i(tag: string, message: string, ...args: Object[]): void {
  this.log(LogLevel.INFO, tag, message, args).catch((_e: Error) => {});
}
w(tag: string, message: string, ...args: Object[]): void {
  this.log(LogLevel.WARN, tag, message, args).catch((_e: Error) => {});
}
e(tag: string, message: string, ...args: Object[]): void {
  this.log(LogLevel.ERROR, tag, message, args).catch((_e: Error) => {});
}
f(tag: string, message: string, ...args: Object[]): void {
  this.log(LogLevel.FATAL, tag, message, args).catch((_e: Error) => {});
}

额外提供的工具方法包括:setLevel() 动态调整日志级别、readLogFile() 读取日志文件内容、readLastLines() 读取最新 N 行日志、getLogStats() 获取日志统计信息、clearLogs() 清空所有日志文件。

3.8 模块导出

在文件末尾实例化并导出默认 logger 供全工程使用:

export const logger = Logger.getInstance({
  level: LogLevel.DEBUG,
  enableHiLog: true,
  enableFile: true,
  logFileName: "app_log",
  maxFileSize: 5 * 1024 * 1024,
  maxFileCount: 5,
  logDir: "",
  showTimestamp: true,
  showLevel: true
});

export default logger;

四、集成使用

在实际业务代码中使用极为简洁:

import logger from '../util/Logger';

// 初始化时绑定 Ability 上下文
logger.setContext(context);

// 日常日志记录
logger.d("Network", "Request sent: %{public}s", url);
logger.i("Network", "Response received: %{public}s", JSON.stringify(data));
logger.w("Cache", "Cache miss for key: %{public}s", key);
logger.e("Database", "Query failed: %{public}s", error.message);
logger.f("Crash", "Fatal error occurred: %{public}s", error.stack);

// 查看日志统计
const stats = logger.getLogStats(); // "Log files: 3, Total size: 2.35 MB"

// 读取最近日志
const recentLogs = logger.readLastLines(50);

// 动态调整级别(生产环境可关闭 DEBUG)
logger.setLevel(LogLevel.INFO);

日志文件默认保存在应用私有目录下的 /logs/app_log_N.txt 中,通过设备文件管理器或 DevEco Studio 的 File Explorer 即可查看。

五、运行验证

5.1 编译验证

在 DevEco Studio 中打开项目,执行 Build > Build Hap(s) / App(s) > Build Debug Hap 或使用 hvigor 命令行构建:

hvigor.bat build --mode module -p product=phone -p target=arkJsRelease

预期输出:BUILD SUCCESS,无 ArkTS 编译错误(ERROR:0)。

5.2 模拟器运行验证

将应用部署到 OpenHarmony 模拟器上运行,通过 DevEco Studio 的 Log窗口hilog 命令行工具可以实时查看日志输出:

hilog | grep Logger

同时验证本地日志文件是否正确生成。日志内容将包含时间戳、级别、Tag 和消息四个部分,例如:

[2026-04-20T10:30:45.123Z] [I] [Network] Response received: {"code":200,"data":[...]}
[2026-04-20T10:30:46.456Z] [E] [Database] Query failed: SQLITE_ERROR: no such table

运行截图

在这里插入图片描述

5.3 功能验证清单

验证项 预期结果 验证方法
DEBUG 日志输出 HiLog 中可见 DEBUG 级别日志 设置 level=DEBUG,观察 HiLog
INFO 日志输出 HiLog 中可见 INFO 级别日志 调用 logger.i()
ERROR 日志输出 HiLog 中可见 ERROR 级别日志,红色高亮 调用 logger.e()
日志文件生成 /filesDir/logs/ 下出现 app_log_0.txt DevEco File Explorer 查看
日志滚动 写入大量日志后出现 app_log_1.txt 写入超过 5MB 数据后检查
旧文件清理 超过 5 个文件后,最老的文件被删除 写入大量数据后检查
级别过滤 设置 level=INFO 后 DEBUG 日志不再输出 调用 logger.setLevel(LogLevel.INFO)
文件读取 readLastLines() 返回正确的日志内容 调用方法检查返回值

六、扩展建议

6.1 接入 Flutter Dart 层

当前模块运行在 ArkTS 原生层。在 Flutter for OpenHarmony 工程中,可以通过 OH_LOG 方法通道将日志能力桥接到 Dart 层,使 Dart 代码也能使用同一套日志体系,实现跨层日志统一管理。

6.2 日志上传与远程分析

可进一步对接网络请求模块,将日志文件压缩后定期上传到服务器,支持远程实时分析和异常告警。

6.3 日志加密

敏感应用场景下,可对日志文件内容进行 AES 加密,仅在特定解密工具中查看,防止日志内容泄露。

6.4 结构化日志格式

可将日志格式从纯文本升级为 JSON 结构化格式,便于日志解析工具(如 ELK、Graylog)直接入库分析。

七、总结

本文详细介绍了在 Flutter for OpenHarmony 跨平台工程中,如何从零设计并实现一个功能完备的日志模块。通过合理运用 ArkTS 的 hilog 系统日志接口和 fs 文件管理能力,我们实现了分级日志输出、本地文件持久化、自动滚动清理等核心功能。

该方案具备以下优势:

  • 零依赖:纯使用 OpenHarmony 原生 API,无第三方库依赖
  • 轻量级:单例模式 + 异步写入,对主线程性能影响极小
  • 高可用:文件锁 + pending 队列确保写入安全可靠
  • 易集成:导出默认实例,业务代码一行导入即可使用
  • 可扩展:接口设计清晰,便于后续扩展加密、上传等能力

希望本文能为正在进行 OpenHarmony 跨平台开发的工程师们提供有价值的参考,也欢迎大家基于 AtomGit 平台共同完善这一日志模块。

感谢各位阅读!

参考链接

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐