问卷从静态题目走到条件跳题后,提交按钮就不再只是检查“还有没有空白输入框”。上一节完成规则版本升级和旧草稿迁移:题目重命名、选项更替以及已经移除的题目,都会经过迁移映射并重新裁剪答案。紧接着出现一个更实际的问题:用户回答了“有实践项目”,实践满意度随之显示为必填;填写到一半切到别的页面,再次进入问卷时,页面需要恢复到原来的条件分支,同时知道还有哪一题未完成。

如果提交校验仍遍历配置里的所有题目,会把已经隐藏的评分题列入必填;如果只根据当前界面上有多少个输入框判断,又可能漏掉分页中尚未渲染的必填题。因此,必填范围必须来自规则引擎计算后的可见题集合,恢复草稿也必须重新跑一遍规则,而不是把旧页面状态直接放回组件树。

条件必填与草稿恢复的处理顺序

先统一答案语义,再谈必填

课程评价的答案不只有字符串。单选题存储选项ID,多选题存储字符串数组,五分制评分存储整数,意见建议可以是自由文本。缺答判断不能写成if (!value),否则评分为0或选项值为数字0时会被误判。虽然这份课程问卷的评分范围定义为1到5,但通用引擎应明确区分“不存在”和“不符合题型要求”两类情况。

上一节已经为题目维护稳定ID和规则版本。本节在题目结构中补充required、kind、minChoice等字段,并让校验器只接收当前可见的题目ID。这样规则层管显示顺序,提交层管答案是否满足业务约束,两边不会因为渲染位置发生变化而出现不同结论。

要解决的问题是让空值判断与题型语义一致,尤其要保留多选空数组、空白文本及无效评分的差异。

export type AnswerValue = string | number | string[];
export type AnswerMap = Record<string, AnswerValue>;

export interface Question {
  id: string;
  title: string;
  kind: 'single' | 'multi' | 'rating' | 'text';
  required: boolean;
  minChoice: number;
}

export function hasValidAnswer(
  question: Question, value: AnswerValue | undefined
): boolean {
  if (value === undefined) {
    return false;
  }
  if (question.kind === 'rating') {
    return typeof value === 'number' &&
      Number.isInteger(value) && value >= 1 && value <= 5;
  }
  if (question.kind === 'multi') {
    return Array.isArray(value) &&
      value.length >= question.minChoice;
  }
  return typeof value === 'string' && value.trim().length > 0;
}

这里没有把题目是否必填放进hasValidAnswer,因为“该题允许不回答”和“提交的答案是否合法”是两个不同判断。选答题可以留空,但一旦填写,多选值的每个选项仍应在当前选项字典内。若后续需要校验文本长度、数字边界或跨题依赖,也应继续由独立的答案规则处理,不应把这些条件塞进输入框组件的事件回调里。

可见题集合是唯一的校验入口

上一节使用依赖拓扑顺序解决了多级条件跳题。现在应继续沿用同一顺序生成可见题ID,再从其中筛选必填题。特别注意:题目从可见变为隐藏时,先裁剪旧答案,再计算当前的缺答集合。如果反过来执行,已隐藏题的旧答案可能暂时让页面误以为已经满足提交条件。

例如选择“安排了实践项目”,三道当前可见的必填题分别为实践安排、实践满意度和课程难度,草稿中前后两题已填写,中间评分尚为空。此时页面应提示“可见必填3项,完成2项”。如果将实践安排改成“没有”,评分题隐藏且旧评分从数据中移除,缺答集合也随之减少。提交时不应再传递旧评分。

要解决的问题是在不依赖分页渲染数量的情况下得到明确的阻断原因。

export interface GateResult {
  visibleRequired: number;
  completedRequired: number;
  missingIds: string[];
}

export function inspectSubmission(
  questions: Question[],
  visibleIds: string[],
  answers: AnswerMap
): GateResult {
  const visibleSet: Set<string> = new Set<string>(visibleIds);
  const missing: string[] = [];
  let total: number = 0;
  let done: number = 0;
  for (const q of questions) {
    if (!q.required || !visibleSet.has(q.id)) {
      continue;
    }
    total++;
    if (hasValidAnswer(q, answers[q.id])) {
      done++;
    } else {
      missing.push(q.id);
    }
  }
  return {
    visibleRequired: total,
    completedRequired: done,
    missingIds: missing
  };
}

校验结果保存题目ID而非页面数组下标。题目排序变化、条件分组折叠或分页切换后,缺答提示仍能准确定位同一道题。页面可以根据missingIds决定滚动位置,但不要把“当前没有滚动到某题”理解成题目不存在。多级依赖图发生改变时,应重新计算整个可见ID列表,而不能只增删当前卡片。

恢复的不是旧屏幕,而是旧答案与版本身份

用户中断填写后再次打开问卷,需要知道草稿属于哪一版规则。假设教研人员发布了第三版,草稿却仍写着第二版,不能仅根据JSON字段碰巧相同就跳过迁移。上一节的迁移器应以版本号为入口,完成ID映射、选项换算、废弃答案裁剪,然后再进入本节的可见性与必填复核。

我将持久草稿设计成surveyVersion、savedAt和answers三个主要字段。页面上当前滚动位置、哪张卡片被展开等属于交互状态,恢复时可以使用合理默认值,不必混入答案正文。若要长期保留阅读位置,最好另存一个与问卷ID绑定的界面字段,避免把UI实现细节误当成可提交内容。

要解决的问题是持久化小体量草稿,并保证页面退出前最后一次编辑有确定的保存入口。

import { preferences } from '@kit.ArkData';

export interface SurveyDraft {
  surveyVersion: number;
  savedAt: number;
  answers: AnswerMap;
}

export class DraftRepository {
  private pref: preferences.Preferences;

  constructor(context: Context) {
    this.pref = preferences.getPreferencesSync(
      context, { name: 'course_survey_draft' });
  }

  load(): SurveyDraft | undefined {
    const raw: string = this.pref.getSync('draft', '') as string;
    if (raw.length === 0) {
      return undefined;
    }
    return JSON.parse(raw) as SurveyDraft;
  }

  async save(draft: SurveyDraft): Promise<void> {
    await this.pref.put('draft', JSON.stringify(draft));
    await this.pref.flush();
  }
}

getPreferencesSync、getSync、put和flush均可从ArkData的Preferences接口资料核对。这里使用一个键保存完整草稿,避免先写版本号、再写答案的过程中意外中断,导致持久化状态出现前后不一致。对于小问卷,这种结构直观;对于包含大量附件或多人协作的表单,应该改用具备事务语义的存储方式。

保存草稿是一项异步操作,不能在aboutToDisappear里才匆忙启动。页面可以在用户点击“保存草稿”时显式保存,也可以在每次答案改变后安排一次串行保存。序列化工作不应放进@Watch的回调里进行长时间的异步等待,华为文档已提醒这类回调适合快速更新;更稳妥的做法是让业务服务在答案改变时统一调度保存。

中断与恢复需要一个有序的状态入口

即便持久草稿本身完整,恢复流程仍可能出现竞态:页面先显示空表单,异步恢复数据返回后将用户刚选择的答案覆盖。解决方式不是添加更多“正在加载”布尔量,而是明确界面状态:loading、editing、saving、error。只有完成版本迁移、裁剪和可见题重算以后,才允许进入编辑状态。

下一个代码片段保留上一节规则引擎的接口:migrateDraft按版本迁移答案,calculateVisible返回拓扑排序后的可见ID,pruneHidden清理不可见回答。三者都是应用业务函数,前端不依赖额外的系统级问卷接口。

要解决的问题是将恢复结果一次性写入页面,并重新计算缺答集合。

export interface RestoredSurvey {
  answers: AnswerMap;
  visibleIds: string[];
  gate: GateResult;
}

export function restoreSurvey(
  draft: SurveyDraft,
  currentVersion: number,
  questions: Question[],
  migrateDraft: (old: SurveyDraft) => AnswerMap,
  calculateVisible: (answers: AnswerMap) => string[],
  pruneHidden: (answers: AnswerMap, ids: string[]) => AnswerMap
): RestoredSurvey {
  const migrated: AnswerMap = draft.surveyVersion === currentVersion
    ? { ...draft.answers } : migrateDraft(draft);
  const visible: string[] = calculateVisible(migrated);
  const cleaned: AnswerMap = pruneHidden(migrated, visible);
  const recalculated: string[] = calculateVisible(cleaned);
  return {
    answers: cleaned,
    visibleIds: recalculated,
    gate: inspectSubmission(questions, recalculated, cleaned)
  };
}

pruneHidden之后再跑一次可见性,是为了避免条件变更后沿多级依赖留下无效后代;如果规则引擎对裁剪结果可能产生进一步变化,应迭代到稳定,并设置最多迭代次数以捕获配置错误。若草稿JSON损坏,仓库读取层应把异常转化为“可重新开始”的状态,不要默默把坏数据当成完全填写的问卷。若迁移找不到旧题映射,应记录被舍弃的题目ID,并允许用户重新回答新增必填项。

开发环境中的提交校验与草稿仓库

把提交入口变成可解释的门禁

前端按钮是否可点只是提示,不能替代最终提交前的业务复核。用户可能在提交过程中修改上游条件,导致可见题变化;因此发送答案之前需要重新取一次最新答案、可见题与规则版本。缺答返回missingIds后,优先定位第一道未完成题,并在卡片旁展示具体错误,而不是只弹出一句“填写不完整”。

调试时可以准备三条路径:有实践项目且评分为空,应返回缺答;改成没有实践项目,评分被隐藏,提交载荷中不含旧评分;第二版草稿恢复到第三版,新增的必填题应在界面中明确出现。还应检查快速切换答案时的写入次序,避免较早的草稿保存操作晚于较新的操作完成,最终覆盖新答案。串行队列或带版本号的最新快照写入可以解决这一问题。

恢复后的问卷与未完成必填项提示

页面生命周期也要按不同职责划分。aboutToAppear发起读取和迁移,恢复过程中保持编辑入口不可用;用户交互触发状态更新和保存;进入后台或页面离开前若还有待写队列,应由应用层的持久化协调器继续完成已发起的写入。不要依赖组件即将销毁时才创建保存任务。提交成功之后再清理草稿,并保留必要的提交回执标识,防止网络重试导致重复提交。

经过这一节,问卷从“能正确显示题目”推进到“能正确恢复、定位缺答和提交当前路径的答案”。下一节可以把校验提示进一步接入分组导航,让跨页面的未完成题目具备稳定定位能力,而不用让每个题目组件自己维护一套提交规则。

华为开发者资料:ArkTS语言基础、Preferences接口、https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-watch@Watch状态变量通知、页面生命周期。

Logo

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

更多推荐