搜索是 App 最高频的功能之一。本文用 ArkUI 构建一个完整的搜索页面,覆盖实时筛选、搜索历史管理、热门搜索排行,以及 TextInputonSubmit 事件处理。


一、我们要做什么

一个完整的搜索页面,三个区域在不同状态下交替显示:

  1. 默认状态 — 搜索栏为空时,显示搜索历史 + 热门搜索
  2. 搜索中 — 输入文字时,实时筛选结果列表
  3. 无结果 — 输入了文字但没匹配到任何内容,显示空状态

另外:
4. 搜索历史管理 — 执行搜索时自动记录,去重,最多保留 10 条,可单条删除、一键清除
5. 热门搜索 — 10 个热词,前 3 名红色数字 + "热"标签


二、数据准备

2.1 搜索源数据

class SearchItem {
  id: number;
  title: string;
  category: string;
  reads: number;
}

const ALL_DATA: SearchItem[] = [
  new SearchItem(1, '鸿蒙ArkUI状态管理深度解析', '技术文章', 3421),
  new SearchItem(2, 'DevEco Studio 6.0新特性一览', '工具', 2890),
  // ... 共 15 条
];

15 条模拟数据,每条有标题、分类、阅读数。搜索结果按标题和分类字段匹配。

2.2 热门搜索

const HOT_SEARCHES: string[] = [
  '鸿蒙', 'ArkUI', '状态管理', '性能优化', '分布式',
  '多端部署', 'DevEco Studio', 'HarmonyOS NEXT', '元服务', '动画',
];

10 个热词,常量数组,不参与状态管理。


在这里插入图片描述

三、交互点1:实时筛选

TextInput({ placeholder: '搜索文章、教程、话题...', text: $$this.searchText })
  .fontSize(FontSize.BODY)
  .layoutWeight(1)
  .height(36)
  .backgroundColor(Color.Transparent)
  .onChange(() => this.filterResults())
  .onSubmit(() => {
    this.doSearch(this.searchText);  // 键盘回车 → 记录历史
  })

两个事件的分工:

  • onChangefilterResults() — 实时筛选,每输入一个字都过滤一次
  • onSubmitdoSearch() — 键盘回车,记录到搜索历史

为什么要分开?因为用户在输入过程中不应该触发历史记录——比如输入"鸿"看到结果了,但并不想把这个单字存为搜索词。只有按回车确认才是"真正的搜索"。

筛选逻辑

private filterResults(): void {
  const kw = this.searchText.trim().toLowerCase();
  if (kw.length === 0) {
    this.filteredResults = [];  // 空输入 → 回到默认页面
    return;
  }
  this.filteredResults = ALL_DATA.filter((item: SearchItem) =>
    item.title.toLowerCase().includes(kw) ||
    item.category.toLowerCase().includes(kw)
  );
}

toLowerCase() 做大小写不敏感匹配——用户搜"arkui" 能匹配到"ArkUI"。

搜索结果用 ForEach 直接在 List 中渲染,和前面文章的分页列表不同——搜索结果数量有限(最多 15 条),不需要分页。

取消按钮

if (this.searchText.length > 0) {
  Text('取消')
    .fontSize(FontSize.BODY)
    .fontColor(AppColors.PRIMARY)
    .margin({ left: Spacing.SM })
    .onClick(() => this.cancelSearch())
}

只在有输入时才显示"取消"按钮——不输入时不需要取消。点击取消 → 清空搜索文字 → 清空筛选结果 → 回到历史+热榜的默认页面。


在这里插入图片描述

四、交互点2:搜索历史管理

4.1 添加历史

private doSearch(term: string): void {
  const t = term.trim();
  if (t.length === 0) return;

  // 去重:如果已存在,先删除
  const idx = this.searchHistory.indexOf(t);
  if (idx >= 0) {
    this.searchHistory.splice(idx, 1);
  }

  // 插入到最前面
  this.searchHistory = [t].concat(this.searchHistory);

  // 最多保留 10 条
  if (this.searchHistory.length > 10) {
    this.searchHistory = this.searchHistory.slice(0, 10);
  }

  this.searchText = t;   // 把搜索词填入输入框
  this.filterResults();  // 立即展示结果
}

四个步骤:

  1. 去重 — 如果"鸿蒙"已存在于历史中,先删掉旧的(用户刚才输入"鸿蒙"又搜了一次,不需要在历史里显示两次)
  2. 前置插入[t].concat(this.searchHistory) 新搜索词放在第一位
  3. 裁剪 — 超过 10 条就保留前 10 条,历史不会无限增长
  4. 同步更新 UI — 把搜索词填入输入框并展示结果

4.2 单条删除

private deleteHistoryItem(term: string): void {
  const idx = this.searchHistory.indexOf(term);
  if (idx >= 0) {
    this.searchHistory.splice(idx, 1);
    this.searchHistory = [...this.searchHistory];  // 触发 @State 更新
  }
}

每条历史右侧有一个 × 按钮。splice[...this.searchHistory] 展开创建新数组——又回到了待办事项文章讲过的不可变更新模式。

4.3 一键清除

private clearHistory(): void {
  promptAction.showDialog({
    title: '清除搜索历史',
    message: '确定要清除全部搜索历史吗?',
    buttons: [
      { text: '取消', color: AppColors.TEXT_TERTIARY },
      { text: '确定', color: AppColors.ERROR }
    ]
  }).then((result) => {
    if (result.index === 1) {
      this.searchHistory = [];
      promptAction.showToast({ message: '已清除全部历史', duration: 1500 });
    }
  });
}

清除是破坏性操作——需要弹窗确认。这和 ProfilePage 的退出登录确认是同一个模式。

4.4 历史列表 UI

Column() {
  ForEach(this.searchHistory, (term: string) => {
    Row() {
      Image($r('sys.symbol.clock'))
        .width(16).height(16)
        .fillColor(AppColors.TEXT_DISABLED)
        .margin({ right: Spacing.SM })
      Text(term)
        .fontSize(FontSize.BODY)
        .fontColor(AppColors.TEXT_PRIMARY)
        .layoutWeight(1)
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text('×')
        .fontSize(18)
        .fontColor(AppColors.TEXT_DISABLED)
        .padding({ left: Spacing.SM, right: Spacing.XS })
        .onClick(() => this.deleteHistoryItem(term))
    }
    .width('100%').height(44)
    .padding({ left: Spacing.LG, right: Spacing.LG })
    .onClick(() => this.doSearch(term))
  }, (term: string) => term)
}

整行可以点击(搜索该词),右侧 × 按钮单独处理删除。(term: string) => term 作为 ForEach 的 key——历史中不会有重复词(添加时已去重),所以 term 本身就是唯一的 key。


五、交互点3:热门搜索排行

ForEach(HOT_SEARCHES, (term: string, index: number) => {
  Row() {
    Text(`${index + 1}`)                      // 排名数字
      .fontSize(FontSize.BODY)
      .fontWeight(index < 3 ? FontWeight.Bold : FontWeight.Regular)
      .fontColor(index < 3 ? AppColors.ERROR : AppColors.TEXT_TERTIARY)
      .width(24)
      .textAlign(TextAlign.Center)

    Text(term)                                 // 热词
      .fontSize(FontSize.BODY)
      .fontColor(AppColors.TEXT_PRIMARY)
      .layoutWeight(1)
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })

    if (index < 3) {                           // 前三名"热"标签
      Text('热')
        .fontSize(10)
        .fontColor(Color.White)
        .backgroundColor(AppColors.ERROR)
        .borderRadius(BorderRadius.SM)
        .padding({ left: Spacing.XS, right: Spacing.XS, top: 1, bottom: 1 })
    }
  }
  .width('100%').height(44)
  .padding({ left: Spacing.LG, right: Spacing.LG })
  .onClick(() => this.doSearch(term))
}, (term: string) => term)

排名视觉差异化:

  • 前 3 名 — 红色粗体数字 + "热"标签,抓眼球
  • 4-10 名 — 灰色常规数字,不抢注意力

"热"标签是红色小方块——尺寸 10、圆角 SM、白字红底。和前几篇文章的标签/Tag 保持同一视觉语言。


六、页面状态流转

初始状态(搜索栏为空)
  → 显示搜索历史(如有)+ 热门搜索
  → 点击历史/热词 → 填充搜索栏 → 展示筛选结果 → 记录历史

输入状态(搜索栏有文字)
  → 实时筛选 ALL_DATA → 匹配项展示列表
  → 匹配到 0 条 → 空状态:"未找到相关内容,换个关键词试试吧"

键盘回车
  → 记录搜索词到历史 → 去重 + 前置 + 裁剪

取消按钮
  → 清空搜索文字 → 清空结果 → 回到初始状态

七、代码结构

entry/src/main/ets/
├── common/Constants.ets
├── pages/
│   ├── Index.ets             # 入口页(十个按钮)
│   ├── SearchPage.ets        # 本篇核心:搜索页面(~260行)
│   └── ...                   # 前九篇

SearchPage 约 260 行,数据模型(SearchItem)和常量(ALL_DATA、HOT_SEARCHES)均定义在页面文件内。


八、常见面试题 / 踩坑点

8.1 onChange 中做筛选会不会太频繁?

15 条数据的数组 filter 是 O(n) 操作,在 JSCore 中 15 次比较不到 1ms。即使是 1000 条数据,filter 也能在几毫秒内完成。真正的性能瓶颈不在这里,而在 UI 渲染——15 条 ListItem 的重绘比 1000 次字符串比较重得多。

如果数据量大(10000+),可以加节流(debounce 300ms)。但 15 条就完全不需要。

8.2 搜索历史的 key 为什么用 term 自身?

因为去重逻辑保证了历史中不会有重复词。(term: string) => term 是最简 key 生成器,既保证唯一性又零计算开销。

但如果将来允许重复(比如按时间戳区分同词的不同搜索),key 就需要改为 term + timestamp。

8.3 onSubmit 在 ArkUI 中的触发条件?

TextInput.onSubmit 在用户按键盘上的回车/搜索键时触发。如果系统键盘没有显示搜索键(比如用了普通键盘),onSubmit 可能不会触发。这时用户可以通过点击历史词或热门词来触发 doSearch——保留回车的便利,但不依赖回车。

8.4 搜索历史最多 10 条是为什么?

防止无限增长。用户可能每天搜索几十次,如果不清除,几个月后历史列表会非常长——不仅占内存,用户翻很久也找不到想用的历史词。10 条是一个合理的上限,刚好在一屏内展示完。

8.5 "取消"按钮为什么不收起键盘?

ArkUI 的 TextInput 没有直接的 dismissKeyboard() 方法。点击"取消"后清空搜索内容、回到默认页面——键盘可能还开着,但不影响体验(用户看到结果已经变了)。如果一定要收起键盘,需要用到 focusControl.requestFocus(null) 或类似 API,但不同版本支持度不一。


九、运行方式

代码位于 dev/entry/src/main/ets/pages/SearchPage.ets

用 DevEco Studio 打开 dev/ 项目,首页点击"搜索页面 — 实时筛选与历史记录"即可体验:

  1. 进入页面 → 搜索栏为空,显示"热门搜索"(10 个热词,前 3 红色)
  2. 点击任意热词 → 填充搜索栏,展示筛选结果,该词加入历史
  3. 点击历史中的词 → 直接搜索
  4. 点击历史右侧 × → 删除单条
  5. 点击"清除全部" → 弹窗确认 → 清空全部历史
  6. 手动输入"鸿蒙" → 实时显示匹配结果(标题或分类含"鸿蒙"的条目)
  7. 输入"xyz" → 显示空状态"未找到相关内容"
  8. 键盘回车 → 搜索词存入历史最前面
  9. 多次搜索不同词 → 历史按最近优先排列,超过 10 条自动裁剪

十、扩展方向

  • 防抖/节流 — 为 onChange 增加 setTimeout 300ms 防抖,适合数据量大的场景
  • 高亮搜索词 — 搜索结果中匹配的文字用不同颜色标记(需要富文本或样式化处理)
  • 拼音搜索 — 引入拼音库,输入"hm" 匹配到"鸿蒙"
  • 搜索提示/联想 — 输入时同时展示搜索建议下拉(类似 Google 的 autocomplete)
  • 远程搜索 — 替换本地 filter 为 HTTP 请求,onChange 中发请求并 abort 上一次未完成的请求
  • 搜索历史同步 — 用 Preferences 持久化搜索历史,重启 App 不丢
  • 搜索结果分页 — 远程搜索结果多了需要分页,复用 FeedPage 的分页模式
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐