news 2026/10/27 18:36:39

【句匠|10】HarmonyOS ArkTS 分类句库实战:复用列表结构并保持导航参数类型安全

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【句匠|10】HarmonyOS ArkTS 分类句库实战:复用列表结构并保持导航参数类型安全

题库类应用经常会同时出现两套入口:一套按题库进入,比如“高频错词集锦”“介词搭配大全”;另一套按题型进入,比如“语法纠错”“拼写纠错”“句型表达”。如果两套入口各写一套列表、各写一套筛选、各自传参,后续最容易出现的问题就是:分类页点进去和搜索页搜出来的结果不一致,题库列表排序和首页入口不一致,导航参数在页面之间变成一堆无法校验的散字段。

句匠项目的做法比较克制。BankListPage.ets负责题库列表和排序,CategoryPage.ets负责题型分类入口,真正按categoryType过滤题目的动作交给SearchPage.ets。分类页不重复写题目列表,也不自己扫描题库题目;它只从MockBanks.ets读取CATEGORIES,渲染分类卡片,然后把{ categoryType, categoryName }传给搜索页。

本文唯一标记:com.jiaweikang.one18。以下内容只基于真实源码:entry/src/main/ets/pages/CategoryPage.ets、entry/src/main/ets/views/BankListPage.ets、entry/src/main/ets/pages/SearchPage.ets、entry/src/main/ets/mock/MockBanks.ets和entry/src/main/ets/common/constants/ThemeConstants.ets。边界也先说明:当前分类句库入口是本地分类筛选,不是云端分类服务;分类页不直接展示题目详情;点击分类后进入搜索页,由搜索页按Question.type过滤;题库列表页只做题库排序和复用BankCard,不做分类筛选。

一、分类入口和题库列表为什么要分开

实际项目里,分类入口最常见的误区是“分类页也做一个题目列表”。短期看用户少跳一步,长期会导致三个重复:

重复点后果句匠源码的处理
重复渲染题目结果分类页和搜索页样式不一致分类页跳转SearchPage
重复扫描题库数据筛选口径容易分叉统一由搜索页过滤Question.type
重复管理题库卡片排序和自适应布局维护两份题库列表页复用BankCard

当前链路可以拆成五步。

这条链路的关键不是页面数量,而是职责分配:分类页只做入口,题库列表只做题库集合,搜索页只做结果筛选。三者都依赖MockBanks的静态数据,但不互相复制业务逻辑。

二、MockBanks 把分类和题库目录集中起来

分类页和题库列表页的共同底座是MockBanks.ets。它导出CATEGORIES和BANKS,前者是题型入口,后者是题库目录。

export const CATEGORIES: Category[] = [ { type: 'vocab', name: '语法纠错', count: 0 }, { type: 'guess', name: '拼写纠错', count: 0 }, { type: 'diff', name: '时态纠错', count: 0 }, { type: 'dialog', name: '介词搭配', count: 0 }, { type: 'culture', name: '句型表达', count: 0 }, { type: 'proverb', name: '英文聊天', count: 0 }, { type: 'region', name: '考试专题', count: 0 } ]

type是分类筛选的真正业务字段,name是显示文案,count是分类题量。当前源码里分类数量会在题库数据同步过程中回填,分类页只消费结果,不重新计算。

题库目录则包含题库 ID、地区 ID、题库名、封面、总题数、热度和章节列表。

export const BANKS: Bank[] = [ { id: 'b_sichuan', regionId: 'sichuan', name: '基础语法纠错', cover: $r('app.media.img_bank_cover_sichuan'), totalCount: 0, accuracy: 0, hot: 98, chapters: SICHUAN_CHAPTERS } ]

把分类目录和题库目录放在同一个 mock 数据模块里,有一个现实好处:页面之间不会各自维护一份类型表。CategoryPage、SearchPage、BankListPage都使用同一组 ID 和题库数据,导航参数就更容易保持一致。

三、CategoryPage 只负责分类入口

CategoryPage的结构很清楚:顶部栏、说明文字、分类卡片网格、题型说明卡片。

@Entry @Component struct CategoryPage { @State pageWidth: number = 360 private itemWidth(): string { return this.pageWidth >= 720 ? '24%' : '48%' } build() { Column() { TopBar({ title: '全部分类' }) Scroll() { Column({ space: 18 }) { this.CategoryGrid() this.TypeDescriptionPanel() } } .layoutWeight(1) } .width('100%') .height('100%') .backgroundColor(Colors.BACKGROUND) } }

上面这段是按源码结构抽出来的表达。真实文件中没有单独命名CategoryGrid和TypeDescriptionPanel,但页面职责就是这两块。为了保持 ArkUI UI 树可读,分类卡片本身被提取成CategoryCard(cat)。

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { ForEach(CATEGORIES, (cat: Category) => { this.CategoryCard(cat) }, (cat: Category) => cat.type) }

这里的ForEachkey 使用cat.type,比使用数组下标更稳。分类顺序可以调整,但同一个分类的身份不变。后续如果需要在分类卡片上加选中态、动画或缓存,稳定 key 能减少不必要的刷新问题。

四、分类卡片把显示字段和导航参数分清楚

CategoryCard是分类页最关键的 Builder。它显示图标、名称和题量,并在点击时把分类参数传给搜索页。

@Builder CategoryCard(cat: Category) { Column({ space: 10 }) { Image(questionTypeIcon(cat.type)) .width(52) .height(52) .borderRadius(26) .objectFit(ImageFit.Cover) Text(cat.name) .fontSize(Sizes.BODY_FONT) .fontWeight(FontWeight.Bold) .fontColor(Colors.TEXT_PRIMARY) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(`${cat.count} 题`) .fontSize(Sizes.CAPTION_FONT) .fontColor(Colors.TEXT_HINT) } .width(this.itemWidth()) .height(142) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .backgroundColor(Colors.SURFACE) .borderRadius(Sizes.CARD_RADIUS) .margin({ bottom: 12 }) .onClick(() => { router.pushUrl({ url: 'pages/SearchPage', params: { categoryType: cat.type, categoryName: cat.name } }) }) }

这段代码有两个值得保留的边界。

第一,图标按cat.type查,而不是按cat.name查。显示文案可以改,类型 ID 不应该随便变。

第二,导航参数同时传categoryType和categoryName。categoryType用于筛选,categoryName用于回显。这样搜索页不用通过类型再反查名称,分类入口的显示体验更直接。

可以把参数含义写成一个接口,降低后续误传风险。

interface CategorySearchParams { categoryType: string categoryName: string } const params: CategorySearchParams = { categoryType: cat.type, categoryName: cat.name } router.pushUrl({ url: 'pages/SearchPage', params })

当前源码已经在SearchPage里定义了接收侧接口,这类接口如果进一步放到公共模型中,分类页和搜索页就能共享同一份参数约束。

五、SearchPage 是分类筛选的统一落点

分类页点击后进入SearchPage。搜索页通过SearchParams接收分类参数。

interface SearchParams { categoryType?: string categoryName?: string } aboutToAppear(): void { const params = router.getParams() as SearchParams if (params && params.categoryType) { this.categoryType = params.categoryType this.categoryName = params.categoryName || '' this.keyword = this.categoryName this.doSearch() } }

这段接收逻辑做了三件事:

动作目的
保存categoryType后续按题型筛选
保存categoryName搜索框和分类提示回显
设置keyword让用户看到当前入口含义

真正的筛选在doSearch()中完成。分类模式下,搜索页不会搜索题库名称,而是扫描每个题库的题目,找出q.type === categoryType的题。

private doSearch(): void { const kw = this.keyword.trim().toLowerCase() this.bankResults = this.categoryType ? [] : BANKS.filter(b => b.name.toLowerCase().includes(kw)) const qs: Question[] = [] for (const bank of BANKS) { const items = getQuestions(bank.id) for (const q of items) { if (this.categoryType && q.type === this.categoryType) { qs.push(q) } else if (!this.categoryType && q.stem.toLowerCase().includes(kw)) { qs.push(q) } if (qs.length >= 20) break } if (qs.length >= 20) break } this.questionResults = qs }

这里有一个产品边界:分类模式只返回题目结果,不返回题库结果。也就是说,用户从“语法纠错”进入后,看到的是该类型的题目卡片,而不是所有包含语法题的题库列表。这个边界必须如实描述,不能说分类页已经实现了题库级分组结果。

六、分类参数变化时要清理分类态

分类入口进入搜索页后,搜索框会显示分类名称。如果用户手动修改输入内容,搜索页会清理分类态,避免“关键词搜索”和“分类筛选”混在一起。

TextInput({ placeholder: '搜索地区、题库、题目', text: this.keyword }) .onChange((value: string) => { this.keyword = value if (this.categoryType && value !== this.categoryName) { this.categoryType = '' this.categoryName = '' } })

这个细节很实用。假设用户从“拼写纠错”进入搜索页,然后把输入框改成environment。如果不清理categoryType,页面仍会按拼写纠错分类过滤,用户以为自己在做关键词搜索,结果实际上还是分类结果。源码通过输入变化时清空分类态,避免了这个歧义。

分类提示区域也提供了清除入口。

if (this.categoryType) { Row() { Text('当前分类') Text(this.categoryName) Blank() Text('清除') .onClick(() => { this.categoryType = '' this.categoryName = '' this.keyword = '' this.doSearch() }) } }

这里的交互原则是:分类入口可以帮助用户快速定位,但用户随时可以退回普通搜索。搜索页不是被分类页锁死的结果页。

七、BankListPage 复用题库卡片和排序逻辑

分类页解决题型入口,题库列表页解决题库入口。BankListPage不是分类页的子页面,它是主 Tab 中的题库列表视图。整体结构可以用下面这张图理解。

@Component export struct BankListPage { @State sortAsc: boolean = true @State pageWidth: number = 360 @StorageLink('currentBreakpoint') currentBp: string = 'sm' private filteredBanks(): Bank[] { const result = [...BANKS] if (this.sortAsc) { result.sort((a, b) => b.hot - a.hot) } else { result.sort((a, b) => b.totalCount - a.totalCount) } return result } }

这里的方法名叫filteredBanks,但当前源码实际做的是排序,不做搜索过滤。排序规则有两种:

sortAsc排序含义排序字段
true按热度优先b.hot - a.hot
false按题数优先b.totalCount - a.totalCount

它先复制BANKS,再排序。

const result = [...BANKS] result.sort((a, b) => b.hot - a.hot)

这个细节能避免直接修改全局BANKS的顺序。题库目录是公共数据源,页面排序应该产生视图层副本,而不是改变底层数组。

八、题库列表页的搜索入口只跳转,不自己搜索

BankListPage.Header()右侧有一个搜索按钮。它只负责跳转SearchPage,不在题库列表页里写搜索输入框。

Stack() { Image($r('app.media.ic_common_search')) .width(24) .height(24) .objectFit(ImageFit.Contain) } .width(Sizes.TOUCH_TARGET) .height(Sizes.TOUCH_TARGET) .borderRadius(Sizes.TOUCH_TARGET / 2) .backgroundColor(Colors.SURFACE) .onClick(() => { router.pushUrl({ url: 'pages/SearchPage' }) })

这样做的好处是搜索功能只有一个落点。分类页进入搜索页时带参数,题库列表页进入搜索页时不带参数,搜索页根据是否存在categoryType决定是分类模式还是普通关键词模式。

来源跳转参数搜索页行为
分类页{ categoryType, categoryName }自动执行分类筛选
题库列表页无参数显示搜索首页和热门入口
用户手动搜索输入关键词搜题库名称和题目题干

这个设计比在每个入口都做一套搜索更可维护。

九、列表结构的复用体现在 BankCard,而不是复制 UI

题库列表页没有直接写题库卡片 UI,而是复用公共组件BankCard。

ForEach(this.filteredBanks(), (bank: Bank) => { ListItem() { BankCard({ bank: bank }) } .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE }) }, (bank: Bank) => `${bank.id}_${this.sortAsc}`)

宽屏布局中也是复用同一个组件。

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { ForEach(this.filteredBanks(), (bank: Bank) => { Column() { BankCard({ bank: bank }) } .width('32%') .margin({ bottom: 12 }) }, (bank: Bank) => `${bank.id}_${this.sortAsc}`) }

这就是“复用列表结构”的核心:列表容器可以根据设备形态在List和Flex之间切换,但单个题库卡片仍然使用同一套BankCard。这样后续如果要调整题库封面、进度条、收藏状态或点击行为,只改组件即可。

ForEach的 key 使用${bank.id}_${this.sortAsc}。排序切换时 key 会变化,列表能按当前排序状态重新组织。对于简单列表这足够可控;如果后续需要保留卡片内部状态,则可以改为稳定的bank.id,并让排序由数据顺序驱动。

十、分类页和题库页使用不同的自适应策略

CategoryPage的分类卡片宽度由页面宽度直接决定。

private itemWidth(): string { return this.pageWidth >= 720 ? '24%' : '48%' }

题库列表页的宽屏判断则同时参考全局断点和页面宽度。

private useGridLayout(): boolean { return this.currentBp === 'lg' && this.pageWidth >= 700 }

两者差异是合理的。

页面判断方式原因
分类页pageWidth >= 720分类卡片规则简单,只需要两列或四列
题库列表页currentBp === 'lg' && pageWidth >= 700题库卡片信息更多,需要结合全局断点

两个页面都通过onAreaChange更新宽度。

.onAreaChange((oldArea: Area, newArea: Area) => { const width = Number(newArea.width) if (width > 0) this.pageWidth = width })

这能覆盖分屏、旋转和 2in1 窗口变化。对于 HarmonyOS 多设备应用,这类页面级宽度感知比固定vp宽度更稳。

十一、图标映射要跟 type 绑定

分类图标来自questionTypeIcon(type)。它在ThemeConstants.ets中维护。

export function questionTypeIcon(type: string): Resource { switch (type) { case 'vocab': return $r('app.media.ic_category_vocab') case 'audio': return $r('app.media.ic_category_vocab') case 'guess': return $r('app.media.ic_category_guess') case 'diff': return $r('app.media.ic_category_diff') case 'dialog': return $r('app.media.ic_category_dialog') case 'culture': return $r('app.media.ic_category_culture') case 'proverb': return $r('app.media.ic_category_proverb') case 'region': return $r('app.media.ic_category_region') default: return $r('app.media.ic_category_vocab') } }

这个函数把资源选择集中在主题常量文件里,避免分类页到处写$r('app.media.xxx')。如果后续 UI 换一套图标,只需要改映射函数。

audio被合并到vocab图标,这也符合MockBanks里的注释边界:音频题型已经下线并自动并入语法纠错。文章不能把audio说成一个独立分类入口,因为CATEGORIES里并没有单独列出它。

十二、导航参数类型安全的最小改造建议

当前源码接收侧有SearchParams,发送侧直接写对象字面量。

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

在 ArkTS 项目里,更稳的做法是把参数模型放到共享位置,例如common/models/RouteParams.ets。

export interface SearchParams { categoryType?: string categoryName?: string } export function makeCategorySearchParams(type: string, name: string): SearchParams { return { categoryType: type, categoryName: name } }

分类页使用构造函数:

router.pushUrl({ url: 'pages/SearchPage', params: makeCategorySearchParams(cat.type, cat.name) })

搜索页继续使用同一个接口:

const params = router.getParams() as SearchParams if (params && params.categoryType) { this.categoryType = params.categoryType this.categoryName = params.categoryName || '' }

这不是当前源码已有文件,而是基于当前结构的低风险扩展。它能防止后续有人把字段写成typeName、categoryId或catType,导致搜索页收不到参数。

十三、验证分类句库链路

分类句库链路的验证要覆盖分类入口、搜索页接收、普通搜索回退和题库列表复用。

验证项操作期望结果
分类卡片渲染打开全部分类页看到 CATEGORIES 中的所有分类卡片
分类跳转点击“语法纠错”等分类进入 SearchPage,显示当前分类并自动搜索
分类筛选检查结果卡片结果题目的q.type与categoryType一致
手动改关键词在搜索框改成其他词categoryType被清空,进入普通关键词搜索
清除分类点击当前分类的清除入口分类名和关键词清空
题库排序在题库页切换排序按钮热度优先和题数优先切换
宽屏布局平板或 2in1 宽窗口打开题库页使用 Flex 三列,分类页使用四列卡片

排查时优先看三个点:router.pushUrl的参数、SearchPage.aboutToAppear()的接收结果、doSearch()中的q.type === this.categoryType判断。不要先去改 UI 卡片。

十四、常见问题与修复方向

问题可能原因修复方向
点击分类后没有结果categoryType和题目q.type不一致检查 CATEGORIES 的 type 与题目数据
搜索页显示分类名但结果像普通搜索doSearch没进入分类分支检查categoryType是否为空字符串
分类卡片图标错乱questionTypeIcon映射缺失在主题常量里补充 type 到资源映射
题库列表排序影响其他页面直接 sort 了BANKS保持[...BANKS]后再排序
宽屏卡片挤压百分比宽度和间距不匹配分类页保留 24%/48%,题库页保留 32%
长分类名溢出未设置省略保留maxLines(1)和textOverflow

还有一个容易混淆的问题:分类页并不是题库列表页的筛选器。它不会把BankListPage切成某个分类下的题库,而是跳到SearchPage展示题目结果。如果产品要做“按分类筛选题库”,需要新增题库到题型的聚合关系,而不是复用现在的categoryType搜索题目链路。

十五、总结:入口轻,筛选集中,列表复用

句匠的分类句库实现给出的工程方法是:分类入口保持轻量,筛选逻辑集中到搜索页,题库列表继续复用公共卡片和排序逻辑。

可复核的源码点包括:

  • CategoryPage从CATEGORIES渲染分类卡片。
  • CategoryCard使用questionTypeIcon(cat.type)和cat.count呈现分类信息。
  • 分类点击时通过router.pushUrl传递categoryType和categoryName。
  • SearchPage.aboutToAppear()接收分类参数并自动执行搜索。
  • SearchPage.doSearch()在分类模式下按q.type === categoryType筛选题目。
  • BankListPage通过[...BANKS]复制后排序,避免修改公共题库目录。
  • BankListPage在宽屏下用Flex三列,在窄屏下用List单列,并复用BankCard。

对于 HarmonyOS 5.0 以上 ArkTS 应用,这种结构比“每个入口都写一套列表”更稳。后续要增加更严格的路由参数模型、题型统计、分类下题库分组或章节级筛选,都可以沿着CategoryPage -> SearchPage -> MockBanks这条链路继续扩展,而不会把页面职责搅在一起。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 18:32:42

2000-2024年 上市公司-企业全要素生产率TFP数据(+代码+文献)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 18:31:50

2001-2024年 上市公司-供应链网络排名数据(xlsx)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 18:29:58

基于 Qwen Code Skills 实践构建自定义数据分析智能体

一、Qwen Code Skills Qwen Code 是一个面向开发者的智能编码助手,类似于 Claude Code,随着 Claude Code 推出 Skills 功能, Qwen Code 也迅速集成了相关功能,通过 Skills 可以将专业知识打包成可发现的功能。每个技能由一个 SKI…

作者头像 李华
网站建设 2026/10/27 18:28:52

YOLOv5 OBB旋转框训练实战:从DOTA标注到角度回归与部署

简介:面向目标检测开发者与计算机视觉学习者的 YOLOv5 OBB 旋转框训练演示工程,在 YOLOv5 上扩展倾斜框支持,用顶点与旋转角度描述树木、车辆等带方向目标,弥补轴对齐框定位不准的缺陷。压缩包共 583 个文件、约 445.58MB&#xf…

作者头像 李华
网站建设 2026/10/27 18:27:25

内容副业降本增效指南:小说、漫画、漫剧成本结构与流程优化

做内容副业这件事,前两年我也觉得是“多写多画就能多赚”,直到年终把账摊开算了一遍才发现,真正吃掉利润的不是产出不够,而是单位成本压不下来。小说、漫画、漫剧这三条线,看起来都是“内容创作”,骨子里的…

作者头像 李华
网站建设 2026/10/27 18:26:58

赣州30m DEM数字高程数据实战:从裁剪、坡度分析到流域提取与淹没模拟

简介:这份资源面向地理信息科学、城乡规划、环境与灾害研究等领域的从业者及学生,提供江西省赣州市30米分辨率的DEM数字高程数据,并附带本市行政范围的矢量边界文件,可用于地形分析、坡度坡向计算、水文建模与地图制图等场景。压缩…

作者头像 李华