题库类应用经常会同时出现两套入口:一套按题库进入,比如"高频错词集锦""介词搭配大全";另一套按题型进入,比如"语法纠错""拼写纠错""句型表达"。如果两套入口各写一套列表、各写一套筛选、各自传参,后续最容易出现的问题就是:分类页点进去和搜索页搜出来的结果不一致,题库列表排序和首页入口不一致,导航参数在页面之间变成一堆无法校验的散字段。
句匠项目的做法比较克制。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,前者是题型入口,后者是题库目录。
ts
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、题库名、封面、总题数、热度和章节列表。
ts
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 的结构很清楚:顶部栏、说明文字、分类卡片网格、题型说明卡片。
ts
@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)。
ts
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(CATEGORIES, (cat: Category) => {
this.CategoryCard(cat)
}, (cat: Category) => cat.type)
}
这里的 ForEach key 使用 cat.type,比使用数组下标更稳。分类顺序可以调整,但同一个分类的身份不变。后续如果需要在分类卡片上加选中态、动画或缓存,稳定 key 能减少不必要的刷新问题。
四、分类卡片把显示字段和导航参数分清楚
CategoryCard 是分类页最关键的 Builder。它显示图标、名称和题量,并在点击时把分类参数传给搜索页。
ts
@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 用于回显。这样搜索页不用通过类型再反查名称,分类入口的显示体验更直接。
可以把参数含义写成一个接口,降低后续误传风险。
ts
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 接收分类参数。
ts
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 的题。
ts
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
}
这里有一个产品边界:分类模式只返回题目结果,不返回题库结果。也就是说,用户从"语法纠错"进入后,看到的是该类型的题目卡片,而不是所有包含语法题的题库列表。这个边界必须如实描述,不能说分类页已经实现了题库级分组结果。
六、分类参数变化时要清理分类态
分类入口进入搜索页后,搜索框会显示分类名称。如果用户手动修改输入内容,搜索页会清理分类态,避免"关键词搜索"和"分类筛选"混在一起。
ts
TextInput({ placeholder: '搜索地区、题库、题目', text: this.keyword })
.onChange((value: string) => {
this.keyword = value
if (this.categoryType && value !== this.categoryName) {
this.categoryType = ''
this.categoryName = ''
}
})
这个细节很实用。假设用户从"拼写纠错"进入搜索页,然后把输入框改成 environment。如果不清理 categoryType,页面仍会按拼写纠错分类过滤,用户以为自己在做关键词搜索,结果实际上还是分类结果。源码通过输入变化时清空分类态,避免了这个歧义。
分类提示区域也提供了清除入口。
ts
if (this.categoryType) {
Row() {
Text('当前分类')
Text(this.categoryName)
Blank()
Text('清除')
.onClick(() => {
this.categoryType = ''
this.categoryName = ''
this.keyword = ''
this.doSearch()
})
}
}
这里的交互原则是:分类入口可以帮助用户快速定位,但用户随时可以退回普通搜索。搜索页不是被分类页锁死的结果页。
七、BankListPage 复用题库卡片和排序逻辑
分类页解决题型入口,题库列表页解决题库入口。BankListPage 不是分类页的子页面,它是主 Tab 中的题库列表视图。整体结构可以用下面这张图理解。

ts
@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,再排序。
ts
const result = [...BANKS]
result.sort((a, b) => b.hot - a.hot)
这个细节能避免直接修改全局 BANKS 的顺序。题库目录是公共数据源,页面排序应该产生视图层副本,而不是改变底层数组。
八、题库列表页的搜索入口只跳转,不自己搜索
BankListPage.Header() 右侧有一个搜索按钮。它只负责跳转 SearchPage,不在题库列表页里写搜索输入框。
ts
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。
ts
ForEach(this.filteredBanks(), (bank: Bank) => {
ListItem() {
BankCard({ bank: bank })
}
.padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE })
}, (bank: Bank) => `${bank.id}_${this.sortAsc}`)
宽屏布局中也是复用同一个组件。
ts
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 的分类卡片宽度由页面宽度直接决定。
ts
private itemWidth(): string {
return this.pageWidth >= 720 ? '24%' : '48%'
}
题库列表页的宽屏判断则同时参考全局断点和页面宽度。
ts
private useGridLayout(): boolean {
return this.currentBp === 'lg' && this.pageWidth >= 700
}
两者差异是合理的。
| 页面 | 判断方式 | 原因 |
|---|---|---|
| 分类页 | pageWidth >= 720 |
分类卡片规则简单,只需要两列或四列 |
| 题库列表页 | currentBp === 'lg' && pageWidth >= 700 |
题库卡片信息更多,需要结合全局断点 |
两个页面都通过 onAreaChange 更新宽度。
ts
.onAreaChange((oldArea: Area, newArea: Area) => {
const width = Number(newArea.width)
if (width > 0) this.pageWidth = width
})
这能覆盖分屏、旋转和 2in1 窗口变化。对于 HarmonyOS 多设备应用,这类页面级宽度感知比固定 vp 宽度更稳。
十一、图标映射要跟 type 绑定
分类图标来自 questionTypeIcon(type)。它在 ThemeConstants.ets 中维护。
ts
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,发送侧直接写对象字面量。
ts
router.pushUrl({ url: 'pages/SearchPage', params: { categoryType: cat.type, categoryName: cat.name } })
在 ArkTS 项目里,更稳的做法是把参数模型放到共享位置,例如 common/models/RouteParams.ets。
ts
export interface SearchParams {
categoryType?: string
categoryName?: string
}
export function makeCategorySearchParams(type: string, name: string): SearchParams {
return {
categoryType: type,
categoryName: name
}
}
分类页使用构造函数:
ts
router.pushUrl({
url: 'pages/SearchPage',
params: makeCategorySearchParams(cat.type, cat.name)
})
搜索页继续使用同一个接口:
ts
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 这条链路继续扩展,而不会把页面职责搅在一起。