为什么“能导出”很重要

本地应用经常把“不上传云端”作为卖点,但如果用户只能在应用里查看、无法导出,那么数据依然被锁在产品内部。

在“心晴手记”中,我提供了两种导出:

  • 完整 JSON:包含心情、习惯和打卡,适合备份;
  • 心情 CSV:适合用表格软件查看或做个人分析。

保存位置不由应用擅自决定,而是打开系统文件保存器,让用户选择目标目录和文件名。

一、把系统保存流程封装成通用函数

先引入 AbilityKit 和 CoreFileKit:

import { common } from '@kit.AbilityKit';
import { fileIo, picker } from '@kit.CoreFileKit';

通用函数接收文件名和文本内容:

export async function exportTextFile(
  context: common.UIAbilityContext,
  fileName: string,
  content: string
): Promise<boolean> {
  try {
    const options = new picker.DocumentSaveOptions();
    options.newFileNames = [fileName];

    const documentPicker =
      new picker.DocumentViewPicker(context);

    const uris: string[] = await documentPicker.save(options);
    if (uris.length === 0) {
      return false;
    }

    const file = fileIo.openSync(
      uris[0],
      fileIo.OpenMode.READ_WRITE |
        fileIo.OpenMode.TRUNC
    );

    fileIo.writeSync(file.fd, content);
    fileIo.closeSync(file);
    return true;
  } catch (_) {
    return false;
  }
}

系统 Picker 返回 URI 后,应用只对用户选择的目标写入,不需要自己扫描整个文件系统。

TRUNC 表示如果目标已有内容则截断,避免旧文件尾部残留。写入完成后必须关闭文件句柄。

二、用户取消保存不是程序异常

用户进入文件保存器后可能直接返回。业务层应把它当作正常分支,而不是弹出严重错误。

当前封装把“没有 URI”“抛出异常”和“写入失败”统一返回 false,页面显示简短状态:

const ok: boolean = await exportTextFile(
  this.context,
  `moodmemoir-${Date.now()}.json`,
  payload
);

this.statusMessage = this.named(
  ok ? 'export_ready' : 'export_failed'
);

如果产品需要更精细的体验,可以把结果改成枚举:

enum ExportResult {
  SUCCESS,
  CANCELLED,
  FAILED
}

这样取消时可以不提示,真正写入失败时再给出重试建议。

三、完整 JSON 要包含格式版本

备份文件不是简单地把页面上看到的文字拼起来,而是一个未来可能需要恢复的数据协议。

const payload: string = JSON.stringify({
  exportVersion: 1,
  exportedAt: new Date().toISOString(),
  moodEntries: this.moodEntries,
  habits: this.habits,
  habitCompletions: this.completions
}, null, 2);

其中两个字段很重要:

  • exportVersion:未来导入或迁移时判断结构;
  • exportedAt:告诉用户备份生成时间。

JSON.stringify(..., null, 2) 使用缩进输出,文件略大一点,但便于用户检查,也方便问题排查。

设置项是否导出需要根据隐私和恢复需求决定。当前项目导出用户记录,不包含认证凭据,也不会导出任何系统生物特征信息。

四、CSV 的难点不是 join,而是转义

最简单的 CSV 代码经常这样写:

rows.push([date, mood, note, tags].join(','));

一旦日记中出现逗号、换行或双引号,列就会错位。

项目统一把字段放进双引号,并把内部双引号替换成两个双引号:

export function escapeCsv(value: string): string {
  return `"${value.replace(/"/g, '""')}"`;
}

生成内容:

const rows: string[] = ['date,mood,note,tags'];

this.moodEntries
  .slice()
  .reverse()
  .forEach((entry: MoodEntry) => {
    rows.push([
      escapeCsv(entry.dayIdentifier),
      escapeCsv(entry.mood),
      escapeCsv(entry.note),
      escapeCsv(entry.tags.join(','))
    ].join(','));
  });

例如原文:

今天说了“你好”,心情不错

会变成:

"今天说了""你好"",心情不错"

表格软件才能正确识别为同一个字段。

五、为什么在 CSV 前增加 UTF-8 BOM

一些桌面表格软件打开没有 BOM 的 UTF-8 CSV 时,可能错误猜测编码,导致中文乱码。

项目在内容前增加:

`\uFEFF${rows.join('\n')}`

完整调用:

await exportTextFile(
  this.context,
  `moodmemoir-moods-${Date.now()}.csv`,
  `\uFEFF${rows.join('\n')}`
);

BOM 并不是所有 CSV 消费方都必需,但如果目标用户会直接用常见表格软件打开,它通常能减少中文编码问题。

六、文件名要可识别,也要避免冲突

当前使用时间戳:

`moodmemoir-${Date.now()}.json`

优点是简单且不容易重名。若更重视可读性,可以使用本地日期:

moodmemoir-backup-2026-08-14.json

如果一天可能导出多次,再附加时分秒。文件名不要包含不同文件系统不支持的字符。

七、导出前后要验证什么

建议至少覆盖下面这些测试:

  1. 用户正常选择目录并保存;
  2. 在 Picker 中取消;
  3. 目标文件已存在;
  4. 日记包含英文逗号;
  5. 日记包含双引号;
  6. 日记包含换行;
  7. 日记包含中文、英文、日文和 Emoji;
  8. 空数据导出;
  9. JSON 可以重新解析;
  10. CSV 在不同表格软件中列数正确;
  11. 写入失败时没有残留未关闭句柄;
  12. 真机上的 URI 写入行为与模拟器一致。

还可以在自动测试中构造特殊文本,验证 escapeCsv()

escapeCsv('a,"b"')
// 期望:"a,""b"""

八、导出不等于备份恢复

提供 JSON 导出后,用户自然会期待未来可以导入。因此产品文案需要准确:

  • 如果暂时只有导出,称为“导出完整 JSON”更合适;
  • 如果称为“备份”,最好同时具备经过验证的恢复能力;
  • 导入前必须校验版本、字段和引用关系;
  • 不能导入失败后覆盖当前数据。

清晰的命名能避免用户把“可查看的数据文件”误解成“保证可恢复的完整备份”。

总结

HarmonyOS 文本文件导出的完整链路包括:

  1. 业务层构造结构化内容;
  2. 系统 DocumentViewPicker 让用户选择位置;
  3. 使用返回 URI 打开并写入文件;
  4. 正确关闭句柄;
  5. 区分成功、取消和失败;
  6. 对 JSON 做版本化,对 CSV 做转义和编码兼容。

真正体现“数据属于用户”的,不只是不上传,也包括用户随时能把自己的记录带走。

本文案例来自“心晴手记”HarmonyOS 版的数据导出功能。

Logo

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

更多推荐