【笔下生辉|03】HarmonyOS ArkTS 地区表达素材实战:复用四川、粤语、东北等分库页面结构
题库型 HarmonyOS 应用很容易在“分库页面”上失控。产品最初只有一个错别字题库时,详情页写一套 UI 没有问题;后来又加病句、标点、词语误用、网络热词、古诗纠错,每个分库都需要封面、题量、进度、章节和练习入口。如果每个页面复制一份详情页,后续只要进度规则、卡片样式、底部按钮或多设备布局改一次,就要在多处同步,风险会迅速放大。
笔下生辉的源码没有把每个分库做成完全独立的详情页。它采用的是一层很薄的地区/分库 wrapper:SichuanBankPage.ets、YueBankPage.ets、NortheastBankPage.ets等页面只负责固定一个bankId,真正的详情布局、章节进度、底部练习入口仍然复用BankDetailPage.ets中导出的BankDetailContent。题库列表里的BankCard.ets根据bank.id决定跳转到哪个 wrapper page;底层题库数据来自MockBanks.ets中的BANK_SPECS。
本文基于本地工程D:\huawei\one17-11的真实源码编写,包名标记为com.jiaweikang.one17。这里需要先说明边界:队列标题使用了“地区表达素材”的说法,但当前源码里的b_sichuan、b_yue、b_northeast并不是四川话、粤语、东北话内容,而是错别字、病句、标点等题库分区;所谓“分库页面结构”,指的是这些题库使用不同入口页复用同一套详情页组件。
这篇文章解决四个工程问题:
- 如何让多个分库入口复用同一个详情页,而不是复制整套 ArkUI。
- 如何用
BankCard.detailPageUrl()把bank.id映射到不同页面。 - 如何用
fixedBankId让 wrapper page 固定某个题库,同时保留通用bankId路由能力。 - 如何从
MockBanks.ets的BANK_SPECS扩展新分库,并让列表、详情、练习页自然串起来。
一、先确认源码里的真实分库边界
这类文章最重要的是不要把命名误读成能力。SichuanBankPage.ets的页面名看起来像“地区页”,但打开源码后可以看到,它没有任何地区表达解析、语音、方言词典或地域语料处理逻辑。它只是导入公共详情组件,并传入一个固定题库 ID。
import { BankDetailContent } from './BankDetailPage' @Entry @Component struct SichuanBankPage { build() { Column() { BankDetailContent({ fixedBankId: 'b_sichuan' }) } .width('100%') .height('100%') } }这段代码的职责非常窄:
SichuanBankPage是一个可被路由系统直接打开的页面。- 它没有自己的状态、列表、进度计算和练习入口。
- 它把
fixedBankId固定为b_sichuan,把后续渲染交给BankDetailContent。 - 页面根容器保持
width('100%')和height('100%'),避免外层 wrapper 影响公共详情页布局。
YueBankPage.ets和NortheastBankPage.ets的结构完全一致,只是fixedBankId分别变成b_yue和b_northeast。
@Entry @Component struct YueBankPage { build() { Column() { BankDetailContent({ fixedBankId: 'b_yue' }) } .width('100%') .height('100%') } } @Entry @Component struct NortheastBankPage { build() { Column() { BankDetailContent({ fixedBankId: 'b_northeast' }) } .width('100%') .height('100%') } }实际项目里这种写法很适合“需要独立页面入口,但页面主体完全一致”的场景。比如运营位、首页卡片、搜索结果、分类页都可能希望打开一个稳定的页面 URL;与此同时,工程上又不希望为每个题库维护一份详情页 UI。
二、为什么不是把所有分库都推到同一个 BankDetailPage
源码里其实同时保留了两种入口:一种是普通BankDetailPage接收路由参数bankId;另一种是地区 wrapper page 传入fixedBankId。如果只看最终展示,两种入口都会进入BankDetailContent,但工程意义不同。
@Entry @Component export struct BankDetailPage { build() { BankDetailContent() } } @Component export struct BankDetailContent { fixedBankId: string = '' @State bank: Bank | undefined = undefined aboutToAppear() { if (this.fixedBankId.length > 0) { this.bank = getBankById(this.fixedBankId) return } const params = router.getParams() as BankDetailParams if (params && params.bankId) { this.bank = getBankById(params.bankId) } } }这段逻辑把入口优先级写得很清楚:如果 wrapper page 已经传了fixedBankId,公共详情页就直接按固定题库加载;如果没有固定 ID,才读取router.getParams()中的bankId。这样做有两个好处。
第一,独立页面入口不依赖外部参数。pages/SichuanBankPage被打开时,即使外部没有传参,也能稳定加载b_sichuan。这对首页运营入口、固定快捷入口和测试用例都更可控。
第二,公共详情页仍然可复用。搜索页、列表页、未来的推荐模块仍然可以直接跳到pages/BankDetailPage,并通过bankId参数指定题库。也就是说,wrapper page 是增强入口,不是替代公共详情页。
三、BankCard 如何把题库 ID 映射为页面 URL
分库入口真正串起来的地方在BankCard.ets。题库列表和首页推荐都渲染BankCard,卡片点击时不会无脑跳到同一个详情页,而是先根据bank.id找到对应页面。
private detailPageUrl(): string { switch (this.bank.id) { case 'b_sichuan': return 'pages/SichuanBankPage' case 'b_yue': return 'pages/YueBankPage' case 'b_northeast': return 'pages/NortheastBankPage' case 'b_shanghai': return 'pages/ShanghaiBankPage' case 'b_minnan': return 'pages/MinnanBankPage' case 'b_hakka': return 'pages/HakkaBankPage' default: return 'pages/BankDetailPage' } }这不是一个复杂算法,但它解决了页面治理问题。BankCard是题库入口卡片,所以它知道“某个题库应该进入哪个页面”。公共详情页不需要反向关心自己是从哪个入口来的,wrapper page 也不需要复制卡片逻辑。
点击行为同样集中在卡片里:
.onClick(() => { router.pushUrl({ url: this.detailPageUrl(), params: { bankId: this.bank.id } }) })这里即使跳到 wrapper page,也仍然带上bankId参数。由于BankDetailContent会优先使用fixedBankId,这个参数不会破坏 wrapper 的固定绑定;但它对默认详情页入口、日志排查和未来扩展仍有价值。更稳的工程习惯是:点击入口保留上下文参数,详情组件再决定使用固定值还是路由值。
四、分库数据不是页面硬编码,而是来自 MockBanks
页面层只绑定bankId,分库内容来自MockBanks.ets中的BANK_SPECS。源码里的BankSpec定义了题库 ID、分类 ID、展示名称、封面、热度和章节。
interface BankSpec { id: string regionId: string name: string shortName: string cover: Resource hot: number chapters: string[] } const BANK_SPECS: BankSpec[] = [ { id: 'b_sichuan', regionId: 'typo', name: '错别字挑战', shortName: '错', cover: $r('app.media.img_bank_cover_sichuan'), hot: 99, chapters: ['常见错字', '形近字辨析', '同音字陷阱', '词组纠错', '成语错字', '综合挑战'] }, { id: 'b_yue', regionId: 'sentence', name: '病句修改', shortName: '句', cover: $r('app.media.img_bank_cover_yue'), hot: 96, chapters: ['成分残缺', '搭配不当', '语序问题', '句式杂糅', '重复赘余', '歧义辨析'] } ]上面的中文名称是按源码字段含义还原后的说明;本地文件在终端输出中存在编码显示问题,但字段结构和 ID 是可复核的。关键点不在名称,而在数据建模方式:页面不写章节数组,卡片不写题量,详情页不写封面资源;这些都来自同一份题库规格。
BANKS则由BANK_SPECS映射生成。
function makeChapters(prefix: string, items: string[]): Chapter[] { return items.map((title: string, i: number) => { return { id: `${prefix}_c${i + 1}`, index: i + 1, title, total: 0, finished: 0, done: false } as Chapter }) } export const BANKS: Bank[] = BANK_SPECS.map((spec: BankSpec) => { return { id: spec.id, regionId: spec.regionId, name: spec.name, cover: spec.cover, totalCount: 0, accuracy: 0, hot: spec.hot, chapters: makeChapters(spec.id.replace('b_', ''), spec.chapters) } as Bank })这种写法把“配置”和“运行模型”分开:BANK_SPECS适合维护内容,BANKS适合页面消费。新增题库时,工程师首先改规格,不需要直接构造完整的Bank对象,也不用手动拼章节 ID。
五、首页和题库列表共用 BankCard,避免入口样式分裂
如果首页推荐卡片和题库列表卡片各写一套,分库入口很容易出现两种问题:一个入口可以跳转,另一个入口忘了更新;一个入口显示进度,另一个入口仍然显示静态题量。笔下生辉把首页和题库列表都接到BankCard。
首页推荐区的源码使用BANKS渲染卡片:
@Builder BankSection() { if (this.useGridLayout()) { Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { ForEach(BANKS, (bank: Bank) => { Column() { BankCard({ bank: bank }) } .width('32%') .margin({ bottom: 12 }) }, (bank: Bank) => bank.id) } } else { Column({ space: 12 }) { ForEach(BANKS, (bank: Bank) => { BankCard({ bank: bank }) }, (bank: Bank) => bank.id) } } }题库列表页也使用同一个组件:
@Builder BankList() { if (this.useGridLayout()) { Scroll() { 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}`) } } } else { List({ space: 12 }) { ForEach(this.filteredBanks(), (bank: Bank) => { ListItem() { BankCard({ bank: bank }) } }, (bank: Bank) => `${bank.id}_${this.sortAsc}`) } } }这意味着分库卡片的图、标题、题量、热度、进度和跳转都被收敛到一个组件里。后续要调整多设备布局,只需要重点看BankCard、HomePage.BankSection()和BankListPage.BankList(),而不是全项目搜索所有“题库卡片”。
六、卡片自身同时处理普通布局和紧凑布局
分库页面入口不只要能跳,还要能在手机、平板或宽屏布局里稳定展示。BankCard通过onAreaChange记录自身宽度,再决定使用横向卡片还是紧凑卡片。
@State cardWidth: number = 360 private useCompactLayout(): boolean { return this.cardWidth > 0 && this.cardWidth < 280 } build() { if (this.useCompactLayout()) { this.CompactCard() } else { this.HorizontalCard() } }宽度判断放在组件内部,而不是由父页面传入一个compact参数。这样首页、题库列表、搜索结果如果都使用BankCard,它们不需要各自理解卡片布局规则。父页面只负责给卡片所在容器合适的宽度,卡片自己适配内容密度。
在HorizontalCard()和CompactCard()中,源码都使用了layoutWeight(1)、constraintSize({ minWidth: 0 })、maxLines(1)和textOverflow({ overflow: TextOverflow.Ellipsis })这类 ArkUI 防溢出写法。分库名称一旦变长,卡片不会把按钮挤出屏幕。
七、页面清单必须注册 wrapper page
HarmonyOS 的路由页面不只是写一个.ets文件就结束,还需要在页面清单里可发现。当前工程的main_pages.json已经列出了这些分库 wrapper。
{ "src": [ "pages/SplashPage", "pages/Index", "pages/BankDetailPage", "pages/SichuanBankPage", "pages/YueBankPage", "pages/NortheastBankPage", "pages/ShanghaiBankPage", "pages/MinnanBankPage", "pages/HakkaBankPage", "pages/PracticePage" ] }如果新增pages/HunanBankPage这类入口,至少要同步三处:
| 位置 | 需要做什么 | 不做的后果 |
|---|---|---|
MockBanks.ets | 增加BANK_SPECS项 | 列表没有数据源 |
pages/HunanBankPage.ets | 绑定BankDetailContent({ fixedBankId }) | 没有固定入口页 |
main_pages.json | 注册pages/HunanBankPage | router.pushUrl找不到页面 |
BankCard.detailPageUrl() | 把新bank.id映射到页面 | 卡片只能走默认详情页 |
这里的取舍也很清楚:如果新分库不需要独立页面 URL,可以不写 wrapper,直接让默认BankDetailPage接收bankId;如果需要独立入口,就要完整注册 wrapper。
八、公共详情页仍然负责章节、进度和练习入口
地区/分库 wrapper 不碰详情内容,真正的章节和练习入口仍然在BankDetailContent。上一篇已经分析过详情页,这里只看和分库复用相关的部分。
private bankId(): string { return this.bank ? this.bank.id : '' } private bankProgressRatio(): number { if (!this.bank || this.bank.totalCount === 0) return 0 return Math.min(this.bankFinished() / this.bank.totalCount, 1) } @Builder ChapterSection() { ForEach(this.bank!.chapters, (chapter: Chapter) => { this.ChapterItem(chapter) }, (chapter: Chapter) => chapter.id) }这段逻辑依赖的是this.bank,而不是某个具体分库页面名。只要fixedBankId能找到对应Bank,详情页就能展示该题库的章节、题量、完成度和正确率。
章节点击进入练习页时,也继续使用当前题库 ID。
router.pushUrl({ url: 'pages/PracticePage', params: { bankId: this.bank.id, chapterId: chapter.id, mode: 'chapter' } })这就是复用价值所在:SichuanBankPage不需要知道练习页有chapter、random、exam模式;它只把固定题库交给详情页,后续链路由公共组件负责。
九、题库排序和列表展示不破坏分库入口
BankListPage允许按热度或题量排序。排序只改变BANKS的展示顺序,不改变每个bank.id和页面映射。
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 }这里有一个细节:result使用[...BANKS]拷贝数组后排序,避免直接修改原始BANKS顺序。对 UI 来说,这是一个小动作;对跨页面状态来说,它避免首页、搜索页和题库列表在同一个静态数组上互相影响。
分库入口的稳定性来自bank.id,不是来自列表位置。无论排序后b_sichuan在第一位还是第三位,BankCard.detailPageUrl()都会按 ID 跳转到pages/SichuanBankPage。
十、扩展新分库时建议按四步走
如果后续要增加一个真实的“地区表达素材”分库,不建议直接复制SichuanBankPage后把页面内部改成一堆独立 UI。更稳的扩展路径是四步。
第一步,在BANK_SPECS中定义题库规格。
{ id: 'b_hunan', regionId: 'regional_expression', name: '地区表达规范', shortName: '湘', cover: $r('app.media.img_bank_cover_hunan'), hot: 82, chapters: ['高频表达', '书面替换', '口语转写', '场景辨析', '误用纠正', '综合练习'] }第二步,新增 wrapper page,只绑定固定题库 ID。
import { BankDetailContent } from './BankDetailPage' @Entry @Component struct HunanBankPage { build() { Column() { BankDetailContent({ fixedBankId: 'b_hunan' }) } .width('100%') .height('100%') } }第三步,在main_pages.json注册新页面。
"pages/HunanBankPage"第四步,在BankCard.detailPageUrl()中增加映射。
case 'b_hunan': return 'pages/HunanBankPage'这四步的验证也很直接:题库列表能看到新卡片,点击能进入新 wrapper,详情页能加载对应Bank,章节点击能进入PracticePage。
十一、这个结构的风险点
当前实现简单有效,但也有几个需要持续注意的风险。
| 风险 | 现象 | 建议 |
|---|---|---|
detailPageUrl()映射遗漏 | 新分库卡片走默认页或打不开 | 新增BANK_SPECS时同步检查映射 |
main_pages.json漏注册 | router.pushUrl运行失败 | 新增 wrapper 后马上注册页面 |
wrapper 与BANK_SPECS.id不一致 | 详情页进入空态 | 固定 ID 使用常量或集中表维护 |
| 卡片文案过长 | 小屏幕文字挤压 | 保留constraintSize、maxLines、textOverflow |
| 复制公共详情页 | 多处 UI 规则不一致 | wrapper 只绑定 ID,不复制详情 UI |
如果分库数量继续增长,detailPageUrl()的switch可以考虑改成映射表,比如Record<string, string>。不过在当前六个题库规模下,switch可读性很高,也便于直接搜索页面关系。是否抽象,不应该只看“代码是否优雅”,而要看维护成本是否真的下降。
十二、验证清单
本篇文章对应的源码验证可以按下面顺序执行。
- 打开
entry/src/main/ets/pages/SichuanBankPage.ets,确认它只传入fixedBankId: 'b_sichuan'。 - 打开
YueBankPage.ets、NortheastBankPage.ets、ShanghaiBankPage.ets、MinnanBankPage.ets、HakkaBankPage.ets,确认它们都复用BankDetailContent。 - 打开
BankCard.ets,确认detailPageUrl()根据bank.id映射到 wrapper page。 - 打开
MockBanks.ets,确认BANK_SPECS生成BANKS,章节由makeChapters()构造。 - 打开
main_pages.json,确认 wrapper page 已注册。 - 在应用中从首页推荐题库或题库列表点击卡片,确认能进入对应详情页。
- 在详情页点击章节、随机练习或限时挑战,确认进入
PracticePage时携带当前bankId。
十三、常见问题排查
| 问题 | 优先检查 |
|---|---|
| 点击某个题库没有反应 | BankCard.onClick()是否触发,detailPageUrl()是否返回正确页面 |
| 页面打开后是空态 | wrapper 的fixedBankId是否能在MockBanks.getBankById()找到 |
| 新分库不显示 | BANK_SPECS是否添加,BANKS是否参与首页或列表渲染 |
| 只有默认详情页可用 | 新 wrapper 是否注册到main_pages.json |
| 章节练习进入错误题库 | PracticePage参数里的bankId是否来自当前this.bank.id |
| 宽屏卡片错位 | HomePage或BankListPage的网格宽度是否过窄,BankCard是否切到紧凑布局 |
十四、总结
笔下生辉的分库页面结构并不复杂,但它体现了一个很实用的 HarmonyOS ArkTS 页面复用策略:列表入口使用BankCard统一跳转,地区/分库 wrapper 只绑定固定bankId,公共详情页BankDetailContent负责加载题库、展示章节、计算进度和进入练习页,题库内容则由MockBanks.ets的规格数据驱动。
这个结构的价值不是少写几个页面文件,而是把变化边界压窄。新增分库时,只需要补数据、补 wrapper、补路由注册和补卡片映射;详情页布局、进度展示和练习入口不用复制。对面向 HarmonyOS 5.0+ 的 ArkTS 应用来说,这种“轻入口 + 重复用组件 + 数据规格驱动”的写法,比把每个分库页面做成独立大页面更容易维护,也更适合后续做多设备布局和内容扩展。
部分内容由AI辅助生成。
