我是在一次换手机的时候才真正明白,手办收藏数据的价值一点都不比实物低。旧设备里存着两百多条购入记录——手办名称、系列、厂商、购入价、证书编号、开箱照片,甚至还有当时在哪个店淘到的备注。结果系统重置时导出的备份文件不完整,照片目录碎了一半,数据库虽然还在,但那些"当时为什么买它"的细节全乱了。从那时候开始,我下决心给这个基于 Flutter 的手办收藏 App 补上完整的备份与恢复功能,而且是在 OpenHarmony 平台上跑通整套逻辑。
这篇文章就是把这次备份与恢复的实现过程完整记录了下来。涉及数据模型设计、JSON 序列化、gzip 压缩、沙盒文件读写、导入校验、合并策略,以及 Flutter 在 OpenHarmony 上的几个平台适配细节。如果你也在做一个收藏类或记录类的 Flutter 应用,或者说正准备把已有的 Flutter 工程迁移到 OpenHarmony,需要搞定"本地数据导出/导入"这类需求,这篇文章可以直接当参考。
1. 手办收藏数据不是"几条文本记录",丢一次就知道有多痛
1.1 收藏品背后的信息才是真正不可再生的东西
手办实物掉了可以再买,但一串证书编号、一个购入渠道、一张开箱照片,这些信息丢了基本就找不回来了。尤其是限定版手办,你在什么时间、什么价格、从哪个渠道拿到的,这些信息本身就是收藏故事的一部分。
我的 App 里每只手办记录除了名称和照片,还有购入日期、购入价格、证书编号、系列归属、瑕疵备注、购买渠道、附带部件列表。这些字段在录入的时候很耗时,一条可能要正经填五分钟。如果哪一天用户因为换机、误删、App 异常导致本地数据库丢失,几百条记录的信息就彻底没了。
所以在做备份恢复时我给自己提了一个要求:备份文件必须承载完整的语义信息,而不只是"数据库导出一份数据"——要让用户把备份文件交给另一台设备,能够完整还原出当初的收藏页面。
1.2 先定数据模型:备份文件里到底要装哪些信息
动手写备份之前,我先把收藏品的数据结构重新梳理了一遍。这个模型既是 App 界面展示的基础,也是备份文件里每个条目的骨架。
class FigureItem { final String id; // 唯一ID,用UUID final String name; // 手办名称 final String series; // 所属系列 final String manufacturer; // 厂商 final String scale; // 比例,比如1/7 final double purchasePrice; // 购入价格 final DateTime purchaseDate; // 购入日期 final String purchaseChannel; // 购买渠道 final String certNo; // 证书编号 final String condition; // 品相描述 final List<String> mediaPaths; // 本地照片/视频路径列表 final String notes; // 备注 final DateTime createTime; // 创建时间 final DateTime updateTime; // 最近修改时间 }字段里特别要提醒的是updateTime。它平时用于排序展示,但在备份恢复的冲突处理阶段,它会成为非常关键的判断依据,后面我会专门讲。
mediaPaths存的是本地文件路径,备份时有两种处理思路。第一种是把照片原图直接打进备份文件里,安全性最高,但备份体积会变得很大;第二种是只备份路径和相关元信息,恢复后如果照片还在就重新关联,不在就展示占位图。我最终选了第二种,因为手办照片原图动辄几 MB,两百条记录打进去,备份文件会膨胀到几百 MB,不适合做日常快速备份。路径方式加上一个"照片缺失提示"机制,已经能满足收藏用户的实际需求。
1.3 为什么选 JSON 快照式备份,而不是直接复制数据库文件
很多收藏类 App 做备份时图省事,直接把 SQLite 文件拷贝到另一个目录。最开始我也试过这个方案,代码确实少,但踩到几个问题之后放弃了。
最核心的问题是数据库结构升级。我自己的 App 从第一版到现在,数据库表结构已经改过三次:第一次加了updateTime字段,第二次把单张主图的路径拆成了mediaPaths数组,第三次新增了certNo。每一次结构升级之后再拿旧备份恢复,数据库版本不匹配,要么迁移代码要写一堆,要么直接恢复失败。
JSON 快照则要灵活很多。备份文件里除了业务数据,还会带一个version字段。以后数据结构再变,只需要在导入解析时按version做一次映射就行——旧版本备份里的字段怎么迁移到新结构,全部由应用侧控制,数据库本身不需要动。另外 JSON 是文本格式,用户用文件管理器打开还能看到里面的内容,对收藏用户来说这种透明感也更好。
2. 备份功能动手实践:把收藏清单打包成一份可靠的文件
2.1 手办对象到 JSON 的序列化映射
序列化的核心是保证"Java对象转出来的JSON,能原样转回去"。看似简单,但有几个细节容易被忽略,比如空列表不能输出成null,日期时间要用固定的ISO8601格式,数字字段要检查NaN。
Map<String, dynamic> toJson() { return { 'id': id, 'name': name, 'series': series, 'manufacturer': manufacturer, 'scale': scale, 'purchasePrice': purchasePrice, 'purchaseDate': purchaseDate?.toIso8601String(), 'purchaseChannel': purchaseChannel ?? '', 'certNo': certNo ?? '', 'condition': condition ?? '', 'mediaPaths': mediaPaths ?? [], 'notes': notes ?? '', 'createTime': createTime.toIso8601String(), 'updateTime': updateTime.toIso8601String(), }; }我特意把purchaseDate、createTime、updateTime都转成 ISO8601 字符串,而不是时间戳毫秒数。理由很简单:字符串可读性强,用户打开备份文件能直接看懂;时间戳数值还需要一个转换步骤,而且不同平台对时区的处理方式有差异,字符串不容易踩坑。
mediaPaths这种可选字段,序列化时保证至少输出一个空数组,反序列化时就不会出现类型转换异常。备份生成过程中我还会对toJson()的输出做一个统计,比如这次备份有多少条记录、多少条带照片,这些统计信息会一起写进备份文件的头部。
2.2 用 gzip 压缩备份体,几十条手办记录也能明显减肥
备份数据主要是 JSON 文本,里面充满了重复率很高的字段名和系列名。比如两百条记录里,"purchaseChannel" 这个 key 可能出现两百次,同一个系列的字符串也会重复很多次。这种文本对 gzip 压缩非常友好,实测下来压缩率通常能达到 60% 到 70%。
我实现的备份打包流程分三步,每一步都很直接,但加在一起对稳定性和体积都有很大帮助。
Future<File> createBackup({ required List<FigureItem> items, required String appVersion, }) async { final backupData = { 'version': currentBackupVersion, 'app': 'figure_collector', 'appVersion': appVersion, 'exportedAt': DateTime.now().toUtc().toIso8601String(), 'stats': { 'total': items.length, 'withMedia': items.where((e) => e.mediaPaths.isNotEmpty).length, }, 'items': items.map((e) => e.toJson()).toList(), }; final jsonString = jsonEncode(backupData); final compressed = gzip.encode(utf8.encode(jsonString)); final dir = await getBackupDirectory(); final fileName = 'figure_backup_${formatTimestamp(DateTime.now())}.bak'; final file = File('${dir.path}/$fileName'); await file.writeAsBytes(compressed, flush: true); return file; }先utf8.encode把 JSON 字符串变成字节数组,再用gzip.encode压缩,最后writeAsBytes落盘。flush: true确保数据从缓冲区真正写入磁盘,这一步很关键,否则在极端情况下可能落盘一半。
压缩之后的备份文件以.bak为扩展名,不过这只是一个约定。导入恢复的时候我会先读文件头几个字节,判断它是不是 gzip 的魔术字节1f 8b,再决定走压缩解压还是直接按普通解析,这样兼容性更好。
2.3 备份文件落盘:沙盒目录、文件名与元信息的细节
备份文件存到哪里,这个看似基础的问题在 OpenHarmony 上其实值得单独拎出来说一说。Flutter 应用默认只能访问自己的沙盒目录,不能直接往系统公共目录乱写文件。我通过 MethodChannel 在原生端获取应用的 files 目录,具体实现后面平台适配那一节会详细展开。
文件名的设计我也踩过一次坑,最初用过中文名"手办备份_20250115.bak",结果在部分文件管理器里显示乱码,分享到某些应用时文件名解析也出问题。后来改成统一的 ASCII 命名规则:
figure_backup_20250115103000.bak格式是figure_backup_年月日时分秒.bak,如果是用户手动在备份管理页触发的,我会在文件名前加manual_前缀:
manual_figure_backup_20250115103000.bak这样在备份文件列表里,一眼就能看出哪些是手动备份,哪些是自动备份生成的。
备份文件的元信息我也统一写到 JSON 根节点上(version、app、appVersion、exportedAt、stats)。后面做恢复时,恢复界面可以先不动业务数据,仅凭这些元信息展示出"这条备份包含多少条记录、什么时间导出的、App 版本是多少",让用户确认无误后再真正写入。
2.4 把备份文件"送出去":沙盒内文件如何变成用户能保存的东西
备份文件生成后如果一直窝在沙盒目录里,用户根本拿不到,也就失去了"防丢"的意义。我的做法是提供一个"分享备份"入口,点击后调用系统分享面板,把备份文件以临时 URI 的形式交出去。
在 Flutter 侧的接口设计上,我封装成了这样:
Future<void> shareBackupFile(File file) async { final shareUri = await platformChannel.invokeMethod('shareFile', { 'path': file.path, 'fileName': file.uri.pathSegments.last, 'mimeType': 'application/octet-stream', }); // 分享面板由 OpenHarmony 系统原生拉起 }原生端收到这个调起请求后,会把文件路径转成file://协议的临时 URI,再交给系统的分享能力。用户可以把备份文件发到网盘、传到另一台设备,或者存到本机公共下载目录。这个能力本质上就是把应用里的私有数据"释放"到用户自己可控的范围,对收藏类应用来说特别重要。
3. 恢复可不是"读回来写库"那么简单:导入校验与合并策略
3.1 反序列化前的全面校验,不让脏数据混进收藏库
恢复流程的第一步是从文件系统里选中备份文件,然后读取、解压、解析。这里最容易出的问题是:用户选了一个根本不是备份文件的文件进来。所以我在解析之前加了几层校验,顺序也基本固定了。
Future<RestorePlan> parseBackupFile(File file) async { final bytes = await file.readAsBytes(); final isGzip = bytes.length >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b; final raw = isGzip ? utf8.decode(gzip.decode(bytes)) : utf8.decode(bytes); final data = jsonDecode(raw); if (data is! Map<String, dynamic>) { throw BackupParseException('备份文件格式不正确'); } final version = data['version']; if (version is! int || version < 1 || version > currentBackupVersion) { throw BackupParseException('备份文件版本不兼容'); } final appMark = data['app']; if (appMark != 'figure_collector') { throw BackupParseException('不是本应用生成的备份文件'); } final rawItems = data['items']; if (rawItems is! List || rawItems.isEmpty) { throw BackupParseException('备份中没有收藏记录'); } return buildRestorePlan(data); }第一层校验是文件头和 gzip 魔数,确认是不是备份文件;第二层是 JSON 解析,确认整个文件是合法的、根节点是一个 Map;第三层就是版本和来源校验。这三个校验做完,基本能挡住 99% 的误操作。
关键点在于:在反序列化成FigureItem之前,不要贸然把 JSON 转成业务对象。因为如果中途某条数据缺字段,你很难定位到具体是哪一条记录出了问题。先整体解析成Map后,再逐条走字段校验,错误信息里带上索引号,这样用户看到提示时能知道"第几条记录有问题"。
3.2 恢复模式设计:全量覆盖与增量合并
导入校验通过之后,接下来要决定恢复方式。我给恢复功能做了两种模式:覆盖模式和合并模式。覆盖模式适合换新设备后"从备份还原到当时状态";合并模式适合"当前设备已有部分数据,想从备份里补充进来"。
| 对比项 | 覆盖模式 | 合并模式 |
|---|---|---|
| 恢复效果 | 删除现有全部记录,用备份数据重建 | 保留现有记录,按规则插入或更新 |
| 适用场景 | 新设备初始化、数据彻底损坏 | 多设备间数据同步补充 |
| 冲突处理 | 不需要判断,直接覆盖 | 需要逐条比较 updateTime |
| 风险等级 | 高风险,必须二次确认 | 低风险,备份缺失字段不影响现有数据 |
我建议 UI 上把这两种模式做成一个单选步骤,而不是直接默认某一种。因为如果用户选错了模式,恢复结果完全不一样:覆盖模式会把当前数据清空,而合并模式则会把有价值的现有数据保留下来。
覆盖模式执行前必须弹一个确认对话框,提示"当前 N 条记录将被删除,是否继续",并且默认按钮不要选中"立即覆盖",防止误触。这是我测试时总结出来的交互细节,宁可多一步确认,也不要让用户懊悔两步。
3.3 冲突处理:同一条手办记录在新旧设备上都改过怎么办
合并模式里最麻烦的是冲突。同一只手办,在旧设备上把购入价改成了 600,在新设备上又改了备注"带特典"。如果只是简单地把两份数据拼在一起,就会出现同一条记录被重复插入,或者旧数据覆盖新数据的问题。
我的处理策略是以updateTime为准:备份里的记录如果updateTime比本地相同 ID 的记录新,就用备份的数据覆盖本地;如果本地记录更新,就保留本地数据。每一条被跳过或覆盖的记录,恢复结束后都会反馈到结果页里。
FigureItem mergeItem(FigureItem local, FigureItem fromBackup) { final localTime = local.updateTime.millisecondsSinceEpoch; final backupTime = fromBackup.updateTime.millisecondsSinceEpoch; return localTime > backupTime ? local : fromBackup; }这个逻辑看着简单,但在实际实现里我加了一个步骤:在合并模式下,相同的 ID 判断用的是 UUID 字符串,UUID 在导入时保持不变,才能保证合并的准确度。如果导出备份时没有保留原始 ID,而是重新生成了一个,那么合并就会退化成"全部当新数据插入",不但冲突判断失效,还可能出现同一只手办在列表里出现两条的尴尬情况。
所以序列化时一定要把id原样输出,这一点我在备份服务里写死了一个注释:导出时不允许对id做任何重映射。
3.4 恢复后的状态同步与"后悔药"机制
恢复完成后,不是简单地把数据插到数据库就算完事。手办列表页可能还显示着旧的数据缓存,缩略图也可能指向已经被替换的记录。所以我在恢复成功后会做三件事:刷新内存中的收藏列表、清除图片缓存目录里的临时缩略图、刷新界面状态。
更重要的一个设计是"恢复前快照"。无论用户选择覆盖还是合并,我在执行恢复操作前的瞬间,会先把当前数据库里已有的全部记录自动生成一份备份,命名为auto_before_restore_xxx.bak。这个文件就是"后悔药",如果恢复后发现结果不对,或者合并策略产生了意外的数据结果,用户还能拿这份快照回到恢复前的状态。
这个设计最初只是我给自己调试用的小功能,后来发现在真实使用场景里价值非常大。收藏数据这种东西,每一次操作都可能不可逆,多一道保险就有多一份从容。
4. Flutter 在 OpenHarmony 平台落盘时的几个适配细节
4.1 获取沙盒路径:通过平台通道拿原生文件目录
Flutter 的dart:io提供了Directory、File这些文件操作类,但应用沙盒的根目录怎么拿,它不管。OpenHarmony 上暂时没有一个通用插件帮你做这件事,所以最稳妥的做法就是通过 MethodChannel 自己写一个小通道,让原生端返回应用私有目录。
static const channel = MethodChannel('figure_collector/file'); final String filesDir = await channel.invokeMethod('getFilesDir');原生端实现并不复杂,本质上是把系统提供的应用文件目录路径转成一个字符串传回去。我在项目里用的是 ArkTS 写平台通道,几十行代码就完成了。拿到路径后,Flutter 侧就用这个路径拼出备份目录:$filesDir/backups/,目录不存在就先创建。
路径这个环节我踩过一个真实的坑:Flutter 工程在 OpenHarmony 上运行时,应用沙盒目录每次启动可能不完全一致,尤其是在调试安装覆盖之后。所以路径不应该被硬编码,也不能缓存到静态变量里。我改为每次备份操作前都通过 MethodChannel 实时获取一次,虽然多了一次跨通道通信,但确保了落盘点永远正确。
4.2 文件选择器与分享面板的集成方式
恢复功能需要一个"选择备份文件"的入口。OpenHarmony 上有一套公共文件选择能力,但它的触发流程和 Flutter 默认的file_picker插件不同,需要走平台通道间接调用。在原生端拉起文件选择器,拿到用户选中的文件 URI,再读取 URI 对应的文件流。
选择到文件之后,还要注意一点:传入 Flutter 侧的不应该是一个绝对路径字符串,因为某些 URI 指向的内容可能不在应用沙盒内,应用直接使用绝对路径访问会被权限拦截。我在实现中让原生端先通过 URI 打开一个输入流,把文件内容以字节数组返回给 Flutter 侧,这样既不依赖临时文件,也不会碰到路径权限问题。
分享面板的集成方式则稍微不同。需要让系统级分享面板识别到这个文件,必须通过 FileProvider 把沙盒文件转成一个临时可读的 URI 暴露出去。我在原生端写了一个shareFile方法,参数是备份文件的绝对路径,内部完成 FileProvider 的 URI 转换之后再拉起分享界面,Flutter 侧不用感知这些细节。
4.3 备份格式版本化:以后增加字段也不会打垮老用户
备份恢复跨版本兼容是收藏类应用尤其要重视的问题。用户可能一年前导出一份备份,一年后才恢复,中间 App 自己升级了好几个版本,数据结构也可能变了。
我设计了currentBackupVersion这个常量,当前是 1。当前端导入备份时拿到了一个小于等于当前版本号的备份,就会走正常的解析逻辑;如果备份版本号比当前版本还大,说明这是一个未来版本 App 导出的备份,当前版本不认识里面的结构,这种情况直接拒绝恢复,并提示用户升级 App。
对于版本内的字段变化,我有两个原则:新版本如果删除了某个字段,导入时忽略它,不影响旧数据处理;新版本如果增加了字段,旧的备份里没有这个字段,就给默认值。反正校验只检查必填字段,新字段是否是空值完全可接受。这样即使以后我往FigureItem里增加一个新的limitedEdition字段,老备份恢复时也不会报错,顶多新增字段显示为空。
4.4 大备份文件的内存安全与读取策略
备份文件如果包含很多照片路径和备注文字,体积可能达到几 MB 甚至十几 MB。我最初用File.readAsBytes()一次性读取,文件不大时完全没问题。但有一次我拿一个包含三千条记录、每条都带十来张照片路径的备份做测试,JSON 解压后接近 20 MB,内存峰值瞬间上去,在低内存设备上很可能触发卡顿甚至被系统回收。
后续调整后的方案是分级处理。读取阶段先读文件头 2 字节判断 gzip 格式,再分段读整个文件到内存;解压阶段使用gzip.decode的时候,我会先把压缩字节长度打印到日志,如果超过 10 MB 就走流式解压,避免一次性膨胀。实际项目里一般手办收藏用户不太可能有三四千条记录,但既然做系统适配,内存安全还是值得留个心眼。
5. 除了备份恢复本身,收藏管理 App 里值得配套的设计
5.1 自动备份触发时机:别让用户"想起来了才备份"
备份恢复做得再完善,如果用户压根想不起来备份,等于白做。我加了两个自动触发机制。
第一个是启动检查。每天第一次打开 App 时,判断当前时间与最近一次自动备份时间相差是否超过 7 天,如果超过就在界面上弹一条非阻塞提示:"距离上次备份已 7 天,建议尽快备份",用户点一下就可以执行。这种非打扰式的提醒,实测下来比弹窗强制要求更容易被接受。
第二个是危险操作前强制备份。比如用户打算清空收藏列表、批量删除记录、或者执行恢复操作时,App 会先自动生成一份快照,然后再继续操作。这个机制理论上能挽回很多误操作造成的数据损失。
5.2 备份管理页:每个备份文件都看得见、能管理
光有自动备份还不够,用户需要能直观看到自己有哪些备份文件、每个文件多大、什么时候创建的。我在设置页里加了一个"备份管理"入口,进去是一个备份文件列表,每一行显示文件名、文件大小、备份条数和创建时间。
列表支持三种操作:分享、删除、从该备份恢复。恢复操作会跳转到恢复预览页,先展示备份概况,再让用户选覆盖还是合并模式,最后确认执行。这个管理页让我自己用起来非常顺手,测试时也能直接看到每次生成的备份文件是否符合预期。
备份文件还可以单独清理。考虑到用户不可能永远保留所有历史备份,我在管理页里提供了一个"清理 7 天前的自动备份"按钮,保留最近一周内的自动备份以及所有手动备份,防止备份目录无限制膨胀。
5.3 实测中发现的几个备份恢复细节坑
备份恢复这个功能,表面上就是"导出 JSON + 导入 JSON",但实际做完,我发现细节坑比想象中多。整理几个我印象比较深的:
日期时区问题。测试时我在不同时区环境下创建一条手办记录,导出的备份文件里时间字符串带+08:00或Z后缀,恢复后 UI 显示的时间来回偏移。最后的解法是统一用 UTC 时间存储,导出和导入都按 UTC 处理,只有界面展示时才转成本地时区。这个统一约定比任何时区转换库都管用。
照片路径的断链提示。备份文件里只存路径,不存照片本体,换机恢复后原来的照片肯定不在新设备里。为了让用户不至于疑惑,我在恢复完成后的列表页给缺失照片的卡片显示一个"照片缺失"占位图,并提供一个"重新关联照片"的入口。这样既保证了备份文件的轻量,又不至于让用户觉得恢复后功能残缺。
文件选择的类型过滤。有的文件选择器如果不加类型限制,用户可能选到乱七八糟的文件类型,导致解析报错。我在拉起文件选择器时把 MIME 类型限制为application/octet-stream和application/json,虽然不能完全拦截,但至少减少了很多无效选择。
5.4 备份恢复之外,还能顺手扩展的两个方向
这套备份恢复机制做完之后,我发现它还可以很自然地扩展成两个更实用的功能。第一个是"导出收藏清单分享"。备份文件里已经有完整的 JSON 数据,只要再加一个转换模板,就能生成一份纯文本或 Markdown 格式的收藏目录,用户可以分享给同样玩收藏的朋友,或者存成表格文件归档。
第二个方向是数据迁移到云端。备份文件的格式是完全开放的 JSON,只要把备份文件上传到云存储,再在另一台设备上下载并走恢复流程,就相当于完成了云端同步。当然这需要额外处理账号体系和云端存储服务,比单纯本地备份复杂得多,但底层的备份数据格式完全可以复用,不需要重新设计。
以我自己的使用经验来看,手办收藏这类应用的真正护城河不是界面做得多漂亮,而是用户数据放不放心。备份与恢复这个功能可能平时存在感很低,但真到换机、误删、扩容的关键时刻,它决定了一个用户是继续留在你的 App 里,还是转身去用下一个小众收藏软件。至少在目前,我更愿意把这层底线的功夫做扎实。