【口算王|08】HarmonyOS ArkTS 分类训练实战:让年级与运算分类参数保持一致
证据边界:本文的“当前实现”来自本轮对本地口算王工程中
MockBanks.ets、CategoryPage.ets、HomePage.ets、SearchPage.ets、PracticePage.ets与共享模型的静态复核。可以确认七类题型、全题库分类计数、分类路由、搜索过滤和训练页参数链路;年级与题型交集、QuestionType联合类型和CategoryScope属于建议扩展。本轮未执行构建、安装、真机点击、性能测量或用户数据验证。
在口算应用里,“分类训练”看起来只是把加法、减法、乘法、除法做成几张卡片。真正容易出错的地方,却发生在卡片点击之后:页面显示的是“加法练习”,路由传递的是add,搜索页按Question.type过滤,而训练页还要继续沿用同一条件。只要其中一层改用中文名称、临时序号或另一套枚举,用户看到的分类和实际进入的题目就可能不一致。
另一个更隐蔽的问题是年级范围。分类页可能让用户误以为“当前年级的加法”,但如果路由只传categoryType,搜索逻辑就会扫描全部题库。此时结果本质上是“所有年级中的加法题”,并不是“二年级加法”。要实现年级与运算分类的交集,必须把两个维度都写进参数契约,而不能仅靠页面标题暗示。
本文基于口算王项目本地口算王工程(脱敏路径)的真实源码,复核CategoryPage.ets、HomePage.ets、SearchPage.ets、PracticePage.ets与MockBanks.ets。项目包名com.jiaweikang.one16是本文草稿核验使用的唯一标记。当前代码已经实现七类题型、分类计数、响应式卡片和按题型进入搜索结果;年级与分类联动则作为可落地的增强方案单独说明,不把设计建议写成现成功能。
本文重点解决四件事:
- 用稳定的
type作为分类主键,避免展示文案参与业务判断; - 解释分类计数、首页入口、分类页与搜索页如何共享同一数据源;
- 明确当前“跨全部题库分类”和“年级+分类交集”的边界;
- 给出可验证的路由参数、归一化、响应式布局与回归测试方法。
一、七种分类不是七段文案,而是七个稳定标识
项目的分类目录来自MockBanks.ets中的CATEGORIES。当前真实分类包括:
| type | 页面名称 | 业务含义 |
|---|---|---|
add | 加法练习 | 加法类题目 |
sub | 减法练习 | 减法类题目 |
mul | 乘法口诀 | 乘法类题目 |
div | 除法练习 | 除法类题目 |
mixed | 混合运算 | 混合运算题 |
word | 应用题 | 应用题 |
speed | 限时挑战 | 速度训练类题目 |
这里最值得保留的设计是:业务判断依赖type,用户界面显示name。两者职责不同。
export interface Category { type: string name: string count: number } export const CATEGORIES: Category[] = [ { type: 'add', name: '加法练习', count: 0 }, { type: 'sub', name: '减法练习', count: 0 }, { type: 'mul', name: '乘法口诀', count: 0 }, { type: 'div', name: '除法练习', count: 0 }, { type: 'mixed', name: '混合运算', count: 0 }, { type: 'word', name: '应用题', count: 0 }, { type: 'speed', name: '限时挑战', count: 0 } ]中文名称会随着产品文案调整,例如“乘法口诀”可能改成“乘法训练”;稳定标识则不应跟着变化。若搜索逻辑使用name,一次文案改版就可能让历史路由、收藏记录和统计维度全部失效。type才是分类协议,name只是协议的可读视图。
进一步收紧类型时,可以把字符串联合类型放到共享模型中:
export type QuestionType = 'add' | 'sub' | 'mul' | 'div' | 'mixed' | 'word' | 'speed' export interface Category { type: QuestionType name: string count: number }这样,分类目录、路由参数和题目模型都能复用QuestionType。拼错mixed、传入不存在的addition等问题,会更早暴露在编译或参数校验阶段。
二、分类数量来自题库扫描,不是写死的展示数字
每张分类卡都会显示题目数量。源码不是手工填写七个数字,而是通过syncCatalogCounts()扫描全部题库和题目,再按question.type累计。
function syncCatalogCounts(): void { const categoryCounts: Map<string, number> = new Map<string, number>() for (const bank of BANKS) { const questions = getQuestions(bank.id) bank.totalCount = questions.length for (const question of questions) { categoryCounts.set( question.type, (categoryCounts.get(question.type) || 0) + 1 ) } } for (const category of CATEGORIES) { category.count = categoryCounts.get(category.type) || 0 } }这段逻辑把“题库是事实源”落实到了分类目录。新增一道div题后,只要重新同步目录,除法分类数量会自动增加;没有某种题型时,数量回落为0,不会显示undefined。
但也要看清统计口径:它遍历的是全部BANKS。因此分类页上的“加法练习 300 题”代表所有题库中type === 'add'的总量,不是当前年级数量。如果产品要显示“二年级加法 42 题”,计数函数必须增加题库范围:
function countQuestions( type: QuestionType, bankId?: string ): number { let count: number = 0 for (const bank of BANKS) { if (bankId && bank.id !== bankId) continue count += getQuestions(bank.id).filter( (question: Question): boolean => question.type === type ).length } return count }计数范围必须与进入结果页后的筛选范围一致。否则卡片写着 42 题,进入后却出现 300 题,用户会把它理解为数据错误。
三、首页和分类页共享同一份目录
源码中有两个分类入口:HomePage.ets的横向分类区,以及CategoryPage.ets的完整分类网格。两个入口都遍历CATEGORIES,并用cat.type作为路由值。
ForEach(CATEGORIES, (cat: Category) => { this.CategoryItem(cat) }, (cat: Category) => cat.type)这里的cat.type同时承担三个职责:
- 作为
ForEach的稳定 key; - 作为题型筛选条件;
- 作为跨页面路由参数。
复用目录可以避免首页写一套“加减乘除”,分类页再写一套“加法、减法、乘法、除法”。当新增word或speed时,两个页面都能看到同一项,计数也来自同一处。
页面仍然可以拥有不同布局。首页适合短入口,分类页适合展示完整名称、描述、图标和数量。共享的是业务目录,不是强行共享全部 UI。把数据与视觉组件分开,能让多设备布局更容易演进。
四、当前路由只传题型,不传年级
分类卡点击后的真实路由如下:
router.pushUrl({ url: 'pages/SearchPage', params: { categoryType: cat.type, categoryName: cat.name } })categoryType是查询值,categoryName是显示值。SearchPage出现时读取参数,并自动执行一次搜索:
interface SearchParams { categoryType?: string categoryName?: string } aboutToAppear(): void { const params = router.getParams() as SearchParams | undefined if (params && params.categoryType) { this.categoryType = params.categoryType this.categoryName = params.categoryName || params.categoryType this.keyword = this.categoryName this.doSearch() } }搜索框里显示“加法练习”,真正过滤时仍使用add。这是合理的,因为用户不需要理解内部枚举,代码也不依赖可变中文。
需要特别说明的是,路由参数里没有bankId、gradeId或regionId。因此当前分类结果会扫描全部题库,只按题型过滤。文章标题中的“让年级与运算分类参数保持一致”,指的是如何把现有题型协议扩展成两个维度都明确的契约,而不是宣称源码已经完成年级交集筛选。
五、搜索页按 Question.type 精确过滤
分类模式与自由文本搜索不是一回事。分类模式收到categoryType后,应按题目模型的type精确匹配:
const results: Question[] = [] for (const bank of BANKS) { const questions = getQuestions(bank.id) for (const question of questions) { const matchesType = this.categoryType.length > 0 && question.type === this.categoryType if (matchesType) results.push(question) } } this.questionResults = results精确比较比对标题做模糊匹配可靠。例如一道题的题干里出现“加法”,不代表它的训练类型一定是add;应用题也可能包含加法运算。模型字段决定分类,题干只负责内容。
结果卡进入PracticePage时,也要继续传递真实题型或目标题目标识,不能把条件丢在搜索页:
router.pushUrl({ url: 'pages/PracticePage', params: { bankId: question.bankId, mode: 'random', questionType: question.type, startQuestionId: question.id } })训练页收到questionType后再次按同一枚举取题,并让startQuestionId对应的题目优先出现。这样“分类入口 -> 搜索结果 -> 开始训练”使用的是一条完整协议。
六、年级和题型是两个正交维度
把“二年级加法”建模成一个长字符串,例如grade2_add,短期看起来省事,后续却会迅速膨胀。六个年级乘七种题型就是四十二个组合;再加入地区、教材版本或难度,组合数量继续增长。
更稳妥的做法是保持维度独立:
export interface CategoryScope { type: QuestionType bankId?: string regionId?: string } export interface CategoryRouteParams { categoryType: QuestionType categoryName: string bankId?: string bankName?: string }当用户从“二年级题库”内部点击加法时,路由可以携带交集范围:
const params: CategoryRouteParams = { categoryType: 'add', categoryName: '加法练习', bankId: 'b_grade2', bankName: '二年级口算' } router.pushUrl({ url: 'pages/SearchPage', params })当用户从全局分类页点击时,则只传题型:
const params: CategoryRouteParams = { categoryType: 'add', categoryName: '加法练习' }两种入口使用同一个参数结构,但范围不同:有bankId就做交集,没有就做全局题型筛选。这比用页面来源判断更稳定,因为目标页只需要解释参数,不必猜测用户从哪个页面来。
七、把筛选条件收敛成一个纯函数
如果搜索页和训练页分别手写过滤,规则很容易漂移。可以把题型与题库范围组合成纯函数,供多个页面复用。
function matchesScope( question: Question, bank: Bank, scope: CategoryScope ): boolean { if (question.type !== scope.type) { return false } if (scope.bankId && bank.id !== scope.bankId) { return false } if ( scope.regionId && bank.regionId !== scope.regionId ) { return false } return true }然后用同一方法生成结果:
function findQuestions( scope: CategoryScope ): Question[] { const result: Question[] = [] for (const bank of BANKS) { const questions = getQuestions(bank.id) for (const question of questions) { if (matchesScope(question, bank, scope)) { result.push(question) } } } return result }纯函数不依赖页面状态,适合单元测试。它同时回答了三个问题:什么题型、哪个题库、哪个地区。未来增加难度时,也能在CategoryScope中增加difficulty,而不是复制第三套过滤循环。
八、路由边界必须拒绝未知分类
ArkTS 的接口只在编译阶段提供约束,router.getParams()仍然是运行时输入。旧版本页面、错误跳转或异常状态都可能传入未知字符串。目标页应先归一化,再进入查询。
const SUPPORTED_TYPES: QuestionType[] = [ 'add', 'sub', 'mul', 'div', 'mixed', 'word', 'speed' ] function normalizeQuestionType( value?: string ): QuestionType | undefined { if (!value) { return undefined } const found = SUPPORTED_TYPES.find( (item: QuestionType) => item === value ) return found }页面读取参数时可显式处理失败:
const params = router.getParams() as SearchParams | undefined const type = normalizeQuestionType( params?.categoryType ) if (!type) { this.searched = false this.categoryType = '' this.questionResults = [] return }未知分类不应伪装成一个“正常但没有题”的类别。回到搜索初始态、显示参数错误提示,或安全返回上一页,都比默默制造空结果更容易排查。
bankId也要执行同样校验。不能仅凭路由带了一个字符串,就认为它对应真实题库:
function findBank(bankId?: string): Bank | undefined { if (!bankId) { return undefined } return BANKS.find((bank: Bank) => bank.id === bankId ) }当bankId无效时,产品要明确选择:退回全局分类,还是提示题库不存在。不要在不同页面里各自猜测。
九、分类视觉信息应集中,避免多个 switch 漂移
CategoryPage.ets会为不同类型提供副标题、颜色、背景和图标。当前实现中,图标使用共享的questionTypeIcon,部分副标题和颜色则由页面内的switch返回。只要分类增加或改名,多个分支就可能漏改。
可以把展示信息和分类目录放在同一份配置中:
export interface CategoryPresentation { type: QuestionType name: string subtitle: string icon: Resource color: ResourceColor background: ResourceColor } export const CATEGORY_PRESENTATIONS: CategoryPresentation[] = [ { type: 'add', name: '加法练习', subtitle: '从基础口算开始', icon: $r('app.media.ic_add'), color: '#2563EB', background: '#EFF6FF' } ]页面只根据type读取配置,不再分别维护四个switch。这能保证首页、分类页、搜索结果和统计图例的颜色语义一致。
这里还要区分speed的业务含义。分类名“限时挑战”对应的是type === 'speed'的速度类题目;分类页副标题中的“60秒挑战”是一条展示文案。它不能与完整考试模式的 20、30、45、60 分钟配置混为一谈。一个是题目类型,一个是考试时长,两者需要不同字段。
十、响应式网格与底部安全区是分类页的真实能力
分类页通过onAreaChange记录页面宽度,并根据阈值调整卡片宽度:
private categoryCardWidth(): string { return this.pageWidth >= 720 ? '31.5%' : '47.8%' }宽屏每行约三张卡,窄屏每行两张卡。百分比宽度给卡间距留下余量,比固定vp宽度更适合手机、平板和窗口化场景。根容器监听面积变化后,折叠、旋转或拖拽窗口都会触发布局重新计算。
底部还需要避开系统导航区域。项目使用系统导航指示区高度与最小安全值取较大者:
private bottomSafePadding(): number { return Math.max( PAGE_BOTTOM_MIN, this.navigationIndicatorHeight ) }这部分不是装饰细节。分类列表如果只给固定小间距,最后一行卡片可能进入手势区,造成难以点击或内容被遮挡。多设备验收时至少要检查:
- 手机竖屏两列是否完整;
- 手机横屏和小窗口是否溢出;
- 720vp 以上窗口是否稳定切换三列;
- 最后一张卡是否能滚动到系统导航区上方;
- 长分类名称是否截断或挤压数量标签。
十一、静态“训练建议”不能写成个性化推荐
分类页底部还有“训练建议”区域,展示“课后练习”“每日打卡”“考前冲刺”等场景。源码中这些是固定文本,没有读取学习记录、正确率、年级或薄弱题型,也没有推荐算法。
因此准确的产品描述应是“提供训练场景提示”,而不是“根据学习情况智能推荐训练”。如果后续确实要做个性化,可以定义可核验的输入:
interface TrainingProfile { bankId: string weakTypes: QuestionType[] recentAccuracy: number dailyCompleted: boolean } interface TrainingSuggestion { type: QuestionType reason: string questionCount: number }只有当页面根据真实训练记录生成TrainingSuggestion,并能解释推荐原因时,才适合使用“个性化建议”。静态场景文案本身不等于推荐能力。
同样,页面顶部的“每日练习”“错题巩固”“限时挑战”芯片当前也是展示元素,不是可点击筛选器。测试时不要把它们列入交互功能,发布说明也不应声称用户可以切换这些模式。
十二、分类页状态与无题分类要显式处理
当前本地目录加载很快,没有网络请求,但分类数量仍可能为零。卡片是否可点击需要明确策略:
private openCategory(cat: Category): void { if (cat.count <= 0) { this.promptMessage = '当前分类暂无可用题目' return } router.pushUrl({ url: 'pages/SearchPage', params: { categoryType: cat.type, categoryName: cat.name } }) }如果允许点击零题分类,目标页必须显示清晰空状态,并提供返回分类或选择其他题型的动作;如果不允许点击,则卡片要有禁用样式和可理解原因。仅让点击“没有反应”会被误判为按钮故障。
本地数据也可能因初始化顺序出现计数未同步。分类页出现前应保证syncCatalogCounts()已执行,或者把计数改成查询时计算。选择哪一种都可以,关键是不要让可变全局数组的更新时间隐含在某个无关页面里。
十三、测试矩阵要覆盖范围,而不只是点击成功
分类训练的核心验证不是“卡片能打开”,而是结果范围是否符合参数。建议用下面的矩阵回归:
| 用例 | 输入 | 预期结果 |
|---|---|---|
| 全局加法 | type=add | 全部题库中的加法题 |
| 二年级加法 | type=add, bankId=b_grade2 | 仅二年级加法题 |
| 全局应用题 | type=word | 全部应用题 |
| 未知题型 | type=addition | 拒绝参数或安全降级 |
| 未知题库 | type=add, bankId=missing | 明确提示或按既定策略降级 |
| 零题分类 | 合法 type,结果为空 | 空状态可返回、可换分类 |
| 宽屏布局 | pageWidth>=720 | 三列卡片且无重叠 |
| 窄屏布局 | pageWidth<720 | 两列卡片且文本可读 |
还可以验证“计数与结果一致”:
const scope: CategoryScope = { type: 'add', bankId: 'b_grade2' } const count = countQuestions( scope.type, scope.bankId ) const results = findQuestions(scope) console.info( `category count=${count}, results=${results.length}` )两者数量不同,优先检查是不是一个函数按全部题库计数,另一个函数按单个题库过滤。此类问题通常不是 UI 渲染错误,而是统计口径不一致。
十四、常见问题与排查顺序
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 点击“加法”进入空结果 | 路由是否传add | 不要传中文名或临时序号 |
| 卡片数量与结果数不同 | 计数和过滤的题库范围 | 复用同一个CategoryScope |
| 二年级入口出现其他年级题 | 是否缺少bankId | 路由携带题库维度并做交集 |
| 修改分类名后搜索失效 | 是否按name判断 | 业务层统一使用稳定type |
| 新分类没有图标或颜色 | 多处switch是否漏改 | 集中CategoryPresentation |
| 未知参数显示成正常分类 | 是否缺少运行时校验 | 白名单归一化并显式降级 |
| 平板仍显示两列 | onAreaChange是否更新宽度 | 检查根容器尺寸与阈值 |
| 最后一行被手势区遮挡 | 底部 padding 是否固定 | 合并系统避让区高度 |
| “限时挑战”时长不一致 | 是否混淆题型与考试时长 | 分离QuestionType和 duration |
排查时先打印“入口参数、归一化结果、筛选范围、结果数量”四项,不要先改卡片样式。分类错误多数来自数据契约,而不是视觉层。
十五、发布前的可复核清单
在 HarmonyOS 5.0 及以上设备或模拟环境中,可以按以下顺序验收:
- 七种分类都来自同一
CATEGORIES目录; - 首页与分类页使用相同
type,不存在名称映射差异; - 卡片数量由真实题目扫描得到;
- 全局分类的统计口径明确为全部题库;
- 若启用年级范围,计数与搜索都使用同一
bankId; - 搜索页拒绝未知
categoryType; - 训练页继续沿用题型条件,不在跳转时丢失范围;
speed题型没有被误写成固定考试时长;- 720vp 上下切换时列数正确;
- 长名称、零题分类、空结果和返回操作均可用;
- 底部内容不进入系统手势或导航区域;
- 静态训练建议和静态芯片没有被描述成可交互或智能推荐。
十六、题库 ID 与年级 ID 不能混用
当前工程里,二年级题库的Bank.id是b_grade2,对应的regionId才是grade2。两者虽然只差前缀,语义却不同:前者用于getQuestions(bankId)、练习页参数和进度记录,后者用于年级目录归属。建议扩展年级筛选时,必须先决定路由传题库 ID 还是年级 ID,不能把grade2直接交给只认识b_grade2的取题函数。
若一个年级未来可能对应多个题库,年级入口更适合传regionId,目标页先筛选BANKS,再依次调用getQuestions(bank.id);若当前产品明确是一年级一个题库,则传bankId更直接。无论选择哪种契约,字段名、示例值和消费函数必须一致,不能用gradeId字段装入题库 ID,也不能让页面标题替代真实筛选条件。
十七、编译期收紧与运行时校验要分两层完成
把Question.type从普通字符串收紧为QuestionType,可以在工程内部阻止大部分拼写错误,但路由参数仍来自运行时。旧版本页面、外部拉起、恢复栈或手工构造的参数都可能传入未知值,因此目标页仍要使用白名单检查。校验失败时应显示“分类参数无效”并停止搜索,而不是把未知值当成空条件后返回全部题目。
同样,bankId必须在BANKS中查到,且当前点击的题目确实属于这个题库。startQuestionId只是让一题在随机题集中优先出现,不应绕过题型和题库范围。更稳的顺序是先验证题库,再按questionType过滤候选集,最后在候选集中寻找startQuestionId。这样参数被篡改或历史链接失效时,不会把不属于当前分类的题目塞进训练。
十八、用同一批断言核对数量、结果与训练范围
分类参数最适合用纯函数测试,而不是只看页面能否跳转。准备包含多个题库、多个题型的固定小数据集后,先断言全局add数量,再断言b_grade2 + add的交集数量;随后把同一范围交给结果页和训练页,比较题目 ID 集合。卡片数量、搜索结果和训练候选集三者必须来自同一规则,任何一处多出或少掉都说明契约发生漂移。
还要覆盖无题分类、未知题型、未知题库、题目 ID 不在候选集、同名中文分类、清空搜索词后退出分类模式,以及从系统返回栈恢复页面。多设备测试则重点检查长分类名、两列与三列切换、底部安全区和空状态按钮是否可达。以上属于建议验收方案;本轮只完成源码静态复核,没有把这些场景描述为已经运行通过。
总结
分类训练的关键不是多画几张彩色卡片,而是让“目录、路由、查询、训练、统计”共享同一套稳定参数。当前口算王源码已经用add、sub、mul、div、mixed、word、speed串起首页、分类页与搜索页,并通过扫描真实题目同步分类数量。
需要继续增强时,应把年级或题库作为独立维度加入CategoryScope:全局入口只传题型,年级入口同时传type + bankId,计数和结果都使用相同交集。再配合运行时白名单、统一展示配置、响应式网格和明确空状态,分类训练才能从“看起来能点”变成范围可解释、结果可验证、后续可扩展的 HarmonyOS 工程能力。
CSDN-SERIES:ALL-163188257