ArkData + HarmonyOS 配方备份:校验导入与收藏关系恢复(第 6 讲)【共创稿事节】
咖啡手记已经具备筛选、收藏、自定义配方、集中提交和稳定列表,真正交付之前却仍有一个必须回答的问题:用户准备换设备,复制了一段配方备份文本,如何确认它不会覆盖内置配方、制造重复 ID,或者把已经不存在的配方重新放进收藏列表?如果只在“导入”按钮里调用 JSON.parse() 再把数组赋给 @State,页面也许能立即出现几张卡片,但下一次启动、删除配方和开启收藏筛选时就可能暴露数据断裂。
这篇作为 CoffeeNotes 连载的收官,不重新设计一套应用,也不假定有云端账号服务。我们延续内置 latte、cold、pour 三个稳定 ID,自建配方采用 u_N,实体仍为 CoffeeRecipe{id,name,category,ratio,steps};收藏只存配方 ID。目标是实现可阅读的文本备份、导入预览、结构校验、关系修复和单键提交。导出采用页面上的文本区域,用户自行复制;导入通过粘贴文本,不需要文件选择器或存储权限,避免将文件系统能力和首选项存储混为一谈。

一、备份的边界:只保存可重建的业务事实
在第五篇,搜索关键词、冷热分类和“仅看收藏”都属于页面视角。它们可以清空后重新计算,绝不应该跟配方备份绑定。要带走的只有用户自己录入的配方与收藏关系:三条内置配方由程序内置,备份里不重复保存;用户新建的配方作为 userRecipes;收藏列表作为 favoriteIds。这样无论新设备上 UI 怎么变化,导入格式仍然具有清晰的业务含义。
定义文本格式 formatVersion=1,并显式约束用户配方上限100条、收藏ID上限200个、备份文本最大100000字符。限制不是为了人为减少功能,而是为了防止一份异常输入让同步解析长期阻塞 UI 线程。应用把空收藏视为合法结果,却不能把缺失 favoriteIds 字段自动当空数组:缺失可能是复制截断,静默修复会掩盖真实问题。三个内置 ID 和自建 ID 的命名空间也不能重叠,否则下次仓储合并时将出现同一 ID 对应两份实体。
// model/BackupModel.ets:沿用此前的 CoffeeRecipe 类型
import { CoffeeRecipe } from './CoffeeRecipe';
export interface RecipeBackup {
formatVersion: number;
userRecipes: CoffeeRecipe[];
favoriteIds: string[];
}
const BUILTIN_IDS: string[] = ['latte', 'cold', 'pour'];
export function validateBackup(data: RecipeBackup): RecipeBackup {
if (data === null || typeof data !== 'object' || data.formatVersion !== 1 ||
!Array.isArray(data.userRecipes) || !Array.isArray(data.favoriteIds) ||
data.userRecipes.length > 100 || data.favoriteIds.length > 200) {
throw new Error('备份结构或版本不受支持');
}
const seen: Set<string> = new Set<string>();
data.userRecipes.forEach((r: CoffeeRecipe) => {
if (r === null || typeof r !== 'object' || typeof r.id !== 'string' ||
!/^u_[1-9][0-9]*$/.test(r.id) || seen.has(r.id) ||
typeof r.name !== 'string' || r.name.trim().length === 0 ||
r.name.length > 24 || typeof r.ratio !== 'string' ||
r.ratio.length > 100 ||
(r.category !== '热饮' && r.category !== '冷饮') ||
!Array.isArray(r.steps) || r.steps.length > 12 ||
r.steps.some((s: string) => typeof s !== 'string' || s.length > 120)) {
throw new Error('配方字段、步骤或ID无效');
}
seen.add(r.id);
});
const permitted: string[] = [...BUILTIN_IDS, ...Array.from(seen)];
if (new Set(data.favoriteIds).size !== data.favoriteIds.length ||
data.favoriteIds.some((id: string) => typeof id !== 'string' ||
!permitted.includes(id))) {
throw new Error('收藏存在重复或悬挂引用');
}
return data;
}
这段校验采用明确字段而不是在每个按钮里写一段临时判断。特别要注意 JSON.parse() 成功并不代表类型正确,类型断言也不负责运行时验证。生产项目若要接收完全不可信的外部文本,应进一步逐层验证每个元素是否是对象及其属性类型;上面的示例将有限制的业务备份作为输入契约,配合长度限制和异常捕获。所有错误最终都变成用户可理解的提示,而不是将原始异常堆栈展示到页面。

二、让导入拥有“预览—确认—提交”三个阶段
导入应当允许用户后悔。粘贴后先验证并预览“自建配方3条、收藏2条”等结构摘要,确认前完全不触碰 Repository;若编辑了文本,即刻作废上一次预览。这样可防止用户在预览通过后继续粘贴一段新数据,却沿用旧的“确认导入”资格。这里采用覆盖本机自建配方策略,不做看似省事的按名称合并。名称不是唯一键;两台设备各自生成的 u_1 甚至可能表示不同配方,直接合并会使收藏指错对象。如果未来实现跨设备合并,需要新的全局标识与冲突决策,而不是在本地导入流程里偷加一个数组拼接。
// utils/BackupCodec.ets
import { RecipeBackup, validateBackup } from '../model/BackupModel';
export function decodeBackup(text: string): RecipeBackup {
if (text.trim().length === 0 || text.length > 100000) {
throw new Error('备份为空或超过限制');
}
const candidate: RecipeBackup = JSON.parse(text) as RecipeBackup;
return validateBackup(candidate);
}
export function encodeBackup(data: RecipeBackup): string {
return JSON.stringify(validateBackup(data), null, 2);
}
导出时先从 Repository 取最后成功提交的配方和收藏数组,构造 formatVersion=1 的对象,再调用 encodeBackup()。只展示文本,不自动复制到剪贴板,更不上传服务器。这样的设计有一个明确收益:测试人员可以保存一个合法的最小文本,逐个改坏版本号、配方 ID 或收藏关系,马上检查导入预览如何拒绝它,而不需要额外制造一个外部文件服务。
导入的最终写入采用一个新的规范状态键 recipe_state_v2,一次序列化包含全部自建配方与收藏。原先 user_recipes_v1 和 favorite_ids_v1 可以继续保留作老版本读取入口,但新版本 Repository 必须优先读取 v2,且首次从旧键加载后先校验并迁移,不能让旧键覆盖新键。如果依旧分开向两个旧键写入,就有机会出现“配方已导入、收藏还没导入”的分裂状态。迁移期间尤其要保证老版本读者和新版本读者不会同时写两套键:应用启动只选择一个权威读取路径,完成升级后所有新增与修改都落在新键上,并在回归测试中覆盖从老数据升级后再次启动的情形。单键并不等同数据库级事务,但能减少多个键之间的局部提交窗口。
// data/BackupRepository.ets
import { preferences } from '@kit.ArkData';
import { RecipeBackup, validateBackup } from '../model/BackupModel';
export class BackupRepository {
private db: preferences.Preferences;
private readonly KEY: string = 'recipe_state_v2';
constructor(context: Context) {
this.db = preferences.getPreferencesSync(context, { name: 'coffee_notes' });
}
loadNew(): RecipeBackup | undefined {
const raw: string = this.db.getSync(this.KEY, '') as string;
if (!raw) { return undefined; }
return validateBackup(JSON.parse(raw) as RecipeBackup);
}
async replace(data: RecipeBackup): Promise<void> {
const valid: RecipeBackup = validateBackup(data);
await this.db.put(this.KEY, JSON.stringify(valid));
await this.db.flush();
}
}
put() 改变的是当前 Preferences 实例缓存,flush() 才将其持久化;两者应进入同一个 try/catch。若写盘失败,页面不要立即替换正式的 recipes 和 favoriteIds,而应继续显示最后一次可信数据、提示“保存失败,请重试”。还须理解缓存可能已被 put 修改,因此严谨的恢复实现应维护上一份成功的序列化文本,在失败后尝试恢复并重新刷盘;若回退也失败,标记存储不可用,禁止继续发布“已恢复”的状态。这里不把一次 flush() 包装成强一致性保证。有关缓存、写入与刷盘的语义可直接对照华为官方 Preferences 持久化指南 和 API 参考。

三、页面只收集动作,不负责决定数据真伪
在 entry/src/main/ets/pages/BackupPage.ets 中,输入框绑定 @State 文本,预览后暂存校验通过的规范 JSON,真正提交时再复核一次。页面负责反馈与提交时禁用按钮;仓储负责持久化;返回首页后仍由第五篇的 selectRecipes() 根据当前筛选条件重新计算可见列表。这样导入不会偷偷改变用户正在使用的“热饮”或“仅看收藏”过滤条件,也不会产生数组下标错位。
// pages/BackupPage.ets 核心页面;页面需通过项目导航接入
import { decodeBackup } from '../utils/BackupCodec';
import { BackupRepository } from '../data/BackupRepository';
import { RecipeBackup } from '../model/BackupModel';
@Entry
@Component
struct BackupPage {
@State inputText: string = '';
@State previewText: string = '';
@State message: string = '粘贴备份文本并预览';
@State saving: boolean = false;
private repository: BackupRepository = new BackupRepository(getContext(this));
private preview(): void {
try {
const candidate: RecipeBackup = decodeBackup(this.inputText);
this.previewText = JSON.stringify(candidate);
this.message = `可导入:${candidate.userRecipes.length}条配方、` +
`${candidate.favoriteIds.length}条收藏;确认后将覆盖本机自建配方`;
} catch (e) {
this.previewText = '';
this.message = '文本校验失败,请检查版本、ID和收藏引用';
}
}
private async confirm(): Promise<void> {
if (this.saving || this.previewText.length === 0) { return; }
this.saving = true;
try {
await this.repository.replace(decodeBackup(this.previewText));
this.message = '导入已保存,请返回首页刷新';
this.previewText = '';
} catch (e) {
this.message = '写入失败,原有页面内容保持不变';
} finally { this.saving = false; }
}
build() {
Column({ space: 18 }) {
Text('咖啡手记 · 备份恢复').fontSize(26).fontWeight(FontWeight.Bold)
Text('备份文本不会自动上传').fontColor('#876B57')
TextArea({ text: this.inputText, placeholder: '粘贴 JSON 备份' })
.height(310).onChange((value: string) => {
this.inputText = value;
this.previewText = '';
})
Button('校验并预览').onClick(() => this.preview())
Text(this.message).fontSize(15)
Button('确认覆盖并恢复').enabled(this.previewText.length > 0 && !this.saving)
.onClick(() => { this.confirm(); })
}.padding(24).width('100%')
}
}
完整工程可以把页面的 BackupRepository 构造移到统一依赖入口,防止多个页面分别维护 Preferences 实例;当用户确认时,首页应重新 loadNew(),合并三条内置配方与导入的 userRecipes,再调用原有的 selectRecipes(),不要直接把导入前的 visibleRecipes 继续显示。若应用只允许一个顶层 @Entry 路由页面,应把本页的 @Entry 调整为符合工程导航配置的入口,不在组件里额外伪造第二个运行入口。
一个很容易遗漏的错误是“预览可以通过,但写完后并没有重新解析磁盘状态”。只检查按钮弹出的成功提示没有意义;应退出重启应用,再查看自建配方、收藏标识和稳定ID是否一致。展示的 P 图是符合预期界面结构的设计视图,不应被拿来当编译或运行成功的记录。
四、把边界案例变成可重复走查表
第一组输入是空配方、空收藏的合法备份;确认后应只剩三个内置配方。第二组是两条 u_1/u_2 自建配方和 ['latte','u_2'] 收藏;导入后选“仅看收藏”应显示燕麦拿铁和对应自建项,取消收藏条件时还应看到其他数据。第三组将 u_2 删除却保留收藏 u_2,必须在预览阶段被拒绝,不能依赖渲染层自行过滤。第四组把 formatVersion 改为9、给出重复ID、非法冷热类别和长度超限字符串,所有情况都不得改变正式配方。
还要考虑备份传递途中被手动修改的情况:即使两个配方名称完全相同,只要ID不同也应按不同记录处理;反过来,同一ID对应两份不同内容则必须拒绝。收藏是引用关系而不是展示文案,绝不能将名称相等当作引用有效。对于历史版本中已存在的不合法记录,优先保留原数据并提示修复,不应在未经确认的情况下自动执行覆盖式清理。
第五组更贴近真实事故:用户打开合法文本预览后又修改其中某个字,确认按钮必须重新禁用;第六组让 flush() 抛错,页面依然展示旧数据并允许有条件重试。第七组在此前已经选择“冷饮、只看收藏”的情况下导入覆盖文本,返回首页后必须重新计算交集而不是缓存旧结果。上述核查不能仅比较配方条数,还应逐项检查 ID、比例、步骤顺序和收藏引用。ForEach 的键继续使用 item.id,不使用数组下标;华为官方 ForEach 指南 说明了为何稳定键可以减少错位和组件误复用。TextArea 的多行输入与 onChange 绑定详见 TextArea API。

五、六篇连载最终形成了什么
从第01篇的配方卡片和收藏状态,到第02篇 Preferences 的偏好恢复、第03篇自定义配方增改删、第04篇单向组件通信和集中提交、第05篇组合检索,本篇只增加一个有明确边界的备份恢复通道,没有让数据模型重新分叉。对单设备、几十条个人配方的工具来说,这套架构的价值在于每个可见状态都能追溯到一个稳定业务事实;所有写入都经过校验;页面展示失败与持久化失败有不同语义。
需要强调适用边界:文本备份包含用户手工记录的内容,应该提醒用户妥善保管,不应在公共日志里打印;Preferences 是轻量 KV 存储,不适合把大量图片、跨设备同步冲突或敏感凭据塞进一份 JSON。若未来接入云端同步,必须增加全局记录标识、版本向量或其他明确的冲突解决协议,不能把“导入覆盖”直接当作云同步。至此,“咖啡手记”形成从列表操作到可解释恢复的完整单设备工程闭环。
更多推荐




所有评论(0)