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

在口算应用里,"分类训练"看起来只是把加法、减法、乘法、除法做成几张卡片。真正容易出错的地方,却发生在卡片点击之后:页面显示的是"加法练习",路由传递的是 add,搜索页按 Question.type 过滤,而训练页还要继续沿用同一条件。只要其中一层改用中文名称、临时序号或另一套枚举,用户看到的分类和实际进入的题目就可能不一致。

另一个更隐蔽的问题是年级范围。分类页可能让用户误以为"当前年级的加法",但如果路由只传 categoryType,搜索逻辑就会扫描全部题库。此时结果本质上是"所有年级中的加法题",并不是"二年级加法"。要实现年级与运算分类的交集,必须把两个维度都写进参数契约,而不能仅靠页面标题暗示。

本文基于口算王项目 D:\huawei\one16-11 的真实源码,复核 CategoryPage.etsHomePage.etsSearchPage.etsPracticePage.etsMockBanks.ets。项目包名 com.jiaweikang.one16 是本文草稿核验使用的唯一标记。当前代码已经实现七类题型、分类计数、响应式卡片和按题型进入搜索结果;年级与分类联动则作为可落地的增强方案单独说明,不把设计建议写成现成功能。

本文重点解决四件事:

  • 用稳定的 type 作为分类主键,避免展示文案参与业务判断;
  • 解释分类计数、首页入口、分类页与搜索页如何共享同一数据源;
  • 明确当前"跨全部题库分类"和"年级+分类交集"的边界;
  • 给出可验证的路由参数、归一化、响应式布局与回归测试方法。

一、七种分类不是七段文案,而是七个稳定标识

项目的分类目录来自 MockBanks.ets 中的 CATEGORIES。当前真实分类包括:

type 页面名称 业务含义
add 加法练习 加法类题目
sub 减法练习 减法类题目
mul 乘法口诀 乘法类题目
div 除法练习 除法类题目
mixed 混合运算 混合运算题
word 应用题 应用题
speed 限时挑战 速度训练类题目

这里最值得保留的设计是:业务判断依赖 type,用户界面显示 name。两者职责不同。

ts 复制代码
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 只是协议的可读视图。

进一步收紧类型时,可以把字符串联合类型放到共享模型中:

ts 复制代码
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 累计。

ts 复制代码
export function syncCatalogCounts(): void {
  const counts: Record<string, number> = {}

  BANKS.forEach((bank: Bank) => {
    bank.chapters.forEach((chapter: Chapter) => {
      chapter.questions.forEach((question: Question) => {
        counts[question.type] =
          (counts[question.type] || 0) + 1
      })
    })
  })

  CATEGORIES.forEach((category: Category) => {
    category.count = counts[category.type] || 0
  })
}

这段逻辑把"题库是事实源"落实到了分类目录。新增一道 div 题后,只要重新同步目录,除法分类数量会自动增加;没有某种题型时,数量回落为 0,不会显示 undefined

但也要看清统计口径:它遍历的是全部 BANKS。因此分类页上的"加法练习 300 题"代表所有题库中 type === 'add' 的总量,不是当前年级数量。如果产品要显示"二年级加法 42 题",计数函数必须增加题库范围:

ts 复制代码
function countQuestions(
  type: QuestionType,
  bankId?: string
): number {
  return BANKS
    .filter((bank: Bank) =>
      !bankId || bank.id === bankId
    )
    .flatMap((bank: Bank) => bank.chapters)
    .flatMap((chapter: Chapter) => chapter.questions)
    .filter((question: Question) =>
      question.type === type
    )
    .length
}

计数范围必须与进入结果页后的筛选范围一致。否则卡片写着 42 题,进入后却出现 300 题,用户会把它理解为数据错误。

三、首页和分类页共享同一份目录

源码中有两个分类入口:HomePage.ets 的横向分类区,以及 CategoryPage.ets 的完整分类网格。两个入口都遍历 CATEGORIES,并用 cat.type 作为路由值。

ts 复制代码
ForEach(CATEGORIES, (cat: Category) => {
  this.CategoryItem(cat)
}, (cat: Category) => cat.type)

这里的 cat.type 同时承担三个职责:

  1. 作为 ForEach 的稳定 key;
  2. 作为题型筛选条件;
  3. 作为跨页面路由参数。

复用目录可以避免首页写一套"加减乘除",分类页再写一套"加法、减法、乘法、除法"。当新增 wordspeed 时,两个页面都能看到同一项,计数也来自同一处。

页面仍然可以拥有不同布局。首页适合短入口,分类页适合展示完整名称、描述、图标和数量。共享的是业务目录,不是强行共享全部 UI。把数据与视觉组件分开,能让多设备布局更容易演进。

四、当前路由只传题型,不传年级

分类卡点击后的真实路由如下:

ts 复制代码
router.pushUrl({
  url: 'pages/SearchPage',
  params: {
    categoryType: cat.type,
    categoryName: cat.name
  }
})

categoryType 是查询值,categoryName 是显示值。SearchPage 出现时读取参数,并自动执行一次搜索:

ts 复制代码
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。这是合理的,因为用户不需要理解内部枚举,代码也不依赖可变中文。

需要特别说明的是,路由参数里没有 bankIdgradeIdregionId。因此当前分类结果会扫描全部题库,只按题型过滤。文章标题中的"让年级与运算分类参数保持一致",指的是如何把现有题型协议扩展成两个维度都明确的契约,而不是宣称源码已经完成年级交集筛选。

五、搜索页按 Question.type 精确过滤

分类模式与自由文本搜索不是一回事。分类模式收到 categoryType 后,应按题目模型的 type 精确匹配:

ts 复制代码
private searchByCategory(type: string): void {
  const matched: Question[] = []

  BANKS.forEach((bank: Bank) => {
    bank.chapters.forEach((chapter: Chapter) => {
      chapter.questions.forEach((question: Question) => {
        if (question.type === type) {
          matched.push(question)
        }
      })
    })
  })

  this.questionResults = matched
}

精确比较比对标题做模糊匹配可靠。例如一道题的题干里出现"加法",不代表它的训练类型一定是 add;应用题也可能包含加法运算。模型字段决定分类,题干只负责内容。

结果卡进入 PracticePage 时,也要继续传递真实题型或目标题目标识,不能把条件丢在搜索页:

ts 复制代码
router.pushUrl({
  url: 'pages/PracticePage',
  params: {
    questionType: this.categoryType,
    targetQuestionId: question.id
  }
})

训练页收到 questionType 后再次按同一枚举取题,并让 targetQuestionId 对应的题目优先出现。这样"分类入口 -> 搜索结果 -> 开始训练"使用的是一条完整协议。

六、年级和题型是两个正交维度

把"二年级加法"建模成一个长字符串,例如 grade2_add,短期看起来省事,后续却会迅速膨胀。六个年级乘七种题型就是四十二个组合;再加入地区、教材版本或难度,组合数量继续增长。

更稳妥的做法是保持维度独立:

ts 复制代码
export interface CategoryScope {
  type: QuestionType
  bankId?: string
  regionId?: string
}

export interface CategoryRouteParams {
  categoryType: QuestionType
  categoryName: string
  bankId?: string
  bankName?: string
}

当用户从"二年级题库"内部点击加法时,路由可以携带交集范围:

ts 复制代码
const params: CategoryRouteParams = {
  categoryType: 'add',
  categoryName: '加法练习',
  bankId: 'grade-2',
  bankName: '二年级'
}

router.pushUrl({
  url: 'pages/SearchPage',
  params
})

当用户从全局分类页点击时,则只传题型:

ts 复制代码
const params: CategoryRouteParams = {
  categoryType: 'add',
  categoryName: '加法练习'
}

两种入口使用同一个参数结构,但范围不同:有 bankId 就做交集,没有就做全局题型筛选。这比用页面来源判断更稳定,因为目标页只需要解释参数,不必猜测用户从哪个页面来。

七、把筛选条件收敛成一个纯函数

如果搜索页和训练页分别手写过滤,规则很容易漂移。可以把题型与题库范围组合成纯函数,供多个页面复用。

ts 复制代码
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
}

然后用同一方法生成结果:

ts 复制代码
function findQuestions(
  scope: CategoryScope
): Question[] {
  const result: Question[] = []

  BANKS.forEach((bank: Bank) => {
    bank.chapters.forEach((chapter: Chapter) => {
      chapter.questions.forEach((question: Question) => {
        if (matchesScope(question, bank, scope)) {
          result.push(question)
        }
      })
    })
  })

  return result
}

纯函数不依赖页面状态,适合单元测试。它同时回答了三个问题:什么题型、哪个题库、哪个地区。未来增加难度时,也能在 CategoryScope 中增加 difficulty,而不是复制第三套过滤循环。

八、路由边界必须拒绝未知分类

ArkTS 的接口只在编译阶段提供约束,router.getParams() 仍然是运行时输入。旧版本页面、错误跳转或异常状态都可能传入未知字符串。目标页应先归一化,再进入查询。

ts 复制代码
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
}

页面读取参数时可显式处理失败:

ts 复制代码
const params =
  router.getParams() as SearchParams | undefined
const type = normalizeQuestionType(
  params?.categoryType
)

if (!type) {
  this.searched = false
  this.categoryType = ''
  this.questionResults = []
  return
}

未知分类不应伪装成一个"正常但没有题"的类别。回到搜索初始态、显示参数错误提示,或安全返回上一页,都比默默制造空结果更容易排查。

bankId 也要执行同样校验。不能仅凭路由带了一个字符串,就认为它对应真实题库:

ts 复制代码
function findBank(bankId?: string): Bank | undefined {
  if (!bankId) {
    return undefined
  }
  return BANKS.find((bank: Bank) =>
    bank.id === bankId
  )
}

bankId 无效时,产品要明确选择:退回全局分类,还是提示题库不存在。不要在不同页面里各自猜测。

九、分类视觉信息应集中,避免多个 switch 漂移

CategoryPage.ets 会为不同类型提供副标题、颜色、背景和图标。当前实现中,图标使用共享的 questionTypeIcon,部分副标题和颜色则由页面内的 switch 返回。只要分类增加或改名,多个分支就可能漏改。

可以把展示信息和分类目录放在同一份配置中:

ts 复制代码
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 记录页面宽度,并根据阈值调整卡片宽度:

ts 复制代码
private categoryCardWidth(): string {
  return this.pageWidth >= 720
    ? '31.5%'
    : '47.8%'
}

宽屏每行约三张卡,窄屏每行两张卡。百分比宽度给卡间距留下余量,比固定 vp 宽度更适合手机、平板和窗口化场景。根容器监听面积变化后,折叠、旋转或拖拽窗口都会触发布局重新计算。

底部还需要避开系统导航区域。项目使用系统导航指示区高度与最小安全值取较大者:

ts 复制代码
private bottomSafePadding(): number {
  return Math.max(
    PAGE_BOTTOM_MIN,
    this.navigationIndicatorHeight
  )
}

这部分不是装饰细节。分类列表如果只给固定小间距,最后一行卡片可能进入手势区,造成难以点击或内容被遮挡。多设备验收时至少要检查:

  • 手机竖屏两列是否完整;
  • 手机横屏和小窗口是否溢出;
  • 720vp 以上窗口是否稳定切换三列;
  • 最后一张卡是否能滚动到系统导航区上方;
  • 长分类名称是否截断或挤压数量标签。

十一、静态"训练建议"不能写成个性化推荐

分类页底部还有"训练建议"区域,展示"课后练习""每日打卡""考前冲刺"等场景。源码中这些是固定文本,没有读取学习记录、正确率、年级或薄弱题型,也没有推荐算法。

因此准确的产品描述应是"提供训练场景提示",而不是"根据学习情况智能推荐训练"。如果后续确实要做个性化,可以定义可核验的输入:

ts 复制代码
interface TrainingProfile {
  bankId: string
  weakTypes: QuestionType[]
  recentAccuracy: number
  dailyCompleted: boolean
}

interface TrainingSuggestion {
  type: QuestionType
  reason: string
  questionCount: number
}

只有当页面根据真实训练记录生成 TrainingSuggestion,并能解释推荐原因时,才适合使用"个性化建议"。静态场景文案本身不等于推荐能力。

同样,页面顶部的"每日练习""错题巩固""限时挑战"芯片当前也是展示元素,不是可点击筛选器。测试时不要把它们列入交互功能,发布说明也不应声称用户可以切换这些模式。

十二、分类页状态与无题分类要显式处理

当前本地目录加载很快,没有网络请求,但分类数量仍可能为零。卡片是否可点击需要明确策略:

ts 复制代码
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=grade-2 仅二年级加法题
全局应用题 type=word 全部应用题
未知题型 type=addition 拒绝参数或安全降级
未知题库 type=add, bankId=missing 明确提示或按既定策略降级
零题分类 合法 type,结果为空 空状态可返回、可换分类
宽屏布局 pageWidth>=720 三列卡片且无重叠
窄屏布局 pageWidth<720 两列卡片且文本可读

还可以验证"计数与结果一致":

ts 复制代码
const scope: CategoryScope = {
  type: 'add',
  bankId: 'grade-2'
}

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 上下切换时列数正确;
  • 长名称、零题分类、空结果和返回操作均可用;
  • 底部内容不进入系统手势或导航区域;
  • 静态训练建议和静态芯片没有被描述成可交互或智能推荐。

总结

分类训练的关键不是多画几张彩色卡片,而是让"目录、路由、查询、训练、统计"共享同一套稳定参数。当前口算王源码已经用 addsubmuldivmixedwordspeed 串起首页、分类页与搜索页,并通过扫描真实题目同步分类数量。

需要继续增强时,应把年级或题库作为独立维度加入 CategoryScope:全局入口只传题型,年级入口同时传 type + bankId,计数和结果都使用相同交集。再配合运行时白名单、统一展示配置、响应式网格和明确空状态,分类训练才能从"看起来能点"变成范围可解释、结果可验证、后续可扩展的 HarmonyOS 工程能力。

本文由 AI 辅助整理,所有功能边界、代码路径与结论均依据项目真实源码复核。

相关推荐
光锥智能1 小时前
华为新麒麟芯片、鸿蒙7问世,手机底层竞赛升级
华为·智能手机·harmonyos
贾伟康2 小时前
【口算王|09】HarmonyOS ArkTS 学习统计实战:计算连续训练和正确率趋势
harmonyos·arkts·数据可视化·preferences·学习统计
lqj_本人2 小时前
Flutter 鸿蒙实战:用 wakelock_plus 三方库给阅读页加上屏幕常亮
flutter·华为·harmonyos
aqi0013 小时前
鸿蒙版本的JSBridge兼容与安卓配套的H5啦
android·华为·harmonyos·鸿蒙·移动应用
熊猫钓鱼>_>14 小时前
Flutter app_settings 鸿蒙适配实战:Intent 体系到 Want 的跨越
flutter·华为·harmonyos·openharmony·intent·want
贾伟康17 小时前
【句匠|20】HarmonyOS ArkTS AppGallery 发布复查实战:核对包名、版本、设备、素材和离线声明
harmonyos·arkts·应用上架·appgallery·发布审核
贾伟康17 小时前
【句匠|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
harmonyos·arkts·数据持久化·appstorage·preferences
贾伟康20 小时前
【句匠|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化
harmonyos·arkts·arkui·响应式布局·多设备适配
小雨青年20 小时前
【HarmonyOS 7 平行视界深度实战】03 购物模式怎么实现连续浏览和左右推挤
华为·harmonyos