【化学实验室|07】HarmonyOS ArkTS 知识全局搜索实战:实现跨知识点、公式与阅读内容的本地检索

全局搜索最容易做成"看起来能搜"的功能:把几组数组拼在一起,把标题和正文连接成字符串,再用 indexOf() 判断是否包含关键词。数据只有几十条时,这段代码确实可以运行;但一旦用户输入化学式、同义词或跨类型关键词,结果顺序、空状态和目标跳转就会暴露问题。

本文基于 化学实验室 1.0.5 的真实 ArkTS 源码,分析 GlobalSearchPage.ets、Experiment.ets、KnowledgePoint.ets、ReadingArticle.ets 与 FormulaItem.ets。当前应用共有 13 个实验、6 个知识点、4 篇阅读和 15 条公式,总计 38 个本地内容项。页面已经能聚合四类数据,并使用复合键 ${type}_${id} 渲染稳定列表,知识点和阅读也有详情跳转。

本文唯一核验标记:CHEM-LAB-ONE12-GLOBAL-SEARCH-INDEX-20260730。

现有链路仍有四个明显断点。第一,空关键词时执行 allItems().slice(0, 12),由于实验最先加入,首页推荐只会显示前 12 个实验,知识点、阅读和公式完全没有露出。第二,每次输入都重新构造 38 个搜索对象。第三,所有命中只按源数组顺序展示,没有标题、正文和公式的相关度区别。第四,公式结果只跳到 FormulaPage,没有携带公式 ID,用户点击结果后仍要再次寻找。

更稳的设计是:全局搜索不是把四组数组拼起来过滤,而是把不同内容转换为同一检索契约 。业务模型仍是事实源,搜索索引只是只读投影;查询先归一化,再评分和排序;结果保留稳定的 type + id,导航层据此直达具体内容。

本文将完成以下工程拆解:

  • 把实验、知识点、阅读和公式转换成统一 SearchDocument。
  • 处理大小写、空白、化学上下标、箭头和常见别名。
  • 建立标题、分类、正文与公式的分层相关度评分。
  • 让空关键词推荐保持类型均衡,而不是被实验列表占满。
  • 修复公式跳转丢失目标 ID,并统一四类导航契约。
  • 补齐加载、推荐、结果、空结果和非法目标状态。
  • 提供可复核的查询样例、测试矩阵与排障表。

一、先统计真实内容规模

当前搜索页的 allItems() 依次读取四类模型。

类型 模型函数 数量 主要可检索字段
实验 getAllExperiments() 13 名称、分类、描述、难度
知识点 getAllKnowledgePoints() 6 标题、分类、摘要、正文
阅读 getReadingArticles() 4 标题、分类、摘要、关键词
公式 getAllFormulas() 15 名称、方程式、说明、分类

现有统一结果类型如下:

ts 复制代码
type SearchType = 'experiment' | 'knowledge' | 'reading' | 'formula'

interface SearchResultItem {
  id: string
  type: SearchType
  typeName: string
  title: string
  subtitle: string
  keywords: string
}

这个方向是正确的,因为页面不需要知道每个业务模型的全部字段。但 keywords 是提前拼好的单字符串,缺少字段来源、别名和权重,后续无法解释"为什么它排在前面"。

二、空关键词推荐被数据追加顺序控制

当前空查询逻辑是:

ts 复制代码
if (q.length === 0) {
  return this.allItems().slice(0, 12)
}

allItems() 先追加 13 个实验,再追加知识点、阅读和公式。因此前 12 项全部是实验。用户还没有输入时,"全局搜索"实际上展示的是"实验推荐",其他内容类型没有机会出现。

推荐结果应该显式定义类型配额:

ts 复制代码
const DEFAULT_LIMIT: number = 12
const TYPE_QUOTA: Record<SearchType, number> = {
  experiment: 3,
  knowledge: 3,
  reading: 3,
  formula: 3
}

export function buildBalancedSuggestions(
  documents: SearchDocument[]
): SearchDocument[] {
  const result: SearchDocument[] = []
  const types: SearchType[] = ['experiment', 'knowledge', 'reading', 'formula']
  for (const type of types) {
    result.push(...documents.filter(doc => doc.type === type).slice(0, TYPE_QUOTA[type]))
  }
  return result.slice(0, DEFAULT_LIMIT)
}

这是产品规则,不应由数组碰巧的先后顺序决定。

三、搜索文档要保留字段结构

统一索引不等于把所有内容压成一个字符串。建议保留标题、分类、摘要、正文、别名和目的地:

ts 复制代码
export interface SearchDestination {
  route: string
  params: Record<string, string>
}

export interface SearchDocument {
  key: string
  id: string
  type: SearchType
  typeName: string
  title: string
  subtitle: string
  category: string
  body: string
  aliases: string[]
  destination: SearchDestination
}

key 使用 ${type}:${id},用于列表和去重;destination 由索引构建时产生,点击结果不再重新扫描业务数组;aliases 专门保存用户可能输入但正文没有出现的表达。

四、为实验构建搜索文档

实验结果需要保留 expId,目的地是实验模拟页:

ts 复制代码
function indexExperiment(item: Experiment): SearchDocument {
  return {
    key: `experiment:${item.id}`,
    id: item.id,
    type: 'experiment',
    typeName: '实验',
    title: item.name,
    subtitle: `${item.category} · ${item.description}`,
    category: item.category,
    body: `${item.description} ${item.level}`,
    aliases: experimentAliases(item.id),
    destination: {
      route: 'views/experiment/ExperimentSimPage',
      params: { expId: item.id, expName: item.name }
    }
  }
}

例如 acid_base 可以增加"中和""滴定""pH"等别名,oxygen_prep 可以增加"高锰酸钾""制氧"。别名应由内容维护者显式配置,避免搜索层猜测化学含义。

五、知识点详情只传 ID 更稳

现有知识点跳转把标题、分类、摘要和正文全部放入路由参数:

ts 复制代码
params: {
  id: item.id,
  title: kp ? kp.title : item.title,
  category: kp ? kp.category : '基础化学',
  summary: kp ? kp.summary : item.subtitle,
  content: kp ? kp.content : item.subtitle
}

这会复制内容,模型更新后,索引和详情参数可能不一致。更稳的契约只传稳定 ID,详情页从模型重新读取:

ts 复制代码
function indexKnowledge(item: KnowledgePoint): SearchDocument {
  return {
    key: `knowledge:${item.id}`,
    id: item.id,
    type: 'knowledge',
    typeName: '知识点',
    title: item.title,
    subtitle: `${item.category} · ${item.summary}`,
    category: item.category,
    body: `${item.summary} ${item.content}`,
    aliases: [],
    destination: {
      route: 'views/learning/KnowledgeDetailPage',
      params: { id: item.id }
    }
  }
}

详情页若找不到 ID,应显示内容已更新的错误状态,而不是使用搜索摘要拼成临时正文。

六、阅读索引需要包含 sections 正文

当前阅读搜索只使用标题、分类、摘要和 keywords,没有搜索各章节正文。用户输入"试管口不能朝向他人"时,真实内容存在于 sections.body,但不会命中。

ts 复制代码
function indexReading(article: ReadingArticle): SearchDocument {
  const sectionText = article.sections
    .map((section: ReadingSection) => `${section.title} ${section.body}`)
    .join(' ')
  return {
    key: `reading:${article.id}`,
    id: article.id,
    type: 'reading',
    typeName: '阅读',
    title: article.title,
    subtitle: `${article.category} · ${article.minutes}分钟阅读`,
    category: article.category,
    body: `${article.summary} ${sectionText}`,
    aliases: article.keywords.slice(),
    destination: {
      route: 'views/learning/ReadingDetailPage',
      params: { id: article.id }
    }
  }
}

正文加入索引后,要通过权重保证标题命中仍排在正文命中之前。

七、公式结果必须携带公式 ID

现有公式结果点击后只执行:

ts 复制代码
router.pushUrl({ url: 'views/learning/FormulaPage' })

用户搜索"理想气体方程",点击结果却只进入公式列表,没有定位目标。公式文档应携带 ID:

ts 复制代码
function indexFormula(item: FormulaItem): SearchDocument {
  return {
    key: `formula:${item.id}`,
    id: item.id,
    type: 'formula',
    typeName: '方程式',
    title: item.name,
    subtitle: `${item.formula} · ${item.description}`,
    category: item.category,
    body: `${item.formula} ${item.description}`,
    aliases: formulaAliases(item),
    destination: {
      route: 'views/learning/FormulaPage',
      params: { formulaId: item.id }
    }
  }
}

FormulaPage 读取 formulaId 后滚动或高亮对应项。找不到 ID 时回到完整列表,并给出轻量提示。

八、构建索引时只聚合一次

当前 filteredItems() 每次执行都调用 allItems(),ArkUI 重新构建时可能重复创建所有搜索对象。38 项规模影响不大,但结构上没有必要。

ts 复制代码
export class LocalSearchIndex {
  private readonly documents: SearchDocument[]

  constructor() {
    this.documents = [
      ...getAllExperiments().map(indexExperiment),
      ...getAllKnowledgePoints().map(indexKnowledge),
      ...getReadingArticles().map(indexReading),
      ...getAllFormulas().map(indexFormula)
    ]
  }

  all(): SearchDocument[] {
    return this.documents.slice()
  }
}

如果内容未来支持编辑或在线更新,再增加显式 rebuild();当前离线静态内容只需页面创建时构建一次。

九、查询归一化要适配化学文本

简单 trim().toLowerCase() 不能处理所有化学输入。用户可能输入 CO2,模型里是 CO₂;可能输入 ->,公式使用 →;也可能插入全角空格。

ts 复制代码
const SUBSCRIPT_MAP: Record<string, string> = {
  '₀': '0', '₁': '1', '₂': '2', '₃': '3', '₄': '4',
  '₅': '5', '₆': '6', '₇': '7', '₈': '8', '₉': '9'
}

export function normalizeChemicalText(value: string): string {
  let output = value.normalize('NFKC').toLowerCase()
  Object.keys(SUBSCRIPT_MAP).forEach((key: string) => {
    output = output.split(key).join(SUBSCRIPT_MAP[key])
  })
  return output
    .replace(/→|⟶|=>/g, '->')
    .replace(/\s+/g, ' ')
    .trim()
}

索引文档在构建时生成归一化字段,查询时只归一化输入一次。

十、不要把字段直接无分隔拼接

现有关键词使用:

ts 复制代码
keywords: `${item.name}${item.category}${item.description}${item.level}`

字段边界消失后,可能产生跨字段的偶然命中。更稳的文档索引保留各字段,并用空格连接归一化文本:

ts 复制代码
export interface IndexedSearchDocument extends SearchDocument {
  normalizedTitle: string
  normalizedCategory: string
  normalizedBody: string
  normalizedAliases: string[]
}

function compileDocument(doc: SearchDocument): IndexedSearchDocument {
  return {
    ...doc,
    normalizedTitle: normalizeChemicalText(doc.title),
    normalizedCategory: normalizeChemicalText(doc.category),
    normalizedBody: normalizeChemicalText(doc.body),
    normalizedAliases: doc.aliases.map(normalizeChemicalText)
  }
}

字段结构为相关度评分提供了基础。

十一、相关度评分应能解释

"质量守恒"会同时命中知识点、阅读文章和公式。源数组顺序不等于相关度,可以使用简单、稳定的本地评分:

ts 复制代码
export function scoreDocument(
  doc: IndexedSearchDocument,
  normalizedQuery: string
): number {
  if (!normalizedQuery) return 0
  let score = 0
  if (doc.normalizedTitle === normalizedQuery) score += 120
  else if (doc.normalizedTitle.startsWith(normalizedQuery)) score += 90
  else if (doc.normalizedTitle.includes(normalizedQuery)) score += 70

  if (doc.normalizedAliases.some(alias => alias === normalizedQuery)) score += 60
  else if (doc.normalizedAliases.some(alias => alias.includes(normalizedQuery))) score += 40
  if (doc.normalizedCategory.includes(normalizedQuery)) score += 25
  if (doc.normalizedBody.includes(normalizedQuery)) score += 15
  return score
}

这不是复杂搜索引擎,却能稳定保证标题精确命中优先于正文偶然命中。

十二、多词查询需要明确规则

用户可能输入"酸碱 反应"或"实验 安全"。可以把归一化查询按空格拆分,要求所有词至少命中一个字段:

ts 复制代码
function queryTokens(value: string): string[] {
  return normalizeChemicalText(value)
    .split(' ')
    .map(token => token.trim())
    .filter(token => token.length > 0)
}

function scoreTokens(doc: IndexedSearchDocument, tokens: string[]): number {
  let total = 0
  for (const token of tokens) {
    const score = scoreDocument(doc, token)
    if (score === 0) return 0
    total += score
  }
  return total
}

使用 AND 规则可以减少无关结果。若产品需要更宽松的 OR 搜索,应明确展示命中了哪些词,而不是混合两种语义。

十三、搜索结果需要稳定排序与去重

统一文档 key 已包含类型和 ID,可以在查询阶段去重:

ts 复制代码
export interface SearchHit {
  document: SearchDocument
  score: number
}

function sortHits(hits: SearchHit[]): SearchHit[] {
  return hits.slice().sort((left, right) => {
    if (right.score !== left.score) return right.score - left.score
    const titleCompare = left.document.title.localeCompare(right.document.title, 'zh-CN')
    return titleCompare !== 0
      ? titleCompare
      : left.document.key.localeCompare(right.document.key)
  })
}

分数相同时按中文标题与稳定 key 排序,同一查询不会因数组实例变化而跳动。

十四、可以增加类型筛选,但不要复制查询逻辑

全局结果上方可以提供"全部、实验、知识点、阅读、公式"分段控件。类型筛选只作用于评分后的结果:

ts 复制代码
export type SearchScope = 'all' | SearchType

function applyScope(hits: SearchHit[], scope: SearchScope): SearchHit[] {
  if (scope === 'all') return hits
  return hits.filter(hit => hit.document.type === scope)
}

不要为每种类型写一套搜索函数,否则标准化和评分规则会逐渐不一致。

十五、页面状态要区分推荐、结果和空结果

当前关键词为空时显示推荐,有关键词但无匹配时 List 直接空白。建议明确状态:

ts 复制代码
export type SearchPageMode = 'suggestions' | 'results' | 'empty'

interface SearchViewState {
  query: string
  scope: SearchScope
  mode: SearchPageMode
  hits: SearchHit[]
  total: number
}

无结果时应展示当前查询和可操作建议,例如"尝试输入 CO2、酸碱、质量守恒",并提供清空按钮。不要让用户面对完全空白页面。

十六、输入响应不必过度防抖

当前只有 38 条静态文档,纯内存评分开销很低,可以在 onChange 中同步查询。只有当正文索引显著增长,或加入拼音、分词后,才需要 100~200ms 防抖。

ts 复制代码
private updateQuery(value: string): void {
  this.keyword = value
  this.refreshHits()
}

private refreshHits(): void {
  const result = this.searchIndex.search(this.keyword, this.scope, 40)
  this.viewState = toSearchViewState(this.keyword, this.scope, result)
}

关键优化是索引只构建一次,而不是盲目加入计时器增加异步复杂度。

十七、结果数量限制要向用户可解释

现有查询使用 .slice(0, 40),当前总数 38,不会截断。但内容扩展后,用户可能不知道还有结果。

ts 复制代码
export interface SearchResponse {
  hits: SearchHit[]
  total: number
  limited: boolean
}

function limitHits(hits: SearchHit[], limit: number): SearchResponse {
  return {
    hits: hits.slice(0, limit),
    total: hits.length,
    limited: hits.length > limit
  }
}

页面可以显示"找到 63 项,当前展示前 40 项",并通过类型筛选缩小范围。

十八、导航层只处理 SearchDestination

当前 openResult() 按类型重新查询模型,导航逻辑与索引构建重复。统一目的地后,页面只调用导航器:

ts 复制代码
export class SearchNavigator {
  static open(document: SearchDocument): void {
    const allowedRoutes: string[] = [
      'views/experiment/ExperimentSimPage',
      'views/learning/KnowledgeDetailPage',
      'views/learning/ReadingDetailPage',
      'views/learning/FormulaPage'
    ]
    if (!allowedRoutes.includes(document.destination.route)) return
    router.pushUrl({
      url: document.destination.route,
      params: document.destination.params
    })
  }
}

允许列表避免索引数据意外跳到未注册页面。所有目标仍来自本地代码,不接受用户输入的任意 route。

十九、详情页必须验证 ID

搜索命中只是"当时索引存在",详情页打开时仍要重新验证。以知识点为例:

ts 复制代码
interface KnowledgeRouteParams {
  id?: string
}

private loadKnowledge(): void {
  const params = router.getParams() as KnowledgeRouteParams | undefined
  const item = getKnowledgePointById(params?.id ?? '')
  if (!item) {
    this.phase = 'not_found'
    return
  }
  this.item = item
  this.phase = 'content'
}

公式页、阅读页和实验页都应采用同样的 ID 校验,避免旧索引或错误参数显示成默认内容。

二十、关键词高亮要避免直接拼 HTML

搜索结果可高亮标题中的命中词,但 ArkUI 中不应把用户输入拼进 HTML。可以计算文本片段,再用 Text 与 Span 渲染。

ts 复制代码
interface HighlightPart {
  text: string
  matched: boolean
}

function splitHighlight(source: string, query: string): HighlightPart[] {
  const normalized = normalizeChemicalText(query)
  if (!normalized) return [{ text: source, matched: false }]
  const index = normalizeChemicalText(source).indexOf(normalized)
  if (index < 0) return [{ text: source, matched: false }]
  return [
    { text: source.slice(0, index), matched: false },
    { text: source.slice(index, index + query.length), matched: true },
    { text: source.slice(index + query.length), matched: false }
  ].filter(part => part.text.length > 0)
}

对于 Unicode 归一化后长度变化的情况,可以只在原始标题直接包含查询时高亮,其余命中保持普通文本,避免错误切片。

二十一、验证查询覆盖四种内容

本地索引适合用固定数据做确定性测试:

ts 复制代码
const index = new LocalSearchIndex()
const hits = index.search('质量守恒', 'all', 40)
console.info(hits.map(hit => `${hit.document.type}:${hit.document.id}`).join(','))
查询 预期首要类型 关键验证
质量守恒 知识点或公式 标题精确命中优先
CO2 公式、实验、阅读 能匹配 CO₂
酸碱 反应 实验、知识点 两个词都必须命中
试管口 阅读 sections 正文参与索引
PV=nRT 公式 空白与符号归一化
电解水 实验、知识点、公式 多类型结果稳定排序
空字符串 四类推荐 每类都有露出
不存在词 空结果 显示建议与清空动作

二十二、页面与多设备验证

搜索页使用 List 和 layoutWeight(1),基础布局适合长结果。还需要检查:

  • 键盘弹出后结果列表是否仍可滚动。
  • 小窗口下类型标签、标题和箭头是否互相挤压。
  • 长公式是否允许两行显示,而不是全部截断。
  • 大字体下搜索框占位和返回按钮是否完整。
  • 深色模式下输入框背景、类型标签和正文对比度是否合格。
  • 2in1 设备使用 Enter 时能否打开首个结果或收起键盘。

结果行应保持稳定最小高度,但不要用固定高度裁掉长公式。

二十三、常见故障与排查顺序

现象 优先检查 典型原因 修复方向
默认推荐全是实验 推荐配额 直接截取聚合数组前 12 项 按类型均衡取样
CO2 搜不到 CO₂ 文本归一化 上下标字符不同 统一化学字符
正文命中排在标题前 评分规则 只做布尔过滤 按字段赋权
点击公式后找不到目标 路由参数 只跳公式列表 携带 formulaId 并定位
阅读正文搜不到 索引字段 未加入 sections 聚合章节标题和正文
同一查询顺序跳动 排序后备规则 同分时没有稳定 key 分数、标题、key 三级排序
无结果页面空白 页面模式 List 为空但无 empty state 明确空结果与建议
输入卡顿 索引构建 每次渲染重建所有文档 页面初始化时编译一次

排查顺序应是查询归一化、文档字段、单词评分、类型筛选、数量限制、目的地参数,最后才看列表样式。

二十四、建议的落地顺序

  1. 新建 SearchDocument 与四个类型转换函数。
  2. 页面初始化时构建一次 LocalSearchIndex。
  3. 增加化学文本归一化和别名字段。
  4. 用字段权重替换简单 indexOf 过滤。
  5. 空查询改为四种类型均衡推荐。
  6. 加入稳定排序、复合键去重和结果总数。
  7. 公式结果携带 formulaId,详情页按 ID 定位。
  8. 知识点跳转收敛为 ID,详情页重新读取事实源。
  9. 补齐推荐、结果、空结果和非法目标状态。
  10. 执行化学符号、多词查询、键盘与多设备验证。

先解决索引契约和导航身份,再增加高亮、历史搜索或拼音功能,改动会更可控。

二十五、源码事实与建议实现的边界

本地搜索的讨论很容易把"能通过统一索引实现"写成"当前页面已经具备"。下面把本轮打开源码后可确认的行为与本文建议分开,便于按文件逐项复核。

类别 当前源码可直接确认 本文建议但未声称已合入
内容规模 13 个实验、6 个知识点、4 篇阅读、15 条公式,共 38 项 为内容扩展增加显式索引重建机制
聚合时机 filteredItems() 每次调用都会重新执行 allItems() 页面初始化时构建一次 LocalSearchIndex
空关键词 直接返回聚合数组前 12 项,因此当前全部来自实验 按四种类型设置明确推荐配额
匹配规则 trim().toLowerCase() 后对拼接字符串执行 indexOf() 化学字符归一化、多词规则和字段权重评分
阅读字段 当前只索引标题、分类、摘要和 keywords 将 sections 的标题与正文加入只读索引
列表身份 ForEach 使用 ${type}_${id} 作为键 继续使用稳定复合键并在查询层去重
结果数量 非空查询最多截取 40 项 同时返回总数和是否截断
实验跳转 携带 expId 与 expName 进入模拟页 详情页以 ID 重新读取事实源
知识点跳转 当前把 ID、标题、分类、摘要和正文都放入路由参数 收敛为只传 ID,并验证目标是否存在
阅读跳转 当前携带阅读 ID 进入详情页 保持 ID 契约并补齐非法目标状态
公式跳转 当前只打开 FormulaPage,没有携带公式 ID 携带 formulaId 并定位或高亮目标
页面状态 无结果时列表为空,没有专门空状态 区分推荐、结果、空结果和非法目标

文中的 SearchDocument、相关度评分、类型配额、化学文本归一化和 SearchNavigator 都是基于当前断点提出的设计,不代表这些类型已经存在于工程,也没有声称完成 ArkTS 编译、性能测试或真机键盘验证。实现后仍需按第二十一、二十二节保存查询结果、跳转结果和多设备验证证据。

总结

一个可靠的本地全局搜索,不是把字符串连接得更长,而是把不同业务内容编译成统一、可解释的搜索文档。标题、分类、正文和别名保持字段边界,查询经过化学字符归一化,结果按相关度与稳定规则排序,点击时使用 type + id 直达真实内容。

完成这套结构后,38 条离线内容即使继续扩展,也不会因数组顺序导致推荐偏斜;CO2、CO₂、方程式箭头和多词查询能得到一致结果;知识点、阅读、公式和实验都能从搜索页进入正确目标。搜索索引只是业务模型的只读投影,内容更新、页面渲染和导航职责也因此保持清晰。

AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布。

相关推荐
马剑威(威哥爱编程)1 小时前
【共创稿事节】HarmonyOS 7 Agent 开发实战 08:结构化输出解析
华为·harmonyos
donk13142 小时前
React Native for OpenHarmony 实战:三方库 react-native-segmented-control-tab 的鸿蒙化适配指南
javascript·react native·react.js·华为·harmonyos
360智汇云3 小时前
鸿蒙开发中如何实现画中画
华为·harmonyos
donk13144 小时前
React Native for OpenHarmony 实战:三方库 react-native-toast-message 的鸿蒙化适配指南
react native·react.js·音视频·harmonyos
Flutter_OH4 小时前
Flutter OHOS Dart 堆栈符号化
harmonyos
Flutter_OH5 小时前
Flutter OHOS Flutter 多设备自适应 DPI 指导文档
harmonyos
SuperHeroWu75 小时前
【HarmonyOS 7】 应用 Agent 接入云 A2A 与端 A2A 的实践详解
ai·harmonyos·小艺
用户29469405448166 小时前
个人开发者上架鸿蒙 AI 应用,最后倒在安全评估实名制这一格
harmonyos·deepseek
Keya6 小时前
HarmonyOS 开发实战:用 util.Aspect 玩转 AOP 切面编程
harmonyos