【句匠|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,前者是题型入口,后者是题库目录。

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 这条链路继续扩展,而不会把页面职责搅在一起。

相关推荐
youyin5 天前
HarmonyOS ArkUI 组件与自定义组件零基础:从搭页面到组件化开发
华为·harmonyos
HwJack205 天前
【HarmonyOS开发小实践】ArkTS 从 TypeScript 到方舟语言的演进
华为·harmonyos
威哥爱编程5 天前
HarmonyOS 7 星盾机密风控实战:设备风险因子端侧融合计算,可用不可见
harmonyos·arkts
威哥爱编程5 天前
HarmonyOS 7 应用快启实战:关键资源预加载进内存,冷启告别白屏
华为·harmonyos·arkts
马剑威(威哥爱编程)5 天前
【共创稿事节】HarmonyOS 7 视觉 AI 进阶实战:人脸检测 + 通用文字识别(OCR)两步接入
华为·harmonyos·arkts
HMS Core6 天前
HarmonyOS智慧多窗,让应用在任意窗口都“恰到好处”
harmonyos
梦想不只是梦与想6 天前
鸿蒙 应用发布准备工作(一)
harmonyos·鸿蒙上架·鸿蒙应用发布
马剑威(威哥爱编程)6 天前
【共创稿事节】HarmonyOS 7 文搜图实战:自然语言检索本地图片,全流程端侧闭环
华为·harmonyos
李游Leo6 天前
HarmonyOS 7 HAR + HSP 工程化实战:模块复用、包体积控制与依赖边界
harmonyos