装修那阵子我差点被家具采购搞疯。今天在线上看中一套餐桌,记了个标签价,周末又跑去实体店看,发现店庆折扣和线上价格差了不少,还得把送货安装费、保修年限给记上。手机上换来换去用备忘录记,信息越记越乱,有一回系统重置,大半年的购买信息直接没了。于是我用 Flutter 给 OpenHarmony 的板子写了一个家具购买记录 App,一开始图省事,数据只存在应用沙箱里的 JSON 文件里。用了一阵子觉得不对劲——沙箱里的数据不是绝对安全,应用升级、恢复出厂、误卸载都可能把它带走。所以我把数据备份和恢复功能补上了,这篇就聊这块的实现过程,重点放在备份数据的格式设计、导出/导入的完整链路,以及我在真机和开发板上遇到的那些坑。
1. 备份功能的需求拆解:到底要备份什么、用什么格式
1.1 家具购买记录App里存的都是什么数据
在动手写备份功能之前,我先把自己日常记录的东西列了个清单。购买记录不是只有“买了什么、花了多少钱”这么简单,特别是家具这种低频、高客单、还涉及售后的东西。
我最终在App里维护了这些字段:家具名称、分类(沙发、床、餐桌这些)、品牌型号、购买渠道(线上平台、线下门店)、原价、实付价、购买日期、送货安装时间、保修期时长、发票照片路径、备注。除了这些基础字段,我还加了一个“是否仍在保修期内”的推导字段。这个字段不用存,计算出来就行,但导出备份时要考虑别人能不能看懂。
这些数据对用户来说,最关键的是两份东西:购买明细和保修凭证。一旦设备出问题,发票照片可能也没了,所以备份文件里还要记录照片是否缺失、路径指向哪里,至少给恢复时一个提醒。
1.2 JSON、CSV、数据库文件直拷,为什么我选了JSON
备份格式我认真对比过。
CSV的好处是能用Excel直接打开,坏处是字段一多,逗号、引号、换行转义就容易出事,而且它只能表格式描述数据,嵌套结构完全没有。SQLite文件直拷是另一个思路,数据完整性最好,恢复也不用重新解析,但备份文件跟数据库版本绑定得太死,以后我升级数据库表结构,旧备份可能打不开,而且用户拿到.sqlite文件也很懵。云同步最省心,但设备上没有稳定的同步条件时,最核心的“设备可离线备份”能力反而没了。
最终选了JSON格式,理由很直接:结构清晰,什么字段都能带,版本升级时做兼容简单,而且Flutter/Dart解析JSON非常顺手。家具购买记录的体量不大,一个用户几年也就几百条记录,JSON文件哪怕写上几百KB,解析都在可接受范围内。
| 格式 | 人类可读 | 嵌套结构 | 版本兼容 | 离线可用 | 我的结论 |
|---|---|---|---|---|---|
| JSON | 好 | 支持 | 容易 | 是 | 最终选择 |
| CSV | 好 | 不支持 | 一般 | 是 | 字段一多就麻烦 |
| SQLite直拷 | 差 | 支持 | 较难 | 是 | 依赖数据库结构 |
| 云同步 | 差 | 支持 | 较容易 | 否 | 需要外部服务 |
选JSON不等于把数据结构随便定。备份文件里的字段要稳定,字段名不能随代码重构乱改。所以我把备份包格式当成一个“版本化接口”来设计,这个在第三章详聊。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把Flutter跑在OpenHarmony上:环境准备和版本配套是第一步
2.1 需要准备的完整组件
当时我手上是一块RK3568的开发板,系统是OpenHarmony,想在板子上跑Flutter应用,需要的不只是DevEco Studio。Flutter for OpenHarmony目前是通过Flutter的ohos分支构建的,所以我本地装了一套Flutter SDK,专门用来构建hap包;DevEco Studio负责编译OpenHarmony原生工程、签名和安装。注意:你用普通流程下载的Flutter SDK不支持hap构建,需要拉取支持OpenHarmony的Flutter分支。
版本配套这事特别容易翻车。我一开始随手装了个较新的Flutter版本,结果构建时OpenHarmony SDK跟它不匹配,报了一堆编译错误。后来我把Flutter的ohos分支版本、OpenHarmony SDK版本、DevEco Studio版本、开发板系统版本四个东西固定为同一批配套版本,问题基本消失。建议先确认开发板的OpenHarmony版本,再倒推SDK和Flutter版本。
2.2 查看开发板系统版本:hdc命令
连接开发板后第一步,先看系统版本。OpenHarmony上不能用adb那套,调试和工具都得用hdc。
bash复制# 连接设备
hdc list targets
# 查看系统版本
hdc shell param get const.product.software.version
# 查看产品名
hdc shell param get const.product.name
这个命令我每次都要用。确认开发板是OpenHarmony哪个版本之后,再去选相应的SDK和Flutter分支,能少踩很多坑。
2.3 构建hap包和安装调试
OpenHarmony上Flutter工程结构和普通Flutter工程不太一样,工程下会有一个ohos目录,里面是ArkTS的原生工程。构建命令是:
bash复制flutter build hap
生成产物路径一般在 build/ohos/outputs/...,然后通过hdc安装:
bash复制hdc install <路径>/xxx.hap
安装后在开发板的桌面能看到App图标。第一版跑起来后,我就开始加备份功能了。调试时如果只想看日志,可以用 hdc hilog 过滤Flutter输出。
这里不是教学,而是想强调一点:不管后面写不写代码,先保证“能构建、能安装、能看日志”这条链路是通的,否则到后面调试备份导入导出会非常痛苦。
3. 备份文件的数据模型设计:不是随便存几个字段那么简单
3.1 PurchaseRecord的字段与Json映射
我在Flutter侧定义了一个PurchaseRecord类,核心字段如下:
dart复制class PurchaseRecord {
final String id;
final String name;
final String category;
final String brand;
final String model;
final String channel;
final double originalPrice;
final double actualPrice;
final DateTime purchaseDate;
final DateTime? deliveryDate;
final int warrantyMonths;
final String? invoicePath;
final String notes;
final DateTime updatedAt;
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'category': category,
'brand': brand,
'model': model,
'channel': channel,
'originalPrice': originalPrice,
'actualPrice': actualPrice,
'purchaseDate': purchaseDate.toIso8601String(),
'deliveryDate': deliveryDate?.toIso8601String(),
'warrantyMonths': warrantyMonths,
'invoicePath': invoicePath,
'notes': notes,
'updatedAt': updatedAt.toIso8601String(),
};
factory PurchaseRecord.fromJson(Map<String, dynamic> json) {
return PurchaseRecord(
id: json['id'] as String,
name: json['name'] as String,
category: json['category'] as String,
brand: json['brand'] as String? ?? '',
model: json['model'] as String? ?? '',
channel: json['channel'] as String? ?? '',
originalPrice: (json['originalPrice'] as num?)?.toDouble() ?? 0,
actualPrice: (json['actualPrice'] as num?)?.toDouble() ?? 0,
purchaseDate: DateTime.parse(json['purchaseDate'] as String),
deliveryDate: json['deliveryDate'] == null ? null : DateTime.parse(json['deliveryDate'] as String),
warrantyMonths: json['warrantyMonths'] as int? ?? 0,
invoicePath: json['invoicePath'] as String?,
notes: json['notes'] as String? ?? '',
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
}
字段名这里有个习惯要养好:日期统一存ISO8601字符串,价格统一用double,ID统一用String。不要今天存 purchaseDate,明天改成 buyTime,否则老备份恢复时全得兼容。每个字段给默认值,也是为了让老数据缺字段时不至于崩。
3.2 备份包的外层结构
一个备份文件不能只塞一个“记录数组”,因为恢复程序需要知道:这是哪个App的备份、备份格式版本是多少、什么时候导出的、里面有多少条记录。我设计了这样的外层:
json复制{
"app": "furniture_purchase_records",
"formatVersion": 1,
"exportedAt": "2025-01-15T14:30:00.000Z",
"recordCount": 128,
"records": [
{ "...": "每条购买记录" }
],
"meta": {
"description": "家具购买记录App导出文件"
}
}
这里有三个关键设计决策。
第一,formatVersion必须放在最外层且单独校验。将来备份格式升级,我可以根据version决定走哪套解析逻辑,而不是靠猜。
第二,exportedAt是导出时间,和记录的purchaseDate不是一回事。导入时可以提示用户“这是一份多久之前的备份”,避免误导入旧数据。
第三,recordCount写进去其实是为了完整性校验。解析完records后对比数量,数量和实际长度对不上就说明文件损坏。
3.3 版本号不能省:老备份恢复时的兼容策略
我见过不少App备份功能不做版本号,结果产品迭代两年后旧备份就废了。备份文件不是一次性的,它可能要在两三年后、甚至换了一台设备后恢复。所以恢复逻辑一定要对老版本友好。
我的经验是:解析时先读formatVersion,如果版本比当前支持的版本低,走兼容分支;如果版本比当前支持的版本高,就提示“该备份文件版本过新,请升级App后再恢复”,而不是直接崩溃。这属于“备份协议”的一部分。
4. 导出功能实现:从沙箱路径到落盘,再到拉回电脑
4.1 获取应用沙箱路径:用MethodChannel最稳
在OpenHarmony上,路径相关插件生态还不像Android那么全。我用MethodChannel自己取路径,把原生侧返回的沙箱目录传给Dart层。Dart侧:
dart复制class OhosPathService {
static const MethodChannel _channel = MethodChannel('furniture_app/path');
static Future<String> getAppFilesDir() async {
final String? dir = await _channel.invokeMethod('getAppFilesDir');
if (dir == null || dir.isEmpty) {
throw Exception('无法获取应用沙箱路径');
}
return dir;
}
}
原生侧(ArkTS)主要是把应用的files目录通过 result 返回。不同OpenHarmony版本API名称可能有差异,但思路一致:在Ability或WindowStage里注册MethodChannel,返回 context.filesDir 这类的沙箱路径。
我没有直接依赖社区里可能还没适配的 path_provider 版本,因为备份功能是数据安全相关的基础能力,越少依赖越好。这个选择后面让我省了很多事,有一版插件适配出问题,备份功能完全没受影响。
4.2 组装备份文件并写入沙箱
拿到路径后就可以组装并写文件了。我单独封装了一个 BackupService:
dart复制class BackupService {
final String appFilesDir;
BackupService(this.appFilesDir);
Future<String> exportRecords(List<PurchaseRecord> records) async {
final dir = Directory('$appFilesDir/backup');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final timestamp = DateTime.now();
final fileName =
'furniture_backup_${timestamp.year}${timestamp.month.toString().padLeft(2, '0')}${timestamp.day.toString().padLeft(2, '0')}_'
'${timestamp.hour.toString().padLeft(2, '0')}${timestamp.minute.toString().padLeft(2, '0')}${timestamp.second.toString().padLeft(2, '0')}.json';
final payload = {
'app': 'furniture_purchase_records',
'formatVersion': 1,
'exportedAt': timestamp.toIso8601String(),
'recordCount': records.length,
'records': records.map((e) => e.toJson()).toList(),
};
final file = File('${dir.path}/$fileName');
await file.writeAsString(
const JsonEncoder.withIndent(' ').convert(payload),
encoding: utf8,
flush: true,
);
return file.path;
}
}
这里两个细节值得讲一下。
第一个是文件名里带时间戳,并且精确到秒。这样可以保证同一秒钟多次导出也不会覆盖,用户一眼能看出哪个备份是最新的。用本地时间没问题,因为这是给用户自己看的文件。
第二个是 flush: true。写文件时如果不flush,断电或App被杀,可能只写了一半,恢复时会解析失败。备份文件最重要的是完整,宁可慢一点也要强制落盘。
4.3 文件在开发板里,怎么拿回电脑
导出后在App界面上显示一个完整路径,下一步就是要把它弄到电脑上。OpenHarmony和电脑之间的文件收发靠hdc:
bash复制# 创建本地目录
mkdir -p ~/furniture_backups
# 从开发板拉取备份文件到电脑
hdc file recv /data/storage/el2/base/haps/entry/files/backup/furniture_backup_20250115_143000.json ~/furniture_backups/
反过来,如果要把备份从电脑恢复到开发板:
bash复制hdc file send ~/furniture_backups/furniture_backup_20250115_143000.json /data/storage/el2/base/haps/entry/files/backup/
开发板的沙箱路径如果记不住,先在App里点击“导出备份”,界面会打印出完整路径,直接复制到hdc命令里就行。这一步是调试初期最频繁的操作。
我还遇到过一个细节:hdc file recv 的目标路径如果带中文或者空格,最好加引号,否则会被拆成两个参数。后面踩坑章节我会再提。
5. 导入恢复功能:安全永远是第一位的
5.1 扫描备份目录,而不是依赖文件选择器
导入功能我不走系统文件选择器。原因很简单:OpenHarmony上Flutter可用的文件选择插件并不成熟,而且恢复备份是低频操作,没必要为它引入一个不稳定的依赖。
我的方案是让App扫描沙箱里的 backup/ 目录,把里面的 .json 文件列出来给用户选。这样用户只需先把备份文件用 hdc file send 放进固定目录,再在App里点“导入”,就能看到可选文件。
dart复制Future<List<String>> listBackupFiles() async {
final dir = Directory('$appFilesDir/backup');
if (!await dir.exists()) {
return [];
}
final files = await dir
.list()
.where((entity) => entity is File && entity.path.endsWith('.json'))
.map((entity) => entity.path)
.toList();
files.sort((a, b) => b.compareTo(a)); // 最新的排前面
return files;
}
按修改时间倒序排一下,用户最常导入的是最新备份。
5.2 解析校验:版本、数量、字段都要查
解析备份文件时,我做了三层校验。
第一层是文件完整性校验。文件能否正常读取、是否UTF-8编码、JSON格式是否有错误。第二层是外层包校验,app标识符、formatVersion、recordCount是否合法。第三层是记录级校验,逐条解析PurchaseRecord,解析失败的记录单独记录,而不是让整批恢复失败。
dart复制Future<List<PurchaseRecord>> parseBackupFile(String filePath) async {
final content = await File(filePath).readAsString(encoding: utf8);
final Map<String, dynamic> json = jsonDecode(content);
if (json['app'] != 'furniture_purchase_records') {
throw FormatException('不是本App导出的备份文件');
}
final int formatVersion = json['formatVersion'] as int;
if (formatVersion > kCurrentBackupVersion) {
throw FormatException('备份文件版本过新,请升级App后再恢复');
}
final int recordCount = json['recordCount'] as int;
final List<dynamic> recordsJson = json['records'] as List<dynamic>;
if (recordsJson.length != recordCount) {
throw FormatException('记录数量不一致,文件可能损坏');
}
return recordsJson
.map((item) => PurchaseRecord.fromJson(item as Map<String, dynamic>))
.toList();
}
这里需要强调:把“版本过新”和“文件损坏”分开提示,用户体验完全不一样。用户看到“版本过新”知道去升级App,看到“文件损坏”会去找原备份重新拷贝,两者解决方向不同,混在一起会让用户无从下手。
5.3 恢复前先备份现有数据:这个习惯不能省
数据恢复最大的风险,不是恢复失败,而是“恢复成功但把当前数据覆盖了”。我在导入逻辑里强制加了一步:恢复之前,先把当前的数据自动导出一份,文件名加上 pre_restore_ 前缀。
这样即便用户导入后发现“新数据还不如旧数据”,也能马上回到恢复前的状态。这个功能写起来就几行:
dart复制Future<void> restoreFromFile(String filePath) async {
final preBackupPath = await exportRecords(_currentRecords, prefix: 'pre_restore_');
// 再执行解析、覆盖、刷新UI
}
我的原则是:备份和恢复功能本身不复杂,复杂的是各种边界条件。宁可多生成一个文件,也不要让人后悔。
5.4 写入时的原子性:避免恢复一半断电
恢复数据时,千万不要直接拿解析出来的记录马上覆盖当前数据文件。正确做法是先把完整的新数据写到一个临时文件,写入成功后用重命名替换正式文件,最后再删除临时文件。dart:io的 File.rename 在OpenHarmony的文件系统上是可用的。
dart复制final tempFile = File('${filePath}.tmp');
await tempFile.writeAsString(newContent, encoding: utf8, flush: true);
await tempFile.rename(dataFilePath);
这跟数据库里“先写日志再落盘”是一个道理。万一写入过程中App被系统杀掉,正式数据文件还是旧的,至少数据没丢,最坏情况只是这次恢复没成功。重新打开App再试一次就行。
6. 实测中的坑:我替你们踩过的那些
6.1 插件适配不全,慎用第三方依赖
上面提到没用path_provider,其实是踩过坑才做的决定。有一版我引入了一个社区适配的路径插件,安装没问题,但运行到真机上时返回的路径始终是空字符串,后台日志也没有报错,排查了很久发现是插件在OpenHarmony上的MethodChannel实现没走通。
在OpenHarmony的Flutter生态还没完全成熟之前,我的建议是:只要是数据安全相关的核心能力,能自己用MethodChannel实现就自己实现。备份导出、导入恢复都不需要太多原生能力,无非就是路径、文件读写、重命名,这些自己写完全可控,也方便日后排查。
6.2 构建时的Gradle/Plugin报错,锁定版本能解一大半
构建hap包时,我遇到过类似 You are applying Flutter's main Gradle plugin imperatively using the apply... 的报错。这个报错信息其实是在Android工程里出现的,原因是项目中老插件模板和新版Flutter构建插件不匹配。OpenHarmony工程里因为同时存在ohos和android目录,很容易被这种模板问题波及。
我的处理方式比较务实:不看网上零散的“删掉某行”方案,而是把整个Flutter ohos分支、DevEco Studio、OpenHarmony SDK锁到官方配套版本列表,然后清掉build缓存重新构建。这比乱改Gradle配置文件靠谱得多。版本不配套才是报错的根源。
6.3 中文路径和引号:hdc命令不是shell那种宽松环境
我在备份文件名里没有用中文,就是为了减少hdc命令和文件系统的意外。OpenHarmony的沙箱路径本身很长,如果再加上空格、中文,hdc file recv 时会因为参数拆分问题,把路径截断或者传错。用hdc时一定要养成习惯:
bash复制# 正确:路径加引号
hdc file recv "/data/storage/el2/base/haps/entry/files/backup/家具备份_20250115.json" ~/furniture_backups/
# 错误:不带引号,空格或中文会被拆分
hdc file recv /data/storage/el2/base/haps/entry/files/backup/家具备份_20250115.json ~/furniture_backups/
6.4 记录量大时,别在主Isolate里解析JSON
几百条记录的备份文件解析很快,用户感知不到,但万一日后用户攒了几千条记录、每条里还带长文本备注,主Isolate解析那一下可能卡UI几百毫秒。Flutter提供了compute,把解析逻辑丢到后台Isolate,避免界面卡顿。
dart复制final List<PurchaseRecord> records = await compute(parseBackupFile, filePath);
parseBackupFile必须是一个顶层函数或者静态方法,不能是实例方法,这个细节容易漏。文件读写本身也有延迟,建议在调用前先显示一个“正在导入”的加载状态,导入完成后刷新列表。
备份功能做完之后,我自己用了一个多月,最大的感受是“导出->拉回电脑->放网盘”这条链路一定要顺手,否则没人愿意用。App里写了导出,不等于用户真的有备份。所以我在导出成功界面加了完整路径展示,并且把“备份文件请自行保存到至少两个地方”这个建议放在按钮下面。这类细节和代码功能本身无关,但决定了这个备份功能到底有没有用。另外,每次在开发板上做系统升级或者清数据之前,我都会先导出一次,这已经成了习惯。
以上是整个数据备份实现的完整过程,代码都不长,真正花时间的是设计格式、考虑恢复边界、排查环境问题。如果你们也在OpenHarmony上做Flutter应用,建议先从小功能开始跑通全链路,再做数据安全相关的事,会稳很多。
