【观止·诗史汇 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 持久化,并在多个页面之间保持一致。
更多推荐



所有评论(0)