灯光模拟HarmonyOS应用实战-70-LegacyRecord读到questionText却在下一次保存消失:用MigrationDisposition记录字段去留
灯光模拟HarmonyOS应用实战-70-LegacyRecord读到questionText却在下一次保存消失:用MigrationDisposition记录字段去留
升级后的应用能正常打开旧历史,列表也没有报错。用户再答一题,整个 exam_history 被重新保存;下一次排查旧记录时才发现,原先 JSON 里的 questionText 已经全部不见了。读取没有失败,保存也没有异常,字段却在一次看似普通的读改写中被悄悄删除。
The_kemusan 当前 LegacyRecord 声明了 questionText,但规范化目标 PracticeRecord 没有这个字段,normalizeRecord() 也不复制它。addRecord() 会把规范化后的列表与新记录一起 JSON.stringify(),因此旧字段不会进入新 JSON。要让这种变化可解释,迁移不能只返回一个新对象,还应返回每个旧字段的 MigrationDisposition:保留、改名、推导、主动舍弃或隔离。

这篇文章会把一次隐式字段丢失改造成可预览、可回读、可审计的迁移流程,同时处理项目中两个历史存储类共享同一 key 的协议风险。
一、questionText在旧接口里存在,在当前目标模型里缺席
PracticeStore.ets 的 LegacyRecord 含有 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 | 无 | 有 | 可选 |
ExamHistoryStore | 30 | 有 | 无 | 无 |
字段迁移前应先确立唯一写入所有者。否则一边迁移为新模型,另一边仍可能按旧模型重新写回。
四、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 | 唯一写入所有者规则阻止旧端口 |
| M09 | questionText 含长文本 | 日志只记长度和摘要 |
| M10 | 已迁移 V2 再加载 | 返回无需迁移,不重复改名 |
字段矩阵还应覆盖 subject、mode、选项和 mastered 的默认规则。它们当前已有回退逻辑,但每一个默认值都应有 DERIVE 决定,才能区分“源字段本来就是该值”和“迁移器补出的值”。
十、验证清单、排障表与事实边界
-
exam_history只有一个正式写入所有者。 - 每个旧字段都有
MigrationDisposition。 -
questionText的保留或退役由明确策略决定。 - 未知字段不会默认静默丢弃。
- 默认值都记录为
DERIVE,并带稳定原因码。 - 迁移先形成预览,不借普通新增记录隐式提交。
- 预览绑定来源摘要,来源变化时重新生成。
- 候选写入后回读并逐字段核对。
- 同一输入与策略版本产生相同候选。
- 日志不输出完整题目和答题正文。
- V2 记录再次加载不会重复迁移。
- 30 条与 100 条容量策略已经统一。
| 现象 | 优先核对 | 常见原因 | 修复方向 |
|---|---|---|---|
| 新增一题后旧文本全消失 | 规范化目标字段 | 投影后覆盖正式 key | 先生成字段去留预览 |
| 页面能读、导出少字段 | 序列化对象 | 类型断言被误认为保留字段 | 核对运行时目标对象 |
| 历史突然只剩 30 条 | 写入所有者 | 旧 ExamHistoryStore 被重新接入 | 统一容量与协议 |
| 同一字段有时保留有时删除 | 策略入口 | 调用点各自迁移 | 用版本化目录集中决定 |
| 未知字段升级后消失 | 来源字段目录 | 默认只构造目标对象 | 未登记字段先隔离 |
| 迁移覆盖新答题 | 提交前来源摘要 | 使用过期预览 | 比较摘要,变化则重做 |
| 日志包含完整题干 | 预览输出 | 直接打印原始记录 | 只记字段、动作、长度与摘要 |
当前源码能够确认:LegacyRecord 与 ExamHistoryRecord 都声明 questionText;PracticeRecord 没有该字段;normalizeRecord() 没有复制它;addRecord() 会把规范化后的记录数组重新序列化到同一个 exam_history。还可以确认 ExamHistoryStore 与 PracticeStore 共享存储名和 key,但容量不同,且当前 entry/src/main/ets 未发现 ExamHistoryStore 的其他使用点。
MigrationDisposition、V2 模型、字段目录、迁移预览和候选提交端口都是建议方案,尚未写入 The_kemusan。这次没有读取任何用户设备上的真实历史,没有执行迁移,没有运行构建,没有生成新的 HAP,也没有在模拟器或真机验证升级回读。questionText 最终应保留还是退役属于产品与数据策略决定,不能由示例替团队作出。
更多推荐


所有评论(0)