题库类应用经常会同时出现两套入口:一套按题库进入,比如“高频错词集锦”“介词搭配大全”;另一套按题型进入,比如“语法纠错”“拼写纠错”“句型表达”。如果两套入口各写一套列表、各写一套筛选、各自传参,后续最容易出现的问题就是:分类页点进去和搜索页搜出来的结果不一致,题库列表排序和首页入口不一致,导航参数在页面之间变成一堆无法校验的散字段。
句匠项目的做法比较克制。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这条链路继续扩展,而不会把页面职责搅在一起。