HarmonyOS 全链路备份/恢复技术实践(附代码详解)

基于「喵屿」App 真实项目提炼,涵盖自定义二进制归档、zlib 压缩、DocumentViewPicker 文件存取的全链路可复用方案。


一、前言

数据备份与恢复是生产级应用的标配功能。在宠物管理类 App「喵屿」中,用户需要将宠物档案、疫苗记录、库存物品、日记附件等完整数据打包导出与恢复。

HarmonyOS 提供了 @kit.CoreFileKit 的文件选择器(DocumentViewPicker)和 @kit.BasicServicesKit 的数据压缩(zlib),但将它们组合为一套完整的导入导出方案需要解决以下问题:

  1. 多源数据聚合:Preferences 键值对 + 沙箱文件(图片、日记 JSON)如何打包为单一文件?
  2. 二进制归档格式设计:如何设计可扩展、可校验的自定义备份格式?
  3. 大文件分块读写:备份文件可能达几十 MB,如何避免内存溢出?
  4. 压缩/解压流程:如何集成 zlib 压缩并确保解压后数据完整性?
  5. 导入后状态刷新:恢复数据后如何通知所有页面刷新 UI?

本文从「喵屿」的实际实现出发,按数据结构设计 → 构建归档 → 压缩 → 保存/读取 → 解压恢复的流程逐步拆解,并提炼出可直接复用的工具代码。


二、相关知识介绍

2.1 文件选择与保存 —— picker.DocumentViewPicker

DocumentViewPicker 是 HarmonyOS 用于选择文件和指定保存位置的统一组件,来自 @kit.CoreFileKit

导入模块:

import { picker } from '@kit.CoreFileKit';

保存文件(导出场景):

import { common } from '@kit.AbilityKit';

const context = getContext() as common.UIAbilityContext;
const documentViewPicker = new picker.DocumentViewPicker(context);

const saveOptions = new picker.DocumentSaveOptions();
// 建议文件名
saveOptions.newFileNames = ['喵屿备份_20260101.backup'];
// 下载模式,返回目录 URI
saveOptions.pickerMode = picker.DocumentPickerMode.DOWNLOAD;

const saveResult = await documentViewPicker.save(saveOptions);
// saveResult[0] 是用户选择的目录 URI
// 在 DOWNLOAD 模式下,需自行拼接文件名写入文件

选择文件(导入场景):

const documentViewPicker = new picker.DocumentViewPicker(context);

const selectOptions = new picker.DocumentSelectOptions();
// 最多选择 1 个文件
selectOptions.maxSelectNumber = 1;

const selectResult = await documentViewPicker.select(selectOptions);
// selectResult[0] 是选中文件的 URI
// 通过 fileUri.FileUri(selectResult[0]).path 获取实际文件路径

关键注意事项:

  • DocumentViewPicker 构造函数需要传入 UIAbilityContext,不能使用 ApplicationContext
  • save() 返回的是目录 URI(DOWNLOAD 模式),需要自行拼接文件名写入
  • select() 返回的是文件 URI,通过 fileUri.FileUri().path 转换为可读路径

2.2 数据压缩 —— zlib (ZIP)

HarmonyOS 的 @kit.BasicServicesKit 提供了 zlib 模块,支持 ZIP 格式的压缩和解压(deflate/inflate 算法)。

导入模块:

import { zlib } from '@kit.BasicServicesKit';

压缩:

const zip = zlib.createZipSync();
const srcLen = data.byteLength;

// 预计算压缩后最大尺寸
const maxSize = await zip.compressBound(srcLen);
const compressedDest = new ArrayBuffer(maxSize);

// 执行压缩
const info: zlib.ZipOutputInfo = await zip.compress(compressedDest, data, srcLen);
// info.destLen 是实际压缩后的大小

// 打包格式:[4字节原始大小] + [4字节压缩大小] + [压缩数据]
const result = new ArrayBuffer(8 + info.destLen);
const dataView = new DataView(result);
dataView.setUint32(0, srcLen, true);        // 小端序
dataView.setUint32(4, info.destLen, true);  // 小端序
new Uint8Array(result).set(new Uint8Array(compressedDest, 0, info.destLen), 8);

解压:

const dataView = new DataView(data);
const originalSize = dataView.getUint32(0, true);    // 读取原始大小
const compressedSize = dataView.getUint32(4, true);  // 读取压缩大小
const compressedBuffer = data.slice(8, 8 + compressedSize);

const dest = new ArrayBuffer(originalSize);
const zip = zlib.createZipSync();
await zip.uncompress(dest, compressedBuffer, compressedSize);

设计要点:在压缩数据前拼接 8 字节头(原始大小 + 压缩大小),解压时先读取头再解压,避免不知道目标 Buffer 大小的问题。

2.3 沙箱文件操作 —— fs

HarmonyOS 应用的沙箱文件系统通过 @kit.CoreFileKit(或 @ohos.file.fs)访问:

读写文件(分块方式):

import fs from '@ohos.file.fs';

// 分块写入(Chunked Write)—— 防止大文件内存溢出
const file = fs.openSync(filePath, fs.OpenMode.WRITE_ONLY | fs.OpenMode.CREATE);
const chunkSize = 1024 * 1024; // 1MB per chunk
let written = 0;
while (written < totalSize) {
  const end = Math.min(written + chunkSize, totalSize);
  const chunk = data.slice(written, end);
  fs.writeSync(file.fd, chunk);
  written = end;
}
fs.closeSync(file);

// 分块读取
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
const buffer = new ArrayBuffer(fileSize);
let bytesRead = 0;
while (bytesRead < fileSize) {
  const end = Math.min(bytesRead + chunkSize, fileSize);
  const chunk = new ArrayBuffer(end - bytesRead);
  const n = fs.readSync(file.fd, chunk, { offset: bytesRead });
  new Uint8Array(buffer, bytesRead, n).set(new Uint8Array(chunk, 0, n));
  bytesRead += n;
}
fs.closeSync(file);

三、项目实战

以下从「喵屿」项目的数据导入导出完整实现中提炼核心代码。

3.1 自定义二进制归档格式设计

为了将 Preferences 数据 + 沙箱文件打包为单一备份文件,设计如下二进制格式:

┌───────────────────────────────────────────────┐
│  Header (16 bytes)                            │
│  ┌──────────┬────────┬──────────┬───────────┐ │
│  │ Magic(4) │ Ver(2) │ Files(2) │ Size(4)   │ │
│  │ "CATB"   │  0x01  │  count   │ total     │ │
│  ├──────────┴────────┴──────────┴───────────┤ │
│  │         Reserved (4 bytes)               │ │
│  └──────────────────────────────────────────┘ │
├───────────────────────────────────────────────┤
│  Manifest JSON (UTF-8)                        │
│  {                                            │
│    "version": 1,                              │
│    "appName": "喵屿",                         │
│    "exportTimestamp": "2026-01-01T...",       │
│    "preferences": {...},                      │
│    "files": [{path, size}, ...],              │
│    "summary": {catCount, itemCount, ...}      │
│  }                                            │
├───────────────────────────────────────────────┤
│  File Entries (N items)                       │
│  Per entry:                                   │
│  ┌────────────┬──────────┬──────────┬───────┐ │
│  │ PathLen(2) │ Path(utf8)│ DataLen(4)│ Data│ │
│  └────────────┴──────────┴──────────┴───────┘ │
└───────────────────────────────────────────────┘

数据结构定义:

export interface BackupFileEntry {
  path: string;  // 相对路径,如 "Avatar/avatar_xxx.png"
  size: number;  // 文件大小(字节)
}

export interface BackupSummary {
  catCount: number;
  friendCount: number;
  itemCount: number;
  billCount: number;
  vaccineCount: number;
  dewormingCount: number;
  diaryCount: number;
  avatarFileCount: number;
  diaryImageCount: number;
  customPetCount: number;
}

export interface BackupManifest {
  version: number;
  appName: string;
  exportTimestamp: string;
  preferences: Record<string, Record<string, Object>>;
  files: BackupFileEntry[];
  summary: BackupSummary;
}

3.2 导出流程 —— 数据聚合 → 归档 → 压缩 → 保存

整体导出细分为以下流程图:

在这里插入图片描述

3.2.1 读取 Preferences 数据

「喵屿」的数据分布在两个 Preferences 存储区中,需要遍历所有键值对并深拷贝:

// 定义需要备份的键列表
const CATISLAND_KEYS: string[] = [
  'cats', 'friends', 'items', 'bill', 'vaccine', 'deworming',
  // ... 设置相关键
];

// 读取 catIsland 存储区
const catIslandPrefs: Record<string, Object> = {};
for (const key of CATISLAND_KEYS) {
  const value = PreferenceUtil.getPreferenceByNameSync('catIsland', key);
  if (value !== '' && value !== undefined) {
    catIslandPrefs[key] = deepCopy(value);
  }
}

3.2.2 枚举并读取沙箱文件

// 遍历 Avatar、Timeline、CustomPets 等目录
function enumerateDir(dirPath: string, relativePrefix: string) {
  const files: BackupFileEntry[] = [];
  const data = new Map<string, ArrayBuffer>();

  if (!FileUtil.accessSync(dirPath)) return { files, data };

  const fileNames = FileUtil.listFileSync(dirPath, { recursion: true });
  for (const fileName of fileNames) {
    const fullPath = dirPath + '/' + fileName;
    const stat = fs.statSync(fullPath);
    if (!stat.isFile()) continue;

    const relativePath = relativePrefix + '/' + fileName;
    const fileBytes = readFileInChunks(fullPath, stat.size);
    files.push({ path: relativePath, size: stat.size });
    data.set(relativePath, fileBytes);
  }
  return { files, data };
}

3.2.3 构建二进制归档

function buildArchive(
  manifest: BackupManifest,
  fileData: Map<string, ArrayBuffer>
): ArrayBuffer {
  const manifestJson = JSON.stringify(manifest);
  const manifestBytes = buffer.from(manifestJson, 'utf8').buffer;
  const manifestLen = manifestBytes.byteLength;
  const fileCount = manifest.files.length;

  // 计算总大小
  let totalSize = HEADER_SIZE + manifestLen;
  for (const entry of manifest.files) {
    totalSize += 2 + entry.path.length + 4 + entry.size;
  }

  const result = new ArrayBuffer(totalSize);
  const view = new DataView(result);
  let offset = 0;

  // 写入 Magic "CATB" (4 bytes)
  for (let i = 0; i < 4; i++) view.setUint8(offset++, MAGIC[i]);
  // 版本号 (2 bytes, little-endian)
  view.setUint16(offset, CURRENT_VERSION, true);
  offset += 2;
  // 文件数量 (2 bytes)
  view.setUint16(offset, fileCount, true);
  offset += 2;
  // Manifest 长度 (4 bytes)
  view.setUint32(offset, manifestLen, true);
  offset += 4;
  // 总大小 (4 bytes)
  view.setUint32(offset, totalSize, true);
  offset += 4;

  // 写入 Manifest JSON
  new Uint8Array(result).set(new Uint8Array(manifestBytes), offset);
  offset += manifestLen;

  // 逐个写入文件条目
  for (const entry of manifest.files) {
    const fileBytes = fileData.get(entry.path);
    if (!fileBytes) continue;

    // 路径长度 (2 bytes) + 路径 (UTF-8) + 数据长度 (4 bytes) + 数据
    const pathU8 = new Uint8Array(buffer.from(entry.path, 'utf8').buffer);
    view.setUint16(offset, pathU8.byteLength, true);
    offset += 2;
    new Uint8Array(result).set(pathU8, offset);
    offset += pathU8.byteLength;
    view.setUint32(offset, fileBytes.byteLength, true);
    offset += 4;
    new Uint8Array(result).set(new Uint8Array(fileBytes), offset);
    offset += fileBytes.byteLength;
  }

  return result;
}

3.2.4 zlib 压缩 + 头拼接

static async compress(data: ArrayBuffer): Promise<ArrayBuffer> {
  const srcLen = data.byteLength;
  const zip = zlib.createZipSync();
  const maxSize = await zip.compressBound(srcLen);
  const compressedDest = new ArrayBuffer(maxSize);
  const info: zlib.ZipOutputInfo = await zip.compress(compressedDest, data, srcLen);

  // 打包头:[4字节原始大小] + [4字节压缩大小] + [压缩数据]
  const result = new ArrayBuffer(8 + info.destLen);
  const dataView = new DataView(result);
  dataView.setUint32(0, srcLen, true);
  dataView.setUint32(4, info.destLen, true);
  new Uint8Array(result).set(
    new Uint8Array(compressedDest, 0, info.destLen),
    8
  );
  return result;
}

3.2.5 写入用户选定目录

const context = getContext() as common.UIAbilityContext;
const documentViewPicker = new picker.DocumentViewPicker(context);
const saveOptions = new picker.DocumentSaveOptions();
saveOptions.newFileNames = [fileName];
saveOptions.pickerMode = picker.DocumentPickerMode.DOWNLOAD;

const saveResult = await documentViewPicker.save(saveOptions);
const targetPath = new fileUri.FileUri(saveResult[0] + '/' + fileName).path;

// 分块写入,防止大文件导致内存溢出
const file = fs.openSync(targetPath, fs.OpenMode.WRITE_ONLY | fs.OpenMode.CREATE);
let written = 0;
const CHUNK = 1024 * 1024; // 1MB
while (written < compressedData.byteLength) {
  const end = Math.min(written + CHUNK, compressedData.byteLength);
  fs.writeSync(file.fd, compressedData.slice(written, end));
  written = end;
}
fs.closeSync(file);

在这里插入图片描述
在这里插入图片描述

3.3 导入流程 —— 读取 → 解压 → 校验 → 恢复

导入是导出的逆过程,额外增加了版本校验和用户确认环节。

3.3.1 文件选择与读取

const selectResult = await documentViewPicker.select(selectOptions);
const selectedPath = new fileUri.FileUri(selectResult[0]).path;

// 分块读取文件到内存
const stat = fs.statSync(selectedPath);
const fileSize = stat.size;
const file = fs.openSync(selectedPath, fs.OpenMode.READ_ONLY);
const fileData = new ArrayBuffer(fileSize);
let bytesRead = 0;
while (bytesRead < fileSize) {
  const end = Math.min(bytesRead + CHUNK, fileSize);
  const chunk = new ArrayBuffer(end - bytesRead);
  const n = fs.readSync(file.fd, chunk, { offset: bytesRead });
  new Uint8Array(fileData, bytesRead, n).set(new Uint8Array(chunk, 0, n));
  bytesRead += n;
}
fs.closeSync(file);

3.3.2 解压

static async decompress(data: ArrayBuffer): Promise<ArrayBuffer> {
  const dataView = new DataView(data);
  const originalSize = dataView.getUint32(0, true);
  const compressedSize = dataView.getUint32(4, true);
  const compressedBuffer = data.slice(8, 8 + compressedSize);
  const dest = new ArrayBuffer(originalSize);
  const zip = zlib.createZipSync();
  await zip.uncompress(dest, compressedBuffer, compressedSize);
  return dest;
}

3.3.3 校验文件头

static validateHeader(data: ArrayBuffer): { valid: boolean; error?: string } {
  if (data.byteLength < HEADER_SIZE) {
    return { valid: false, error: '文件太小,不是有效的备份文件' };
  }
  const view = new DataView(data);
  // Magic "CATB" 校验
  for (let i = 0; i < 4; i++) {
    if (view.getUint8(i) !== MAGIC[i]) {
      return { valid: false, error: '文件格式不正确' };
    }
  }
  // 版本校验
  const version = view.getUint16(4, true);
  if (version > CURRENT_VERSION) {
    return { valid: false, error: '备份文件版本过高,请升级App后再试' };
  }
  return { valid: true, version };
}

3.3.4 解析归档并恢复数据

// 解析 Manifest
const manifestJson = buffer.from(manifestData).toString('utf8');
const manifest: BackupManifest = JSON.parse(manifestJson);

// 恢复 Preferences 数据
for (const key of Object.keys(manifest.preferences['catIsland'] || {})) {
  PreferenceUtil.putPreferenceByNameSync('catIsland', key,
    deepCopy(manifest.preferences['catIsland'][key]));
}

// 恢复沙箱文件
for (const entry of manifest.files) {
  const fileBytes = archivedFileData.get(entry.path);
  if (!fileBytes) continue;

  const fullPath = filesDir + '/' + entry.path;
  ensureDir(parentDir(fullPath));
  // 分块写入
  const file = fs.openSync(fullPath, fs.OpenMode.WRITE_ONLY | fs.OpenMode.CREATE);
  let written = 0;
  while (written < fileBytes.byteLength) {
    const end = Math.min(written + CHUNK, fileBytes.byteLength);
    fs.writeSync(file.fd, fileBytes.slice(written, end));
    written = end;
  }
  fs.closeSync(file);
}

// 触发全局 UI 刷新事件
emitter.emit(PET_MANAGEMENT_EVENT, { data: {} });
emitter.emit(ITEMS_EVENT, { data: {} });
emitter.emit(BILL_EVENT, { data: {} });
emitter.emit(VACCINE_EVENT, { data: {} });
// ...

3.4 喵屿项目中的使用示例

在「喵屿」中,导出按钮位于「数据备份」页面。点击“立即导出”后,系统调起文件选择器让用户指定保存位置,同时显示进度弹窗:

在这里插入图片描述

导入时,用户选择 .backup 文件后弹出确认对话框,展示备份摘要(宠物数量、物品数量、文件数量等),用户确认后执行恢复,完成后自动重启 App 使所有数据生效。


四、总结

本文从「喵屿」App 的数据备份功能出发,完整覆盖了 HarmonyOS 中自定义数据归档、压缩、文件存取的全链路实现。

核心要点回顾:

环节 关键 API 注意事项
文件选择(保存) DocumentViewPicker.save() + DocumentSaveOptions DOWNLOAD 模式返回目录 URI,需自行拼接文件名
文件选择(打开) DocumentViewPicker.select() + DocumentSelectOptions 通过 fileUri.FileUri().path 获取可读路径
数据压缩 zlib.createZipSync().compress() 先调用 compressBound 获取目标 Buffer 大小
数据解压 zlib.createZipSync().uncompress() 需提前知道原始大小,可通过头信息传递
二进制归档 DataView + ArrayBuffer.slice() 手动管理 offset,按 TLV 格式(Type-Length-Value)写入
分块读写 fs.openSync() + 循环 readSync/writeSync 避免单次分配几十 MB 的 ArrayBuffer 导致内存问题
版本校验 文件头 Magic + Version Magic 用于识别文件类型,Version 保证向前兼容

设计取舍:

  • 自定义二进制格式 vs JSON+GZIP:选择了前者,因为需要将 Manifest(元数据)和 File Data(二进制文件)打包为单一文件,JSON+GZIP 无法高效处理二进制文件嵌入
  • zlib 压缩 vs 不压缩:备份可能包含多张图片,压缩后通常能减少 50%+ 体积
  • 分块读写 vs 一次性读取:对于 50MB+ 的备份文件,分块读写避免内存峰值
  • 导入后重启 App vs 动态刷新:选择了重启 App,确保所有页面的数据一致性

Logo

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

更多推荐