【句匠|08】HarmonyOS ArkTS 句库搜索实战:支持关键词、分类和无结果反馈

搜索页在学习类应用里经常被低估。用户真正需要的不是一个输入框,而是从"我想找某个题库""我想搜一道题里的关键词""我从分类页点进来想看某类题"这几种入口里,都能得到稳定反馈。若状态边界不清楚,搜索页很容易出现三个问题:还没搜索就显示无结果、分类筛选和关键词互相污染、结果为空时没有明确解释。

句匠项目的 SearchPage.ets 是一个轻量但完整的本地搜索页。它不做云端检索,不建全文索引,而是基于本地 BANKS、REGIONS 和 getQuestions(),把题库名称、题目题干和分类类型收束到同一套状态:keyword、categoryType、bankResults、questionResults、searched。本文基于真实源码 D:\huawei\one18-11\entry\src\main\ets\pages\SearchPage.ets,并结合 BankDetailPage.ets 的题库资料边界,复盘 HarmonyOS 5.0+ ArkTS 搜索页如何支持关键词、分类和无结果反馈。

正文唯一复核标记:com.jiaweikang.one18。本文只讨论源码可复核能力:SearchParams、aboutToAppear()、doSearch()、SearchHeader()、EmptySearchHome()、NoResult()、ResultList()、QuestionResultCard()、BankDetailProfile 和本地题库数据。它不声称当前版本实现了云端搜索、拼音搜索、语义搜索、服务端排序或真实用户搜索数据统计。

1. 搜索页先区分"未搜索"和"无结果"

搜索页最容易犯的错,是把初始页面和无结果页面都写成空数组判断。句匠源码里单独用了 searched:

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

页面主体根据 searched 和两个结果数组分三段渲染:

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

这条边界很关键。初始页应该引导用户搜索,空结果页应该告诉用户换关键词。两者都可能是 bankResults=[] 和 questionResults=[],但用户感知完全不同。searched 让页面可以明确表达"你还没搜"和"已经搜过但没有匹配"。

2. 路由分类入口会自动触发搜索

SearchPage 支持从分类页或其他入口带参数进入。参数模型很小:

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

页面出现时,如果路由参数带了 categoryType,就把分类写入状态,并把 keyword 设置为分类名,随后执行搜索:

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()
  }
}

这段逻辑说明分类入口不是简单地预填输入框,而是一次完整搜索。这样用户从"介词搭配""地道表达"等分类点进来时,不需要再手动点击搜索按钮。

需要注意边界:分类搜索真正使用的是 q.type === this.categoryType,不是 keyword 文本匹配。keyword = categoryName 主要用于输入框展示,让用户知道当前筛选来自哪个分类。

3. doSearch() 把题库和题目拆成两类结果

核心搜索函数如下:

ts 复制代码
private doSearch(): void {
  if (this.keyword.trim().length === 0 && this.categoryType.length === 0) {
    this.bankResults = []
    this.questionResults = []
    this.searched = false
    return
  }
  this.searched = true
  const kw = this.keyword.trim().toLowerCase()
  this.bankResults = this.categoryType.length > 0
    ? []
    : BANKS.filter(b => b.name.toLowerCase().includes(kw))
  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
}

这段代码有三个明确决策:

决策 源码表现 作用
空输入不算搜索 清空结果并 searched=false 避免初始页误显示无结果
分类模式不搜题库 categoryType.length > 0 ? [] : ... 分类页只展示题目,不混入题库卡片
题目最多 20 条 qResults.length >= 20 控制本地遍历后的列表长度

本地搜索没有建立索引,所以结果收集上限很重要。句匠题库规模不大,直接遍历 BANKS -> getQuestions(bank.id) 足够;但如果后续题量上万,就应该把搜索逻辑从页面里抽到服务层或索引模块。

4. 分类和关键词不能同时长期生效

搜索框 onChange 有一段状态清理:

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

这解决了一个常见交互问题:用户从分类入口进来后,输入框显示分类名。如果用户开始手动修改输入内容,页面就不应该继续按原分类筛选,否则输入"receive"却还在展示"介词搭配"分类结果,会让人误判搜索失效。

因此源码采用的规则是:分类入口进入时使用 categoryType;一旦用户输入内容不等于原分类名,就清除分类状态,回到关键词搜索。

这个规则简单但有效。它避免了"分类筛选 + 关键词筛选"的组合复杂度。当前页面没有实现多条件联合过滤,所以不应该在文章或产品文案里声称支持多维组合筛选。

5. 搜索头部同时承载返回、输入、提交和分类提示

SearchHeader() 不是只放一个输入框,它还包含返回按钮、搜索按钮和分类提示条。

ts 复制代码
TextInput({ placeholder: '搜索地区、题库、题目', text: this.keyword })
  .layoutWeight(1)
  .height(42)
  .fontSize(Sizes.BODY_FONT)
  .fontColor(Colors.INPUT_TEXT)
  .placeholderColor(Colors.INPUT_PLACEHOLDER)
  .caretColor(Colors.PRIMARY)
  .backgroundColor(Colors.INPUT_BG)
  .onChange((value: string) => {
    this.keyword = value
    if (this.categoryType.length > 0 && value !== this.categoryName) {
      this.categoryType = ''
      this.categoryName = ''
    }
  })
  .onSubmit(() => { this.doSearch() })

搜索按钮也直接调用同一个函数:

ts 复制代码
Text('搜索')
  .fontSize(Sizes.BODY_FONT)
  .fontColor(Colors.PRIMARY)
  .fontWeight(FontWeight.Bold)
  .onClick(() => { this.doSearch() })

键盘提交和按钮提交共用 doSearch(),这点要保留。否则很容易出现键盘搜索和按钮搜索结果不一致。

分类提示条则只在 categoryType.length > 0 时显示:

ts 复制代码
if (this.categoryType.length > 0) {
  Row() {
    Text(`当前分类:${this.categoryName}`)
    Blank()
    Text('清除')
      .onClick(() => {
        this.categoryType = ''
        this.categoryName = ''
        this.keyword = ''
        this.doSearch()
      })
  }
}

清除分类后调用 doSearch(),由于关键词和分类都为空,页面会回到未搜索状态,而不是展示空结果。

6. 初始页用热门地区做搜索入口

未搜索时,页面展示 EmptySearchHome()。它使用 REGIONS 渲染热门搜索词:

ts 复制代码
Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(REGIONS, (region: Region) => {
    Text(region.name)
      .fontSize(Sizes.BODY_FONT)
      .fontColor(Colors.TEXT_SECONDARY)
      .height(38)
      .padding({ left: 16, right: 16 })
      .backgroundColor(Colors.SURFACE)
      .borderRadius(19)
      .margin({ right: 10, bottom: 10 })
      .onClick(() => {
        this.keyword = region.name
        this.doSearch()
      })
  }, (region: Region) => region.id)
}

这类入口不是装饰,它承担了两个作用:让首次进入搜索页的用户知道可以搜什么;给本地搜索提供可复现输入。点击地区名会写入 keyword 并立即搜索。

源码下方还有一句说明:

ts 复制代码
Text('可以搜索题库名称、语法点、关键词,也可以从分类页进入题型结果。')

这个说明与实现基本一致,但要注意精确表达:源码当前题目搜索匹配的是 q.stem,题库搜索匹配的是 b.name。它没有搜索 analysis、options、chapter.name 或 BankDetailProfile.focusTags。

7. 无结果页要给出下一步动作

NoResult() 使用图片和两行文案:

ts 复制代码
@Builder
NoResult() {
  Column({ space: 12 }) {
    Image($r('app.media.img_empty_default'))
      .width(120)
      .height(120)
      .objectFit(ImageFit.Contain)
      .opacity(0.6)
    Text('未找到相关内容')
      .fontSize(Sizes.BODY_FONT)
      .fontColor(Colors.TEXT_HINT)
    Text('换个关键词试试')
      .fontSize(Sizes.CAPTION_FONT)
      .fontColor(Colors.TEXT_HINT)
  }
}

空状态的重点是避免用户困惑。由于当前搜索只覆盖题库名和题干关键词,用户搜一个选项文本或解析里的词,可能不会命中。无结果页不应该暗示系统故障,而应该提示换关键词。

如果后续扩展搜索范围,建议同步调整无结果文案。例如支持解析搜索后,可以提示"试试语法点、例句或解析关键词";支持拼音搜索后,可以提示"支持中文、英文或拼音首字母"。

8. 结果列表按题库和题目分区

ResultList() 先渲染题库结果,再渲染题目结果:

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

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

分区的价值是让用户知道自己搜到的是"题库入口"还是"具体题目"。题库卡片使用已有 BankCard,题目结果则走专门的 QuestionResultCard。

这种分区也保留了后续扩展空间。比如后面可以加入"章节""收藏""错题"分区,但每个分区都应该从明确的数据源来,不能把不同类型混成一个没有来源标识的列表。

9. 题目结果卡片只展示必要信息

QuestionResultCard() 展示题干、题型、题库名和练习入口:

ts 复制代码
@Builder
QuestionResultCard(q: Question) {
  Column({ space: 10 }) {
    Text(q.stem)
      .fontSize(Sizes.BODY_FONT)
      .fontWeight(FontWeight.Medium)
      .fontColor(Colors.TEXT_PRIMARY)
      .lineHeight(21)
      .maxLines(3)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .width('100%')

    Row({ space: 8 }) {
      Text(questionTypeLabel(q.type))
      Text(this.bankName(q.bankId))
      Blank()
      Text('练习')
    }
  }
  .onClick(() => {
    router.pushUrl({
      url: 'pages/PracticePage',
      params: { bankId: q.bankId, mode: 'random' }
    })
  })
}

这里的 maxLines(3) 和 textOverflow 很必要。题干可能包含英文句子、中文说明和下划线空缺,小屏上如果不限制行数,结果列表会变得很难扫读。

点击题目结果后进入 PracticePage,参数是 { bankId: q.bankId, mode: 'random' }。这说明当前源码并不是直接打开这道题,而是进入对应题库的随机练习。文章必须如实说明这一点,不能写成"点击搜索结果直接定位到该题并开始作答"。如果未来要支持精确定位,需要给 PracticePage 增加 questionId 参数和定位逻辑。

10. 题库详情页提供分类语义,但不参与搜索计算

BankDetailPage.ets 中定义了 BankDetailProfile:

ts 复制代码
interface BankDetailProfile {
  subtitle: string
  intro: string
  cultureNote: string
  focusTags: string[]
  sceneTags: string[]
}

不同题库有不同资料,例如介词搭配、地道表达、真题语法等。它们用于题库详情页展示:

ts 复制代码
private profile(): BankDetailProfile {
  const profile = BANK_DETAIL_PROFILES.get(this.bankId())
  return profile ? profile : DEFAULT_BANK_DETAIL_PROFILE
}

这部分和搜索页的关系要说清楚:BankDetailProfile 提供题库语义展示,但当前 SearchPage.doSearch() 并没有搜索 focusTags、sceneTags 或 cultureNote。用户能通过题库名或题干关键词搜索,而不是通过题库详情文案全文搜索。

这个边界对后续迭代有指导意义。如果产品想让"地道表达""邮件用词""高频错词"等标签可搜索,就应该把 BankDetailProfile 或分类数据纳入搜索数据源,而不是只改 placeholder。

11. 本地搜索的真实边界

当前实现适合轻量题库搜索,但有明确边界:

能力 当前源码状态
题库名称搜索 已实现,使用 BANKS.filter
题干关键词搜索 已实现,使用 q.stem.toLowerCase().includes(kw)
分类题型搜索 已实现,使用 q.type === categoryType
结果数量限制 已实现,题目结果最多 20 条
无结果反馈 已实现,NoResult()
拼音/首字母搜索 未实现
解析/选项全文搜索 未实现
云端搜索/服务端排序 未实现
点击结果定位到具体题 未实现,当前进入题库随机练习

把边界写清楚,是技术文章和上架材料都需要遵守的基本要求。尤其是"搜索"这个词很容易被理解成全量检索,源码没有做的能力不应该过度包装。

12. 可复核测试清单

可以按下面方式验证搜索链路:

场景 操作 预期
初始进入 打开搜索页不输入 展示热门搜索,不显示无结果
空搜索 输入空白后点击搜索 清空结果并回到未搜索状态
题库名搜索 输入题库名称的一部分 bankResults 出现题库卡片
题干关键词 输入题干中存在的英文词 questionResults 出现题目卡片
分类入口 带 categoryType 跳转 自动搜索并显示当前分类条
修改分类关键词 分类入口后手动改输入 清除 categoryType,改为关键词搜索
无命中 输入不存在的词 展示 NoResult()
点击题目 点击题目卡片 进入对应题库的随机练习页

调试时建议先看 searched,再看两个结果数组。很多 UI 误判不是搜索逻辑错,而是页面把"未搜索"和"已搜索无结果"混在一起。

13. 常见问题与处理建议

问题 可能原因 处理方向
初始页显示"未找到" 只按结果数组判断,没有使用 searched 保留三段式渲染:未搜索、无结果、有结果
分类入口后输入关键词无效 categoryType 没有被清除 在 onChange 中判断输入是否偏离 categoryName
搜索结果过多卡顿 本地遍历没有上限 保留 20 条上限,题量变大后抽服务层或索引
搜选项文本搜不到 当前只搜 q.stem 若有需求,扩展到 options/analysis/chapter
点击题目没有定位具体题 当前只传 bankId 和 random 增加 questionId 参数和练习页定位逻辑
题库详情标签搜不到 BankDetailProfile 未纳入搜索源 将 focusTags/sceneTags 作为可搜索字段

总结

句匠的 SearchPage.ets 没有把搜索页做成复杂系统,而是用很少的状态把入口、查询、结果和反馈分开:keyword 负责用户输入,categoryType 负责分类入口,searched 区分未搜索和无结果,bankResults 与 questionResults 分别承载题库和题目结果。BankDetailPage.ets 提供了题库语义资料,但当前并不参与搜索计算,这个边界同样需要明确。

对 HarmonyOS ArkTS 学习应用来说,这套实现的可复用点是状态边界,而不是某个样式。先把搜索范围讲清楚,再把未搜索、无结果、有结果分开渲染,最后让点击结果进入明确的练习入口。这样即使后续扩展全文索引、分类组合筛选或具体题定位,也能在现有结构上继续演进。

相关推荐
youyin5 天前
HarmonyOS ArkUI 组件与自定义组件零基础:从搭页面到组件化开发
华为·harmonyos
HwJack205 天前
【HarmonyOS开发小实践】ArkTS 从 TypeScript 到方舟语言的演进
华为·harmonyos
威哥爱编程5 天前
HarmonyOS 7 星盾机密风控实战:设备风险因子端侧融合计算,可用不可见
harmonyos·arkts
威哥爱编程5 天前
HarmonyOS 7 应用快启实战:关键资源预加载进内存,冷启告别白屏
华为·harmonyos·arkts
马剑威(威哥爱编程)5 天前
【共创稿事节】HarmonyOS 7 视觉 AI 进阶实战:人脸检测 + 通用文字识别(OCR)两步接入
华为·harmonyos·arkts
HMS Core6 天前
HarmonyOS智慧多窗,让应用在任意窗口都“恰到好处”
harmonyos
梦想不只是梦与想6 天前
鸿蒙 应用发布准备工作(一)
harmonyos·鸿蒙上架·鸿蒙应用发布
马剑威(威哥爱编程)6 天前
【共创稿事节】HarmonyOS 7 文搜图实战:自然语言检索本地图片,全流程端侧闭环
华为·harmonyos
李游Leo6 天前
HarmonyOS 7 HAR + HSP 工程化实战:模块复用、包体积控制与依赖边界
harmonyos