【中国方言题库|05】HarmonyOS ArkTS 东北话分库实战:让地区内容与通用列表解耦

摘要:地区题库很容易走向两种极端:所有地区挤在一个巨型页面里,或者每个地区复制一套列表、进度和跳转代码。"中国方言题库"的东北话分库采用第三种方式: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_c1northeast_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() 的过滤条件同时使用 bankIdchapterId

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 用相同的 bankIdchapterId 更新。只要入口到结果页始终保留 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_CACHEbankId 缓存展开后的题目:

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_northeastBankDetailContent 统一详情、空态、进度、多设备布局和三类入口;MockBanks 按题库 ID 合并并转换东北话内容;PracticePage 再根据题库与章节参数选择题目。

当前实现已经避免了为每个地区复制列表页面,也保留了独立地区文案和本地数据。但源码仍存在可验证的演进空间:章节按索引取模、题目 ID 依赖数组顺序、去重只做 trim(),历史听音题被重分类为词汇题。把这些事实讲清楚,比包装不存在的能力更有工程价值。

当新增更多地区时,继续坚持"薄入口、稳定主键、共享能力、独立内容、可追踪进度"这五条原则,才能让题库规模增长而页面复杂度不成倍增长。


AI 辅助声明: 本文由 AI 辅助整理,所有现状结论均基于"中国方言题库"当前 NortheastBankPageBankDetailContentMockBanksPracticePage 与本地东北话题目源码复核;建议代码均明确标注为演进方案,未虚构真人音频、人工章节标注或在线题库能力。

相关推荐
HwJack202 小时前
鸿蒙AbilityKit深度解析:应用模型的核心骨架
华为·harmonyos
●VON2 小时前
芯稿 MarkDeck 使用指南:用 Markdown 写出可编辑的 PPT
华为·编辑器·powerpoint·harmonyos·鸿蒙
2501_9197490317 小时前
华为鸿蒙记录咖啡APP—小羊咖啡
华为·harmonyos·鸿蒙
柠落少女244018 小时前
React Native 全能商城应用实现与鸿蒙跨端适配深度解析
react native·华为·harmonyos
2501_9197490318 小时前
华为鸿蒙图片像素工具APP—小羊像素
华为·harmonyos·鸿蒙
见山是山-见水是水19 小时前
鸿蒙版 Flutter Video 视频播放组件:播放控制、全屏切换与倍速播放
flutter·音视频·harmonyos
HarmonyOS_SDK19 小时前
从启动到畅玩:HarmonyOS SDK如何重塑一场连续的游戏体验
harmonyos
YM52e20 小时前
语言地区选择器-鸿蒙ArkTS国际化页面设计
学习·华为·harmonyos
旭日猎鹰20 小时前
鸿蒙日志采集命令
华为·harmonyos