《HarmonyOS技术精讲-Basic Services Kit》压缩与解压:文件打包与释放

HarmonyOS技术精讲-Basic Services Kit 压缩与解压:文件打包与释放
聊聊实际开发中的“文件处理”这件事
HarmonyOS NEXT 开发里,文件打包和解压这个功能,看着简单,用起来却容易踩坑。
很多人第一次接触 ZipFile 类时,会发现官方示例能运行,但一旦涉及真实文件夹、大文件、或者跨设备解压,各种奇怪的问题就冒出来了。比如压缩后文件大小没变、解压路径不对、或者手机上跑得好好的,平板上就报权限错误。
这个功能本身不复杂,但真正麻烦的是几个边界情况的处理:文件数量特别多的时候怎么办、压缩级别怎么选、解压时目标目录已存在会不会覆盖。这些问题官方文档一笔带过,但实际项目里必须明确。
先澄清一个基本认知:Basic Services Kit 提供的压缩库是满足常规需求的,它支持 zip 格式的压缩与解压。底层实现基于原生的 Zlib,所以在效率和兼容性上没有问题。但和成熟的第三方库(比如 Java 的 Zip4j)比,它在 API 设计上更加“平台原生”,没有那么多花哨的特性,但也因此更加稳定。
这个功能适合的场景:
- 应用内数据备份/恢复(压缩数据库、配置文件)
- 批量导出/分享文件(压缩多个文档为一个包)
- 资源包离线加载(解压下发到设备上的资源文件)
不适合的场景:
- 对压缩包加密(目前不支持设置密码)
- 超大文件(单个文件超过 2GB)的流式处理
- 跨平台解压时文件名编码不一致(主要涉及中文乱码)
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机 / 平板
核心实现:压缩文件夹并解压到指定目录
下面这段流程,我会按照“先压缩、再解压”的顺序组织代码。为了让代码有实际意义,我们实现一个功能:把应用沙箱内的 /data/storage/el2/base/haps/entry/files/backup/ 文件夹压缩,然后解压到 /data/storage/el2/base/haps/entry/files/restore/ 目录。
第一步:准备要压缩的文件
在压缩之前,你要有一个实际存在的文件夹。这里提供一个函数,用来向沙箱目录写入一些测试文件。
// file: src/main/ets/entryability/EntryAbility.ets
import { fileIo } from '@kit.CoreFileKit';
export async function prepareTestFiles(): Promise<void> {
const context: Context = getContext(this);
const dir = context.filesDir + '/backup';
// 创建备份目录
await fileIo.mkdir(dir, true);
// 写入几个测试文件
const file1Path = dir + '/config.json';
const file2Path = dir + '/data.db';
await fileIo.writeText(file1Path, JSON.stringify({ version: '1.0', timestamp: Date.now() }));
await fileIo.writeText(file2Path, 'mock database content');
console.info('Test files prepared at: ' + dir);
}
这个代码做了三件事:获取应用沙箱路径、创建目录、写入测试文件。注意 mkdir 的第二个参数 true 表示递归创建父目录,这个在实际开发中很实用,可以减少路径不存在导致的异常。
第二步:把文件夹压缩为 ZIP
核心压缩逻辑使用 ZipFile 类。这里的关键是理解它的工作模式:它不是一个“一次性压缩所有文件”的工具,而是需要你逐个添加文件条目。
// file: src/main/ets/entryability/ZipUtils.ets
import { zip } from '@kit.BasicServicesKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
export async function compressFolderToZip(srcFolder: string, destZipPath: string): Promise<void> {
// 清理旧的压缩包(如果有)
try {
await fileIo.unlink(destZipPath);
} catch (e) {
// 文件不存在,忽略
}
// 创建新的压缩包
const zipFile = new zip.ZipFile(destZipPath, zip.CompressionLevel.DEFAULT_COMPRESSION);
// 遍历源文件夹内的所有文件
const files = await fileIo.listFile(srcFolder);
for (const file of files) {
const fullPath = srcFolder + '/' + file;
const stat = await fileIo.stat(fullPath);
// 跳过目录(zip 会自动处理结构)
if (stat.isDirectory()) {
// 递归处理子目录(本示例简化,只处理一级目录)
await compressFolderToZip(fullPath, destZipPath);
continue;
}
// 读取文件内容
const content = await fileIo.readArrayBuffer(fullPath);
// 计算在 zip 内的相对路径
const entryName = 'backup/' + file; // 保留父目录结构
// 添加条目到压缩包
zipFile.addEntry(entryName, content);
}
// 必须显式关闭,否则文件可能没有写入磁盘
zipFile.close();
console.info('Compressed to: ' + destZipPath);
}
这里有个细节值得注意:addEntry 方法要求传入文件在 zip 内部的相对路径。如果你传入 backup/config.json,解压时会在目标目录下生成 backup/config.json。如果只想解压成 config.json,传入 config.json 即可。设计上比较灵活,但也很容易因为路径不对导致解压后结构错乱。
第三步:解压到指定目录
解压的过程类似,但需要你自己读取 zip 内的条目信息,然后写文件。ZipFile 提供了 getEntries 方法返回所有条目的元数据。
// file: src/main/ets/entryability/ZipUtils.ets (续)
export async function extractZipToDir(zipPath: string, destDir: string): Promise<void> {
const zipFile = new zip.ZipFile(zipPath);
const entries = zipFile.getEntries();
for (const entry of entries) {
const entryName = entry.entryName; // 比如 "backup/config.json"
const fullDestPath = destDir + '/' + entryName;
// 创建目标文件的父目录
const parentDir = fullDestPath.substring(0, fullDestPath.lastIndexOf('/'));
await fileIo.mkdir(parentDir, true);
// 读取压缩包内文件内容
const arrayBuffer: ArrayBuffer = zipFile.getEntry(entryName); // 返回 ArrayBuffer
const uint8Array = new Uint8Array(arrayBuffer);
// 写入磁盘
await fileIo.writeArrayBuffer(fullDestPath, uint8Array.buffer as ArrayBuffer);
}
zipFile.close();
console.info('Extracted to: ' + destDir);
}
getEntry 返回的是 ArrayBuffer,但 writeArrayBuffer 需要 ArrayBuffer 类型,这里做了一个类型转换。实际开发中这个转换不会出问题,但如果你用到 Buffer 相关工具,要注意类型兼容。
第四步:完整调用示例
把上面的方法组合起来,在 UI 中提供一个按钮来触发。
// file: src/main/ets/pages/Index.ets
import { prepareTestFiles } from '../entryability/EntryAbility';
import { compressFolderToZip, extractZipToDir } from '../entryability/ZipUtils';
import { fileIo } from '@kit.CoreFileKit';
@Entry
@Component
struct Index {
@State message: string = '点击按钮开始操作';
build() {
Column() {
Text(this.message)
.fontSize(20)
.margin({ bottom: 20 })
Button('压缩并解压')
.onClick(async () => {
const context: Context = getContext(this);
const filesDir = context.filesDir;
this.message = '准备测试文件...';
await prepareTestFiles();
this.message = '开始压缩...';
const zipPath = filesDir + '/backup.zip';
await compressFolderToZip(filesDir + '/backup', zipPath);
this.message = '开始解压...';
const restoreDir = filesDir + '/restore';
await extractZipToDir(zipPath, restoreDir);
this.message = '操作完成!解压目录: ' + restoreDir;
})
.margin({ top: 20 })
}
.padding(20)
.width('100%')
.height('100%')
}
}
踩坑记录
这些坑是实际开发中高频问题,建议收藏。
坑1:压缩后文件大小没有明显变化
现象:把一个文件夹压缩后,生成的文件大小和原始文件几乎一样,甚至更大。
原因:HarmonyOS 的压缩库默认使用的压缩级别是 DEFAULT_COMPRESSION。对于已经压缩过的文件(比如 .jpg、.png、.mp4),再压缩不会有明显效果。此外,如果你压缩的文件夹里文件很少(比如只有几 KB),zip 的元数据开销可能让结果更大。
解决方案:明确压缩级别,通过 zip.CompressionLevel 设置。一般用 COMPRESSION_LEVEL_BEST_COMPRESSION(最大压缩率)对文本、日志、配置文件效果明显。注意耗时也会增加。
const zipFile = new zip.ZipFile(destZipPath, zip.CompressionLevel.COMPRESSION_LEVEL_BEST_COMPRESSION);
坑2:解压时目标目录已存在,文件被覆盖但未提示
现象:解压到一个已有文件的目录时,同名文件被静默覆盖,无任何警告。
原因:API 设计上采用“直接覆盖”策略,没有提供回调或选项来让你决定是否覆盖。这是合理的设计(因为大部分场景就是要覆盖),但如果你需要判断是否覆盖,必须在调用解压前自己检查目标文件是否存在。
解决方案:在写入文件前,调用 fileIo.stat 检查目标文件是否已存在。
// 在 extractZipToDir 中添加检查
const destFileStat = await fileIo.stat(fullDestPath).catch(() => null);
if (destFileStat) {
// 文件已存在,你可以选择跳过或覆盖
// 这里示例直接跳过
console.warn('File exists, skip: ' + fullDestPath);
continue; // 跳过这个文件
}
坑3:长文件名压缩包无法解压
现象:在某些设备上(尤其是 32 位系统模拟器),解压包含超长中文路径的压缩包时,报错 General error。
原因:HarmonyOS 文件系统对路径长度有限制,具体取决于文件系统类型(EL2 分区是 ext4 还是 F2FS)。中文 UTF-8 编码会占用更多字节,容易超限。
解决方案:在压缩时,控制 entryName 的长度。如果文件名超过一定字符数(建议不超过 100 个字符),对其进行截断或 hash 处理。这个一般在数据导出场景会遇到,典型日志压缩时容易触发。
最佳实践
-
压缩后务必调用
close()ZipFile不会在对象析构时自动关闭,必须手动close(),否则文件句柄泄漏,可能导致后续 IO 操作失败。建议用try/finally保证关闭。 -
优先使用
COMPRESSION_LEVEL_FAST压缩级别
除非你明确需要压缩到最小体积(比如日志上传到服务器),否则用COMPRESSION_LEVEL_FAST耗时更短,对用户体验更好。默认的DEFAULT_COMPRESSION是中庸选项。 -
解压时创建父目录
如上面代码所示,解压时必须手动创建entryName中的父目录。不然直接写文件时,如果路径中的文件夹不存在,fileIo.writeArrayBuffer会直接报错。
Demo 入口
完整示例的入口文件为 src/main/ets/pages/Index.ets,上面的代码可以直接使用。建议在实际项目中封装为一个 ZipService 类,把错误处理、路径管理统一。
// 封装示例(可选)
@Entry
@Component
struct Index {
build() {
Column() {
// ... ui elements
}
}
}
FAQ
Q:为什么真机上压缩 100M 的文件比在模拟器上慢很多?
A:真机的文件系统(如 ext4)和模拟器的虚拟文件系统(通常是 tmpfs)性能差异大。模拟器上写文件基本不涉及磁盘 IO,所以快。真机上是实际写入存储介质。另外,真机的压缩库实现可能更依赖 CPU 频率。
Q:解压时 getEntry() 返回 undefined 是什么原因?
A:通常是因为 entryName 写错了。ZipFile 的 getEntry() 要求传入的字符串必须和 addEntry 时完全一致,包括大小写和路径分隔符。建议统一使用 / 作为路径分隔符,不要在生成时混用 \ 和 /。
Q:能否同时压缩/解压多个文件?
A:可以,但 ZipFile 不是线程安全的。如果多个协程同时操作同一个 ZipFile 实例,会出现数据错乱。建议一个 ZipFile 实例只在一个协程中使用,或者为每个文件创建独立的实例。
更多推荐



所有评论(0)