一份课程评价问卷刚发布时,题目编号和选项往往比较稳定;教研组后来调整调查口径,旧草稿就成了容易被忽略的问题。有人在第二版填过“参与实践多次”,第三版把题目从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状态变量通知。

Logo

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

更多推荐