当前位置: 首页 > news >正文

【观止·诗史汇 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; }

这个逻辑可以概括为:

  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 持久化,并在多个页面之间保持一致。

http://www.gsyq.cn/news/1620993.html

相关文章:

  • C++20:理解Concepts:C++泛型编程
  • 6DOF IMU与PIC18微控制器的运动追踪系统设计
  • 如何用extract-video-ppt实现3倍效率提升:视频内容智能提取的终极指南
  • AiToEarn 多平台接入架构深度分析
  • 终极指南:3步使用免费工具找回遗忘的压缩包密码
  • 终极原神抽卡记录导出指南:免费永久保存你的祈愿数据
  • 15A无刷电机FOC控制方案设计与实现
  • 多卡通信优化,RCCL 在 AMD 集群中的配置要点
  • 高效解密微信聊天记录:专业数据恢复完整指南
  • Java工程师转型大模型开发:120天实战指南
  • 2026中国制造业精益白皮书哪家专业
  • 如何获客拉新?
  • LED矩阵控制:IS31FL3731与PIC18LF2458的创意开发指南
  • 嵌入式高精度计时系统设计与优化实践
  • 车友必备车载神器合集!精简导航、免费音乐、全能车联、日程服务等
  • rust语言学习笔记(指针六)Cell<T>(内部可变(非指针))
  • 基于Si4731和STM32的数字收音机DIY方案
  • ASM330LHH与STM32F302VC运动跟踪系统设计与优化
  • 基于Si4732与ARM Cortex-M4的专业级收音机设计
  • EM3080-W与MK64FN1M0VDC12的条形码识别系统设计与优化
  • 亚洲基层AI疫情预测系统落地实战:轻量模型+边缘部署+人机协同
  • 案例纪要:某工程设计企业图纸自动签名与批量开票RPA项目
  • 终极GPU内存检测神器:5分钟掌握MemtestCL完整使用指南
  • XZ3445输入电压2.7-36V 输出电压小于30V 5A升压/升降压型DC-DC驱动器
  • 3步掌握Zotero插件市场:一键安装、智能管理、高效升级
  • 【Claude】上下文窗口溢出与 Token 管理优化 — 已解决
  • 如何在Windows上实现4K 240Hz高性能虚拟显示器:ParsecVDD技术深度解析
  • Yakit与流量过滤策略:精准抓取微信小程序核心API
  • 基于Si4731和STM32的FM收音系统开发指南
  • 吕梁本地企业做GEO靠谱服务商推荐:2026年企业GEO服务商优选指南