【观止·诗史汇 HarmonyOS 实战系列 10】文试默写:从诗词内容包动态生成练习题

前九篇已经把《观止·诗史汇》的主干拆到了比较清楚的程度:工程分层、首页入口、诗文内容包、诗文详情、时间轴、兴替明鉴、古今地理和文脉纵览。到第十篇,应用不再只是“阅读内容”,而要开始进入“训练内容”。

这篇聚焦文试默写模块。

如果一个诗文学习 App 只提供静态阅读,用户很容易停在“看过”的层面。真正能形成学习闭环的,是把内容包中的诗文、作者、朝代和历史事件转成可作答、可判题、可统计、可回炉的练习题。当前项目里的练习链路由四个对象组成:

层次 文件 职责
入口页 `features/src/main/ets/practice/PracticeHomePage.ets` 展示四类练习入口和错题重练入口
作答页 `features/src/main/ets/practice/PracticeRunPage.ets` 加载题目、提交答案、展示提示与解析
生成服务 `features/src/main/ets/services/PracticeService.ets` 从诗文内容包和历史事件动态生成题目
状态仓 `features/src/main/ets/state/AppStores.ets` 记录练习统计、错题集和持久化数据

第十篇的核心不是“做一个答题页面”,而是把题目从真实内容中生成出来,并把用户每次作答接入后续学习状态。

> 本篇截图应来自本机 DevEco 模拟器中的“文试默写/练习”模块。截图需要展示练习入口或答题页,不再复用首页图。

观止·诗史汇文试默写截图

本篇要解决什么问题

文试默写模块要解决四个工程问题:

问题 当前实现
题从哪里来 `PracticeService` 从 `PoemPackRepo` 读取诗文详情,从 `MOCK_EVENTS` 读取历史事件
有哪些题型 `next` 上句接下句、`blank` 挖空默写、`famous` 名句填空、`event` 史事辨识
怎样判题 `PracticeService.judge()` 调用 `normalize()` 去掉标点、空白后严格比较
错题怎么闭环 答错写入 `WrongStore`,答对调用 `removeRight()` 清除对应错题

这说明练习模块不是孤立页面,它跨过了内容包、路由、状态仓和统计模块。

PracticeQuestion:练习题的最小模型

领域模型里定义了练习类型和练习题:

export type PracticeType = 'next' | 'blank' | 'famous' | 'event';

export interface PracticeQuestion {
  id: string;
  type: PracticeType;
  prompt: string;
  answer: string;
  analysis: string;
  hint: string;
  poemId: string;
}

这个模型很小,但字段足够支撑一个完整答题流程。

字段 作用
`id` 稳定题目 ID,用于错题去重和移除
`type` 题型,决定入口和统计分类
`prompt` 题干,直接渲染到作答页
`answer` 标准答案,用于判题
`analysis` 解析,提交后展示
`hint` 提示,用户点“提示”后展示
`poemId` 关联诗文,事件题可为空

这里没有把用户答案、是否答对、答题时间放进 PracticeQuestion。这是一个好取舍:题目模型只描述题目本身,用户行为交给 PracticeRunPage 的状态和 StatsStore/WrongStore

PracticeHomePage:四类入口和错题入口

入口页维护的状态很少:

@State wrongCount: number = 0;
private wrongStore: WrongStore = WrongStore.instance();
private listener: () => void = () => {
  this.wrongCount = this.wrongStore.count();
};

页面出现时订阅错题仓:

aboutToAppear(): void {
  this.wrongCount = this.wrongStore.count();
  this.wrongStore.subscribe(this.listener);
}

aboutToDisappear(): void {
  this.wrongStore.unsubscribe(this.listener);
}

这段代码说明“错题重练”不是写死的入口,它会根据 WrongStore.count() 动态显示当前错题数量。用户答错后再回到入口页,错题入口能自动反映变化。

四个练习入口由数组驱动:

private entries: PracticeEntry[] = [
  { type: 'next', title: '上句接下句', subtitle: '系统出上句,您接下句', icon: $r('app.media.ic_goal_poem') },
  { type: 'blank', title: '挖空默写', subtitle: '诗句留空,您填出缺字', icon: $r('app.media.ic_goal_practice') },
  { type: 'famous', title: '名句填空', subtitle: '据语境提示写出千古名句', icon: $r('app.media.ic_metric_articles') },
  { type: 'event', title: '史事辨识', subtitle: '据史实考辨事件名', icon: $r('app.media.ic_goal_history') }
];

点击入口时只传两个参数:

const params: NavigateParams = {
  practiceType: e.type,
  practiceMode: 'normal'
};
Navigator.push(AppRoutes.PRACTICE_RUN, params);

这让入口页非常轻。它不需要加载题,也不需要知道题目数量,只负责把用户选择的练习类型交给作答页。

PracticeRunPage:答题页是一台小状态机

作答页的状态比入口页多很多:

interface RunState {
  loading: boolean;
  mode: string;
  type: PracticeType;
  list: PracticeQuestion[];
  index: number;
  userAnswer: string;
  showAnswer: boolean;
  showHint: boolean;
  judged: boolean;
  judgedRight: boolean;
  rightCount: number;
  wrongCount: number;
}

这些字段可以分成五组:

分组 字段 说明
加载态 `loading` 控制 Loading/Empty/Content
题目态 `mode`、`type`、`list`、`index` 正常练习或错题重练,当前题型和题目列表
输入态 `userAnswer` TextArea 里的用户作答
展示态 `showAnswer`、`showHint`、`judged`、`judgedRight` 是否展示提示、答案、判题结果
统计态 `rightCount`、`wrongCount` 当前练习会话内的对错计数

这类页面最怕状态混在一起。当前实现把“当前题目”“用户输入”“是否判题”“会话统计”都放进一个 RunState,虽然不是最抽象的写法,但在 ArkUI 页面里直观、可追踪。

aboutToAppear:正常练习和错题重练复用同一页面

作答页进入时先读路由参数:

const params: NavigateParams = Navigator.getParams();
const mode: string = params.practiceMode === 'wrong' ? 'wrong' : 'normal';
const t: PracticeType = (params.practiceType as PracticeType) ?? 'next';

然后根据模式决定题目来源:

let list: PracticeQuestion[] = [];
if (mode === 'wrong') {
  const ws: WrongQuestion[] = this.wrongStore.list();
  list = ws.map((w: WrongQuestion) => wrongToQuestion(w));
} else {
  list = await this.svc.listByType(t);
}

这段设计很干净:同一个 PracticeRunPage 既能做正常练习,也能做错题重练。区别只在于题目来源:

模式 题目来源
`normal` `PracticeService.listByType(type)` 动态生成
`wrong` `WrongStore.list()` 转成 `PracticeQuestion[]`

错题重练没有另写一套页面,避免了“正常答题和错题答题两套逻辑越来越不一致”的问题。

PracticeService:题目从内容包里生成

PracticeService 的入口很小:

async listByType(type: PracticeType): Promise<PracticeQuestion[]> {
  let arr: PracticeQuestion[] | undefined = this.questionCache.get(type);
  if (!arr) {
    if (type === 'event') {
      arr = this.buildEventQuestions();
    } else {
      arr = await this.buildPoemQuestions(type);
    }
    this.questionCache.set(type, arr);
  }
  return this.shuffle(arr.slice());
}

这里有三个关键点。

第一,诗文类题目和历史事件题目分开构建。诗文类依赖 PoemPackRepo,事件题依赖 MOCK_EVENTS

第二,构建结果按题型缓存。第一次进入某类练习时生成题库,后续直接使用缓存,避免每次都重新遍历诗文内容包。

第三,返回时 shuffle(arr.slice())。它不会打乱缓存本体,而是复制一份再随机排序。这样同一题型每次进入都有新顺序,但基础题库保持稳定。

loadPoemDetails:从 PoemPackRepo 读取详情

诗文练习需要正文,不能只用列表摘要。因此服务会读取全部 PoemBrief,再逐个取详情:

private async loadPoemDetails(): Promise<PoemDetail[]> {
  if (this.poemDetailsCache) {
    return this.poemDetailsCache;
  }
  try {
    const briefs: PoemBrief[] = await this.repo.listAllBriefs();
    const details: PoemDetail[] = [];
    for (let i = 0; i < briefs.length; i++) {
      const b: PoemBrief = briefs[i];
      const p: PoemDetail | null = await this.repo.getDetail(b.poemId, b.shard);
      if (p) {
        details.push(p);
      }
    }
    this.poemDetailsCache = details;
    return details;
  } catch (_err) {
    this.poemDetailsCache = [];
    return [];
  }
}

这里和第五篇诗文详情页、第四篇内容包文章是连起来的:内容包里有 poemId + shard,练习服务也按这个组合取详情。

这说明题库不是手写在练习模块里的。只要诗文内容包扩展,理论上练习题数量也会随之增长。

splitBody:把诗文正文切成可出题片段

题目生成前,正文要先切分:

private splitBody(body: string): string[] {
  const out: string[] = [];
  let buf: string = '';
  for (let i = 0; i < body.length; i++) {
    const ch: string = body.charAt(i);
    if (ch === '\r' || ch === '\n') {
      this.pushSegment(out, buf);
      buf = '';
    } else {
      buf += ch;
      if (this.isSegmentBreak(ch)) {
        this.pushSegment(out, buf);
        buf = '';
      }
    }
  }
  this.pushSegment(out, buf);
  return out;
}

它既按换行切,也按句读切。切出来的片段会经过 pushSegment() 过滤:

private pushSegment(out: string[], raw: string): void {
  const s: string = raw.trim();
  if (this.isUsefulText(s, 2)) {
    out.push(s);
  }
}

这一步很重要。练习题不能直接拿原文整段出题,而要拆成用户可以输入、可以判定、可以展示解析的片段。

上句接下句:相邻片段生成

next 题型用相邻句生成:

private appendNextQuestions(out: PracticeQuestion[], poemId: string, title: string, author: string,
  dynasty: string, brief: string, lines: string[]): void {
  for (let i = 0; i < lines.length - 1; i++) {
    const prompt: string = lines[i];
    const answer: string = lines[i + 1];
    if (!this.isUsefulText(prompt, 2) || !this.isUsefulText(answer, 2)) {
      continue;
    }
    out.push({
      id: `q_n_${this.safeId(poemId)}_${i}`,
      type: 'next',
      poemId,
      prompt,
      answer,
      analysis: this.sourceText(dynasty, author, title, brief),
      hint: this.hintText(dynasty, author, title)
    });
  }
}

这种题型的好处是自然、稳定、容易理解。它不需要额外标注题库,只要诗文正文切分正确,就能生成“上句接下句”。

题目 ID 也值得注意:q_n_${poemId}_${i}。它包含题型、诗文 ID 和句子索引,便于错题仓去重。

挖空默写:从有效字符中抽连续片段

blank 题不是随机替换任意字符,而是先找出非标点、非空白的可见字符索引:

const visibleIndexes: number[] = [];
for (let i = 0; i < line.length; i++) {
  const ch: string = line.charAt(i);
  if (!this.isPunctuation(ch) && ch.trim().length > 0) {
    visibleIndexes.push(i);
  }
}

然后决定答案长度:

const answerLength: number = Math.min(4, Math.max(2, Math.floor(visibleIndexes.length / 4)));

最后把连续字符替换为 ____

let prompt: string = '';
let blankInserted: boolean = false;
for (let i = 0; i < line.length; i++) {
  if (picked.has(i)) {
    if (!blankInserted) {
      prompt += '____';
      blankInserted = true;
    }
  } else {
    prompt += line.charAt(i);
  }
}

这比直接随机删除一个字更适合练习。它能控制答案长度,也能避免标点和空格进入答案。

名句填空:在一句中部切分

famous 题会在一句话中部切开:

const split: number = this.pickSplitPosition(line);
const head: string = line.substring(0, split);
const answer: string = line.substring(split);

pickSplitPosition() 的策略是选可见字符的三分之一到三分之二区间:

const min: number = Math.max(2, Math.floor(indexes.length / 3));
const max: number = Math.max(min, Math.floor(indexes.length * 2 / 3));
const posInVisible: number = min + Math.floor(Math.random() * (max - min + 1));
return indexes[posInVisible];

这样题干不会只露出一个字,也不会几乎把整句都露出来。它用一个简单规则,让题目难度保持在可接受范围内。

史事辨识:历史事件也能进练习

第四类题目来自 MOCK_EVENTS

private buildEventQuestions(): PracticeQuestion[] {
  const out: PracticeQuestion[] = [];
  for (let i = 0; i < MOCK_EVENTS.length; i++) {
    const e: HistoryEvent = MOCK_EVENTS[i];
    if (!e.title || !e.summary) {
      continue;
    }
    out.push({
      id: `q_e_${this.safeId(e.id)}`,
      type: 'event',
      poemId: '',
      prompt: `${this.formatEventDate(e)}:${e.summary} 这一史事是什么?`,
      answer: e.title,
      analysis: this.trimLong(e.detail.length > 0 ? e.detail : e.summary, 120),
      hint: e.category
    });
  }
  return out;
}

这一步把第六篇时间轴模块和第十篇练习模块接起来了。练习不只训练诗句,也训练历史事件辨识。

从产品角度看,这很符合《观止·诗史汇》的定位:诗文与历史并不是两套孤立内容,而是在学习路径里互相补强。

fallback:内容包失败时仍能生成基础题

如果内容包读取失败,诗文类题目不会直接空白,而是退回 MOCK_POEMS

if (out.length > 0) {
  return out;
}
return this.buildFallbackPoemQuestions(type);

buildFallbackPoemQuestions() 用 mock 诗文重新走一遍题目生成流程。

这和前面几篇文章反复提到的 local-first 思路一致:增强数据可以失败,但页面不能失去基本能力。对练习模块来说,哪怕内容包暂时不可用,也应该至少能让用户进入基础练习。

judge 与 normalize:宽容输入,严格答案

判题入口只有几行:

judge(question: PracticeQuestion, userAnswer: string): boolean {
  const a: string = PracticeService.normalize(question.answer);
  const b: string = PracticeService.normalize(userAnswer);
  if (b.length === 0) return false;
  return a === b;
}

这个逻辑可以概括为:

  1. 标准答案和用户答案都先规范化。
  2. 空答案一定判错。
  3. 规范化后必须完全相等。

normalize() 会去掉常见标点和空白:

static normalize(s: string): string {
  if (!s) return '';
  const r: string = s.trim();
  const puncts: string[] = [
    ',', '.', ';', ':', '!', '?', '(', ')', '[', ']',
    '<', '>', ' ', '\t', '\n', '\r', '"', '\''
  ];
  let out: string = '';
  for (let i = 0; i < r.length; i++) {
    const ch: string = r.charAt(i);
    if (puncts.indexOf(ch) < 0) {
      out += ch;
    }
  }
  return out;
}

这样用户多输一个空格、换行或标点,不会影响结果。但同义替换、错别字、少字多字仍然会判错。

这个取舍适合默写类练习。默写不是主观问答,答案应该明确;但输入法造成的格式差异应该被消除。

submit:一次提交同时影响三个地方

作答页提交时:

private submit(): void {
  const q: PracticeQuestion = this.cur();
  const ok: boolean = this.svc.judge(q, this.state.userAnswer);
  this.statsStore.recordPractice(ok, q.type);
  if (ok) {
    this.wrongStore.removeRight(q.id);
  } else {
    this.wrongStore.addWrong(questionToWrong(q));
  }
  this.state = {
    ...,
    showAnswer: true,
    judged: true,
    judgedRight: ok,
    rightCount: this.state.rightCount + (ok ? 1 : 0),
    wrongCount: this.state.wrongCount + (ok ? 0 : 1)
  };
}

一次提交会影响三类状态:

位置 行为
当前页面 展示答案、解析、对错状态,更新本轮对错计数
`StatsStore` 记录总练习次数、对错次数和题型次数
`WrongStore` 答错加入错题,答对移除对应错题

这就是练习闭环的核心。

用户看到的不是一次孤立判断,而是一条持续学习记录:今天练了多少题,哪类题多,错题是否被清掉,后续统计页都能继续使用这些数据。

WrongStore:错题去重和回炉

错题模型如下:

export interface WrongQuestion {
  id: string;
  type: string;
  prompt: string;
  answer: string;
  analysis: string;
  hint: string;
  poemId: string;
  wrongCount: number;
  lastAt: number;
}

答错时并不是简单追加:

addWrong(q: WrongQuestion): void {
  const idx: number = this.items.findIndex((it: WrongQuestion) => it.id === q.id);
  if (idx >= 0) {
    const old: WrongQuestion = this.items[idx];
    this.items[idx] = {
      id: old.id,
      type: old.type,
      prompt: old.prompt,
      answer: old.answer,
      analysis: old.analysis,
      hint: old.hint,
      poemId: old.poemId,
      wrongCount: old.wrongCount + 1,
      lastAt: Date.now()
    };
  } else {
    this.items.push({ ...q, wrongCount: 1, lastAt: Date.now() });
  }
  this.bus.emit();
  this.persist();
}

同一道题再次答错,会累加 wrongCount 并刷新 lastAt,不会制造重复错题。错题列表按 lastAt 排序,最近错的题会更靠前。

答对时:

removeRight(id: string): void {
  const before: number = this.items.length;
  this.items = this.items.filter((it: WrongQuestion) => it.id !== id);
  if (this.items.length !== before) {
    this.bus.emit();
    this.persist();
  }
}

这就是“错题回炉”的闭环:错了留下,对了清掉。

StatsStore:练习行为进入学习统计

统计仓里有练习聚合字段:

export interface DailyStat {
  date: string;
  durationSec: number;
  poemIds: string[];
  eventIds: string[];
  practiceTotal: number;
  practiceRight: number;
  practiceWrong: number;
  practiceNext?: number;
  practiceBlank?: number;
  practiceFamous?: number;
  practiceEvent?: number;
}

每次提交都会调用:

recordPractice(right: boolean, type?: PracticeType): void {
  const t: DailyStat = this.ensureToday();
  t.practiceTotal += 1;
  if (right) t.practiceRight += 1; else t.practiceWrong += 1;
  if (type === 'blank') {
    t.practiceBlank = (t.practiceBlank || 0) + 1;
  } else if (type === 'famous') {
    t.practiceFamous = (t.practiceFamous || 0) + 1;
  } else if (type === 'event') {
    t.practiceEvent = (t.practiceEvent || 0) + 1;
  } else {
    t.practiceNext = (t.practiceNext || 0) + 1;
  }
  this.notifyChanged();
}

这为第十二篇的统计模块埋好了数据:总练习量、正确率、不同题型训练量都能从 DailyStat 中汇总。

为什么题目 ID 很重要

动态生成题最容易被忽略的是 ID。

如果每次生成题都使用随机 ID,错题仓就无法判断“这道题是不是以前错过”。当前实现给不同题型生成稳定 ID:

题型 ID 形态
上句接下句 `q_n_${poemId}_${i}`
挖空默写 `q_b_${poemId}_${lineIndex}_${start}_${answerLength}`
名句填空 `q_f_${poemId}_${i}_${split}`
史事辨识 `q_e_${eventId}`

其中挖空题因为起始位置是随机的,所以 ID 会随抽空位置变化。这意味着同一句诗的不同挖空片段会被视为不同题,这是合理的。

如果后续希望“同一句诗所有挖空题归为同一错题”,可以把 ID 降级为 q_b_${poemId}_${lineIndex},再把具体空位作为题目版本字段。但当前实现更适合训练细颗粒度。

当前实现的边界

第十篇也要把边界讲清楚。

第一,PracticeService 当前按题型缓存题库,但内容包如果运行时发生变化,缓存不会自动失效。后续可以给 PoemPackRepo 增加版本号,或者在服务层提供 clearCache()

第二,blankfamous 题存在随机生成,因此同一用户不同次进入会看到不同题面。这有利于训练,但如果要做考试回放,需要记录题面快照。

第三,normalize() 当前主要处理标点和空白,没有处理繁简转换、异体字、同义答案等情况。默写场景可以接受,但问答场景不够。

第四,事件题来自 MOCK_EVENTS,还没有像诗文一样内容包化。如果历史事件继续扩展,建议新增 HistoryEventPackRepo 或统一事件数据服务。

第五,作答页提交后会直接显示答案和解析。后续如果要做考试模式,需要增加 practiceMode: 'exam',在整组题完成后再显示结果。

本地验收命令

本篇截图应进入文试默写模块后截取:

git status --short
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" list targets
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" shell aa start -a EntryAbility -b com.example.app_project02
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" shell snapshot_display -i 0 -f /data/local/tmp/guanzhi_10_practice.png -w 1080 -h 2400 -t png
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" file recv /data/local/tmp/guanzhi_10_practice.png .\screenshots\10_practice_run_emulator.png

页面验收清单:

  • 首页进入“文试默写”后能看到四类练习入口。
  • 上句接下句、挖空默写、名句填空、史事辨识都能加载题目。
  • 提交空答案一定判错。
  • 标点、空格、换行不影响正确答案匹配。
  • 答错题目进入 WrongStore
  • 答对错题后从错题集中移除。
  • 每次提交都会进入 StatsStore.recordPractice()
  • 错题重练能复用 PracticeRunPage

常见问题复盘

1. 为什么不提前手写题库?

因为项目已经有诗文内容包。手写题库会让诗文内容和练习内容分裂,新增诗文后还要人工补题。动态生成虽然有边界,但更适合本地内容型 App 的长期扩展。

2. 为什么上句接下句用相邻片段?

这是最稳定的生成方式。只要正文切分正确,题干和答案天然来自同一作品,解析也能直接带出处。

3. 为什么答错要去重?

错题集的价值不是记录“错了多少次同一道题”,而是告诉用户“哪些题还没掌握”。同题累加 wrongCount,比重复插入多条记录更适合重练。

4. 为什么答对会移除错题?

这是错题闭环的关键。用户不是为了维护一个越来越长的失败列表,而是为了把错题清掉。removeRight() 让“重练成功”有明确反馈。

5. 为什么统计要按题型拆分?

总正确率只能说明整体表现,不能说明薄弱项。practiceNext/practiceBlank/practiceFamous/practiceEvent 能让统计页后续判断用户到底是默写弱、名句弱,还是史事辨识弱。

本章小结

第十篇把《观止·诗史汇》从阅读系统推进到练习系统。

当前实现的价值在于:

  • PracticeService 从诗文内容包和历史事件中动态生成题目。
  • PracticeRunPage 用一套页面支持正常练习和错题重练。
  • judge() 通过 normalize() 消除输入格式差异。
  • WrongStore 负责错题去重、累加和答对移除。
  • StatsStore 把每次作答沉淀为学习统计。

这套链路让内容包真正变成训练材料。用户读诗、看史、理解文脉之后,可以通过文试默写把知识再过一遍手。下一篇会继续进入收藏、笔记与错题本:这些本地学习状态如何通过 Preferences 持久化,并在多个页面之间保持一致。

Logo

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

更多推荐