HarmonyOS 应用实战 53:导入备份别直接覆盖,先做差异预览和冲突选择

用户把一段“题库名#答案1#答案2”的文本粘进《答案之书》时,最危险的时刻并不是解析失败,而是解析成功后直接把当前编辑内容替换掉。用户往往在这时才发现:自己刚改的两条答案没了,导入文本里还有重复项,甚至根本分不清这次导入会新增、删掉还是覆盖什么。

这个项目当前的导入链路其实很克制:DeckImport.parseDeckImport 只负责清洗文本;ImportDeckDialog 把成功结果回调给页面;DeckCreatePage.applyImport 仅把名称和答案填回编辑表单,最终仍需用户点击“创建”,才由 DeckService.save 写入仓储。它尚未实现备份合并或覆盖恢复。本文在这个真实边界上,设计一层可审计的“差异预览”,避免把建议能力误说成现有功能。

在这里插入图片描述

先分清:当前“导入文本”不是“恢复备份”

两者都可能带来一组答案,但提交语义不同:

场景 当前项目的真实行为 用户可撤回性 不能省略的判断
在新建页导入 名称#答案… 解析后回填本页表单 关闭页面即可放弃 文本格式、长度、去重后数量
点击创建 DeckService.save 生成新题库并落库 需删除新题库 名称、最小/最大答案数
从备份恢复到已有题库 当前项目未实现 风险高 新增、修改、删除与冲突

因此,不能拿“解析成功”当作“可以覆盖”。当前 parseDeckImporttrim 每一段、跳过空项和超长项、用 Set 去重,并在有效答案不足最小数量时返回错误;它保护的是输入质量,不知道“当前题库里有哪些旧数据”。差异判断必须在读到目标题库之后进行。

在这里插入图片描述
在这里插入图片描述

为什么直接把解析结果塞回页面仍然不够

DeckCreatePage.applyImport(name, answers) 会为每条导入答案生成新的本地 key,促使 ForEach 重新挂载 TextInput;这是为了解决输入框初始文本只读取一次的 UI 行为。它解决了“页面显示不刷新”,却不代表可以承担恢复规则。

如果以后把“导入”入口开放到编辑已有题库,下面两种做法都会出事:

// 不要在确认导入时就把现有数据清空
this.answers = importedAnswers.map((text: string) => ({ text }));

// 也不要让页面自己猜测是否可覆盖
await DeckRepository.saveDeck(rawDeck);

第一段会让用户失去比较机会;第二段绕过了 DeckService.save 的名称、数量和刷新信号约束。页面适合收集选择,Service 才应决定最终写什么。

预览模型只描述差异,不制造持久化副作用

对于当前项目的文本格式,答案没有跨导入稳定 id,因此第一版只能以“清洗后的文本”识别重复。它足够解释新增与重复,但不能可靠地判断“这条是改名后的旧答案”。这种限制应显式展示给用户。

interface ImportPreview {
  targetDeckId?: string;
  importedName: string;
  additions: string[];
  duplicates: string[];
  skipped: string[];
  canCommit: boolean;
  notices: string[];
}

type ImportStrategy = 'create' | 'append' | 'replace';

这里没有 DeckAppStorage 或 Repository 引用。预览阶段是纯计算:同一份文本无论点几次,都只能得到同一份结果;只有用户选定策略后,才进入写入路径。replace 需要额外确认,因为它会移除目标中未出现的答案。

用现有解析器作为第一道门,而不是另写一套规则

建议能力应复用当前 parseDeckImport,否则“弹窗能导入、恢复入口却拒绝”的规则分叉迟早会发生。以下是可放进 HSP 服务层的示例;其中 DeckService.get 是项目已有读入口。

import { DeckService } from '../services/DeckService';
import { parseDeckImport } from '../utils/DeckImport';

async function buildImportPreview(raw: string, targetDeckId?: string): Promise<ImportPreview> {
  const parsed = parseDeckImport(raw);
  if (!parsed.ok || !parsed.deck) {
    return {
      importedName: '', additions: [], duplicates: [], skipped: [],
      canCommit: false, notices: [parsed.error ?? '导入文本无效']
    };
  }

  const target = targetDeckId ? await DeckService.get(targetDeckId) : null;
  const existing: Set<string> = new Set<string>(target?.answers.map((a) => a.text) ?? []);
  const additions = parsed.deck.answers.filter((text) => !existing.has(text));
  const duplicates = parsed.deck.answers.filter((text) => existing.has(text));
  return {
    targetDeckId, importedName: parsed.deck.name, additions, duplicates, skipped: [],
    canCommit: additions.length > 0 || !target,
    notices: target ? ['按文本比较;改写后的答案会被视为新增'] : []
  };
}

这段代码的关键是读取目标题库后才建立 existing 集合。它信任解析器已完成的格式和长度校验,却不信任页面传来的 targetDeckId:拿不到目标题库时不能悄悄降级为覆盖,应停止提交并提示用户重新选择。

三种策略的边界必须写在提交前

差异预览不是多显示一行“发现 3 条变化”,而是让用户在提交前看到操作含义。

策略 写入结果 适合场景 风险控制
create 调用 DeckService.save 新建题库 从剪贴板导入一套新问题 原题库完全不动
append 保留原答案,只追加 additions 补充题目 重复项不再次写入
replace 以导入后的集合为准 用户明确恢复某一份快照 二次确认并展示删除数量

提交时不要直接调用 DeckRepository。项目中的 DeckService.save 会清洗答案、校验最小/最大数量、生成答案 id、写入 Repository,并在成功后更新 AppStorageKey.LastDeckUpdateAt。这条顺序让列表页等订阅者在“最终事实已经落库”后才重新读取。

async function commitPreview(preview: ImportPreview, strategy: ImportStrategy): Promise<void> {
  if (!preview.canCommit) {
    throw new Error(preview.notices.join(';'));
  }
  if (strategy === 'create') {
    await DeckService.save({ name: preview.importedName, answers: preview.additions });
    return;
  }
  const target = preview.targetDeckId ? await DeckService.get(preview.targetDeckId) : null;
  if (!target) {
    throw new Error('目标题库不存在,停止导入');
  }
  const answers = strategy === 'append'
    ? [...target.answers.map((a) => a.text), ...preview.additions]
    : [...preview.duplicates, ...preview.additions];
  await DeckService.save({ id: target.id, name: target.name, answers, colorKey: target.colorKey });
}

示例故意把 replace 的结果写得直白:它只保留本次导入解析到的答案。真实 UI 还应在按钮上标出“将删除 N 条未出现在导入文本中的答案”,并让用户再次确认;不要用一个模糊的“导入成功”掩盖数据删除。

一个容易漏掉的约束:保存会重建答案 id

当前 DeckService.save 接收 string[],然后为每条答案调用 newId('a') 创建新的 Answer。这意味着用它更新已有题库时,答案的 id 会整体变化。对于当前仅按答案文本收藏的项目,这不一定立即出错;但若后续收藏、分享或统计依赖 answerIdappend/replace 就不能直接复用这条保存接口。

更稳的演进路线是:先把“文本导入”限制在创建题库;只有在领域模型为答案引入稳定身份、并设计清楚旧 id 的保留规则后,再开放已有题库的合并恢复。不要为了一个导入按钮,悄悄破坏收藏和路由参数的引用关系。

页面只负责把预览和确认交给用户

页面层可以保留一个 @State preview 来显示数量与风险,但它不应在 onClick 里自行计算并保存。一个安全的交互顺序是:输入变化时重新预览;点击“继续”时固定本次预览;选择策略后展示确认文案;确认按钮调用 Service;成功后关闭弹窗并让页面按 LastDeckUpdateAt 重读。

粘贴文本 → parseDeckImport → buildImportPreview
        → 用户选择 create / append / replace
        → 明确确认 → DeckService.save → Repository
        → LastDeckUpdateAt → 列表重新读取

特别是不要把完整题库对象塞进 AppStorage 来“传递导入结果”。这里的 AppStorage 只承担刷新信号;跨重启可信的数据仍在 Preferences/Repository 中,页面需要时再从 DeckService 读取。

验证时要故意制造反例

以下检查覆盖的是当前源码事实与上文建议能力的衔接,建议在实现后逐项完成:

  1. 输入空文本、没有 # 分隔、空题库名、少于最小答案数,确认 parseDeckImport 返回可读错误,且不出现写入。
  2. 输入首尾空格、空项、重复答案和超长答案,确认预览数量与解析后的有效答案一致。
  3. 新建模式导入成功后,退出并重新进入题库列表,确认由 DeckService.list 读到新题库。
  4. 目标题库在预览后被删除,点击确认时应报“目标题库不存在”,不能创建一个同名替代品。
  5. appendreplace 分别验证:前者不减少旧答案,后者的删除数与确认文案一致。
  6. 如果以后接入收藏或分享,验证保存后旧 answerId 的处理契约,而不是只看页面能否显示。

常见问题与定位顺序

现象 优先看哪里 根因与修复
提示导入成功却没新增题库 DeckCreatePage 是否只回填表单 当前链路需用户继续点击创建;文案应区分“已填入”和“已保存”
同一答案出现两次 比较前是否使用解析后的文本 统一复用 parseDeckImport 的 trim/去重结果
覆盖后收藏或深链失效 是否通过 save 重建了答案 id 先定义稳定身份和迁移规则,别把覆盖能力提前上线
列表未刷新 刷新信号是否早于持久化 只在 DeckService.save 成功后更新 LastDeckUpdateAt
预览与最终写入数量不同 页面和 Service 各写了一份过滤逻辑 把清洗与策略计算集中到同一服务

排查时先打印“策略、目标题库是否存在、解析后数量、预计新增/删除数量”这些非敏感摘要,不要记录用户完整问题、答案正文或整段导入文本。数量足以追踪状态链路,也不会把用户内容带进日志。

小结

《答案之书》当前的导入能力是安全的“文本回填”,它还不是备份恢复。要把它升级为恢复入口,核心不是新增一个覆盖按钮,而是把解析、差异、策略、提交分成四个不可混淆的阶段:解析器保证输入可用,预览服务解释变化,用户确认策略,DeckService 最后保存并通知重读。

这样做能让每一次删除、追加和新建都有来源,也为以后处理稳定答案 id、收藏迁移和真正的备份文件版本打下边界。本文代码中的预览与策略层为设计示例;是否已经在真机实现、恢复或发布,仍需以实际工程验证为准。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐