灯光模拟HarmonyOS应用实战-70-LegacyRecord读到questionText却在下一次保存消失:用MigrationDisposition记录字段去留

升级后的应用能正常打开旧历史,列表也没有报错。用户再答一题,整个 exam_history 被重新保存;下一次排查旧记录时才发现,原先 JSON 里的 questionText 已经全部不见了。读取没有失败,保存也没有异常,字段却在一次看似普通的读改写中被悄悄删除。

The_kemusan 当前 LegacyRecord 声明了 questionText,但规范化目标 PracticeRecord 没有这个字段,normalizeRecord() 也不复制它。addRecord() 会把规范化后的列表与新记录一起 JSON.stringify(),因此旧字段不会进入新 JSON。要让这种变化可解释,迁移不能只返回一个新对象,还应返回每个旧字段的 MigrationDisposition:保留、改名、推导、主动舍弃或隔离。

历史字段去留封面

这篇文章会把一次隐式字段丢失改造成可预览、可回读、可审计的迁移流程,同时处理项目中两个历史存储类共享同一 key 的协议风险。

一、questionText在旧接口里存在,在当前目标模型里缺席

PracticeStore.etsLegacyRecord 含有 questionText: string。同文件导入的 PracticeRecord 则只包含 ID、科目、模式、结果、分数、原因、错题 ID、选项、时间和掌握状态,没有题目文本字段。

interface LegacyRecord {
  id: string;
  subject?: string;
  mode?: string;
  passed: boolean;
  score: number;
  total: number;
  reason: string;
  questionText: string;
  durationSeconds: number;
  createdAt: number;
}

normalizeRecord() 接收 LegacyRecord 后只挑选目标字段构造 PracticeRecord。这属于投影,不是原样读取。即使 JSON.parse() 得到的运行时对象仍带 questionText,返回的 PracticeRecord 数组已经不再包含它。

阶段questionText 是否存在说明
Preferences 原始 JSON可能存在取决于历史版本
JSON.parse() 结果存在于对应旧对象类型断言不会删除字段
normalizeRecord() 返回值不存在构造对象时没有复制
页面 records不存在类型和运行时值都已投影
下一次 JSON.stringify()不存在只能序列化当前对象字段

字段消失不是 JSON 库的行为,而是旧协议到新协议缺少去留决策。

二、下一次addRecord会把隐式投影写回正式key

旧记录字段消失流程

当前 listRecords() 先解析原始字符串,再逐条调用 normalizeRecord()addRecord() 随后读取这批规范化对象,把新记录插到开头,截取最多 100 条并覆盖 exam_history

async addRecord(record: PracticeRecord): Promise<PracticeRecord[]> {
  const records = await this.listRecords();
  records.unshift(record);
  const nextRecords = records.slice(0, MAX_HISTORY_COUNT);
  await this.putString(
    HISTORY_KEY,
    JSON.stringify(nextRecords)
  );
  return nextRecords;
}

这意味着“再答一道题”同时承担了历史迁移提交。用户没有看到迁移预览,程序也没有记录删除了哪些字段。只要新写入成功,旧 JSON 中所有未进入 PracticeRecord 的属性都会消失,而不仅限于 questionText

更安全的边界是:读取可以生成迁移候选,但普通业务新增不应在没有迁移回执时顺便改写全部旧数据。

三、同一个exam_history还有第二套记录协议

项目还定义了 ExamHistoryStore。它使用与 PracticeStore 相同的存储名 kemusan_exam_store 和相同 key exam_history,但记录接口 ExamHistoryRecord 包含 questionText,最大条数是 30;PracticeStore 的最大条数则是 100。

// ExamHistoryStore.ets
const STORE_NAME: string = 'kemusan_exam_store';
const HISTORY_KEY: string = 'exam_history';
const MAX_HISTORY_COUNT: number = 30;

export interface ExamHistoryRecord {
  id: string;
  passed: boolean;
  score: number;
  total: number;
  reason: string;
  questionText: string;
  durationSeconds: number;
  createdAt: number;
}

当前 entry/src/main/ets 中没有发现其他文件导入或实例化 ExamHistoryStore,所以不能说它正在运行时覆盖数据。但两套类对同一 key 的字段和容量理解不同,未来一旦重新接入,就会形成协议竞争。

存储类最大条数题目文本科目/模式掌握状态
PracticeStore 目标模型100可选
ExamHistoryStore30

字段迁移前应先确立唯一写入所有者。否则一边迁移为新模型,另一边仍可能按旧模型重新写回。

四、MigrationDisposition把每个字段的决定写出来

迁移决策、候选载荷与提交结构

迁移结果不能只有“成功对象”。每个来源字段都应有稳定决定和原因码,便于预览与回归。

export enum MigrationAction {
  KEEP = 'keep',
  RENAME = 'rename',
  DERIVE = 'derive',
  DROP = 'drop',
  QUARANTINE = 'quarantine'
}

export interface MigrationDisposition {
  sourceField: string;
  targetField: string;
  action: MigrationAction;
  reasonCode: string;
}

export interface RecordMigrationResult {
  recordId: string;
  candidate: PracticeRecord | null;
  dispositions: MigrationDisposition[];
}

KEEP 表示语义与字段名都不变;RENAME 表示语义不变但目标字段改名;DERIVE 表示目标值由多个来源字段按明确规则计算;DROP 必须有已批准原因;QUARANTINE 表示无法安全决定,整条或相关片段不进入正常候选。

“目标模型没有这个属性”不是足够的 DROP 原因。团队要先决定旧题目文本仍用于复盘、审计或问题定位,还是因数据最小化要求主动删除。两种决定都可以实现,但必须留下版本与原因。

五、questionText可以保留为快照,也可以明确退役

如果历史复盘需要展示当时题目,应把旧文本改名为 questionSnapshot,同时保留稳定题目 ID。快照代表当时内容,不能被当前题库文案覆盖。

export interface PracticeRecordV2 {
  id: string;
  subject: string;
  mode: string;
  passed: boolean;
  score: number;
  total: number;
  reason: string;
  wrongQuestionId: string;
  correctOption: string;
  selectedOption: string;
  mastered?: boolean;
  questionSnapshot?: string;
  durationSeconds: number;
  createdAt: number;
}

const QUESTION_TEXT_RENAME: MigrationDisposition = {
  sourceField: 'questionText',
  targetField: 'questionSnapshot',
  action: MigrationAction.RENAME,
  reasonCode: 'PRESERVE_HISTORICAL_PROMPT'
};

如果产品确认文本不再需要,应产生 DROP 决策,并在报告中统计受影响记录数,不要把正文写进日志。

const QUESTION_TEXT_DROP: MigrationDisposition = {
  sourceField: 'questionText',
  targetField: '',
  action: MigrationAction.DROP,
  reasonCode: 'RETENTION_POLICY_V2'
};

两种策略不能在不同调用点随意选择。迁移版本一旦发布,同一输入在同一策略版本下必须得到同一候选和同一去留报告。

六、迁移函数要遍历已知字段并报告未知字段

仅手写目标对象仍可能漏掉未来新增字段。可以维护来源字段目录,逐项生成决定;发现未登记字段时先隔离或阻止提交,而不是默认丢弃。

const LEGACY_V1_FIELDS: string[] = [
  'id', 'subject', 'mode', 'passed', 'score', 'total',
  'reason', 'questionText', 'wrongQuestionId',
  'correctOption', 'selectedOption', 'durationSeconds',
  'createdAt', 'mastered'
];

function findUnknownFields(raw: Record<string, Object>): string[] {
  const unknown: string[] = [];
  const keys: string[] = Object.keys(raw);
  for (let index: number = 0; index < keys.length; index++) {
    if (LEGACY_V1_FIELDS.indexOf(keys[index]) < 0) {
      unknown.push(keys[index]);
    }
  }
  return unknown;
}

ArkTS 对 Object.keys()、索引签名和 unknown 的支持形式要按项目工具链调整;示例表达的是协议约束:未知字段必须进入报告。若直接兼容性保存未知字段,也应把它们放进受控扩展区,避免无界复制任意载荷。

七、先生成MigrationPreview,再决定是否覆盖正式数据

迁移预览需要同时汇总记录与字段,而不是展示完整答题内容。建议保存来源摘要、策略版本、各动作数量、隔离记录 ID 和候选摘要。

export interface MigrationPreview {
  sourceDigest: string;
  policyVersion: number;
  sourceRecordCount: number;
  candidateRecordCount: number;
  keepCount: number;
  renameCount: number;
  deriveCount: number;
  dropCount: number;
  quarantineRecordIds: string[];
  candidateJson: string;
}

function canAutoCommit(preview: MigrationPreview): boolean {
  return preview.quarantineRecordIds.length === 0 &&
    preview.sourceRecordCount === preview.candidateRecordCount;
}

是否允许自动提交还要结合业务策略。例如主动删除 questionText 即使没有隔离项,也可能需要一次版本迁移确认。candidateJson 可以存在内存或受控暂存区,日志只记计数与摘要。

新增历史时如果发现正式数据仍是旧版本,可以先返回“迁移待处理”,或者在经过批准的无损策略下迁移并提交。不能继续用普通 addRecord() 默默完成协议升级。

八、写入前后都要比较来源和候选

迁移从读取到提交之间可能新增答题记录。预览必须绑定 sourceDigest,提交前重新读取正式 key;来源变化就废弃旧预览。候选写入后还要回读解析并重新统计字段决定。

async function commitMigration(preview: MigrationPreview,
  port: HistoryMigrationPort): Promise<string> {
  const currentRaw: string = await port.readCurrent();
  if (digest(currentRaw) !== preview.sourceDigest) {
    return 'SOURCE_CHANGED';
  }
  await port.writeCandidate(preview.candidateJson);
  const candidateReadback: string = await port.readCandidate();
  if (candidateReadback !== preview.candidateJson) {
    return 'CANDIDATE_MISMATCH';
  }
  await port.replaceCurrent(candidateReadback);
  const finalRaw: string = await port.readCurrent();
  return finalRaw === candidateReadback
    ? 'COMMITTED'
    : 'FINAL_READBACK_MISMATCH';
}

这段端口表达业务步骤,不代表 Preferences 多 key 操作天然具有事务性。真正实现需要查明目标 SDK 的写入与故障语义,并用进程终止实验验证每个中断点。若不能提供原子替换,就要保留可恢复标记和幂等重试策略。

九、迁移回归要把字段去留当作一等断言

普通“能读出一条记录”不会发现字段消失。用例必须检查动作清单、候选 JSON 和重复执行结果。

用例输入关键断言
M01完整旧记录含 questionText产生 RENAME 或已批准 DROP
M02旧记录缺少可选字段产生明确 DERIVE,不伪造必填事实
M03出现未知字段报告未知项,不静默删除
M04多条记录都含旧文本动作计数与记录数一致
M05预览后新增记录返回 SOURCE_CHANGED
M06候选回读不同不覆盖正式 key
M07同输入同策略执行两次候选字节与动作报告一致
M08两个存储类尝试写同一 key唯一写入所有者规则阻止旧端口
M09questionText 含长文本日志只记长度和摘要
M10已迁移 V2 再加载返回无需迁移,不重复改名

字段矩阵还应覆盖 subjectmode、选项和 mastered 的默认规则。它们当前已有回退逻辑,但每一个默认值都应有 DERIVE 决定,才能区分“源字段本来就是该值”和“迁移器补出的值”。

十、验证清单、排障表与事实边界

  • exam_history 只有一个正式写入所有者。
  • 每个旧字段都有 MigrationDisposition
  • questionText 的保留或退役由明确策略决定。
  • 未知字段不会默认静默丢弃。
  • 默认值都记录为 DERIVE,并带稳定原因码。
  • 迁移先形成预览,不借普通新增记录隐式提交。
  • 预览绑定来源摘要,来源变化时重新生成。
  • 候选写入后回读并逐字段核对。
  • 同一输入与策略版本产生相同候选。
  • 日志不输出完整题目和答题正文。
  • V2 记录再次加载不会重复迁移。
  • 30 条与 100 条容量策略已经统一。
现象优先核对常见原因修复方向
新增一题后旧文本全消失规范化目标字段投影后覆盖正式 key先生成字段去留预览
页面能读、导出少字段序列化对象类型断言被误认为保留字段核对运行时目标对象
历史突然只剩 30 条写入所有者ExamHistoryStore 被重新接入统一容量与协议
同一字段有时保留有时删除策略入口调用点各自迁移用版本化目录集中决定
未知字段升级后消失来源字段目录默认只构造目标对象未登记字段先隔离
迁移覆盖新答题提交前来源摘要使用过期预览比较摘要,变化则重做
日志包含完整题干预览输出直接打印原始记录只记字段、动作、长度与摘要

当前源码能够确认:LegacyRecordExamHistoryRecord 都声明 questionTextPracticeRecord 没有该字段;normalizeRecord() 没有复制它;addRecord() 会把规范化后的记录数组重新序列化到同一个 exam_history。还可以确认 ExamHistoryStorePracticeStore 共享存储名和 key,但容量不同,且当前 entry/src/main/ets 未发现 ExamHistoryStore 的其他使用点。

MigrationDisposition、V2 模型、字段目录、迁移预览和候选提交端口都是建议方案,尚未写入 The_kemusan。这次没有读取任何用户设备上的真实历史,没有执行迁移,没有运行构建,没有生成新的 HAP,也没有在模拟器或真机验证升级回读。questionText 最终应保留还是退役属于产品与数据策略决定,不能由示例替团队作出。

Logo

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

更多推荐