【观止·诗史汇 HarmonyOS 实战系列 10】文试默写:从诗词内容包动态生成练习题
【观止·诗史汇 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; }这个逻辑可以概括为:
- 标准答案和用户答案都先规范化。
- 空答案一定判错。
- 规范化后必须完全相等。
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()。
第二,blank和famous题存在随机生成,因此同一用户不同次进入会看到不同题面。这有利于训练,但如果要做考试回放,需要记录题面快照。
第三,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 持久化,并在多个页面之间保持一致。