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

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

本文基于口算王项目 D:\huawei\one16-11 的真实 ArkTS 源码,重点复核 MockBanks.ets、PracticePage.ets、QuestionUtils.ets 与 MathModels.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
}

这里有 bankId、chapterId 和 type,但没有 grade、difficulty、operandRange 或 skills 字段。年级依赖 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_grade1 到 b_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))

由此可见:

  • 一年级:a、b 主要落在 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} = ?`

按从左到右计算。这里的"限时"是题型标签,真正倒计时在 PracticePage 的 exam 模式中实现;单个 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 个 RawQuestion。syncCatalogCounts() 随后调用 getQuestions(),又把所有原始题展开为完整 Question 并放入缓存。

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

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

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

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

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

不能声称:

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

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

建议按以下顺序演进:

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

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

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

相关推荐
贾伟康1 小时前
HarmonyOS API 26 DevEco Code 需求到 ArkTS 骨架的提示词分层
harmonyos·arkts·ai coding·deveco code·工程提效
威哥爱编程2 小时前
模拟面试智能体开发实战 02:回答等半分钟,谁还愿意练?把服务端和流式输出搭起来
python·agent·harmonyos
威哥爱编程3 小时前
模拟面试智能体开发实战 01:面试挂了都不知道挂在哪?用 Python 给自己造一个随叫随到的 AI 面试官
python·agent·harmonyos
武子康4 小时前
驾驶轨迹换个坐标,就能给移动机器人用吗?
人工智能·算法·机器学习
贾伟康7 小时前
HarmonyOS API 26 HMAF Skill 可解释任务计划与失败回退
harmonyos·arkts·智能体·skill·hmaf
贾伟康7 小时前
HarmonyOS API 26 HMAF 2.0 意图解析的可解释动作图
harmonyos·arkts·智能体·a2a·hmaf
KevinChaos9 小时前
阳台小花园开发手记 03|HarmonyOS 7.0 互动卡片:叶片伸出边框仍与花盆对齐
华为·harmonyos
lisw0510 小时前
Dayhoff的PAM替换矩阵理论评介!
线性代数·算法·矩阵
熬夜磕代码丶10 小时前
RN鸿蒙空间音频实战
华为·音视频·harmonyos
行者-全栈开发10 小时前
【鸿蒙心迹】通知与推送集成——emitter 本地通知与推送服务全链路(HarmonyOS 7.x)
华为·harmonyos