【知律|11】HarmonyOS ArkTS 题库列表实战:抽离通用列表并校验路由参数

发布时间:2026/8/17 21:55:29
【知律|11】HarmonyOS ArkTS 题库列表实战:抽离通用列表并校验路由参数 题库列表常被当成“把数组放进ForEach”的小页面真正的故障却集中在列表之外排序后卡片状态被全部重建手机和 2in1 使用两套重复渲染新增题库时忘记补详情路由外部参数经过一次类型断言就进入页面。数据、布局、身份和导航没有共同契约列表越复用错误传播得越快。本文基于知律项目D:\huawei\one19-11、包名com.jiaweikang.one19的真实源码沿着 brief 指向的BankListPage.ets继续追踪已抽离的BankCard.ets、接收参数的BankDetailPage.ets、搜索结果页、MockBanks.ets与main_pages.json。当前版本已经实现手机列表、宽屏三列卡片、题库卡片复用和未找到题库空态但列表容器仍有重复排序语义和 key 不够稳定路由参数只做类型断言详情路径映射也写死在卡片内部。一、先确认当前列表不是“完全没抽组件”BankListPage已经导入import { BankCard } from ../common/components/BankCard无论手机List还是宽屏Flex最终都复用同一个BankCard({ bank })。卡片内部负责封面、题数、正确率、进度、紧凑布局和点击跳转。因此本文所说的“抽离通用列表”不是重复造一个卡片组件而是继续抽离列表状态、稳定 key、布局切换和导航契约。真实已有能力必须保留改造不能把成熟组件拆散。二、数据源会先复制再排序当前方法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 }先复制BANKS是正确做法。Array.sort()会原地修改数组如果直接排序全局目录首页、搜索页和分类页可能在没有显式操作时一起改变顺序。但方法名filteredBanks并没有过滤sortAsc也不是升序true代表热度降序false代表题数降序。命名与行为不一致会让后续维护者错误增加分支。三、把布尔排序改成显式枚举用业务语义代替真假值export type BankSortMode hot | questionCount function sortBanks( source: Bank[], mode: BankSortMode ): Bank[] { const result [...source] if (mode hot) { return result.sort((a, b) b.hot - a.hot) } return result.sort((a, b) b.totalCount - a.totalCount) }页面状态改成State sortMode: BankSortMode hot以后新增“学习进度优先”时可以增加明确模式不需要猜sortAsc false究竟指题数、名称还是时间。四、当前“热度”只是本地静态字段MockBanks.ets中六个题库的hot分别是 98、95、92 等固定值注释写明它用于首页排序。源码没有平台访问量、实时热度接口或用户行为统计。所以 UI 可以写“按热度优先”但技术文章和产品材料不能把这些数解释为真实 PV、收藏量或实时榜单。更准确的工程名称可以是recommendWeight表示本地推荐权重。interface BankCatalogItem { bank: Bank recommendWeight: number }数据含义写清后排序结果才可解释、可测试。五、列表与网格分支存在结构重复宽屏分支Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.filteredBanks(), (bank: Bank) { Column() { BankCard({ bank }) } .width(32%) }) }手机分支List({ space: 12 }) { ForEach(this.filteredBanks(), (bank: Bank) { ListItem() { BankCard({ bank }) } }) }容器不同是合理的数据迭代、卡片创建和 key 规则重复则会导致两端修改不一致。新增加载占位、测试 ID 或点击策略时很容易只改一边。六、先抽“条目构造”不要强行统一容器ListItem和普通Column的语义不同没必要为了一个组件强行包成同一种树。可以抽出卡片内容Builder BankItemContent(bank: Bank) { BankCard({ bank }) }列表和网格各自保留标准容器ListItem() { this.BankItemContent(bank) }Column() { this.BankItemContent(bank) } .width(32%)这是一种保守复用共享真正重复的部分不隐藏 ArkUI 容器差异。七、排序 key 不应包含 sortAsc当前两处分支都使用(bank: Bank) ${bank.id}_${this.sortAsc}切换排序时所有 key 都改变。ArkUI 会把原卡片视为一批新节点可能丢失卡片内部State cardWidth重新触发区域测量和动画状态。排序只是位置变化题库身份没有变化。更稳定的 key 是(bank: Bank) bank.id只有条目真实身份变化时才换 key。稳定身份让框架复用组件实例也让排序动画和局部状态更可预测。八、BankCard 已具备卡片级自适应卡片内部通过private useCompactLayout(): boolean { return this.cardWidth 0 this.cardWidth 280 }在宽度小于 280vp 时选择纵向紧凑卡片否则选择横向卡片。列表页在宽屏为每张卡设置32%所以同一组件可以在三列布局中自动变形。这是一条可复用的好边界外层决定“一列还是多列”卡片根据自己实际宽度决定“横向还是紧凑”。卡片不需要知道设备类型列表也不需要复制卡片细节。九、外层响应判断有两个条件列表页使用private useGridLayout(): boolean { return this.currentBp lg this.pageWidth 700 }既检查全局断点也检查页面实际宽度。这样即使设备属于宽屏类型小窗缩窄到 700vp 以下仍会回到列表。需要测试的不是固定设备名而是临界宽度699、700、701vp以及 2in1 拖动窗口时是否频繁抖动。若断点和页面宽度阈值不一致应统一到一个布局策略对象。十、通用列表应显式表达页面状态当前BANKS永远有六条数据所以页面只呈现内容态。抽成通用目录后至少需要export type CatalogStatus loading | content | empty | error对应行为状态页面内容loading固定尺寸占位不让布局跳动content列表或网格empty解释暂无题库error错误原因与重试即使当前数据内置也应让通用组件的输入契约完整避免未来切换内容包后在空数组上显示一块纯背景。十一、定义只负责渲染的目录组件组件输入可以保持简单Component export struct BankCatalog { items: Bank[] [] layout: list | grid list onOpen: (bank: Bank) void () {} build() { if (this.items.length 0) { this.EmptyState() } else if (this.layout grid) { this.GridContent() } else { this.ListContent() } } }它不直接读取全局BANKS不决定排序不调用router。页面负责准备items导航服务负责处理打开动作目录组件只负责可复用渲染。十二、BankCard 当前同时拥有展示和路由卡片点击直接执行router.pushUrl({ url: this.detailPageUrl(), params: { bankId: this.bank.id } })这让卡片在首页、搜索页、推荐区都能独立导航使用方便代价是组件无法被用于“选择题库但不跳转”的场景也难以在测试中替换导航。更通用的方式是把点击意图作为回调传入Component export struct BankCard { bank: Bank {} as Bank onOpen: (bank: Bank) void () {} }卡片只调用this.onOpen(this.bank)路由由页面或统一导航器决定。十三、详情页路径映射目前硬编码在卡片里真实实现private detailPageUrl(): string { switch (this.bank.id) { case b_sichuan: return pages/SichuanBankPage case b_yue: return pages/YueBankPage // ... default: return pages/BankDetailPage } }新增题库时需要同时修改BANKS、详情页、main_pages.json和这个switch。漏改一处会落到通用详情页或路由失败。路径映射应集中到目录配置或导航服务成为可以扫描校验的数据。十四、用路由注册表统一身份与路径可以定义interface BankRouteEntry { bankId: string detailUrl: string } const BANK_ROUTES: BankRouteEntry[] [ { bankId: b_sichuan, detailUrl: pages/SichuanBankPage }, { bankId: b_yue, detailUrl: pages/YueBankPage }, { bankId: b_northeast, detailUrl: pages/NortheastBankPage }, { bankId: b_shanghai, detailUrl: pages/ShanghaiBankPage }, { bankId: b_minnan, detailUrl: pages/MinnanBankPage }, { bankId: b_hakka, detailUrl: pages/HakkaBankPage } ]构建前校验每个BANKS.id都有且只有一个注册项并确认 URL 出现在main_pages.json。这样新增题库不会依赖人工记忆多个分支。十五、router.getParams 的类型断言不是校验详情页当前写法const params router.getParams() as BankDetailParams | undefined if (params params.bankId) { this.bank getBankById(params.bankId) }as BankDetailParams只告诉编译器“把它当成这个类型”不会在运行时验证。bankId可能是空字符串、错误类型、未知 ID 或旧版本参数。当前getBankById()找不到时返回undefined页面会显示“未找到题库”已经有最后一道 UI 兜底但参数错误的原因没有被区分也没有集中复用。十六、把路由输入当成不可信数据可以写一个窄解析器interface BankDetailParams { bankId: string } function parseBankDetailParams( raw: Object | undefined ): BankDetailParams | undefined { const candidate raw as BankDetailParams | undefined if (!candidate || typeof candidate.bankId ! string) { return undefined } const id candidate.bankId.trim() if (!getBankById(id)) { return undefined } return { bankId: id } }解析器只返回已存在的题库 ID。页面收到undefined时进入参数错误空态并提供返回题库列表的操作。十七、固定题库页面也要遵守同一契约BankDetailContent支持fixedBankId: string 专用详情页可以传固定 ID通用详情页从路由读取。当前逻辑在fixedBankId非空时直接调用getBankById()因此固定值同样可能无效。建议统一入口private resolveBankId(): string | undefined { if (this.fixedBankId.length 0) { return getBankById(this.fixedBankId) ? this.fixedBankId : undefined } const parsed parseBankDetailParams( router.getParams() as Object | undefined ) return parsed?.bankId }无论参数来源都经过相同目录校验。十八、导航失败需要反馈而不是静默当前pushUrl()没有处理失败结果。若页面未注册、URL 拼错或导航栈异常点击后可能看起来“没有反应”。导航器可以返回明确结果interface NavigationResult { ok: boolean message?: string } async function openBankDetail( bank: Bank ): PromiseNavigationResult { const route findBankRoute(bank.id) if (!route) { return { ok: false, message: 题库详情尚未配置 } } try { await router.pushUrl({ url: route.detailUrl, params: { bankId: bank.id } }) return { ok: true } } catch (_) { return { ok: false, message: 页面打开失败请重试 } } }UI 负责展示message不打印参数内容或隐私数据。十九、BankCard 的默认空对象会隐藏调用错误当前声明bank: Bank {} as Bank如果调用方漏传bank组件仍能构建但bank.cover、bank.id和数值字段都可能无效。类型断言把缺失必填参数的问题推迟到运行时。更稳的做法是使用项目支持的必填属性机制或提供可验证的占位模型function isValidBank(bank: Bank): boolean { return bank.id.length 0 bank.name.length 0 bank.totalCount 0 }无效输入显示受控错误卡不继续导航。二十、SearchPage 也复用了 BankCard搜索结果中的题库分支已经ForEach(this.bankResults, (bank: Bank) { Column() { BankCard({ bank }) } }, (bank: Bank) bank.id)这证明卡片抽离是有效的。但题目搜索结果点击后只传{ bankId: q.bankId, mode: random }没有传questionId所以用户点击具体题目并不会精准打开该题。这个问题与题库卡路由不同不能因为“页面打开成功”就判定导航契约正确。二十一、搜索分类参数同样需要白名单SearchPage把router.getParams() as SearchParams | undefined直接赋给categoryType。有效分类实际来自CATEGORIES。应验证function validCategoryType(value: string): boolean { return CATEGORIES.some(item item.type value) }未知分类不能让页面默默搜索空结果并显示错误名称。它应清除分类条件、保留普通搜索能力或显示参数无效提示。二十二、列表排序应有稳定的次级规则如果两个题库热度或题数相同单一比较器返回 0顺序依赖原数组。为了测试稳定可以增加题库 ID 或目录顺序作为次级规则function compareByHot(a: Bank, b: Bank): number { const hotDelta b.hot - a.hot if (hotDelta ! 0) return hotDelta return a.id.localeCompare(b.id) }稳定排序能让截图、自动化测试和无障碍浏览顺序保持一致。二十三、不要在一次构建中重复排序SortBar()调用this.filteredBanks().lengthBankList()又调用一次this.filteredBanks()。当前只有六项性能影响很小但通用列表接入大目录后会重复复制和排序。可以由页面在状态变化时准备一次private visibleBanks(): Bank[] { return sortBanks(BANKS, this.sortMode) }在build()中无法随意写普通赋值时可以让状态层或 ViewModel 持有派生快照。关键是避免在多个 Builder 中各自实现排序规则。二十四、列表组件需要保留滚动与安全区手机分支使用List宽屏分支使用Scroll Flex都关闭滚动条并启用弹性效果。底部只有固定 16vp.padding({ bottom: 16 })作为主 Tab底部还需要结合系统导航手势区和应用 TabBar 实际高度验证。通用目录可以接收bottomInset: number 0最终底部留白取设计最小值与系统避让区的较大值保证最后一张卡可完整点击。二十五、多设备验收要看布局转换过程至少覆盖场景验收重点手机竖屏单列卡片、文字不截断、搜索可达手机横屏/小窗不误进三列最后一项可滚动平板竖屏断点与 700vp 阈值一致平板横屏三列宽度稳定、间距一致2in1 拖动699/700vp 切换无重叠排序切换key 稳定、卡片不闪烁重建大字体标题、标签与进度不互相遮挡深浅色卡片、标签和系统栏均可读只截一张宽屏图无法证明响应布局完整。二十六、路由契约测试矩阵准备以下输入六个合法bankId空字符串前后有空格的合法 ID未注册 IDbankId缺失非字符串值已注册题库但详情 URL 不在main_pages.json导航栈拒绝跳转。预期是合法输入打开正确页空格输入按约定规范化其余输入进入可解释空态或显示失败提示任何情况都不崩溃。二十七、推荐落地顺序第一步把sortAsc改为BankSortMode把 key 改回稳定bank.id。第二步抽BankItemContent或BankCatalog保留List与Flex的容器差异。第三步让BankCard只发出onOpen(bank)把路由放进集中导航器。第四步用注册表统一题库 ID、详情 URL 和main_pages.json校验。第五步为详情页与搜索页增加参数解析器、错误空态和导航失败反馈。第六步补齐加载、空、错误、内容四种目录状态再做手机、平板、2in1 临界宽度测试。二十八、常见故障排查现象优先检查当前源码关联点排序后卡片闪动key 是否拼入排序状态BankList()排序含义难理解是否使用布尔变量sortAsc新题库打开错页路由映射是否漏改detailPageUrl()非法参数只显示空白是否只有类型断言router.getParams()点击搜索题不是原题是否传递questionIdQuestionResultCard()宽屏修改未同步手机两个分支是否重复实现List/Flex卡片缺参后异常是否用空对象断言{} as Bank最后一张卡不可点击是否缺少底部避让固定padding(16)二十九、结语知律的题库列表已经有值得保留的工程基础复制后排序避免污染全局目录BankCard同时服务列表、网格和搜索结果卡片按自身宽度切换布局列表页结合断点与真实宽度选择一列或三列详情页也提供“未找到题库”空态。下一步不需要推倒重写而是把隐含规则变成显式契约排序用业务枚举条目 key 只表达稳定身份列表组件只负责渲染卡片只发出导航意图路由注册表统一题库与页面所有外部参数先校验再使用。这样同一套题库目录才能在手机、平板、2in1、搜索页和未来更多入口中稳定复用。---本文部分内容由 AI 辅助整理。所有现状判断均基于D:\huawei\one19-11中com.jiaweikang.one19的本地源码复核示例改造代码用于说明工程方案不代表当前版本已经实现通用 BankCatalog、集中路由注册表、运行时参数守卫或搜索题精准定位。