HarmonyOS问卷引擎连载03:规则升级与旧草稿迁移【共创稿事节】
一份课程评价问卷刚发布时,题目编号和选项往往比较稳定;教研组后来调整调查口径,旧草稿就成了容易被忽略的问题。有人在第二版填过“参与实践多次”,第三版把题目从Q2改为Q2_FREQ;另一道满意度题从“非常满意、一般、不满意”改成“满意、一般、不满意”。如果只把旧答案对象直接塞进新页面,界面可能显示不出某些回答,提交数据里却仍携带旧的选项值。上一节已经把规则依赖图做了拓扑排序,并拦住循环引用。本节继续处理规则升级后的草稿迁移,重点是识别哪些答案能继承,哪些需要用户重答,以及新旧草稿如何隔离保存。

草稿必须携带规则版本
假设第二版问卷含三个字段:Q1询问是否有实践,Q2询问实践次数,Q3询问满意度。旧草稿记录Q1=有、Q2=多次、Q3=非常满意。第三版发布时,将实践次数改名为Q2_FREQ,而Q3取消“非常满意”选项。此时Q1可以直接继承,Q2需要按明确的字段身份映射迁移,Q3应列入待重新回答。没有版本号便不能判断“Q2”来自哪套规则,更不能凭同一个标题把不兼容的题目答案误配。
一个可靠的草稿快照至少记录问卷标识、规则版本、写入修订号和答案列表。序列化时尽量使用对象数组而不是Map,因为JSON对Map的默认转换不会保留其中的题目与答案。若新问卷增加了只能多选的字段,而旧版保存的是单选字符串,也需要更具体的转换策略;不能把任何可转换的字符串都当成合法答案。
entry/src/main/ets/
├── model/SurveyDraft.ets
├── model/QuestionRule.ets
├── rules/DependencyGraph.ets
├── rules/VisibilityEngine.ets
├── service/DraftMigration.ets
├── service/DraftRepository.ets
└── pages/DraftUpgradePage.ets
先把迁移输入和输出写清楚。映射表是从发布配置中读取的业务数据,不应由应用根据题目展示顺序临时猜测。尤其不能用第几题来表示字段身份;新增一道说明题以后,题目排列顺序就会发生变化。
// model/SurveyDraft.ets
export interface AnswerRow {
questionId: string;
value: string;
}
export interface SurveyDraft {
surveyId: string;
ruleVersion: number;
revision: number;
answers: AnswerRow[];
}
export interface AnswerMapping {
sourceId: string;
targetId: string;
}
export interface NextQuestion {
id: string;
allowedValues: string[];
dependsOnId: string;
equalsValue: string;
}
export interface MigrationReport {
nextDraft: SurveyDraft;
needsReviewIds: string[];
discardedIds: string[];
}
本节刻意把映射关系与可选值校验拆开。Q2改名为Q2_FREQ,只说明目标字段身份,仍需检查“多次”是否在新选项集合中。Q3虽然编号没变,旧答案“非常满意”在新版本已经不合法,所以不能继承。旧答案是历史填写记录,新答案是待提交的新规则数据,两者不应共用同一个可变数组。
迁移先看规则,再看答案
新规则必须先经过上一节的依赖检查。若发布配置中存在循环或悬空依赖,即便某些字段能迁移,也不能把结果写为正式的新草稿。成功取得拓扑顺序后,先按字段映射挑出候选答案,再沿依赖顺序筛除隐藏题的数据。这会同时处理“选项仍合法,但前置条件已不成立”的情况。
下面的纯函数只生成迁移报告,不触碰存储。为了避免同一目标字段收到两条旧答案,使用集合拦截目标重复映射;不存在目标题目的源字段进入丢弃列表。对字段名称有变化但值域没有变化的情况,可通过映射保留答案;值域变化但缺少经过业务确认的选项对照表时,宁可要求重答。
// service/DraftMigration.ets
import { SurveyDraft, AnswerMapping, NextQuestion,
MigrationReport, AnswerRow } from '../model/SurveyDraft';
export function migrateDraft(
oldDraft: SurveyDraft, targetVersion: number,
questions: NextQuestion[], topologicalIds: string[],
mappings: AnswerMapping[]
): MigrationReport {
if (targetVersion <= oldDraft.ruleVersion) {
throw new Error('目标版本必须高于草稿版本');
}
const byId: Map<string, NextQuestion> = new Map();
questions.forEach((q: NextQuestion) => byId.set(q.id, q));
const candidate: Map<string, string> = new Map();
const discarded: string[] = [];
const review: string[] = [];
for (const row of oldDraft.answers) {
const rule = mappings.find((m: AnswerMapping): boolean =>
m.sourceId === row.questionId);
const id = rule ? rule.targetId : row.questionId;
const target = byId.get(id);
if (!target || candidate.has(id)) {
discarded.push(row.questionId);
continue;
}
if (!target.allowedValues.includes(row.value)) {
review.push(id);
continue;
}
candidate.set(id, row.value);
}
const visible: Map<string, string> = new Map();
for (const id of topologicalIds) {
const q = byId.get(id);
if (!q) continue;
const show = q.dependsOnId === '' ||
visible.get(q.dependsOnId) === q.equalsValue;
if (!show) {
if (candidate.has(id)) discarded.push(id);
continue;
}
const value = candidate.get(id);
if (value !== undefined) visible.set(id, value);
}
const rows: AnswerRow[] = [];
visible.forEach((value: string, id: string) => {
rows.push({ questionId: id, value });
});
return {
nextDraft: { surveyId: oldDraft.surveyId,
ruleVersion: targetVersion, revision: 1, answers: rows },
needsReviewIds: review, discardedIds: discarded
};
}
这里的topologicalIds不能用未经检查的原始题目数组直接代替。服务入口应先确认依赖图没有缺失引用与环,再把排列结果交给迁移函数。进一步的细节是:若Q3原答案无效且Q4依赖Q3=满意,那么Q4自然不会保留;这正是迁移完成后需要再次计算可见性与答案集合的原因。needsReviewIds是待确认题目,不表示所有这些题都立刻可见;页面应根据新的依赖结果显示真正需要补答的题目。
持久化不要覆盖旧版草稿
草稿量通常不大,应用可以使用ArkData的Preferences保存JSON字符串。Preferences适合当前应用范围内的轻量键值数据,但不应承担跨进程并发写入协调。官方接口从API 9开始提供,手机与平板均可按对应设备能力使用;getPreferences获取存储对象、get读取、put更新和flush落盘都是已经存在的接口。为了方便排查,使用问卷ID和版本拼接独立键,不能把第三版直接覆盖第二版。
// service/DraftRepository.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { SurveyDraft } from '../model/SurveyDraft';
export class DraftRepository {
constructor(private context: common.UIAbilityContext) {}
private key(surveyId: string, version: number): string {
return `${surveyId}_v${version}`;
}
async load(surveyId: string, version: number): Promise<SurveyDraft | undefined> {
const store = await preferences.getPreferences(this.context, 'survey_draft');
const raw = await store.get(this.key(surveyId, version), '');
if (typeof raw !== 'string' || raw.length === 0) return undefined;
const parsed = JSON.parse(raw) as SurveyDraft;
if (parsed.surveyId !== surveyId || parsed.ruleVersion !== version ||
!Array.isArray(parsed.answers)) {
throw new Error('草稿版本或答案结构无效');
}
return parsed;
}
async save(draft: SurveyDraft): Promise<void> {
const store = await preferences.getPreferences(this.context, 'survey_draft');
await store.put(this.key(draft.surveyId, draft.ruleVersion),
JSON.stringify(draft));
await store.flush();
}
}
若迁移过程中失败,第二版原始草稿仍可以读取;如果写入第三版失败,界面应保留“迁移尚未保存”的状态,不能根据内存中出现了第三版对象就显示已恢复成功。处理重复进入迁移页面的情况时,优先检查目标版本是否已有草稿,再决定是否需要继续迁移。若目标草稿已经包含用户的新回答,再从旧草稿重建会覆盖进度,所以迁移必须以当前目标版本不存在为前提。

页面状态与并发返回要分开
从旧草稿读取、加载新问卷规则、校验依赖、生成映射和保存结果,属于一个异步过程。用户在中途切换课程或关闭页面时,较晚返回的旧请求不能把当前问卷的状态覆盖掉。与其把所有操作压进aboutToAppear,不如用一个请求序号守住UI更新,并明确“未开始、迁移中、待补答、可继续”的状态。
// pages/DraftUpgradePage.ets 相关逻辑
@State statusText: string = '准备读取旧草稿';
@State reviewQuestions: string[] = [];
@State canContinue: boolean = false;
private requestEpoch: number = 0;
private async restoreDraft(
repository: DraftRepository, surveyId: string,
nextQuestions: NextQuestion[], orderIds: string[],
mapping: AnswerMapping[]
): Promise<void> {
const epoch = ++this.requestEpoch;
this.statusText = '正在核对规则版本';
this.canContinue = false;
try {
const existing = await repository.load(surveyId, 3);
if (epoch !== this.requestEpoch) return;
if (existing) {
this.statusText = '已找到新版草稿';
this.canContinue = true;
return;
}
const old = await repository.load(surveyId, 2);
if (!old) {
this.statusText = '没有可迁移的旧草稿';
return;
}
// 参数来自已通过依赖检查的新版规则配置
const result = migrateDraft(old, 3, nextQuestions, orderIds, mapping);
await repository.save(result.nextDraft);
if (epoch !== this.requestEpoch) return;
this.reviewQuestions = result.needsReviewIds;
this.canContinue = true;
this.statusText = result.needsReviewIds.length ?
'已迁移,部分题目需重新回答' : '旧草稿已恢复到新版';
} catch (err) {
if (epoch === this.requestEpoch) {
this.statusText = '草稿处理失败,请检查版本及存储空间';
}
}
}
aboutToDisappear(): void {
this.requestEpoch++;
}
这里展示的是页面成员组合,nextQuestions、orderIds和mapping应由页面对应的规则服务提供,接入时需在实例中声明。即便异步更新被请求序号丢弃,已经完成的持久化操作仍可能存在,下一次进入页面应重新按目标版本键读取,而不是依赖先前的按钮状态。若同一账号在多端填写,还需要更高层的修订比较与服务端冲突处理,不宜误以为Preferences能自动完成跨设备合并。

沿真实差异检查迁移路径
调试样本建议固定三类:其一,Q2映射到Q2_FREQ且“多次”仍合法,应继承;其二,Q3的“非常满意”已不在新选项中,应出现在待补答列表;其三,把Q1改成“没有”,依赖Q1=有的Q2_FREQ和Q3即使通过字段映射也应被清理。再检查新问卷已有草稿、旧草稿JSON损坏、存储写入异常、用户在读取过程中切换问卷四条路径。观察的不只是最后的按钮能否点击,还包括版本键、答案字段、被移除字段和UI状态之间是否一致。
页面交互上,迁移完成不应立即自动提交。把“已保留两题”“待补答一题”展示给填写者,并允许跳到需要重新回答的问题。对于评分定义变化、必填条件变化或单位变化,应由问卷配置明确描述如何处理,而不能自动做未经约定的转换。例如旧版满意度为五档,新版三档,两者看似都能映射到数字,却不具备天然的一一对应语义。
最后要注意依赖图与迁移规则一同版本化。只更新题目数据而没有更新映射版本,下次打开旧草稿可能采用错误的字段对应关系。建议保存规则版本、映射版本及迁移结果中的保留/丢弃计数;业务排障时根据这些结构化信息重建处理路径,无须输出问卷的完整敏感答案到调试文本。由此,条件显示规则、字段身份、值域核对及本地保存形成了相对清晰的职责边界。下一节可以在这一基础上继续处理提交前的必填题检查与中断恢复。
相关开发资料:ArkData Preferences接口、ArkTS简介、ArkUI创建列表、https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-watch@Watch状态变量通知。
更多推荐

所有评论(0)