【观止·诗史汇 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正常练习或错题重练当前题型和题目列表输入态userAnswerTextArea 里的用户作答展示态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既能做正常练习也能做错题重练。区别只在于题目来源模式题目来源normalPracticeService.listByType(type) 动态生成wrongWrongStore.list() 转成 PracticeQuestion[]错题重练没有另写一套页面避免了“正常答题和错题答题两套逻辑越来越不一致”的问题。PracticeService题目从内容包里生成PracticeService的入口很小async listByType(type: PracticeType): PromisePracticeQuestion[] { 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(): PromisePoemDetail[] { 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_EVENTSprivate 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_POEMSif (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 持久化并在多个页面之间保持一致。