【中国方言题库|09】HarmonyOS ArkTS 方言搜索实战:实现词语检索和无结果反馈

摘要:中国方言题库的搜索页不是网络搜索,也没有模糊分词引擎,而是基于本地 BANKS 与题目集合完成确定性的子串检索。页面同时支持关键词入口和题型分类入口,通过 searched 明确区分搜索首页、无结果和结果列表三种界面状态,并在用户修改分类关键词时主动退出分类模式。本文面向 HarmonyOS 5.0 及以上版本,逐段复核 SearchPage.ets 的 ArkUI 状态建模、结果上限、题库与题目双通道检索、安全区适配和导航行为,也会指出"点击题目进入整库随机练习"等真实边界,避免把尚未实现的能力写成产品事实。

一、搜索页真正要解决的是状态语义

一个搜索页通常至少有四个时刻:

  1. 用户尚未搜索;
  2. 用户正在输入;
  3. 搜索完成但没有结果;
  4. 搜索完成并有结果。

如果只用 results.length === 0 判断界面,就无法区分"尚未搜索"和"确实没有结果"。中国方言题库为此增加:

ts 复制代码
@State searched: boolean = false

页面的主分支非常清楚:

ts 复制代码
if (!this.searched) {
  this.EmptySearchHome()
} else if (
  this.bankResults.length === 0 &&
  this.questionResults.length === 0
) {
  this.NoResult()
} else {
  this.ResultList()
}

这段代码是全文最值得复用的设计。搜索首页的"热门搜索"和搜索失败后的"未找到相关内容"虽然都对应空数组,但用户语义完全不同。

唯一校验标记:空数组不等于无结果,searched 才定义搜索是否发生

二、真实源码与能力边界

本文依据以下文件复核:

text 复制代码
entry/src/main/ets/pages/SearchPage.ets
entry/src/main/ets/mock/MockBanks.ets
entry/src/main/ets/common/components/BankCard.ets
librarya/src/main/ets/models/Dialect.ets
librarya/src/main/ets/constants/ThemeConstants.ets

当前搜索能力有明确边界:

  • 数据全部来自本地题库;
  • 题库按名称检索;
  • 题目按题干检索;
  • 使用 trim().toLowerCase() 做简单归一化;
  • 最多返回 20 道题;
  • 支持从题型分类进入结果;
  • 不搜索答案选项、解析文本或拼音;
  • 不做同义词、纠错、分词或相关度排序;
  • 输入变化不会立即搜索,需点击"搜索"或提交键;
  • 点击题目结果会进入该题库的随机练习,不会定位到这一道题。

技术文章不能把本地子串匹配描述成"智能语义检索",也不能宣称支持未实现的词语字段。

三、六组状态如何共同描述搜索会话

页面声明:

ts 复制代码
@State keyword: string = ''
@State categoryType: string = ''
@State categoryName: string = ''
@State bankResults: Bank[] = []
@State questionResults: Question[] = []
@State searched: boolean = false

这些状态可分成三组:

分组 字段 职责
输入 keyword 用户当前看到和编辑的文本
模式 categoryTypecategoryName 区分关键词检索与题型筛选
输出 bankResultsquestionResultssearched 表达结果和界面阶段

categoryType 是机器可比较的题型键,categoryName 是用户可见名称。两者分开,避免业务判断依赖中文显示文案。

四、从题型分类页进入时如何自动搜索

路由参数接口为:

ts 复制代码
interface SearchParams {
  categoryType?: string
  categoryName?: string
}

页面出现时读取参数:

ts 复制代码
aboutToAppear(): void {
  const params = router.getParams() as SearchParams | undefined
  if (params && params.categoryType) {
    this.categoryType = params.categoryType
    this.categoryName =
      params.categoryName || params.categoryType
    this.keyword = this.categoryName
    this.doSearch()
  }
}

这里把分类名称写入搜索框,是为了让用户知道当前筛选语境。随后立即调用 doSearch(),不要求用户再点一次搜索。

categoryName 缺失时,页面回退显示 categoryType。这能保证界面有内容,但机器键可能不够友好,因此调用方最好总是传递可读名称。

五、空关键词为什么要恢复搜索首页

doSearch() 的第一个分支是:

ts 复制代码
if (
  this.keyword.trim().length === 0 &&
  this.categoryType.length === 0
) {
  this.bankResults = []
  this.questionResults = []
  this.searched = false
  return
}

关键词为空且没有分类时,页面清空两类结果,并把 searched 恢复为 false。这意味着用户清除输入再搜索时,会回到"热门搜索"首页,而不是看到"未找到相关内容"。

若分类仍然存在,即使关键词为空也会继续执行,因为分类本身就是有效查询条件。这个布尔条件准确表达了两种搜索入口。

六、输入归一化只做必要处理

关键词处理是:

ts 复制代码
const kw = this.keyword.trim().toLowerCase()

trim() 去掉首尾空格,避免 " 四川 " 搜索失败;toLowerCase() 为可能出现的拉丁字母提供大小写不敏感匹配。中文没有大小写,但这一处理不会破坏中文。

当前实现没有移除中间空格、全半角归一化、繁简转换或拼音转换。例如:

text 复制代码
"四川话" -> 可以匹配题库名或题干
" 四 川 话 " -> 中间空格仍保留,通常无法匹配
"sichuan" -> 除非数据中真的包含英文,否则无结果

这不是缺陷伪装,而是当前产品的真实检索范围。

七、题库与题目为何使用两条结果通道

页面分别保存:

ts 复制代码
@State bankResults: Bank[] = []
@State questionResults: Question[] = []

题库搜索:

ts 复制代码
this.bankResults =
  this.categoryType.length > 0
    ? []
    : BANKS.filter(
        b => b.name.toLowerCase().includes(kw)
      )

题目搜索则遍历每个题库的全部题目:

ts 复制代码
const qResults: Question[] = []
for (const bank of BANKS) {
  const qs = getQuestions(bank.id)
  for (const q of qs) {
    if (
      (this.categoryType.length > 0 &&
        q.type === this.categoryType) ||
      (this.categoryType.length === 0 &&
        q.stem.toLowerCase().includes(kw))
    ) {
      qResults.push(q)
      if (qResults.length >= 20) break
    }
  }
  if (qResults.length >= 20) break
}
this.questionResults = qResults

分开保存让 UI 能用"题库"和"题目"两个区块展示,用户不会把内容类型混在一起。

八、分类模式为什么不返回题库

categoryType.length > 0 时:

ts 复制代码
this.bankResults = []

分类搜索的目标是按题型筛选问题,而题库本身没有"语音、词汇、文化、地区特色"等单一题型属性。若仍显示题库,就很难说明题库为何命中。

题目匹配条件变为:

ts 复制代码
q.type === this.categoryType

此时搜索框中的 categoryName 只是显示标签,不参与题干匹配。这个细节非常重要:分类入口展示"文化典故"时,结果并不是题干中包含"文化典故"的题,而是 q.type 等于对应类型键的题。

九、用户改写分类关键词时自动退出分类模式

输入框的 onChange

ts 复制代码
.onChange((value: string) => {
  this.keyword = value
  if (
    this.categoryType.length > 0 &&
    value !== this.categoryName
  ) {
    this.categoryType = ''
    this.categoryName = ''
  }
})

如果用户从分类页进入,搜索框可能显示"地区特色"。只要用户把它改成别的内容,页面就清空分类状态,下一次搜索转为普通关键词检索。

如果没有这段逻辑,用户输入"四川"后仍可能得到所有 region 类型题目,看起来像搜索失效。这里用输入变化维护了"看见的关键词"和"真实查询模式"的一致性。

十、分类标签的清除动作是一次完整状态复位

分类模式下会显示:

ts 复制代码
Text(`当前分类:${this.categoryName}`)

点击"清除"执行:

ts 复制代码
this.categoryType = ''
this.categoryName = ''
this.keyword = ''
this.doSearch()

因为分类和关键词都被清空,doSearch() 会把结果数组清空、searched=false,最终回到热门搜索首页。

状态复位必须成组完成。只清 categoryType 而保留 keyword,会立刻把分类名当普通关键词检索,产生完全不同的结果。

十一、搜索触发采用显式提交而非实时联想

页面提供两个触发点:

ts 复制代码
.onSubmit(() => {
  this.doSearch()
})

以及:

ts 复制代码
Text('搜索')
  .onClick(() => {
    this.doSearch()
  })

输入变化只更新状态,不立即执行搜索。因此没有防抖、异步竞态或旧请求覆盖新请求的问题。对本地题库而言,显式搜索还能减少每次按键都扫描全部题目的重复计算。

如果未来改为实时联想,再考虑 150 到 300 毫秒防抖;当前架构不需要为了"看起来高级"加入未使用的异步层。

十二、20 条上限如何影响结果语义

题目结果达到 20 条后,内外两层循环都会停止:

ts 复制代码
if (qResults.length >= 20) break

这能控制首屏负担和 ArkUI 节点数量,但也带来三个真实特征:

  1. 结果最多显示 20 道;
  2. 顺序由 BANKS 顺序和题库内部问题顺序决定;
  3. 没有相关度排序,也没有"共找到多少条"的全量统计。

因此标题 题目 (${this.questionResults.length}) 表示"当前返回数量",不一定是全量命中数。若达到 20,页面也不会提示"仅展示前 20 条"。

更透明的改进是增加:

text 复制代码
题目(展示前 20 条)

或者先统计总命中数,再截取展示列表。两者的性能和信息完整度不同,应按实际题量选择。

十三、本地遍历的复杂度是否可接受

算法会遍历题库和问题,最坏复杂度约为:

text 复制代码
O(全部题目数量)

当前数据量有限,且达到 20 条后提前停止,直接扫描简单、可读、无额外索引维护成本。对于几百或几千道本地题目,这种实现通常足够。

如果题量增长到数万,并要求拼音、答案选项和解析文本联合搜索,可以在初始化时建立轻量索引:

ts 复制代码
interface SearchDocument {
  question: Question
  normalizedText: string
}

normalizedText 可由题干、选项、题库名等一次性拼接并归一化。当前源码没有该索引,因此本文只把它作为扩展路径。

十四、热门搜索来自真实地区数据

未搜索时,页面遍历 REGIONS

ts 复制代码
ForEach(REGIONS, (region: Region) => {
  Text(region.name)
    .onClick(() => {
      this.keyword = region.name
      this.doSearch()
    })
}, (region: Region) => region.id)

热门词不是手写一组重复字符串,而是复用地区模型。地区数据更新后,热门搜索会同步变化。

点击地区只设置关键词,没有设置 categoryType,所以仍走普通关键词模式。它能匹配名称包含该地区词的题库,也能匹配题干中出现该词的问题。

十五、占位提示与真实搜索字段并不完全一致

输入框占位文案是:

text 复制代码
搜索地区、题库、题目

从实现看:

  • "题库"实际匹配 Bank.name
  • "题目"实际匹配 Question.stem
  • "地区"没有单独匹配 Region.nameBank.regionId

地区词之所以通常有效,是因为题库名称可能包含"四川话""粤语"等名称,或者题干包含地区词。这是间接匹配,不是独立地区索引。

如果数据模型中地区名和题库名不再一致,占位文案可能超出真实能力。稳妥方案是把地区显式映射到相关题库,或者把提示改成"搜索题库名称和题目内容"。

十六、无结果状态为何要同时检查两类数组

无结果条件是:

ts 复制代码
this.bankResults.length === 0 &&
this.questionResults.length === 0

必须使用逻辑与。关键词可能只命中题库,不命中题目;也可能只命中题目,不命中题库。任何一类有数据都应该进入结果列表。

NoResult() 提供:

text 复制代码
未找到相关内容
换个关键词试试

并使用空状态图片,而不是保留一块空白区域。这个反馈明确告诉用户搜索已经执行,只是没有命中。

十七、搜索首页和无结果页的视觉职责不同

EmptySearchHome() 包含热门搜索和使用说明,主要帮助用户开始搜索;NoResult() 居中展示错误恢复提示,主要帮助用户修改查询。

从交互上看:

text 复制代码
未搜索 -> 发现入口
无结果 -> 修正输入
有结果 -> 浏览与行动

这三种页面不应共享同一个"空状态组件"后只替换一句文字,因为它们的下一步操作不同。当前源码使用三个 Builder,结构清晰。

十八、结果列表按内容类型分段

有结果时,页面先显示题库:

ts 复制代码
if (this.bankResults.length > 0) {
  this.ResultTitle(`题库 (${this.bankResults.length})`)
  ForEach(this.bankResults, (bank: Bank) => {
    Column() {
      BankCard({ bank: bank })
    }
  }, (bank: Bank) => bank.id)
}

再显示题目:

ts 复制代码
if (this.questionResults.length > 0) {
  this.ResultTitle(`题目 (${this.questionResults.length})`)
  ForEach(this.questionResults, (q: Question) => {
    this.QuestionResultCard(q)
  }, (q: Question) => q.id)
}

分段标题提供数量,也帮助用户理解命中类型。ForEach 分别使用 bank.idq.id 作为稳定键,避免用数组下标造成不必要的组件重建。

十九、题目卡片展示了哪些可复核信息

题目卡片包含:

  1. 题干,最多三行;
  2. 题型标签;
  3. 所属题库名称;
  4. "练习"行动标识。

题干采用:

ts 复制代码
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })

这能避免长题干把单张卡片撑得过高。题库名通过:

ts 复制代码
private bankName(bankId: string): string {
  const b = BANKS.find(bk => bk.id === bankId)
  return b ? b.name : bankId
}

关联真实本地题库;找不到时回退显示 ID,不会返回空字符串。

二十、点击题目结果的真实导航行为

整张题目卡点击后执行:

ts 复制代码
router.pushUrl({
  url: 'pages/PracticePage',
  params: {
    bankId: q.bankId,
    mode: 'random'
  }
})

它只传 bankIdmode='random',没有传 questionId。因此用户点击某道搜索结果后,进入的是所属题库随机练习,不保证第一题就是点击的那一道。

UI 中的"练习"文字容易让用户理解为"练习这道题"。若产品希望精确定位,需要扩展 PracticeParams

ts 复制代码
interface PracticeParams {
  bankId: string
  mode: string
  startQuestionId?: string
}

并在加载题目后把目标题放到首位。当前代码没有实现,不能在文章中声称支持精准跳题。

二十一、搜索页的安全区适配

页面读取:

ts 复制代码
@StorageLink('topAvoidAreaHeightPx')
topAvoidAreaHeightPx: number = 0

@StorageLink('navigationIndicatorHeightPx')
navigationIndicatorHeightPx: number = 0

顶部换算:

ts 复制代码
private topSafePadding(): number {
  return Math.max(
    0,
    this.getUIContext().px2vp(this.topAvoidAreaHeightPx)
  )
}

底部换算:

ts 复制代码
private bottomSafePadding(): number {
  return Math.max(
    Sizes.BOTTOM_NAV_MIN_PADDING,
    this.getUIContext().px2vp(
      this.navigationIndicatorHeightPx
    )
  )
}

系统避让值以像素提供,页面转为 vp 后再用于布局。搜索头部避开状态栏,结果列表和热门搜索在底部保留手势导航空间。

二十二、结果列表为何使用 Scroll 和弹性回弹

结果容器是:

ts 复制代码
Scroll() {
  Column({ space: 14 }) {
    // 题库结果
    // 题目结果
    Blank().height(24 + this.bottomSafePadding())
  }
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)

layoutWeight(1) 让列表占满搜索头部下方剩余空间;末尾 Blank 负责底部避让;Spring 提供符合移动端习惯的边缘反馈。

在平板或 2in1 上,当前仍是单列卡片。它功能上可用,但宽屏信息密度偏低。后续可以按窗口宽度把题目卡片切为两列,数据和搜索状态无需变化。

二十三、返回按钮使用标准路由返回

搜索头部有可见返回图标,并绑定:

ts 复制代码
.onClick(() => {
  router.back()
})

按钮容器使用统一触控目标尺寸:

ts 复制代码
.width(Sizes.TOUCH_TARGET)
.height(Sizes.TOUCH_TARGET)

这比只给 24x24 图标绑定点击更容易触达。HarmonyOS 应用还应保留系统返回手势或按键能力,不能只依赖自绘按钮。

二十四、如何把状态逻辑提炼成可测试函数

当前 doSearch() 同时读写页面状态和执行遍历。题量较小时可接受;若需要单元测试,可以提炼纯函数:

ts 复制代码
interface SearchQuery {
  keyword: string
  categoryType: string
  limit: number
}

interface SearchResult {
  banks: Bank[]
  questions: Question[]
}

纯函数只接收查询和数据,返回结果:

ts 复制代码
function searchLocal(
  query: SearchQuery,
  banks: Bank[]
): SearchResult {
  // 归一化、题库过滤、问题遍历和上限控制
  return {
    banks: [],
    questions: []
  }
}

页面继续负责 searched、分类清除和渲染。这样可以独立测试空格、分类、20 条上限和顺序,不必启动 ArkUI 页面。

二十五、建议覆盖的搜索测试矩阵

用例 预期
初次进入 展示热门搜索,不展示无结果
输入全空格并搜索 回到热门搜索
搜索题库完整名称 题库区出现结果
搜索题干关键词 题目区出现结果
搜索不存在词 展示"未找到相关内容"
从题型分类进入 自动搜索,只展示题目
修改分类显示词 清除分类模式
点击分类"清除" 清空输入并回到首页
命中超过 20 题 只显示前 20 条
点击题目卡 进入所属题库随机练习
小屏与横屏 输入区不溢出,结果可滚动
手势导航 末项不被底部区域遮挡

还应测试极长关键词、输入法提交、连续切换分类和多次返回搜索页。

二十六、不要把搜索结果数量当全量统计

因为问题结果达到 20 条后停止扫描,下面的标题:

ts 复制代码
this.ResultTitle(
  `题目 (${this.questionResults.length})`
)

最多只会显示 20。它不是全库命中总数,更不能作为平台运营数据。若技术文章、截图文案或产品说明声称"共找到 20 条",就会误导用户认为只有 20 条。

更准确的显示方式是"题目结果(最多 20 条)",或者增加 totalMatches 单独计数。所有展示数据都应能从实际代码路径复核。

二十七、离线搜索的隐私与上架优势

当前页面没有网络请求、账号、云端索引、搜索日志上传或第三方分析 SDK。关键词只在组件状态中使用,页面没有把搜索词写入 Preferences。

这意味着:

  • 搜索可以离线完成;
  • 不产生服务端搜索历史;
  • 不需要为此新增网络权限;
  • 隐私说明可明确"搜索在本地处理"。

前提是应用其他模块也没有与此矛盾的隐藏上传行为。上架材料必须和整个包的真实行为一致,不能只依据单页代码下结论。

二十八、进一步优化时应保持简单

对当前本地题库,优先级较高的改进是:

  1. 明确提示题目只展示前 20 条;
  2. 点击题目时支持精确定位;
  3. 把地区匹配从名称巧合升级为显式模型关联;
  4. 搜索字段可选地扩展到选项和解析;
  5. 为输入和无结果增加可访问性描述;
  6. 用联合类型描述搜索模式;
  7. 把纯搜索算法提炼为可测试函数。

优先级较低的是引入远程搜索服务、复杂分词库或大型索引框架。没有真实数据规模和业务需求时,这些依赖只会增加包体、权限、故障点和审核说明成本。

二十九、发布前检查清单

搜索功能提交 AppGallery 前,应验证:

  1. 搜索为空、无结果和有结果时均无白屏;
  2. 返回按钮和系统返回均可用;
  3. 输入框、搜索按钮和分类清除按钮触控区域足够;
  4. 长题干不会遮挡题型与操作;
  5. 深浅色下输入文字、占位符和分割线对比度合格;
  6. 手机横屏、小窗、平板和 2in1 下布局不截断;
  7. 底部结果不会进入系统手势区;
  8. 不宣称语义搜索、拼音搜索或云搜索;
  9. 不把最多 20 条描述为全量结果;
  10. 点击题目后的行为与"练习"文案一致;
  11. 离线声明、权限和实际代码一致;
  12. 安装、启动、搜索、进入练习、返回和卸载流程稳定。

三十、结语

SearchPage.ets 的实现没有炫技,却把本地搜索最容易混乱的几个问题处理得很清楚:用 searched 区分首页和无结果,用两组数组区分题库与题目,用分类状态区分题型筛选和普通关键词,用 20 条上限控制渲染规模,并通过顶部、底部安全区保证主要设备形态下的可用性。

同样需要诚实说明边界:当前只匹配题库名和题干,不搜索拼音、选项或解析;"地区"依赖名称间接命中;题目结果没有相关度排序;点击题目会进入整库随机练习。把这些边界讲清楚,才能让后续优化建立在真实系统上,而不是建立在宣传词上。


AI 辅助声明:本文部分内容由 AI 辅助整理,所有功能描述、代码片段和工程结论均依据项目真实源码人工复核;未伪造搜索能力、结果数量或平台数据。

相关推荐
梦想不只是梦与想2 小时前
鸿蒙 AppGallery Connect:查看应用信息(三)
harmonyos·appgallery·client id·app id·developer id
贾伟康3 小时前
【中国方言题库|03】HarmonyOS ArkTS 四川话分库实战:复用题库组件并保持地区参数清晰
harmonyos·arkts·arkui·路由传参·组件复用
贾伟康3 小时前
【中国方言题库|10】HarmonyOS ArkTS 语音播放实战:管理读音播放与页面生命周期
生命周期·harmonyos·arkts·语音合成·texttospeech
YM52e14 小时前
鸿蒙ArkTS实战项目 - 门店陈列巡检台:巡检卡片与多列切换实现
学习·华为·harmonyos
lilian23315 小时前
Harmony os 技术实战|拼豆制图27:用单字符编码承载 50 张 70×70 图纸
前端·数据库·华为·harmonyos
HwJack2020 小时前
UIAbility 生命周期全链路:从冷启动到热启动的实战笔记
笔记·华为·harmonyos
梦想不只是梦与想21 小时前
鸿蒙 AppGallery Connect:应用创建(二)
harmonyos·appgallery·创建应用
●VON1 天前
芯笺 Markdown:面向 HarmonyOS PC 的本地优先 Markdown 编辑器
华为·编辑器·harmonyos·鸿蒙
世人万千丶1 天前
鸿蒙项目实战 - 社区活动编排板:标签云布局算法与自动换行
学习·算法·华为·harmonyos·鸿蒙