HarmonyOS 全链路备份/恢复技术实践(附代码详解)
HarmonyOS 全链路备份/恢复技术实践(附代码详解)
基于「喵屿」App 真实项目提炼,涵盖自定义二进制归档、zlib 压缩、DocumentViewPicker 文件存取的全链路可复用方案。
一、前言
数据备份与恢复是生产级应用的标配功能。在宠物管理类 App「喵屿」中,用户需要将宠物档案、疫苗记录、库存物品、日记附件等完整数据打包导出与恢复。
HarmonyOS 提供了 @kit.CoreFileKit 的文件选择器(DocumentViewPicker)和 @kit.BasicServicesKit 的数据压缩(zlib),但将它们组合为一套完整的导入导出方案需要解决以下问题:
- 多源数据聚合:Preferences 键值对 + 沙箱文件(图片、日记 JSON)如何打包为单一文件?
- 二进制归档格式设计:如何设计可扩展、可校验的自定义备份格式?
- 大文件分块读写:备份文件可能达几十 MB,如何避免内存溢出?
- 压缩/解压流程:如何集成 zlib 压缩并确保解压后数据完整性?
- 导入后状态刷新:恢复数据后如何通知所有页面刷新 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,不能使用ApplicationContextsave()返回的是目录 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,确保所有页面的数据一致性
更多推荐




所有评论(0)