【中国方言题库|12】HarmonyOS ArkTS 题库列表组件实战:减少多地区页面重复并保证点击反馈

在方言学习应用中,题库入口看起来只是"把若干地区卡片排成列表",真正落到工程里却会同时碰到数据排序、组件复用、学习进度回显、页面路由和多设备布局。若每个地区分别维护一套页面,四川话、粤语、东北话等入口很快会出现重复结构;如果只复用外观而没有统一数据与交互,点击目标、进度口径和大屏布局仍然会分叉。

中国方言题库当前版本没有为六个地区复制六份列表代码,而是由 BankListPage 遍历统一的 BANKS 数据,再把单个题库交给 BankCard 渲染。页面负责排序和容器布局,卡片负责题库信息、学习进度与跳转目标,UserDataManager 提供真实本地进度读取。本文面向 HarmonyOS 5.0 及以上版本,基于项目中的真实 ArkTS 源码复核这条链路,并明确当前实现已经解决什么、还没有解决什么。

本文唯一核验标记:题库入口由数据驱动,卡片点击只维护一条路由链路

一、先看真实交付边界

本文涉及的核心源码不是抽象示例,而是以下文件:

text 复制代码
entry/src/main/ets/views/BankListPage.ets
entry/src/main/ets/common/components/BankCard.ets
entry/src/main/ets/common/components/ProgressBar.ets
entry/src/main/ets/mock/MockBanks.ets
librarya/src/main/ets/models/Dialect.ets
librarya/src/main/ets/utils/UserDataManager.ets
entry/src/main/ets/pages/Index.ets

其中 BankListPage 是题库 Tab 的页面组件,BankCard 是可以在列表和网格中复用的题库卡片。BANKS 当前包含四川话、粤语、东北话、上海话、闽南语和客家话六个题库。文章不会把未来规划写成现有能力:当前排序只有"热度优先"和"题数优先",没有关键词筛选、地区筛选、分页加载或远端请求。

二、为什么不能给每个地区复制一套入口

如果六个地区分别写一套卡片,短期代码直观,长期却会形成四种同步成本:

  1. 新增字段时需要修改六处,例如同时增加题数和正确率。
  2. 调整触控区域时需要逐页确认,容易出现某些卡片只能点文字、另一些整卡可点。
  3. 大屏从单列切换三列时,六套页面可能出现不同断点。
  4. 学习进度的读取公式容易分叉,有的显示累计答题数,有的显示题库完成率。

真实实现把这些变化收敛为"数据 + 一个卡片组件 + 一个容器页面"。题库之间不同的是 Bank 数据和少量详情页路由,不同地区不再复制完整的列表视图。

三、Bank 数据模型是复用的起点

公共能力层定义的 Bank 接口包含题库卡片所需的完整字段:

ts 复制代码
export interface Bank {
  id: string
  regionId: string
  name: string
  cover: Resource
  totalCount: number
  accuracy: number
  hot: number
  chapters: Chapter[]
}

id 是进度查询和页面跳转的稳定标识,regionId 表示所属地区,cover 是资源对象,totalCountaccuracyhot 分别对应题数、默认正确率和热度。页面不需要判断"这是粤语还是四川话才显示什么字段",它只依赖同一个 Bank 契约。

这里值得强调:accuracy 是模型里的初始值或兜底值,不等于用户当前真实正确率。真实进度存在时,BankCard 会优先读取本地记录。

四、统一目录数据如何驱动六个题库

MockBanks.ets 中的 BANKS 直接保存六个题库:

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
  },
  // 其余五个题库使用相同结构
]

题库总量在目录同步流程中根据实际题目重新计算,因此卡片读取的是统一目录对象,而不是页面里手工写死的"某地区有多少题"。新增地区时,列表结构本身不需要增加一个新的 @Builder;只要数据契约、题目目录和目标页面满足现有规则,遍历即可渲染新项。

五、复制数组再排序,避免污染全局目录

BankListPage.filteredBanks() 的第一行不是直接对 BANKS 调用 sort(),而是先展开复制:

ts 复制代码
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
}

JavaScript 和 ArkTS 数组的 sort() 会原地修改数组。如果直接写 BANKS.sort(...),题库 Tab 的一次点击就会改变共享目录顺序,首页、考试页等其他使用 BANKS 的页面也可能观察到变化。复制后排序让变化停留在当前渲染计算中,保持共享目录的基准顺序。

六、sortAsc 的名字与实际语义并不完全一致

sortAsc 从字面看像"是否升序",但源码中的真实语义是:

text 复制代码
true  -> 按 hot 从大到小
false -> 按 totalCount 从大到小

两种模式其实都是降序,只是排序字段不同。因此文章不把它描述成"升序/降序切换"。更准确的工程命名可以是 sortMode,并用联合类型或枚举表达 hotcount。不过当前文章只复核现有实现,不擅自宣称源码已经完成这个重构。

七、排序按钮为什么能立即刷新页面

页面把排序状态声明为 @State

ts 复制代码
@State sortAsc: boolean = true

点击文本后切换布尔值:

ts 复制代码
.onClick(() => {
  this.sortAsc = !this.sortAsc
})

ArkUI 状态更新会触发相关 UI 重新构建,filteredBanks() 随之使用另一个排序字段。当前按钮文案也与状态绑定:

ts 复制代码
Text(this.sortAsc ? '按热度优先' : '按题数优先')

这条链路不需要维护另一份"已排序数组"状态,避免目录变化后还要手工同步缓存。

八、ForEach 键为何包含排序状态

列表和网格都使用同一套键生成逻辑:

ts 复制代码
(bank: Bank) => `${bank.id}_${this.sortAsc}`

稳定的 bank.id 标识题库,后缀把当前排序模式纳入键值。切换模式后,键集合发生变化,框架会按新的顺序重建对应项。当前实现以更明确的刷新换取简单可靠的排序反馈。

这不是唯一方案。若卡片内部状态很多,频繁重建可能增加代价;当前卡片状态很轻,只有测量宽度和共享进度链接,因此这种写法在六个题库规模下是可控的。

九、列表页只负责页面级职责

BankListPagebuild() 很克制:

ts 复制代码
build() {
  Column() {
    this.Header()
    this.SortBar()
    this.BankList()
  }
  .width('100%')
  .height('100%')
  .backgroundColor(Colors.BACKGROUND)
}

它把标题、排序区和内容区拆成三个 @Builder。页面级状态只有排序模式、页面宽度和全局断点。题库封面、正确率、已答数量、进度条、卡片紧凑布局和点击跳转都下沉到 BankCard,页面没有重复这些细节。

十、BankCard 如何接收统一数据

卡片通过一个 Bank 属性接收数据:

ts 复制代码
@Component
export struct BankCard {
  bank: Bank = {} as Bank
  @StorageLink('bankProgress') progressList: BankProgress[] = []
  @State cardWidth: number = 360
}

调用侧只需要:

ts 复制代码
BankCard({ bank: bank })

同一个组件既能放进手机 ListItem,也能放进大屏 Flex 网格。它不依赖父容器是列表还是网格,只根据自己的实际宽度决定横向卡片或紧凑卡片。

十一、共享进度不是由父页面层层传递

BankCard@StorageLink('bankProgress') 关联应用级进度数组。卡片根据 bank.id 查找自己的记录:

ts 复制代码
private realFinished(): number {
  const p = UserDataManager.getProgress(this.progressList, this.bank.id)
  return p ? p.finished : 0
}

这样,题库页不需要先遍历进度、组装额外 ViewModel,再把多个数字逐层传给卡片。练习页更新同一个 bankProgress 后,关联状态可反映到卡片显示。

同时要保持边界意识:AppStorage 适合跨页面共享当前运行时状态,持久化仍由 UserDataManager 和 Preferences 负责,卡片并没有直接操作存储文件。

十二、正确率采用"本地记录优先、目录值兜底"

真实实现如下:

ts 复制代码
private realAccuracy(): number {
  const p = UserDataManager.getProgress(this.progressList, this.bank.id)
  if (p && p.finished > 0) {
    return p.correct / p.finished
  }
  return this.bank.accuracy
}

只有存在记录且 finished > 0 时才做除法,避免零除。没有学习记录时回退到 bank.accuracy。当前目录初始值为 0,所以新用户看到的是 0%,不是伪造的历史平均水平。

十三、进度条为什么要限制最大值

完成比例的计算是:

ts 复制代码
private progressRatio(): number {
  if (this.bank.totalCount === 0) return 0
  return Math.min(this.realFinished() / this.bank.totalCount, 1)
}

finished 在当前数据模型中是累计答题次数,重复练习后可能大于题库题量。卡片把比例限制在 1ProgressBar 内部又把输入限制到 [0, 1],因此进度条宽度不会溢出容器。

但界面文字仍显示真实累计值,例如"已答 160 题",即使题库只有 120 道。这是当前数据口径,不应把它误写成"去重完成题数"。

十四、横向卡片适合手机单列

宽度不小于 280vp 时,卡片使用 HorizontalCard()。左侧是 92vp 的封面,右侧依次显示名称、入口提示、题数、正确率和进度条。标题设置了:

ts 复制代码
.layoutWeight(1)
.constraintSize({ minWidth: 0 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })

minWidth: 0 允许文本在弹性布局里真正收缩,配合单行省略,长题库名不会把右侧"进入"挤出卡片。元信息用可换行的 Flex,窄宽度下两个标签可以自然换行。

十五、紧凑卡片服务于大屏三列

当卡片实际宽度小于 280vp 时:

ts 复制代码
private useCompactLayout(): boolean {
  return this.cardWidth > 0 && this.cardWidth < 280
}

组件切换到 CompactCard(),把封面放到顶部并占满卡片宽度,文本信息放在下方。它不是简单缩小横向卡片,而是改变信息排列方向,以免三列网格中的封面和文字互相争夺水平空间。

十六、页面断点与卡片宽度是两层判断

页面是否使用网格由以下条件决定:

ts 复制代码
private useGridLayout(): boolean {
  return this.currentBp === 'lg' && this.pageWidth >= 700
}

第一层依据全局 currentBreakpoint,第二层依据页面真实宽度。进入网格后,每个项目宽度是 32%;卡片再通过自身 onAreaChange 获得实际宽度,并决定是否使用紧凑结构。

这种"容器决定列数、子组件决定内部排版"的两层适配,比只看设备类型更稳健。侧栏、分屏或窗口缩放都会改变内容区宽度,卡片最终以自己拿到的空间为准。

十七、为什么网格使用 Flex 而不是固定 Grid

大屏分支使用:

ts 复制代码
Flex({
  wrap: FlexWrap.Wrap,
  justifyContent: FlexAlign.SpaceBetween
}) {
  // 每项宽度 32%
}

六个题库会按三列排列,SpaceBetween 分配列间剩余空间,项目底部保留 12vp。当前数据量固定且很小,Flex 足以表达换行网格。若未来题库达到数百个,需要懒加载和复杂列策略,再评估 Grid 或数据分页更合理;当前源码没有这类能力。

十八、手机分支为何使用 List

手机和中等宽度分支采用 List

ts 复制代码
List({ space: 12 }) {
  ForEach(this.filteredBanks(), (bank: Bank) => {
    ListItem() {
      BankCard({ bank: bank })
    }
  })
}
.layoutWeight(1)
.edgeEffect(EdgeEffect.Spring)

ListItem 明确表达纵向列表语义,layoutWeight(1) 让内容区占用标题和排序条之外的剩余高度。关闭滚动条保持界面简洁,弹簧边缘效果提供滚动到边界时的反馈。

十九、整卡点击比只点小文字更易操作

横向和紧凑卡片都把 .onClick() 挂在卡片根容器上:

ts 复制代码
.onClick(() => {
  router.pushUrl({
    url: this.detailPageUrl(),
    params: { bankId: this.bank.id }
  })
})

"进入"文字是视觉提示,真正的可点击区域覆盖整个卡片。对手机触控而言,这比要求用户命中一个小按钮更容易;对三列布局而言,也避免不同卡片中的按钮因文字换行而错位。

二十、点击反馈的真实含义

当前源码中可核验的点击反馈有两部分:

  1. 排序文本点击后,文案和卡片顺序发生变化。
  2. 题库卡片点击后,执行 router.pushUrl() 进入目标详情页。

源码没有给卡片配置显式 stateStyles、按压缩放、触觉反馈、加载态或重复点击节流,因此不能宣称已经实现这些效果。这里所说"保证点击反馈",是指点击区域和结果链路统一、可观察,而不是虚构额外动画。

二十一、详情页路由为何集中在卡片内部

detailPageUrl() 根据题库 ID 返回目标页面:

ts 复制代码
private detailPageUrl(): string {
  switch (this.bank.id) {
    case 'b_sichuan':
      return 'pages/SichuanBankPage'
    case 'b_yue':
      return 'pages/YueBankPage'
    // 其余地区
    default:
      return 'pages/BankDetailPage'
  }
}

页面只创建卡片,不需要在两个布局分支中重复六次路由判断。无论卡片位于手机列表还是大屏网格,点击都会走同一个函数,并统一携带 bankId

二十二、默认路由是扩展时的重要保护

switchdefault 返回通用 BankDetailPage。因此新增一个没有专属详情页的题库时,仍可落到通用详情页,而不是生成空 URL。

不过这不代表新增数据一定能完整工作。新题库仍需保证题目目录、章节、资源和详情页对 bankId 的处理一致。默认路由只是减少遗漏导致的直接导航失败,不是全自动注册机制。

二十三、搜索入口同样保持单一跳转

标题区右侧使用固定触控尺寸承载搜索图标:

ts 复制代码
Stack() {
  Image($r('app.media.ic_common_search'))
    .width(24)
    .height(24)
}
.width(Sizes.TOUCH_TARGET)
.height(Sizes.TOUCH_TARGET)
.onClick(() => {
  router.pushUrl({ url: 'pages/SearchPage' })
})

图标视觉尺寸是 24vp,触控容器使用统一 TOUCH_TARGET,避免"看得见但难点中"。搜索本身由独立页面负责,题库列表不混入搜索输入状态。

二十四、公共主题常量减少视觉重复

页面和卡片从 librarya 引入 ColorsSizes,统一使用背景色、表面色、主要文字色、提示文字色、字号、圆角和内边距。这样多个地区卡片保持相同视觉层级,也便于后续做深浅色资源映射。

这不等于当前文件单独完成了完整深色模式验证。是否真正适配仍取决于主题常量和资源在不同限定目录中的定义,以及系统栏、封面图等整体表现。

二十五、卡片职责的四层拆分

可以把当前组件链路归纳为四层:

text 复制代码
Catalog  : BANKS 提供统一题库数据
Page     : BankListPage 负责排序、列表/网格选择
Card     : BankCard 负责信息组合、内部适配和点击路由
State    : UserDataManager + AppStorage 提供真实学习进度

每层都有明确输入输出。页面不直接持久化数据,卡片不修改目录,目录不关心当前屏幕宽度,进度服务不决定视觉布局。

二十六、空数据时当前页面会怎样

如果 BANKS 为空,filteredBanks().length 会显示 0,列表或网格没有项目。源码没有专门的空态图、说明文本或重试按钮。

由于当前目录是本地静态数据,正常构建中不会依赖网络加载,空目录更可能是打包或代码配置错误。但从通用组件质量看,未来若目录改为远端或可配置数据,应该增加明确的 emptyerror 状态,而不是只呈现空白区域。

二十七、排序计算的成本与当前规模

filteredBanks() 在文案计数和内容渲染处都可能被调用,每次都会复制并排序。对六个题库而言成本可以忽略,代码也很直接。

若未来题库数量显著增大,可以在状态变化时更新派生数组,或由 ViewModel 缓存排序结果。但现在引入复杂缓存反而要处理目录更新、状态失效和一致性,超出了当前真实需求。

二十八、当前没有真正的"筛选"

方法名叫 filteredBanks(),但实现没有过滤条件,只做排序。页面标题虽写"按地区与题型挑选练习内容",实际入口目前提供的是地区题库列表和独立搜索页,不包含页面内地区/题型筛选控件。

技术文章必须保留这个事实边界。一个更准确的未来命名是 sortedBanks();如果后续加入筛选,再让"过滤"承担真实业务含义。

二十九、参数传递如何保持目标一致

卡片既通过 detailPageUrl() 选择页面,又总是传递:

ts 复制代码
params: {
  bankId: this.bank.id
}

即使专属页面目前可以通过页面自身确定地区,保留 bankId 仍让通用详情页和后续功能拥有统一上下文。路由目标和参数都从同一个 bank 对象推导,避免标题显示四川话、参数却误传粤语 ID 这类手工绑定错误。

三十、重复点击与路由失败仍需谨慎

当前 .onClick() 直接调用 router.pushUrl(),没有等待 Promise,也没有点击锁。快速连续点击理论上可能多次触发导航;路由表缺失或页面加载失败时,卡片也没有本地错误提示。

如果真实运行测试发现重复入栈,应在保持交互轻量的前提下增加短暂导航锁,并在导航完成或失败后释放。是否需要修改必须以设备测试为依据,不能仅凭推测给当前实现贴上"有 bug"的标签。

三十一、多设备适配还要结合 Index 容器

BankListPage 不是孤立占满整个设备。Index.ets 在当前断点为 smmdlg 时使用底部导航,其余模式使用侧边导航。题库页拿到的 pageWidth 是内容区域宽度,而不是物理屏幕宽度。

这正是同时检查 currentBppageWidth 的价值:同一台大屏设备在侧栏、分屏或窗口化状态下,内容空间可能不足 700vp,此时页面仍回到单列列表,避免硬塞三列。

三十二、建议的验证矩阵

对这套组件做回归时,至少验证以下场景:

text 复制代码
1. 首次进入:六个题库按热度从高到低排列
2. 切换排序:文案变为"按题数优先",顺序按 totalCount 更新
3. 再次切换:恢复热度排序
4. 点击封面、标题、空白区:都进入同一题库详情
5. 点击搜索:进入 SearchPage
6. 已有学习记录:已答数量与正确率回显
7. 重复练习后:累计已答可超过题量,进度条仍不溢出
8. 小屏:单列 List 可滚动
9. 大屏且内容宽度至少 700vp:三列 Flex
10. 三列卡片宽度小于 280vp:切换紧凑结构
11. 长题库名:单行省略,不遮挡"进入"
12. 返回题库页:排序与当前运行时状态表现符合预期

三十三、可进一步演进的方向

在不改变当前职责边界的前提下,可以逐步演进:

  1. 把布尔排序改成显式 SortMode,提高可读性。
  2. filteredBanks() 更名为 sortedBanks(),与真实行为一致。
  3. 用配置映射替代 switch,让专属详情页注册更集中。
  4. 根据真实设备测试决定是否增加按压态与导航节流。
  5. 为目录异常增加空态,但不为本地静态数据虚构网络重试。
  6. 若题库规模扩大,再评估惰性网格、分页和排序缓存。

这些是从现有源码自然延伸的工程方向,不代表当前版本已经实现。

三十四、这套实现真正减少了什么重复

它减少的不是几行 ForEach,而是把多个地区共同拥有的规则集中起来:

text 复制代码
统一数据契约
统一排序入口
统一学习进度口径
统一卡片信息结构
统一整卡点击区域
统一路由参数
统一列表/网格切换
统一窄卡片内部排版

当规则变化时,开发者可以在对应职责层修改一次,而不必逐地区寻找相似代码。

三十五、结语

中国方言题库的题库 Tab 展示了一个很实用的 HarmonyOS ArkTS 组件化思路:页面用状态驱动排序和容器布局,ForEach 用统一数据生成地区入口,BankCard 读取共享学习进度并根据自身宽度调整结构,整张卡片只维护一条可核验的路由链路。

它也保留了清晰的事实边界:当前没有页面内筛选、显式按压动画、导航节流、异步加载和专门空态;finished 是累计答题次数,不是去重完成数。只有把这些边界说清楚,组件复用、点击反馈和多设备适配的经验才真正可复核、可迁移。

AI 辅助声明:本文由 AI 辅助整理与润色,技术结论、代码路径、数据口径和实现边界均依据项目真实源码复核。

相关推荐
梦想不只是梦与想2 小时前
鸿蒙 AGC:华为开放能力管理(四)
harmonyos·agc·开发能力
大锅盖12 小时前
ArkUI声明式范式下的暗夜紫调沉浸式剧本杀组局社区:迷雾粒子双层特效与四套差异化弹框的工程化实践
华为·harmonyos
见山是山-见水是水10 小时前
鸿蒙Divider 分割线组件完全指南:内容分组、视觉分区与自定义样式
华为·harmonyos
贾伟康11 小时前
【知律|18】HarmonyOS ArkTS 权限与隐私实战:让 module.json5、功能说明和拒绝路径一致
harmonyos·arkts·隐私合规·appgallery·应用权限
Kevin Coding1 天前
JsonConvert:适用于 Android、鸿蒙与 Flutter 的 JSON 转 Model 插件
android·flutter·harmonyos
m0_749690231 天前
【寻迹校园 HarmonyOS NEXT 实战 28】不交换手机号也能交接:固定校内交接点的隐私设计
华为·harmonyos·arkts·产品设计·隐私设计·安全交接
Magic-ZYJ1 天前
HarmonyOS Stage 模型实战:UIAbility 生命周期如何驱动页面安全状态
安全·华为·harmonyos·鸿蒙·移动端开发·独立开发者·心晴手记
贾伟康1 天前
【中国方言题库|09】HarmonyOS ArkTS 方言搜索实战:实现词语检索和无结果反馈
harmonyos·arkts·状态管理·arkui·本地搜索
Dovis(誓平步青云)1 天前
从手机单栏到平板分栏:任务清单的筛选、选中态与不可变更新
华为·harmonyos