如果你平时写 Flutter,又对 OpenHarmony 这个新系统有所关注,那这篇内容应该正好踩在你的点上。我最近把手里这个「看书管理记录 App」迁移到 OpenHarmony 平台,整个迁移过程中真正帮我趟开路的,不是首页布局、不是动画组件,而是「数据备份」这个常常被忽略的基础功能。看书类应用和新闻 App 最大的不同在于——用户记录的每一本书、每一条阅读进度、每一段摘抄都只存在于本地数据库,一旦丢了就永远找不回来。所以迁移到一个新平台时,我给自己定的第一条规矩就是:先把备份和恢复跑通,再造 UI。这篇文章把我完整走了一遍的方案、坑和取舍全部记下来,希望能给同样在做 Flutter for OpenHarmony 项目的人省点时间。
1. 为什么在 OpenHarmony 上跑 Flutter 做看书 App,备份又为什么必须前置
1.1 从 Flutter 到 OpenHarmony:这条路现在通了吗
先说结论:能跑,而且基础体验比我想象中好。Flutter for OpenHarmony 目前由社区的 SIG 组织在维护,整个链路可以理解为把 Flutter 引擎适配到 OpenHarmony 的 Native 层,最终用 DevEco Studio 打出 hap 包。日常用到的 Widget、路由、状态管理框架这些,基本都不用改——我在 Android 上写的页面,搬到 OpenHarmony 上编译一遍,大部分直接能显示。
真正需要重新评估的是插件生态。pub.dev 上大量 Flutter 插件底层依赖 Android/iOS 的原生 API,在 OpenHarmony 上要么有社区适配版,要么就得自己用 MethodChannel 把能力补上。我这次做的备份模块,恰好就是"没有现成插件可用"的典型场景:sqflite 有适配版,但文件分享、文件选择器这些跟系统强相关的能力,OpenHarmony 侧基本没有现成 Dart 包,只能自己写通道。
如果你所在团队本身就是 Flutter 技术栈,我的建议是:可以认真考虑用 Flutter for OpenHarmony 来覆盖这个新平台,而不是另起一套原生代码。理由很简单,业务逻辑能复用 80% 以上,省下的维护成本非常可观。但前提是你要有心理准备,遇到插件缺失时,你得有能力补齐平台通道的代码。
1.2 看书 App 的数据结构:哪些数据值得备份
一个看书管理记录 App,核心数据其实就是用户自己积累的阅读资产。我这边的表结构比较简单,总共四张表:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| books | id, title, author, total_pages, status, rating | 书目信息,status 表示在读/读完/搁置 |
| reading_records | id, book_id, start_page, end_page, reading_date, reading_minutes | 每次阅读的记录,用于统计时长和进度 |
| notes | id, book_id, chapter, content, created_at, updated_at | 摘抄、想法、批注 |
| settings | key, value | 阅读偏好、最近一次自动备份时间等 |
这几张表的共同点是一切数据都在本地。用户今天读到第 120 页、在某本书里摘抄了一段话,这些行为如果只存在手机本地,那换机、卸载、系统故障都会导致全部清零。
所以备份功能对这个 App 来说不是"高级功能",而是"底线功能"。在迁移到 OpenHarmony 这个新生态的早期阶段,系统自身的数据迁移工具还不成熟,用户更加依赖应用自身提供的备份能力。这个判断直接决定了我的开发顺序:备份优先于UI优化,优先于动画效果,甚至优先于部分页面功能。
1.3 为什么把备份放到第一优先级
我做跨端迁移的经验是:数据迁移不过关,用户不会给你第二次机会。UI 丑一点、动画卡一点,用户可能会吐槽,但数据丢了,用户是直接流失的。
另外还有一个实际原因:OpenHarmony 设备的用户可能同时在用 Android/iOS 设备,他希望把旧手机里的阅读记录搬到新系统上继续读。没有一套可靠的导出/导入机制,这个需求就完全没法满足。
备份要解决两个层面的问题:
- 应用自身冗余:定期在私有沙箱目录里存一份自动备份,防止数据库文件损坏或误操作。
- 用户可控导出:把备份生成成一个用户能带走的文件,通过系统分享面板发出去,或者通过文件选择器导回来。
这两层缺一不可。只做沙箱内自动备份,用户换机时依然没办法;只做手动导出,用户忘了操作,数据照样裸奔。我这次把两层都做了,下面从文件格式开始,逐段讲实现。
2. 备份文件格式设计:先想清楚恢复才能想清楚导出
2.1 为什么不直接拷贝 db 文件
很多开发者做备份的第一反应是:把 SQLite 数据库文件直接复制一份不就行了?我一开始也这么想,后来认真一推敲,发现这条捷径坑很多。
直接拷贝 db 文件确实实现简单、数据完整,但它有几个很难绕开的问题:
| 对比维度 | 直接拷贝 db | 导出 JSON 文件 |
|---|---|---|
| 跨版本兼容 | 差,数据库表结构升级后,老备份基本没法直接用 | 好,可以针对不同 schemaVersion 做字段过滤和转换 |
| 可读性 | 差,二进制文件,开发者排查问题只能靠工具 | 好,任何文本编辑器都能直接看内容和结构 |
| 数据一致性 | 有坑,如果不处理 WAL 日志,拷贝出来的主库文件可能缺最新提交 | 无影响,通过单事务查询快照,天然忽略 WAL 细节 |
| 未来迁移成本 | 高,如果哪天不用 SQLite 改用云同步,二进制数据迁移很麻烦 | 低,JSON 是通用格式,导出后想进哪个新系统都容易 |
还有一个很现实的点:JSON 文件可以在导出时就做校验和结构检查,而二进制 db 文件你几乎无法预判它损坏到哪种程度。对用户来说,给一份 JSON 备份他也能隐约知道里面是什么;给一份 db 文件,他只能当黑盒用。
2.2 我的备份文件结构长什么样
我设计的备份文件是一个带元信息的 JSON 文档,根节点分两层:元信息区和数据区。看起来像这样:
{ "app": "booknote", "schemaVersion": 1, "exportedAt": "2025-06-12T10:30:00+08:00", "summary": { "books": 23, "reading_records": 156, "notes": 41, "settings": 3 }, "data": { "books": [ { "id": 1, "title": "《置身事内》", "author": "兰小欢", "total_pages": 340, "status": "finished", "rating": 9 } ], "reading_records": [], "notes": [], "settings": [] } }每个字段都有它存在的理由:
app是应用标识,恢复时第一件事就是认这个字段,防止用户拿错文件、拿别人的 App 备份来恢复。schemaVersion是数据结构的版本号。将来加字段、改表结构,恢复逻辑就可以靠它分支处理。exportedAt是导出时间,方便用户在文件管理里识别新旧备份。summary保存每张表的记录数,表面上是给用户一个直观的"这份备份里有多少数据",实际上还有一个作用:解析完成后用它校验数据完整性,如果摘要里的数字跟 data 里实际条数对不上,基本可以判定文件损坏或被篡改过。
data 区按表名组织,每张表就是一个数组,数组里每个元素就是一行记录。
2.3 版本号管理与兼容规则
版本管理是备份功能里最容易偷懒、也最容易埋雷的地方。我定义了一套简单的规则:
currentSchemaVersion表示当前 App 的数据库结构版本,每次改表结构就加 1。minSupportedBackupVersion表示恢复功能能接受的最低备份版本。低于这个版本的备份,直接提示"备份文件版本过旧,请升级应用后再尝试恢复"。
恢复逻辑里开头就做版本检查:
if (backup.schemaVersion < minSupportedBackupVersion) { throw BackupException('backup_version_too_old'); } if (backup.schemaVersion > currentSchemaVersion) { throw BackupException('backup_version_from_future'); }这个判断要放在任何解析动作之前,宁可多写几行校验,不要等到写库写到一半才发现版本不兼容。
3. 备份导出实现:从数据库到沙箱文件的完整链路
3.1 数据库层封装与插件适配
数据库这块我踩了一小段弯路。标准 sqflite 插件在 OpenHarmony 上是不能直接用的,因为它的原生实现是 Android 和 iOS 的 SQLite 接口。我最后用了社区适配过的 sqflite 版本,API 用法跟标准版本基本一致,所以我的业务代码几乎不用动。
这里有个建议:无论你最终选哪种数据库方案——sqflite 适配版、drift、还是直接调用 OpenHarmony 原生 RDB,一定要在存储库层面做一层接口封装。我建了一个BookRepository,把所有查表操作收敛到这一个类里面。这样将来底层数据库实现换了,备份模块根本不用跟着改。
3.2 导出的核心代码
导出逻辑其实就是四步:查表、组装、序列化、写文件。核心代码大致如下:
class BackupService { final Database db; BackupService(this.db); Future<String> exportBackup(String outputDir) async { // 1. 单事务查询所有表,保证导出的是同一时刻的快照 final data = <String, List<Map<String, Object?>>>{}; await db.transaction((txn) async { data['books'] = await txn.query('books'); data['reading_records'] = await txn.query('reading_records'); data['notes'] = await txn.query('notes'); data['settings'] = await txn.query('settings'); }); // 2. 组装元信息 final payload = BackupPayload( schemaVersion: currentSchemaVersion, exportedAt: DateTime.now().toIso8601String(), summary: _buildSummary(data), data: _normalizeData(data), ); // 3. 序列化为带缩进的 JSON,方便用户自行查看 final jsonString = const JsonEncoder.withIndent(' ').convert(payload.toJson()); // 4. 写入沙箱目录 final fileName = 'booknote_backup_${DateTime.now().millisecondsSinceEpoch}.json'; final file = File('$outputDir/$fileName'); await file.writeAsString(jsonString, flush: true); return file.path; } }有几个细节是普通代码片段不会告诉你的,我单独提一下:
第一,查询要放在事务里。如果不放在事务里,导出一半时用户正好在记一条新的阅读进度,那这一份备份里的数据可能就是"半个新 + 半个旧"的混合状态。阅读记录这种低频写入场景虽然概率不高,但养成事务快照的习惯,能避免一个很隐蔽的数据一致性问题。
第二,_normalizeData这一步不能省。SQLite 查询结果里可能包含 DateTime 对象和 Uint8List 之类的 BLOB 数据,而 JSON 原生不支持这两种类型。我统一做两件事:DateTime 转成 ISO8601 字符串,BLOB 做 Base64 编码。恢复的时候再逆变换回来。
第三,文件名带时间戳是一个微不足道但很实用的设计。用户如果经常备份,同一天导出的不同文件不至于互相覆盖,而且在文件管理工具里看名字就能判断新旧。
3.3 自动备份与手动导出的触发策略
备份不能只靠用户手动点。我做了两层触发机制:
自动备份的时机是 App 进入后台(lifecycle 切到 paused)时。记录最近的自动备份时间存在 settings 表里,每次进入后台检查一次,距离上次超过 7 天就自动生成一份备份,写到私有沙箱目录下的autoBackup/文件夹。同时只保留最近 3 份,每次生成新备份就顺手把最老的删掉,避免沙箱空间被无意义占满。
手动导出的时机就是用户在设置页点"导出备份"按钮。此时生成的备份文件不只放沙箱,还要通过系统分享面板把它"发给"用户自己,比如保存到文件管理,或者通过邮件发出去。这一步涉及 OpenHarmony 的平台通道,我放在第 5 章专门讲。
4. 恢复与校验:如何做到"恢复失败也不丢当前数据"
4.1 三步保护策略
恢复是比导出更危险的操作,因为它在动用户当前的数据。一个崩溃、一次断电,可能把原本好好的数据也弄坏。所以我给恢复流程设计了三个保护层:
第一,恢复前自动备份当前数据。执行恢复前,先把当前数据库文件完整复制到沙箱里的recoveryGuard/目录。万一恢复失败,至少能退回原状。
第二,事务性写入。所有表的清理和插入动作必须放在同一个数据库事务里,任何一条失败,整体回滚,不留半新半旧的数据状态。
第三,先校验再写库。解析、版本检查、记录数核对,全部通过之后才允许碰数据库。
这三层叠加起来,用户能遇到的最坏情况就是:恢复失败,但当前数据还在。
4.2 解析与校验
恢复入口拿到的是一个备份文件路径,第一步是读文件、解析 JSON、做校验。
Future<BackupPayload> parseAndValidate(String fileContent) async { final jsonMap = jsonDecode(fileContent) as Map<String, dynamic>; // 校验应用标识 if (jsonMap['app'] != 'booknote') { throw BackupException('not_booknote_backup'); } final payload = BackupPayload.fromJson(jsonMap); // 校验版本 if (payload.schemaVersion < minSupportedBackupVersion) { throw BackupException('backup_version_too_old'); } // 校验摘要与真实条数一致 for (final tableName in payload.data.keys) { final actualCount = payload.data[tableName]!.length; final expectedCount = payload.summary[tableName] ?? 0; if (actualCount != expectedCount) { throw BackupException('summary_mismatch'); } } // 校验关键字段:id 必须有,否则后面没法插入 for (final tableName in payload.data.keys) { for (final row in payload.data[tableName]!) { if (!row.containsKey('id')) { throw BackupException('missing_id_field'); } } } return payload; }这个校验逻辑看起来简单,但每一项都对应真实事故。我遇到过用户拿了一个同 App 旧版本的备份文件来恢复,版本号不兼容;也遇到过用户用第三方工具编辑了备份文件,导致 summary 跟实际数据对不上。这些都在写库之前被拦下来了。
4.3 事务性写入与两种恢复模式
写入阶段最核心的代码是开启一个事务,在事务里完成清表、插入:
await db.transaction((txn) async { // 先清理旧数据 for (final tableName in payload.data.keys) { await txn.delete(tableName); } // 再逐表插入新数据 for (final tableName in payload.data.keys) { final rows = payload.data[tableName]!; for (final row in rows) { await txn.insert(tableName, row); } } });这里有一个产品层面的取舍:我提供了"完全还原"和"合并恢复"两种模式。
- 完全还原:先清表再插入,最终数据跟备份文件完全一致。适合换机场景,或者在另一台设备上恢复。
- 合并恢复:不清表,而是按 id 做 upsert。已有记录更新,没有的记录新增,保留当前设备上备份之后新增的数据。适合"我昨天导出了,但今天又读了几页书,想恢复备份又不丢今天数据"的场景。
默认按钮是"完全还原",但在按钮旁边留了一个"保留当前数据"的选项。核心经验是:恢复功能不要做成只能二选一,给用户选择权,让他在不同场景下都有路径可用。
4.4 恢复之后的收尾
恢复成功之后还有三件善后工作不能漏:
- 刷新 UI。数据库被整体替换了,原来的页面数据全部失效,必须发一个事件让列表页、详情页重新拉数据。
- 清理临时文件。恢复前后的 recoveryGuard 内容,确认一切正常后删除,不占沙箱空间。
- 写一条恢复记录。把恢复时间、备份文件的导出时间、恢复模式写到 settings 表里,方便事后排查问题。
我实际见过一种情况:恢复完成后用户马上杀掉了 App,再打开发现数据是旧的。排查后发现是因为我用的是异步事务,UI 层回调还没触发,用户就强杀了进程。所以收尾工作一定要在事务真正 commit 之后再做回调通知。
5. 文件导出到系统公共目录:OpenHarmony 的文件交互与权限边界
5.1 沙箱与公共目录的矛盾
OpenHarmony 的应用沙箱机制跟其他现代移动系统类似:应用默认只能访问自己沙箱目录下的文件,用户通过系统文件管理器是看不到你沙箱里那份备份文件的。这就产生了一个矛盾——你生成了备份文件,但用户拿不走。
要解决这个问题,OpenHarmony 侧的标准做法是:通过系统分享面板或文件选择器来中转文件。用户在系统 UI 上主动选择保存位置、选择要导入的文件,整个过程应用不会拿到全局存储权限。这也是我最推荐的方案,因为它最符合隐私最小化原则。
5.2 Channel 接口设计与 OpenHarmony 侧逻辑
Flutter for OpenHarmony 的 MethodChannel 机制是支持可用的,所以我在 Flutter 侧定义了两个平台方法:
const platformChannel = MethodChannel('com.booknote/backup'); // 分享备份文件出去 Future<void> shareBackupFile(String filePath, {required String fileName}) async { await platformChannel.invokeMethod('shareFile', { 'path': filePath, 'name': fileName, }); } // 拉起文件选择器,让用户选一个备份文件回来 Future<String?> pickBackupFile() async { final result = await platformChannel.invokeMethod('pickBackupFile'); return result as String?; }OpenHarmony 侧要做的事情也很直接:收到shareFile时,通过系统分享能力把文件转出去;收到pickBackupFile时,调用系统文件选择能力,把用户选中的文件复制到应用沙箱临时目录,再返回一个 Flutter 侧能直接读的路径。
需要注意一点:Flutter for OpenHarmony 生态里,这两个方法大概率没有现成插件实现,需要你在工程里写平台通道的原生逻辑。这也是跨端开发进入 OpenHarmony 生态后最普遍的工作量来源——Dart 侧的核心业务逻辑很舒服,平台侧能力要靠自己补。
5.3 隐私与加密的取舍
备份文件里装的是用户完整的阅读行为数据,包括笔记内容、阅读时长、书目打分。这属于用户隐私数据,我把隐私顾虑分成了两层处理:
第一层,不申请任何全局存储权限。分享和文件选择都走系统 UI,应用拿不到用户存储空间的整体访问权,这样既满足功能需求,又不会让用户在权限弹窗里感到不安。
第二层,加密预留了空间但没有默认启用。我给备份文件设计了一个字段encrypted,预留了 AES 加密的接口,但当前版本默认不加密。原因很实际:如果用户自己把备份文件传到网盘、聊天记录里,明文 JSON 泄露了笔记内容,风险是真实存在的;但如果默认加密,用户每次恢复都要输密码,而忘记密码等于备份永久不可用。这个取舍我最终选择了把选择权交给用户:设置页里有一个开关,开启后导出文件会用用户设置的密码加密。理解起来就是:默认方便,进阶安全。
6. 实测记录与踩坑复盘:几个值得警惕的细节
6.1 数据库文件路径不能写死
我最初在 Flutter 侧写数据库路径时,想当然地拼接了 OpenHarmony 的沙箱物理路径,类似/data/storage/el2/base/...这种。后来换了一台设备测试,直接出问题——不同系统版本、不同机型,这个路径的细节可能有差异。
正确做法是始终通过getApplicationDocumentsDirectory()获取应用文档目录,把数据库文件和备份文件都放在这个标准目录下。路径这种问题,越早抽象越好。
6.2 WAL 模式与导出时机的坑
这是一个典型的"最危险也最隐蔽"的坑。SQLite 在开启 WAL(Write-Ahead Logging)模式时,最新数据是先写入-wal文件,之后才合并回主数据库文件的。如果你直接拷贝主 db 文件,很可能丢失最近几次写入的数据。
我后来验证 JSON 导出方案时发现,由于是走"事务查询 → 序列化",所有的查询都是通过 SQLite 引擎完成的,引擎会正确处理 WAL 文件里的数据,所以 JSON 导出方案天然绕开了这个坑。这是我在第 2 章坚持不直接拷贝 db 文件的又一个重要原因。
6.3 大数据量导出时的内存问题
看书记录如果积累一两年,reading_records 表几千条甚至几万条都是正常的。一次query('reading_records')把所有记录全部加载到内存,再一次性jsonEncode成一个大字符串,在低端设备上可能出现内存抖动甚至 OOM。
我的数据量目前还在可控范围内,直接一次性导出没有问题。但如果你的表有几十万条记录,建议改成"分批查询 + 流式写入",每次取 1000 条,用IOSink边查边写。实现稍复杂,但在数据量大时是必须的。
6.4 一次"恢复被打断"的现场处理
最后分享一次真实事故。测试恢复功能时,我故意在恢复过程中强制杀掉 App,模拟用户手滑或者系统卡死。重新打开后,数据库处于一种"事务没有提交完成"的状态。
好消息是,前面设计的三层保护起了作用:恢复执行前已经备份了当前数据到 recoveryGuard 目录,所以我在启动流程里加了一个检测——如果发现 recoveryGuard 目录存在且数据库文件异常,自动提示用户"检测到一次未完成的恢复操作,是否回滚到恢复前的数据?"
这个提示救了很多次测试环境。现在我的原则是:任何恢复操作,都先备份现状 → 执行恢复 → 成功则删除备份 → 失败则自动回滚。这套流程虽然多写了不少代码,但它把"恢复"这个高风险操作变成了"兜底的安全操作"。
这次做完之后,我的最大体会是:跨平台开发真正费时间的地方,不在于 Widget 怎么摆,而在于平台能力差异带来的那些小细节——路径、权限、文件交互、插件适配。备份功能在 Android/iOS 上可能两个现成插件就搞定了,在 OpenHarmony 上却需要自己把平台通道打通。但也正因为如此,我把数据从"放在数据库里看不见"变成了"结构清晰、可校验、可迁移的文件",这本身就是一次技术沉淀。
如果你现在也在做 Flutter for OpenHarmony 的实战项目,我的建议是:把平台差异相关的代码尽可能收敛到固定的抽象层里,比如我这里的BackupService、FileShareService,未来生态成熟之后直接替换实现,业务层一行都不用改。这条路现在虽然要自己修修补补,但正是这个阶段,才值得把每一个细节都踩明白。