【口算王|01】HarmonyOS ArkTS 口算题生成实战:按年级、运算类型和难度生成可控题目

口算应用看似只要随机两个数、拼一个运算符就能工作,真正落地时却会迅速遇到边界问题:一年级题目不能频繁出现负数,除法不能产生无法口算的小数,四则混合要遵守运算顺序,错误选项不能重复,随机练习不能改坏原题库,所谓"难度"还必须能被代码和测试明确解释。

本文基于口算王项目 D:\huawei\one16-11 的真实 ArkTS 源码,重点复核 MockBanks.etsPracticePage.etsQuestionUtils.etsMathModels.ets。当前实现面向 HarmonyOS 5.0 及以上版本,题目完全由本地算法生成,共有 6 个年级题库、7 类题型,每个年级 250 题,总计 1500 题。文章会区分"已经实现的行为"和"为了让难度真正可控而建议增加的约束",不会把设计方案写成现成功能。

<!-- unique-marker: com.jiaweikang.one16 -->

一、先定义"可控",再谈随机

可控题目不是每次得到同一个结果,而是无论随机种子如何变化,都满足一组不变量:

text 复制代码
题干合法
答案唯一
选项不重复
减法结果符合年级边界
除法保证整除
混合运算遵循明确规则
题型与筛选条件一致
题量不超过候选池
原始题库不被洗牌污染
相同题目 ID 对应稳定内容

口算王当前生成器没有调用网络接口,也没有在页面运行时临时向服务端请求题目。MockBanks.ets 在模块加载时构造原始题目并缓存展开后的 Question[],这让题目总量、ID 和解析都可以从源码复核。

二、真实数据模型决定生成器能表达什么

MathModels.ets 中的题目接口是:

typescript 复制代码
export interface Question {
  id: string
  bankId: string
  chapterId: string
  type: string
  stem: string
  audioHint?: string
  options: Option[]
  answer: string
  analysis: string
  example?: string
}

这里有 bankIdchapterIdtype,但没有 gradedifficultyoperandRangeskills 字段。年级依赖 bankId 的命名约定推导,难度则隐含在操作数范围和题型中。

因此标题中的"按难度生成"必须谨慎解释:当前代码实现的是"按年级间接改变数值范围,并用题型表达部分认知复杂度",不是一个可由用户显式选择的 easy / medium / hard 难度系统。

三、六个年级题库如何映射

源码定义了 6 个年级:

typescript 复制代码
{ id: 'grade1', name: '一年级', shortName: '一', ... }
{ id: 'grade2', name: '二年级', shortName: '二', ... }
{ id: 'grade3', name: '三年级', shortName: '三', ... }
{ id: 'grade4', name: '四年级', shortName: '四', ... }
{ id: 'grade5', name: '五年级', shortName: '五', ... }
{ id: 'grade6', name: '六年级', shortName: '六', ... }

对应题库 ID 为 b_grade1b_grade6。生成时通过字符串替换得到年级:

typescript 复制代码
const grade = Number(bankId.replace('b_grade', ''))

这段代码简洁,但它依赖命名格式。如果传入 grade1、空字符串或未知 ID,grade 可能成为 NaN。当前 getQuestions() 在缓存没有命中时仍会调用 buildQuestions(bankId),所以路由参数校验不能省略。

四、250 题不是手写,而是七类题型轮转

每个题库目标数量为:

typescript 复制代码
const BANK_TARGET_COUNT: number = 250

生成循环使用:

typescript 复制代码
const slot = (i - 1) % 7

slot 对应加、减、乘、除、混合、应用和限时七类题型。250 除以 7 的商是 35、余数是 5,因此每个年级中前五类各生成 36 题,应用题和限时题各生成 35 题。六个年级合计:

题型 每年级 六年级合计
add 36 216
sub 36 216
mul 36 216
div 36 216
mixed 36 216
word 35 210
speed 35 210
总计 250 1500

这不是统计平台数据,而是由确定性循环直接推导出的本地题库数量。

五、年级目前怎样影响操作数范围

加减题的两个基础操作数来自:

typescript 复制代码
const a = 1 + ((i * 7 + grade * 3) %
  (grade <= 1 ? 10 : grade <= 2 ? 60 : 120))
const b = 1 + ((i * 5 + grade * 11) %
  (grade <= 1 ? 10 : grade <= 2 ? 50 : 90))

由此可见:

  • 一年级:ab 主要落在 1 至 10。
  • 二年级:上限分别扩展到 60 和 50。
  • 三至六年级:上限分别为 120 和 90。

这里的"难度"主要表现为数值范围扩大。三至六年级虽然偏移量不同,但模数相同,并没有随年级持续扩大数值边界。源码中的章节名称包含小数、分数、百分数和比例,但当前生成器仍只生成整数四则与简单文字题。

这意味着不能仅根据章节文案宣称五、六年级已实现小数、分数和百分数出题。真实能力边界仍由 buildQuestions() 决定。

六、减法为什么不会出现负数

减法分支先排序两个操作数:

typescript 复制代码
const big = Math.max(a, b)
const small = Math.min(a, b)
pushQuestion(
  raws,
  'sub',
  `${big} - ${small} = ?`,
  big - small,
  '减法先保证被减数更大,避免出现负数。',
  i
)

这个约束保证答案不小于 0,适合基础口算。如果未来增加"有理数"或"负数"难度档,不能直接复用这条规则,而应把 allowNegative 变成显式约束。

还应注意,big === small 时会生成答案 0。这不算错误,但测试用例必须包含零结果,避免错误选项生成器把 0 替换掉或出现重复。

七、除法为什么一定整除

除法不是先随机被除数,而是先生成除数和商:

typescript 复制代码
const divisor = 1 + ((i + grade) % 9)
const quotient = 1 + ((i * 3 + grade) % 9)
pushQuestion(
  raws,
  'div',
  `${divisor * quotient} ÷ ${divisor} = ?`,
  quotient,
  '除法题保证整除,可以用乘法反推答案。',
  i
)

被除数由 divisor * quotient 反推,因此满足:

text 复制代码
divisor != 0
dividend % divisor == 0
answer == quotient

这是比"随机两个数后检查是否整除"更稳定的构造方式,不需要重试,也没有无限循环风险。对于基础口算,先生成答案空间再反推题干通常更容易保证约束。

八、乘法、混合运算与限时题的真实边界

乘法使用 1 至 9 的因子:

typescript 复制代码
const x = 1 + ((i + grade) % 9)
const y = 1 + ((i * 2 + grade) % 9)

这符合九九乘法范围,但各年级都使用同一因子上限。混合运算则固定为:

typescript 复制代码
`${x} × ${y} + ${z} = ?`

解析明确要求先乘后加。限时题固定为:

typescript 复制代码
`${x} - ${y} + ${grade} = ?`

按从左到右计算。这里的"限时"是题型标签,真正倒计时在 PracticePageexam 模式中实现;单个 speed 题本身没有独立计时字段。

九、应用题目前只有一种数量关系

应用题模板为:

typescript 复制代码
`书架上有 ${first} 本书,又放上 ${more} 本,现在共有多少本?`

答案是 first + more,解析是"原有数量 + 新增数量"。它可以验证文字题展示和数量提取,但不能代表已经覆盖减法比较、倍数、平均分、单位换算或多步应用题。

若要增加模板,建议先定义 WordProblemTemplate,让模板携带题型、单位、约束和答案函数,而不是在一个 if 分支中继续堆叠字符串。

十、错误选项怎样避免重复

当前错误选项从答案偏移得到:

typescript 复制代码
const offsets: number[] = [
  0,
  1 + (seed % 4),
  -1 - (seed % 3),
  5 + (seed % 6)
]

每个候选值都会经过两层处理:

typescript 复制代码
if (value < 0) value = Math.abs(value) + 2
while (values.indexOf(value) >= 0) value += 2

第一层避免负数选项,第二层持续加 2,直到四个选项互不重复。随后把正确答案从位置 0 交换到 seed % 4,让正确选项位置在 A 至 D 之间轮转。

这套方法保证"唯一"和"非负",但干扰项质量仍有提升空间。例如答案较小时,负偏移会被镜像成较大的正数;对于乘除题,基于常见计算错误生成干扰项会更有教学价值。

十一、题目 ID 为什么稳定

展开阶段生成:

typescript 复制代码
id: `${bankId}_q${i + 1}`
chapterId: `${regionId}_c${(i % 6) + 1}`

只要题目顺序不改变,同一个 bankId 下的 ID 就稳定,收藏、错题和笔记可以通过 ID 找回题目。但如果以后在 250 题列表中间插入新题,后续 ID 会整体漂移,旧数据可能指向不同题目。

更稳妥的演进方式是由题型、操作数和模板版本生成稳定键,例如:

text 复制代码
b_grade2:add:27:16:v1

现有代码尚未采用这种键,因此扩充题库前要先评估历史收藏和错题数据迁移。

十二、章节标题和生成规则目前没有真正绑定

每个年级有 6 个章节,章节标题很具体,例如一年级有"10以内加法""20以内进位",五年级有"小数加减""分数入门"。

然而当前章节归属只是:

typescript 复制代码
chapterId: `${regionId}_c${(i % 6) + 1}`

这会把七类题型按索引轮流分散到六章,而不是根据章节主题筛选。于是"10以内加法"章节中可能出现除法、混合运算或应用题;"小数加减"章节也仍是整数题。

这不是页面问题,而是生成模型缺少章节约束。文章必须明确这一点,不能把 UI 上的章节名当作题库内容已匹配的证据。

十三、运算类型筛选发生在练习页

PracticePage.aboutToAppear() 先获取题库,再按 questionType 过滤:

typescript 复制代码
this.questions = getQuestions(params.bankId)
if (params.questionType) {
  this.questions = this.questions.filter(
    (question: Question) => question.type === params.questionType
  )
}

如果题型不存在,列表会保持为空,因为后续兜底条件明确排除了 params.questionType。这有利于暴露错误参数,但页面必须提供清晰空状态,否则用户只会看到没有题目的界面。

过滤是对缓存数组的派生结果,不会改写题库。随机模式随后再对候选集取样,顺序正确:

text 复制代码
选择年级题库 -> 可选题型过滤 -> 随机抽取 20 题

十四、随机练习不会修改缓存

QuestionUtils.shuffle() 先复制:

typescript 复制代码
const result = [...arr]

再执行 Fisher-Yates 洗牌。因为洗牌发生在副本上,QUESTIONS_CACHE 中的题目顺序不会被一次练习永久改变。

随机模式最多取 20 题:

typescript 复制代码
const take = Math.min(count, pool.length)
return QuestionUtils.shuffle(pool).slice(0, take)

当候选池不足 20 时,返回全部候选题;候选池为空时返回空数组。指定 startQuestionId 时,找到目标题就把它固定在第一位,再从其余题目中洗牌补足。这些边界都可以用纯函数测试验证。

十五、挑战模式不是完全随机

挑战模式调用 pickHourlyExamQuestions()。它以当前 UTC 小时和 bankId 哈希构造种子,再用线性同余序列驱动 Fisher-Yates 洗牌。

结果具有两个特点:

  • 同一题库在同一小时内题序稳定,便于用户重复进入时保持一致。
  • 小时变化后整体轮换,提供新的挑战组合。

这与普通随机模式的 Math.random() 不同。测试时不要把两者混为一谈:随机模式验证集合约束与不污染原数组;挑战模式还要验证同小时可复现和跨小时可变化。

十六、把"难度"从隐式经验升级为显式约束

当前代码没有难度字段。若要真正支持难度选择,可以先引入纯数据配置:

typescript 复制代码
interface DifficultyRule {
  id: string
  minOperand: number
  maxOperand: number
  allowCarry: boolean
  allowBorrow: boolean
  allowNegative: boolean
  divisionExactOnly: boolean
  maxSteps: number
}

再把年级、章节和题型映射到规则,而不是在 buildQuestions() 中继续增加三元表达式。生成器接收 GenerationSpec

typescript 复制代码
interface GenerationSpec {
  grade: number
  chapterId: string
  type: string
  difficultyId: string
  count: number
  seed: number
}

这是改进建议,不是当前源码已经存在的接口。

十七、生成器适合放在纯逻辑层

口算题生成可以拆成五层:

  1. Spec:年级、章节、题型、难度、数量和种子。
  2. Constraint:操作数范围、进退位、整除、步数和结果边界。
  3. Generator:生成题干、答案、解析与例式。
  4. Distractor:生成唯一、合理的错误选项。
  5. Session:过滤、抽样、计时、作答和持久化。

当前 MockBanks.ets 同时承担目录、题目构造、选项生成、章节分配、缓存和计数同步。规模较小时可工作,但难度系统扩展后,纯函数边界能显著降低测试成本。

十八、必须覆盖的性质测试

生成算法最适合做性质测试,而不是只断言某一道题:

text 复制代码
所有题目恰好 4 个选项
所有选项文本唯一
answer 一定是 A/B/C/D
answer 对应文本等于计算结果
sub 的答案 >= 0
div 的除数 != 0 且保证整除
每个题目 ID 唯一
每个题目 chapterId 属于当前年级
按 type 过滤后所有题型一致
随机抽样数量 <= 20
shuffle 不修改输入数组
同小时挑战顺序稳定

还要测试无效 bankId。当前命名解析可能产生 NaN,如果路由把未知 ID 传入生成器,虽然循环仍可能产出题目,但内容边界失去年级含义。更安全的做法是先验证 ID 是否属于 BANKS

十九、性能与内存要看真实规模

模块加载时 RAW_MAP 会立即为 6 个题库分别构造 250 道原始题,也就是 1500 个 RawQuestionsyncCatalogCounts() 随后调用 getQuestions(),又把所有原始题展开为完整 Question 并放入缓存。

这意味着启动阶段会同时持有原始题与展开题。1500 题对当前应用通常不算大,但如果扩展到小数、分数、单位换算和多模板应用题,双份常驻数据会增加内存和启动成本。

可选优化包括按题库懒生成、只缓存展开题、把目录计数预计算到常量,或使用相同种子按需重建。优化前应先测量启动时间和内存,不能仅凭代码行数判断性能问题。

二十、当前实现可以确认什么

基于真实源码,可以确认:

  • 题目由本地算法生成。
  • 共有 6 个年级题库,每库 250 题,总计 1500 题。
  • 有 7 类题型,题型数量可由循环规则推导。
  • 减法不产生负答案。
  • 除法通过反推被除数保证整除。
  • 每题有 4 个互不重复的非负选项。
  • 正确答案位置随种子在 A 至 D 轮转。
  • 支持按题型过滤、随机 20 题和同小时稳定挑战。
  • 洗牌复制数组,不会改写缓存顺序。

不能声称:

  • 已有独立难度选择器或 difficulty 字段。
  • 章节标题已经约束对应题型和数值范围。
  • 五、六年级已生成小数、分数、百分数或比例题。
  • 所有年级的难度都随年级连续递增。
  • 自动化测试、真机性能或教学效果已经验证通过。

二十一、从现有生成器到可控题库的最小升级路线

建议按以下顺序演进:

text 复制代码
第一步:为 bankId、chapterId、type 增加输入校验
第二步:补齐现有生成器的性质测试
第三步:把难度规则提取成 DifficultyRule
第四步:让章节显式绑定允许题型和数值约束
第五步:为题目建立不随数组位置变化的稳定 ID
第六步:按需生成并测量启动时间与内存
第七步:在 HarmonyOS 手机、平板和 2in1 上验证练习流程

口算题生成的关键不在于"随机得更像随机",而在于把教学边界变成代码能够执行、测试能够证明的约束。口算王当前实现已经具备本地生成、整除控制、非负减法、唯一选项和安全洗牌等基础;下一步真正影响内容质量的,是让章节与难度从界面文案进入数据模型。只有这样,"按年级、运算类型和难度生成"才会从宣传描述变成可复核的工程能力。

AI 辅助声明:本文由 AI 协助梳理文章结构与表达,所有现有能力、题量、算法和限制均依据口算王真实源码复核;难度规则、稳定 ID 和分层接口属于明确标注的改进建议,不代表项目已经实现。

相关推荐
lzx_0021 小时前
C++11(一)
开发语言·c++·算法
529宝宝起名网2 小时前
用 Python 实现名字寓意评分算法:基于 NLP 语义分析的名字内涵深度评估
python·算法·自然语言处理
带多刺的玫瑰3 小时前
Leecode#26刷题之删除有序数组中的重复项
数据结构·算法·leetcode
en.en..3 小时前
C语言核心解析:#define与typedef本质区别
开发语言·c++·算法
HarmonyOS_SDK4 小时前
基于增强QUIC协议优化弱网下的直播观看体验
harmonyos
安好说AI5 小时前
Flutter 三方库 adaptive_image_picker 的鸿蒙化适配指南:零权限选图、纯 Dart 裁剪与目标大小压缩
flutter·harmonyos
wabs6666 小时前
关于二叉树【429.N叉树的层序遍历的思考】
数据结构·c++·算法·leetcode·二叉树
hetao17338376 小时前
2026-09-08 hetao1733837 的刷题记录
c++·算法
C++ 老炮儿的技术栈6 小时前
MFC CPtrArray的用法
开发语言·数据结构·c++·算法·mfc·c