我最近一直在折腾 Flutter for OpenHarmony 的适配开发,做了一个看书管理记录类的小工具,用来维护我的阅读进度、笔记和书单状态。功能本身不复杂,真正让我花了不少心思的反而是最容易被忽略的模块——数据备份。原因很简单,阅读记录这类数据,丢了真的会让人心态崩掉。你想想,一本六百页的书读了三个月,笔记、页码、状态全在里面,App 一重装就全没了,那感觉跟书被撕了差不多。
这篇文章就把我在 Flutter for OpenHarmony 上做数据备份的完整思路、代码实现和踩坑过程整理出来。无论你是想给自研小工具加备份功能,还是正准备把 Flutter 项目往 OpenHarmony 上迁移,这部分内容都可以直接参考。
1. 内容整体设计与思路拆解
1.1 为什么选择 JSON 快照方案而不是直接拷贝数据库
刚开始做备份功能的时候,我的第一反应是“直接备份 SQLite 数据库文件不就行了”。这个方案看起来最省事:数据库文件在哪,把文件复制一份出来就行,恢复的时候再覆盖回去。但真的放到 Flutter for OpenHarmony 这个场景里,我仔细一推演,发现这条路有几道坎:
- 数据库文件是二进制格式,用户拿到备份文件后没法自己查看里面是什么,出了问题也没法人工排查,透明度太低。
- 万一以后我改了数据库表结构,老备份文件的兼容性就会出问题。轻则无法导入,重则导入后数据错乱。
- SQLite 文件在备份那一刻可能处于写入中间状态,直接拷贝存在数据不一致的风险。
- 阅读记录类的数据量其实非常小,完全没必要搬出数据库级备份这种“重武器”。
所以我最终选了 JSON 快照方案。思路很直白:把 App 里的核心业务数据序列化成 JSON 字符串,写入一个带时间戳的.json文件。恢复的时候读取这个文件,解析并校验通过后再导入。整个流程完全是可控的,出了问题还能打开文件自己看。
1.2 备份包的结构设计
备份文件本身我设计成了一种带版本号的自描述格式。参考了常见的经验,我把备份内容分成两层:
- meta 层:存放备份元信息,比如备份版本号、App 版本、导出时间、备份内置的数据库结构版本。
- data 层:存放实际的业务数据,包含书目信息、阅读记录、笔记、书单状态等。
结构草稿大概是这个样子:
{ "backupVersion": 1, "dbVersion": 3, "exportedAt": "2025-06-15 21:30:00", "data": { "books": [], "readingRecords": [], "notes": [], "tags": [] } }这样设计有个好处:以后数据结构升级时,我可以根据 backupVersion 写多个导入兼容分支。旧备份还能继续用,而不是直接变成一堆废文件。
1.3 备份文件命名与存放
文件名我用的是book_backup_yyyyMMdd_HHmmss.json。全英文文件名加时间戳的格式在跨设备传输时最稳,不会因为系统或编码问题导致乱码。
存放位置我分了两个层次:默认备份写入应用沙箱下的backup目录,App 打开后会有“分享备份文件”的功能,用户可以手动把文件发送到云盘、电脑或者其他 App 保存。为什么不在导出时直接写到公共下载目录?因为在 OpenHarmony 上,应用往公共目录(比如 Download)写文件涉及权限和媒体库授权,这在适配早期版本的时候坑非常深,后面我会详细说。
2. 核心细节解析与实操要点
2.1 数据模型的序列化设计
既然选了 JSON 快照方案,第一步就是让业务模型能干净地序列化和反序列化。我项目里的数据模型主要有三个,分别对应书、阅读记录和笔记。
class BookInfo { final String id; final String title; final String author; final int totalPages; final int currentPage; final String status; // reading / finished / wantToRead final DateTime createdAt; BookInfo({ required this.id, required this.title, required this.author, required this.totalPages, required this.currentPage, required this.status, required this.createdAt, }); BookInfo.fromJson(Map<String, dynamic> json) : id = json['id'] as String, title = json['title'] as String, author = json['author'] as String? ?? '', totalPages = json['totalPages'] as int? ?? 0, currentPage = json['currentPage'] as int? ?? 0, status = json['status'] as String? ?? 'wantToRead', createdAt = DateTime.parse(json['createdAt'] as String); Map<String, dynamic> toJson() => { 'id': id, 'title': title, 'author': author, 'totalPages': totalPages, 'currentPage': currentPage, 'status': status, 'createdAt': createdAt.toIso8601String(), }; }写这个类的时候有两个细节值得注意。
第一个是DateTime的序列化,强烈建议用toIso8601String()。如果你用toString(),输出的字符串里会带着系统时区信息,在不同的设备上解析时容易出现时差偏移,看起来像是数据变了,其实是时区问题在捣乱。
第二个是字段的兼容性。我写fromJson的时候,对可选字段全部用了空安全兜底,比如json['author'] as String? ?? ''。为什么要这样?因为旧版本备份里可能没有这个字段,直接强转会在导入时抛错。宁可丢掉一个展示字段,也不能让整个导入流程挂掉。
2.2 备份目录的获取——这个适配坑让我记忆深刻
在传统 Flutter 开发里,获取应用文档目录一般用path_provider的getApplicationDocumentsDirectory()。但在 OpenHarmony 的 Flutter 适配环境中,这套 API 的行为不完全一致。
我实测下来,部分设备上getApplicationDocumentsDirectory()返回的目录并不是真正持久化的文档目录,而是缓存目录。这就意味着:App 被清理缓存后,你的备份文件就没了,这在备份功能里是不可接受的。
我现在采用的稳妥做法是自己构造备份目录:用getApplicationSupportDirectory()(这个在适配里基本正常)作为根目录,然后拼一个book_backup子目录。
Future<Directory> getBackupDirectory() async { final supportDir = await getApplicationSupportDirectory(); final backupDir = Directory('${supportDir.path}/book_backup'); if (!await backupDir.exists()) { await backupDir.create(recursive: true); } return backupDir; }注意:在适配初期,一定要检查你拿到的路径真正指向哪个目录。最保险的办法是在代码里临时打印路径,用文件管理器看真实位置。别相信文档,要信实测结果。
2.3 为什么备份文件不直接写公共下载目录
有一个需求场景是用户点一下“导出备份”,文件直接出现在设备下载目录里。这个听起来很美好,但在 OpenHarmony 上直接写公共目录会遇到权限问题。
早期 Android 上有存储权限申请,OpenHarmony 的公共目录写入同样需要申请媒体读写权限。这个权限申请流程在 Flutter 插件生态里还没有统一的适配方案,而且即便申请成功,不同系统版本的表现也不一致。
我最终选择了“沙箱写入 + 分享导出”的组合方案。这个方案实测稳定得多,而且用户体验并不差:App 内提供备份文件列表,每条记录上有“分享”按钮,通过系统分享能力把文件发送出去就行。不用申请额外权限,也绕开了公共目录的限制。
3. 实操过程与核心环节实现
3.1 备份管理器的整体结构
我写了一个BackupManager单例类来统一管理备份的导出和导入,核心逻辑全部集中在这里,UI 层只管调用和展示结果。
class BackupManager { BackupManager._(); static final BackupManager instance = BackupManager._(); final List<BookInfo> _books = []; List<ReadingRecord> _records = []; List<Note> _notes = []; Future<String> exportBackup() async { final backupDir = await getBackupDirectory(); final fileName = 'book_backup_${_timestamp()}.json'; final file = File('${backupDir.path}/$fileName'); final backupPackage = { 'backupVersion': 1, 'dbVersion': 3, 'exportedAt': DateTime.now().toIso8601String(), 'data': { 'books': _books.map((e) => e.toJson()).toList(), 'readingRecords': _records.map((e) => e.toJson()).toList(), 'notes': _notes.map((e) => e.toJson()).toList(), }, }; final jsonStr = jsonEncode(backupPackage); await file.writeAsString(jsonStr, flush: true); return file.path; } String _timestamp() { final now = DateTime.now(); final y = now.year.toString().padLeft(4, '0'); final m = now.month.toString().padLeft(2, '0'); final d = now.day.toString().padLeft(2, '0'); final h = now.hour.toString().padLeft(2, '0'); final min = now.minute.toString().padLeft(2, '0'); final s = now.second.toString().padLeft(2, '0'); return '$y$m${d}_$h$min$s'; } }写这个类的时候,我特意用了flush: true参数,确保数据真正落盘,而不是停留在内存缓冲里。对备份文件来说,写了一半就返回成功是最可怕的事情。
3.2 导出流程的稳健化处理
单纯调用writeAsString还不够稳。我经过实际测试后,给导出流程增加了一步临时文件机制:
- 先把 JSON 内容写入一个
backup_tmp.json临时文件。 - 写入完成后检查文件大小,如果大于某个合理的最小值(比如 10 字节),说明内容写进去了。
- 再把临时文件重命名为正式文件。
为什么要多这一步?因为如果 App 在写入过程中被系统杀死、断电或者崩溃,临时文件最多变成残留文件,而正式备份区不会留下“半截”文件。用户恢复时拿到的要么是完整文件,要么根本没这个文件,不会出现导入到一半发现数据不对的尴尬局面。
分享文件的逻辑用的是share_plus插件的 OpenHarmony 适配版本。这里提醒一句:在 pubspec.yaml 里依赖 share_plus 时,要确认你用的版本在 OpenHarmony 上有对应的平台实现。如果引入了不兼容的版本,编译期不一定报错,但运行时会直接 MissingPluginException。解决办法是关注社区里由开发者适配维护的 forks 或专用版本。
3.3 导入流程的细节设计
导入和导出看起来是对称的,但细节上要复杂得多,因为它面对的是“用户拿过来什么文件都可能”的场景。
我的导入流程分了五个步骤:
- 读取文件,拿到字符串内容。
- 尝试
jsonDecode,失败则直接提示“文件格式错误”,中止流程。 - 检查
backupVersion字段,判断当前版本是否支持导入。 - 解析
data层,并逐条构造BookInfo、ReadingRecord、Note对象。 - 全部解析成功后,弹确认框让用户选择“覆盖”还是“合并”,确认后才真正写库。
关键点是:不要把确认框放在解析之前。用户还没看到这个文件能不能用,你就先问他“确定要恢复吗”,等他确认后才发现文件是坏的,这个交互流程就很蠢。先做完所有校验,再让用户做决定才是合理的顺序。
合并策略我实现得很简单:按 ID 去重,如果导入的书已存在,就用导入的数据覆盖;不存在则新增。如果你需要保留两边的“新记录”,可以改成按updatedAt时间戳比新旧,旧的让位给新的。这个需求在多设备同步场景里很常见。
3.4 UI 层的接入与用户反馈
备份功能的 UI 我做得比较轻。设置页有一个“数据备份”入口,点进去能看到备份列表、创建时间和文件大小,每个文件右侧有一个分享按钮和一个删除按钮。页面顶部有两个大按钮:立即备份和从备份恢复。
从备份恢复的交互是这样设计的:用户点击后进入文件选择器,选中备份文件后,App 会先解析校验。此时显示一个加载中状态,因为解析是在异步线程里跑的,大文件可能会卡一小会儿。解析成功后弹出包含备份时间和文件里书目数量的确认卡片,用户确认后执行导入,最后 Toast 提示成功或失败。
Future<void> onRestorePressed(BuildContext context) async { final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['json'], ); if (result == null || result.files.isEmpty) return; final path = result.files.single.path; try { final package = await BackupManager.instance.importBackup(path); if (!mounted) return; showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('确认恢复'), content: Text( '备份时间:${package.exportedAt}\n' '包含书目:${package.books.length} 本\n' '包含笔记:${package.notes.length} 条', style: const TextStyle(height: 1.6), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')), TextButton( onPressed: () async { await BackupManager.instance.applyPackage(package); if (ctx.mounted) Navigator.pop(ctx); }, child: const Text('确认恢复'), ), ], ), ); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('备份文件解析失败:$e')), ); } }这里的文件选择器用的也是社区适配的版本,在 OpenHarmony 设备上能拉起系统文件选择界面,选中的文件会以临时路径提供。有一点要注意:有时候返回的 path 指向的是临时缓存文件,App 必须立刻读取并解析,不能只保存这个路径等以后用,因为临时文件随时可能被系统清掉。
4. 常见问题与排查技巧实录
开发过程中我踩过不少坑,挑几个典型的分享出来,这些在文档里真的找不到答案。
4.1 备份文件在工作目录里找不到
一开始我用getApplicationDocumentsDirectory()拿路径,导出的文件测试时也显示成功,但打开文件管理器怎么都找不到。排查了很久才发现,问题出在 Flutter for OpenHarmony 的适配实现上,这个 API 返回的路径和 Android 上的含义不一致,文件被写进了缓存类目录。
解决办法就是我前面说的:改用getApplicationSupportDirectory()再拼子目录,或者干脆自己用沙箱路径拼接。检查文件是否真的写进去了,可以用文件管理器看路径,也可以在代码里读回文件大小来验证。
4.2 分享备份文件时找不到可用的 App
在 OpenHarmony 上测试share_plus的分享功能时,我遇到过调起分享面板但里面没有目标 App 的情况,或者在某些设备上直接抛异常。
这个问题的根源是系统分享通道的适配还在完善中。我的排查思路是看异常日志中是否有 MissingPluginException,如果是,优先确认插件版本;如果分享面板能起来但列表为空,那多半是设备上没有注册相关分享渠道,换一台设备即可确认。
给用户的备选方案就是文件管理器路径提示:分享失败时,在弹窗里把备份文件的完整沙箱路径展示出来,引导用户用第三方文件管理器进入该目录手动拷贝文件。
4.3 备份文件里的中文内容乱码
这个问题是在电脑上打开备份文件时发现的。中文字段显示成了乱码,但 App 内导入是正常的。原因是写入编码不统一,部分版本默认不是 UTF-8。
解决方式是在写入时显式指定 UTF-8 编码:
await file.writeAsString(jsonStr, flush: true, encoding: utf8);读取时同样指定:
final content = await file.readAsString(encoding: utf8);后来我做了一个防御性处理:导入时先按 UTF-8 读,如果内容里有替换符或解析异常,就尝试 GBK 编码再读一次。这样即使旧版本生成的备份文件用的是非 UTF-8 编码,也能被兼容识别。
4.4 大文件备份导致界面卡顿
理论上看书管理的数据量很小,但如果用户的笔记里贴了大段引用文本或者图片路径信息,备份文件也可能达到几 MB。直接用主线程 jsonEncode 和写文件,UI 会卡住一小会儿,体现为掉帧或短暂白屏。
解决思路很简单:把备份的完整流程放到Isolate.run()里执行,UI 层只显示 loading 状态。如果不想引入复杂的 isolate 管理,也可以用Future(() async { ... })把耗时的部分包进去,至少不阻塞主线程。
实测下来,一个包含 200 本书、800 条笔记的备份文件,在普通设备上生成时间大约 200-300ms。虽然不算长,但放到 isolate 里执行后,体验明显更顺滑,值得做。4.5 导入时误选损坏文件导致崩溃
这是我最担心的情况。用户可能在网上随便下载一个 json 文件,或者从聊天记录里转发一个本身就是坏的文件,导入时如果没有防护,App 很可能直接崩溃。
我的防御策略分三层:
- 第一层是 jsonDecode 的 try-catch 包裹,解析失败直接返回错误,不往下走。
- 第二层是格式校验。解析成功后,先检查 backupVersion 是否存在,再检查 data 是否为 Map 类型。这些都不满足就不继续。
- 第三层是逐条数据构造时的容错。单条记录字段缺失时,用空安全兜底,让能恢复的数据先恢复,个别异常记录跳过并汇总到提示信息里告诉用户。
最后在导入前,我会自动把当前数据先打一个临时备份。这样即使导入结果不理想,用户也有后悔药。实测下来这个自动备份的成本极低,但帮助非常大。
4.6 备份后重装 App 文件访问不到了
如果用户重装了 App,应用沙箱里的备份文件会跟着销毁,这是沙箱机制的必然行为。这也是我为什么在 App 里默认做了一步:备份成功后,自动弹出分享面板,引导用户把文件存到外部存储或云盘。受限于权限问题,App 无法直接写入公共目录,但通过分享能力可以做到等价效果。
我在备份列表里特意加了一个“未分享”的提示标签,提醒用户该份备份只存在于设备本地,建议尽快分享保存。这个设计虽然不起眼,但对数据安全感提升很明显。
写在最后
折腾完这套备份实现,我最大的体会是:备份功能看起来只是一读一写,真正做起来全是细节。文件写哪、怎么命名、编码用什么、解析怎么容错、导入要不要确认,每一个环节都可能埋坑。尤其是 Flutter for OpenHarmony 这个适配环境,很多 API 的行为和传统 Flutter 平台并不完全一致,文档覆盖也不够细致,更需要自己实测验证。
如果你也在做类似的跨平台适配项目,建议把备份功能优先级往前提一提,而不是在最后补。数据安全这件事,用户嘴上不说,但一旦丢一次数据,基本就不会再打开你的 App 了。
这个方案后续还可以扩展的方向,一个是备份文件压缩加密,加入密码保护后再分享出去;另一个是增加定期备份提醒,比如每次退出 App 时检查上次备份时间,超过一周就提醒一次。这两个功能加到现有架构上都很顺,接口设计不用动,只加逻辑层就行。