摘要:地区题库很容易走向两种极端:所有地区挤在一个巨型页面里,或者每个地区复制一套列表、进度和跳转代码。"中国方言题库"的东北话分库采用第三种方式:
NortheastBankPage只固定b_northeast,共享的BankDetailContent负责详情、章节、进度、多设备布局和练习入口,MockBanks再通过题库 ID 选择本地内容。本文基于当前 HarmonyOS 5.0+ ArkTS 源码,拆解这条真实链路,并讨论数据边界、章节分配、缓存、进度隔离和后续模块化方式。

一、先看结论:分库页面不等于复制页面
当前 NortheastBankPage.ets 只有一个职责:
ts
import { BankDetailContent } from './BankDetailPage'
@Entry
@Component
struct NortheastBankPage {
build() {
Column() {
BankDetailContent({ fixedBankId: 'b_northeast' })
}
.width('100%')
.height('100%')
}
}
它没有自己渲染题库封面、章节列表、进度条或底部按钮,也没有自行读取东北话题目。它只是把地区身份交给共享组件。
本文的唯一标记是:地区入口只负责选择题库,不复制通用列表。这句话也是判断分库架构是否健康的最简标准。
二、为什么不直接复用一个带路由参数的详情页
项目中原本就有通用 BankDetailPage,可以从路由参数读取 bankId。东北话为何还要保留独立入口?因为两类入口的产品语义不同:
- 通用详情页适合从首页列表、搜索结果等动态位置进入。
NortheastBankPage代表一个稳定的地区目的地。- 固定入口便于路由表、快捷入口和后续地区专题复用。
- 共享组件仍然只有一份,不产生六套 UI。
独立路由与共享实现并不矛盾。前者稳定导航语义,后者控制维护成本。
三、fixedBankId 优先级隔离了外部参数
BankDetailContent 的初始化逻辑是:
ts
aboutToAppear(): void {
if (this.fixedBankId.length > 0) {
this.bank = getBankById(this.fixedBankId)
return
}
const params = router.getParams() as BankDetailParams | undefined
if (params && params.bankId) {
this.bank = getBankById(params.bankId)
}
}
这里的 return 很重要。东北话固定入口不会再被路由参数中的其他 bankId 覆盖。即使调用者错误地携带 b_yue,页面仍按 b_northeast 查找。
这形成一个明确契约:
text
地区专属入口:fixedBankId 优先
通用详情入口:router params 生效
如果把两个来源简单地用"后写覆盖前写"合并,专题入口就可能展示另一个地区的题库,封面、章节和进度也会一起串区。
四、三种 ID 必须各司其职
东北话相关数据至少有三种标识:
ts
regionId: 'northeast'
bankId: 'b_northeast'
chapterId: 'northeast_c1' // 到 northeast_c6
regionId 用于地区目录,bankId 是题库查询和练习进度的主键,chapterId 用于章节过滤与章节进度。它们不能凭字符串相似就混用。
一个安全的路由参数模型可以写成:
ts
interface PracticeParams {
bankId: string
chapterId?: string
mode: 'chapter' | 'random' | 'exam'
}
当前代码使用字符串字段,运行链路真实可用;若后续重构,联合类型能在编译期减少拼写错误,但这属于扩展建议,不是对现状的虚构。
五、题库目录只保存元数据
东北话题库目录项为:
ts
{
id: 'b_northeast',
regionId: 'northeast',
name: '东北话题库',
cover: $r('app.media.img_bank_cover_northeast'),
totalCount: 0,
accuracy: 0,
hot: 92,
chapters: NE_CHAPTERS
}
它保存身份、展示信息和章节引用,没有把所有题目嵌在页面结构里。真实题量会在目录同步时根据题目集合更新,详情页因此可以读取统一的 Bank 模型。
这种分离的价值不是"文件更好看",而是避免 UI 层知道题目由多少个数据文件拼接而来。详情页只问:这是哪个题库、有多少题、有哪些章节。
六、地区内容通过 RAW_MAP 选择
东北话原始题目来自多组数组,再按同一题库 ID 合并:
ts
[
'b_northeast',
(((((NE_QUESTIONS
.concat(NE_MORE_QUESTIONS) as RawQuestion[])
.concat(NE_EXTRA as RawQuestion[]))
.concat(NE_EXTRA2 as RawQuestion[]))
.concat(NE_EXTRA3 as RawQuestion[]))
.concat(NE_EXTRA4 as RawQuestion[]))
.concat(NE_EXTRA5 as RawQuestion[])
]
这里已经实现"地区内容与通用列表解耦":共享列表不知道 NE_EXTRA5,东北题目文件也不知道 ArkUI 卡片如何布局。两者只在 b_northeast 这个边界上会合。
实际题目包含"嘎哈呢""老铁""埋汰""贼拉""咋整""麻溜儿"等词汇,也包含火炕、冰雪活动、二人转、酸菜等文化语境。文章只描述源码中能找到的内容,不扩写不存在的语音库、地图或社区功能。
七、构建阶段统一完成重分类、去重和截取
getQuestions() 并不是简单返回原始数组:
ts
export function getQuestions(bankId: string): Question[] {
let questions = QUESTIONS_CACHE.get(bankId)
if (!questions) {
const source = RAW_MAP.get(bankId) || SICHUAN_QUESTIONS
const reclassified = source.map((r: RawQuestion) => {
if (r.type === 'audio') {
return {
type: 'vocab',
stem: r.stem,
options: r.options,
answer: r.answer,
analysis: r.analysis,
example: r.example,
audioHint: r.audioHint
} as RawQuestion
}
return r
})
// 后续执行题干去重、数量截取和模型展开
}
return questions
}
这段源码明确说明听音题题型已经下线,历史 audio 数据被重分类为 vocab。因此不能把东北话分库宣传成现成的"东北话听力训练"。audioHint 即使保留,也只是字符串字段,不等于可核验音频资源。
八、题干去重有用,但不是完整内容审核
当前去重规则是:
ts
const key = r.stem.trim()
if (seen.has(key)) continue
seen.add(key)
unique.push(r)
它可以删除完全相同、仅首尾空格不同的题干,却识别不了这些情况:
text
"咋整"是什么意思?
"咋整"是什么意思?
东北话"咋整"对应普通话哪句?
工程上可以生成"仅用于比较"的规范化 key:
ts
function dedupKey(stem: string): string {
return stem
.normalize('NFKC')
.replace(/["""]/g, '')
.replace(/\s+/g, '')
.trim()
}
但自动规则不能替代编辑审核。两个相似题干可能考查不同语境;尤其"调侃""不认同""邀请"等语用差异,不能只看词面删除。
九、章节分配当前依赖题目顺序
题目展开时采用:
ts
chapterId: `${bankId.replace('b_', '')}_c${(i % 6) + 1}`
所以东北题目会循环落入 northeast_c1 到 northeast_c6。对应章节标题为:
ts
const NE_CHAPTERS = makeChapters('northeast', [
'儿化音',
'日常用语',
'幽默俗语',
'炕头文化',
'小品高频词',
'冬季生活'
])
这种方式保证六章都能得到题目,适合当前本地题库快速组织;但它不保证语义归类。数组第七题会再次进入第一章,并不意味着它一定考查儿化音。
若要让章节标题与内容严格对应,原始题目应显式携带 chapterId:
ts
interface RegionQuestionSource {
id: string
bankId: 'b_northeast'
chapterId: 'northeast_c1' | 'northeast_c2' | 'northeast_c3'
| 'northeast_c4' | 'northeast_c5' | 'northeast_c6'
type: string
stem: string
options: string[]
answer: number
analysis: string
}
这是后续演进方案。当前真实实现仍是按索引取模,不能在文章中说已经完成人工章节标注。

十、共享详情不是"通用到没有地区特色"
BANK_DETAIL_PROFILES 为东北话提供独立文案:
ts
{
subtitle: '直给、热闹、带节奏的东北话练习页',
intro: '东北话题库更强调口语氛围、夸张语气和辨义理解,练起来会很有生活感和表演感。',
cultureNote: '小品语感、冬季生活和东北家庭交流是这套题库的特色,适合培养辨识度。',
focusTags: ['语气理解', '高频俚语', '幽默表达'],
sceneTags: ['熟人唠嗑', '家庭聊天', '冬天日常']
}
共享组件通过 profile() 按 bankId 取配置。布局、颜色、进度逻辑保持统一,地区导语和场景标签仍然独立。复用的是能力,不是把所有地区文案抹平。
当新增地区时,应该增加目录项、题目集合和 Profile,而不是复制一份 BankDetailPage.ets 再逐处改文字。
十一、空态属于共享组件的责任
题库查找失败时,详情组件展示"未找到题库":
ts
if (this.bank === undefined) {
Column({ space: 12 }) {
Image($r('app.media.img_empty_default'))
Text('未找到题库')
}
}
地区入口无需再写一次错误页。这样无论错误来自路由参数、目录缺失还是开发期 ID 拼写,用户看到的反馈一致。
不过固定入口的自动化测试仍应检查 b_northeast 一定存在。空态是兜底,不应掩盖构建期配置错误。
十二、三种练习入口共享同一个题库主键
章节按钮传递:
ts
{
bankId: this.bank.id,
chapterId: chapter.id,
mode: 'chapter'
}
底部两个操作传递:
ts
{ bankId: this.bank!.id, mode: 'random' }
{ bankId: this.bank!.id, mode: 'exam' }
PracticePage 根据参数选择题目:
ts
if (params.chapterId) {
this.questions = getQuestionsByChapter(params.bankId, params.chapterId)
} else {
this.questions = getQuestions(params.bankId)
}
考试模式再选取 20 题,随机模式进行洗牌。三条路径都保留 b_northeast,因此不会从四川话或粤语集合中混入题目。
十三、章节参数要做归属校验
当前 getQuestionsByChapter() 的过滤条件同时使用 bankId 和 chapterId:
ts
export function getQuestionsByChapter(
bankId: string,
chapterId: string
): Question[] {
return getQuestions(bankId).filter(
(question: Question) => question.chapterId === chapterId
)
}
如果传入 b_northeast + yue_c1,结果为空,练习页随后会回退到整个东北题库。这个回退避免白屏,但也可能把错误章节参数静默转成随机范围。
更严格的方案是在进入练习前验证前缀:
ts
function belongsToBank(bankId: string, chapterId: string): boolean {
return chapterId.startsWith(`${bankId.replace('b_', '')}_c`)
}
验证失败时应记录开发日志并返回详情页,而不是让用户误以为自己进入了某一章。
十四、进度按题库与章节分别聚合
详情页读取题库进度:
ts
const p = UserDataManager.getProgress(
this.progressList,
this.bankId()
)
章节进度同时带两个键:
ts
const cp = UserDataManager.getChapterProgress(
this.chapterProgressList,
this.bankId(),
chapter.id
)
练习完成后,PracticePage 用相同的 bankId、chapterId 更新。只要入口到结果页始终保留 b_northeast,东北话进度就不会写入其他地区。
测试时不能只看总进度数字变化,还要完成一章东北题目后返回其他题库,确认后者的章节数值保持不变。
十五、题目 ID 仍有顺序耦合
当前题目 ID 由索引生成:
ts
id: `${bankId}_q${i + 1}`
它天然带题库前缀,避免不同地区出现同名 q1;但在数组中间插入题目会改变后续 ID。收藏、错题和笔记如果按题目 ID 保存,就可能指向另一道题。
更稳妥的地区内容应使用编辑期固定 ID:
ts
{
id: 'ne_vocab_ga_ha_001',
bankId: 'b_northeast',
chapterId: 'northeast_c2',
stem: '东北话"嘎哈呢"是什么意思?'
}
固定 ID 不应从题干哈希临时生成,因为修正一个标点也会改变哈希。它应作为内容资产的一部分长期维护。
十六、缓存边界与更新策略
QUESTIONS_CACHE 以 bankId 缓存展开后的题目:
ts
const QUESTIONS_CACHE: Map<string, Question[]> =
new Map<string, Question[]>()
对纯本地静态题库而言,这可以避免每次进入详情或练习都重复完成拼接、重分类、去重和模型转换。缓存键是题库 ID,也天然隔离东北话与其他地区。
若未来支持在线更新题包,必须增加版本与失效机制:
ts
interface BankCacheEntry {
bankId: string
contentVersion: number
questions: Question[]
}
更新成功后只清理对应地区缓存,不能为一条东北题目更新而重建全部题库;更新失败则继续使用已验证的本地版本。
十七、多设备适配继续由共享详情承担
详情组件在断点为 lg 且页面宽度不小于 700vp 时使用双栏:
ts
private useWideLayout(): boolean {
return this.currentBp === 'lg' && this.pageWidth >= 700
}
常规窗口是单列滚动,宽屏左侧放封面、概览和地区介绍,右侧放学习重点和章节列表。东北入口根容器只保持满宽满高,不重复任何断点判断。
底部按钮还使用系统导航区域避让:
ts
private bottomSafePadding(): number {
return Math.max(
Sizes.BOTTOM_NAV_MIN_PADDING,
this.getUIContext().px2vp(this.navigationIndicatorHeightPx)
)
}
这也是共享的直接收益:修正一次平板布局或底部安全区,所有地区分库同时得到修复。

十八、内容质量不能只靠代码类型
ArkTS 类型可以保证 options 是字符串数组、answer 是索引,却不能判断解释是否准确、词语是否带冒犯语境、地区差异是否被过度概括。
东北话内容审核至少应覆盖:
- 词义是否注明褒义、贬义或熟人调侃语境。
- "老娘们儿""瘪三"等表达是否给出使用风险。
- 黑龙江、吉林、辽宁内部差异是否被错误说成完全一致。
- 网络流行语是否被误标为东北方言独有。
- 小品、二人转、冬季生活等文化题是否避免刻板化。
- 题干、选项与解析是否真正对应。
技术架构解决"放在哪里、如何加载",内容审核解决"能否这样教"。两者缺一不可。
十九、可执行的测试矩阵
地区身份
text
NortheastBankPage 始终得到 b_northeast
错误路由参数不能覆盖 fixedBankId
目录中 b_northeast 可被 getBankById 找到
封面与标题均属于东北话题库
数据隔离
text
东北题目只来自 b_northeast 映射
随机练习不混入其他 bankId
章节题目的 chapterId 以 northeast_ 开头
去重后题量与目录展示一致
进度与交互
text
完成章节后题库进度增加
章节进度只更新对应 chapterId
随机练习不伪造章节完成度
考试结果仍携带 b_northeast
收藏、错题和笔记可回查原题
布局与可访问性
text
phone 单列内容全部可滚动
tablet 与 2in1 宽屏切为双栏
大字体下章节标题不遮挡按钮
底部操作不进入系统手势区域
空题库和无效 ID 有明确反馈
二十、上架文案的真实边界
可以准确说明:
- 东北话分库有独立路由入口。
- 页面固定选择
b_northeast。 - 共用详情组件展示章节、进度、地区导语与练习入口。
- 本地题库包含词汇、对话、文化、俗语等内容。
- 支持章节练习、随机练习和模拟考试。
- 详情布局适配常规与宽屏窗口。
当前不应说明:
- 每道题都经过方言学专家审定,除非有真实记录。
- 已提供真人东北话音频。
- 历史
audioHint就是可播放录音。 - 六个章节已经全部按语义人工标注。
- 地区内容来自在线实时更新。
- 所有东北地区表达完全一致。
二十一、总结
东北话分库的关键不是新增一个页面,而是把地区身份、共享 UI、地区内容和学习状态分成清晰边界。NortheastBankPage 固定 b_northeast;BankDetailContent 统一详情、空态、进度、多设备布局和三类入口;MockBanks 按题库 ID 合并并转换东北话内容;PracticePage 再根据题库与章节参数选择题目。
当前实现已经避免了为每个地区复制列表页面,也保留了独立地区文案和本地数据。但源码仍存在可验证的演进空间:章节按索引取模、题目 ID 依赖数组顺序、去重只做 trim(),历史听音题被重分类为词汇题。把这些事实讲清楚,比包装不存在的能力更有工程价值。
当新增更多地区时,继续坚持"薄入口、稳定主键、共享能力、独立内容、可追踪进度"这五条原则,才能让题库规模增长而页面复杂度不成倍增长。
AI 辅助声明: 本文由 AI 辅助整理,所有现状结论均基于"中国方言题库"当前 NortheastBankPage、BankDetailContent、MockBanks、PracticePage 与本地东北话题目源码复核;建议代码均明确标注为演进方案,未虚构真人音频、人工章节标注或在线题库能力。