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

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

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

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

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

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

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

当前链路可以拆成五步。

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

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

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

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 数据模块里,有一个现实好处:页面之间不会各自维护一份类型表。CategoryPageSearchPageBankListPage 都使用同一组 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)
  }
}

上面这段是按源码结构抽出来的表达。真实文件中没有单独命名 CategoryGridTypeDescriptionPanel,但页面职责就是这两块。为了保持 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 不应该随便变。

第二,导航参数同时传 categoryTypecategoryNamecategoryType 用于筛选,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}`)
}

这就是"复用列表结构"的核心:列表容器可以根据设备形态在 ListFlex 之间切换,但单个题库卡片仍然使用同一套 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 || ''
}

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

十三、验证分类句库链路

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

验证项 操作 期望结果
分类卡片渲染 打开全部分类页 看到 CATEGORIES 中的所有分类卡片
分类跳转 点击"语法纠错"等分类 进入 SearchPage,显示当前分类并自动搜索
分类筛选 检查结果卡片 结果题目的 q.typecategoryType 一致
手动改关键词 在搜索框改成其他词 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 搜索题目链路。

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

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

可复核的源码点包括:

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

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

相关推荐
体毛旺盛的猿2 小时前
HarmonyOS开发面试题
前端·华为·harmonyos
贾伟康2 小时前
【口算王|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化
harmonyos·arkts·arkui·响应式布局·多设备适配
ChinaDragon12 小时前
HarmonyOS:6.0 新增和增强特性
harmonyos
贾伟康14 小时前
【句匠|01】HarmonyOS ArkTS 英语纠错页实战:把原句、修改建议和解释层级展示清楚
harmonyos·arkts·textarea·学习应用·英语纠错
淡写成灰16 小时前
「Flutter 文件保存太难了?」一个插件打通 7 大平台,我把方案开源了 🎉
flutter·harmonyos
大雷神1 天前
HarmonyOS ArkGraphics 2D 自定义字体实操:注册字体并验证中文回退
pytorch·华为·harmonyos
Nayana1 天前
《Web 到 HarmonyOS》-- 第一课:前端经验哪些能带走
vue.js·harmonyos
大雷神1 天前
HarmonyOS ArkGraphics 2D 复杂文本排版实操:用 ParagraphBuilder 做资讯阅读卡片
华为·harmonyos
大雷神1 天前
HarmonyOS ArkGraphics 2D NativeImage 实操:获取 NativeWindow 与 SurfaceId
华为·harmonyos