资讯详情

【口算王|08】HarmonyOS ArkTS 分类训练实战:让年级与运算分类参数保持一致

📅 2026/10/27 1:39:58 | 华诺云谱 👁 阅读
【口算王|08】HarmonyOS ArkTS 分类训练实战:让年级与运算分类参数保持一致
【口算王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: Mapstring, number new Mapstring, 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()已执行或者把计数改成查询时计算。选择哪一种都可以关键是不要让可变全局数组的更新时间隐含在某个无关页面里。十三、测试矩阵要覆盖范围而不只是点击成功分类训练的核心验证不是“卡片能打开”而是结果范围是否符合参数。建议用下面的矩阵回归用例输入预期结果全局加法typeadd全部题库中的加法题二年级加法typeadd, bankIdb_grade2仅二年级加法题全局应用题typeword全部应用题未知题型typeaddition拒绝参数或安全降级未知题库typeadd, bankIdmissing明确提示或按既定策略降级零题分类合法 type结果为空空状态可返回、可换分类宽屏布局pageWidth720三列卡片且无重叠窄屏布局pageWidth720两列卡片且文本可读还可以验证“计数与结果一致”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
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑