HarmonyOS 展览搜索交互与异步结果管理封面

搜索框的输入变化看起来微不足道,真正棘手的问题却常常发生在结果列表:用户先输入“城”,马上补成“城市摄影”,第一个查询因为缓存或网络波动晚返回,竟然把第二个查询的正确结果覆盖。屏幕上的输入框仍显示“城市摄影”,下方却是“城市艺术节”一类不匹配结果;如果还接入热门推荐与空状态提示,用户会误以为搜索规则不稳定。

这次用独立的 ExhibitFinder 展览发现页面研究一条工程原则:输入值、正在执行的请求与能够展示的响应,是三种不同状态。目标不是搭建完整搜索引擎,而是建立可以复现的 ArkUI 页面:输入“摄影”时展示“城市暗房摄影工坊”和“胶片与光影摄影展”;清空时恢复本地推荐;快速变更关键词时旧请求不得覆盖新结果。案例只使用页面内的静态数据集与构造异步延迟,不请求网络、位置或用户隐私权限。

先把“用户正在输入”和“正在查找”区分开

初学版本经常在 Search.onChange 回调里直接发请求:每输入一个字符就发一次,收到结果立刻覆盖 @State results。这种实现没有错在组件用法,而是把编辑事件与业务查询绑定得太紧。中文输入法存在预编辑过程,用户可能快速删除、选择候选词或直接点击搜索按钮;此时每次改变都立刻查询不仅浪费资源,也增加旧回执覆盖最新输入的可能。

Search 提供 value、placeholder、onChange、onSubmit 等能力。根据华为开发者文档,该组件从 API 8 起提供,且 onChange 在文本变化完成后通知;这里采用普通字符串回调,不依赖更高版本扩展的提交事件参数。页面只负责维护文本与显示状态,数据检索交给单独的数据源,之后接入正式后端仍可以保留同一套代际过滤策略。

// entry/src/main/ets/model/Exhibit.ets
export interface Exhibit {
  id: string
  title: string
  venue: string
  category: string
  dateText: string
}

export const EXHIBITS: Exhibit[] = [
  { id: 'e01', title: '城市暗房摄影工坊', venue: '艺仓空间',
    category: '摄影', dateText: '11月12日—11月24日' },
  { id: 'e02', title: '胶片与光影摄影展', venue: '北岸影像馆',
    category: '摄影', dateText: '11月15日—12月08日' },
  { id: 'e03', title: '纸上建筑', venue: '东区美术馆',
    category: '设计', dateText: '11月10日—12月02日' },
  { id: 'e04', title: '声音的颜色', venue: '回响艺术中心',
    category: '新媒体', dateText: '11月20日—12月16日' }
]

数据项使用稳定的 id,列表渲染时把它作为 ForEach 的 key,不以数组索引替代身份。venue、dateText 等展示字段是示例文案而不是对现实活动日期的宣称;正式产品必须以活动官方日程和票务状态为准。ExhibitFinder 不使用历史章节里的航班列表、包裹核销或选座项目,保证独立文章的业务问题和数据结构都能单独理解。

防抖是节流输入,代际校验才负责正确性

一个常见误解是“设置300毫秒防抖就不会有旧结果”。防抖只意味着在用户连续输入时先不启动下一次任务;已经发出的查询依然可能在后来返回。处理办法应分两层:setTimeout 控制查询发起频率,requestSeq 在每次输入变化时立即递增,使先前的在途结果失效。清空文本同样需要递增代际,否则正在返回的非空结果有可能覆盖首页推荐。

为了能稳定展示迟到结果,数据源允许传入构造延迟。真正的检索实现可以改为网络 API,但仍应确保服务端参数编码、分页、错误码和身份鉴权符合接口契约。这里的延迟只是测试夹具,不能解释为测量得到的网络耗时。

// entry/src/main/ets/service/ExhibitSource.ets
import { Exhibit, EXHIBITS } from '../model/Exhibit'

export class ExhibitSource {
  async search(query: string, delayMs: number): Promise<Exhibit[]> {
    return new Promise<Exhibit[]>((resolve: (items: Exhibit[]) => void) => {
      setTimeout(() => {
        const word: string = query.trim().toLowerCase()
        resolve(EXHIBITS.filter((item: Exhibit) =>
          item.title.toLowerCase().includes(word) ||
          item.venue.toLowerCase().includes(word)))
      }, delayMs)
    })
  }
}

这段 search 不在 word === '' 时直接返回全部数据,因为首页推荐与空关键词搜索在当前交互里是不同页面状态。实际组件会在清空时恢复推荐的前几条,并让“推荐展览”成为明确标题。把推荐逻辑放在页面,能避免未来搜索服务的空查询策略与设计约束冲突。

DevEco Studio 白色主题中 Search、请求序号与列表预览示意

页面实现:一个关键词,三种异步状态

页面上首先需要 keyword、results、loading 和 message 四个可见状态。请求代际与计时器句柄不直接进入 UI,不使用 @State;它们只是控制流变量。每一次输入更新都会调用 scheduleQuery():先取消尚未执行的延迟任务,立即递增代际,再决定显示推荐还是延迟检索。用户点击键盘“搜索”按钮时无需再等待防抖,应立即发起。

// entry/src/main/ets/pages/ExplorePage.ets 的页面状态与调度部分
import { Exhibit, EXHIBITS } from '../model/Exhibit'
import { ExhibitSource } from '../service/ExhibitSource'

@Entry
@Component
struct ExplorePage {
  @State private keyword: string = ''
  @State private results: Exhibit[] = EXHIBITS.slice(0, 3)
  @State private loading: boolean = false
  @State private message: string = '推荐展览'
  private source: ExhibitSource = new ExhibitSource()
  private requestSeq: number = 0
  private debounceTimer: number = -1
  private disposed: boolean = false

  private clearPending(): void {
    if (this.debounceTimer !== -1) {
      clearTimeout(this.debounceTimer)
      this.debounceTimer = -1
    }
  }

  private scheduleQuery(value: string, immediate: boolean = false): void {
    this.keyword = value
    this.clearPending()
    const current: number = ++this.requestSeq
    if (value.trim().length === 0) {
      this.results = EXHIBITS.slice(0, 3)
      this.loading = false
      this.message = '推荐展览'
      return
    }
    this.loading = true
    this.message = '正在搜索…'
    if (immediate) { this.performQuery(current, value); return }
    this.debounceTimer = setTimeout(() => {
      this.debounceTimer = -1
      this.performQuery(current, value)
    }, 280)
  }

  private async performQuery(seq: number, value: string): Promise<void> {
    try {
      const items: Exhibit[] = await this.source.search(value, 350)
      if (this.disposed || seq !== this.requestSeq) { return }
      this.results = items
      this.loading = false
      this.message = items.length > 0 ? `找到 ${items.length} 项展览` : '没有匹配的展览'
    } catch (error) {
      if (this.disposed || seq !== this.requestSeq) { return }
      this.loading = false
      this.message = '暂时无法搜索,请稍后重试'
      this.results = []
    }
  }

  aboutToDisappear(): void {
    this.disposed = true
    this.requestSeq++
    this.clearPending()
  }

  build() {
    Column({ space: 16 }) {
      Text('展览搜索').fontSize(28).fontWeight(FontWeight.Bold)
      Search({ value: this.keyword, placeholder: '搜索展览或展馆' })
        .searchButton('搜索').onChange((value: string) => {
          this.scheduleQuery(value)
        }).onSubmit((value: string) => {
          this.scheduleQuery(value, true)
        })
      Text(this.message).fontSize(15).fontColor('#575F75')
      if (this.loading) {
        Text('查询中…').fontColor('#64748B')
      } else {
        List({ space: 10 }) {
          ForEach(this.results, (item: Exhibit) => {
            ListItem() {
              Column({ space: 6 }) {
                Text(item.title).fontSize(18).fontWeight(FontWeight.Medium)
                Text(`${item.venue} · ${item.dateText}`).fontSize(13)
              }.padding(16).width('100%').backgroundColor('#F2F5FB')
            }
          }, (item: Exhibit) => item.id)
        }.layoutWeight(1)
      }
    }.padding(20).width('100%').height('100%')
  }
}

示例把 disposed 设为普通字段,是为了让正在执行的 Promise 在页面销毁后不再写回状态;同时调用 clearPending() 防止尚未发出的请求继续执行。输入变化时即使取消了上一个 setTimeout,已发出去的异步请求也不能被 clearTimeout 撤回,这就是为什么 requestSeq 必须覆盖整个回执阶段。

一个工程细节是:上述 ExplorePage 保留 build() 的上下文,使页面、结果来源与 Search 回调可以对照阅读;正式集成时应把重复的 UI 卡片抽成独立组件,但别把它们简单地放进 ListItem 外面,否则可能改变懒加载与列表布局。示例只示例静态本地搜索,不需要申请网络权限。若替换为远端 HTTPS 查询,应在 module.json5 中声明适用的网络权限,并处理服务端 TLS、超时、限流与敏感日志。

用故意乱序的响应验证,而不是只看一次正确结果

正确性的验收不应只是输入一次“摄影”,看到两项就结束。需要制造真实风险的时间线:先输入“城市”,让查询A进入等待;随后输入“摄影”,让查询B发出;故意让查询B先完成,再让查询A返回。最后页面必须保留摄影结果,标题为“找到 2 项展览”,而不是任何来自城市关键词的旧数据。为了反复观察,测试数据源可以根据不同查询设置不同的 delayMs,但不能在正式代码里把这种测试延迟固定为业务耗时。

另一条路径是“输入摄影→马上清空”。清空操作会立即将页面切回“推荐展览”,在途的摄影结果即使随后到达,也必须因 seq !== requestSeq 被丢弃。第三条路径是“搜索后退出页面”,后台回执不能再写入销毁后的组件。第四条路径是“输入不存在的词”,应显示无结果文案,而不是保留上一次的卡片误导用户。第五条路径是“构造异常”,应把加载态关闭,并显示可理解的重试入口。

输入变更、发起查询与迟到响应过滤的完整时间线

除了请求乱序,中文搜索还存在归一化问题。示例只使用 trim().toLowerCase(),覆盖空格与英文大小写;正式接入还要确认繁简转换、同义词、拼音、标点、特殊 Unicode 和搜索分词属于服务端还是本地。不能把“前端过滤了四条样本”写成大型展览库的性能结论。建议在实验里记录每次输入的代际、实际发出的词和响应是否被丢弃,但日志中不应完整记录可识别个人的信息。

结果区域建议使用互斥的四种显示态:尚未输入时展示推荐卡片,输入后等待时展示“正在搜索”,完成但为空时展示“没有匹配的展览”,异常时展示错误和重试入口。不要把 results.length === 0 当作唯一条件,因为请求尚未返回时也可能暂时是空数组。这里的 loading 和 message 是两个与结果数组正交的状态,虽然增加了字段,却让测试者能明确判断页面在等待、失败还是完成。对正式项目,可以进一步将它们收敛为单一枚举,防止 loading=true 与“搜索失败”同时出现的互斥问题。

交互上还有个边界:中文输入框的每次 onChange 都不一定代表用户已经完成输入。当前样例没有借助输入法预编辑细节截断搜索词,而是采用短时防抖降低震荡。若业务要求“只有按搜索键才查询”,可让 onChange 仅更新 keyword,将异步查询完全放在 onSubmit;但这属于产品交互选择,不能把其中一种说成 ArkUI 唯一正确写法。若需要更高精度地识别预编辑状态,必须根据目标 SDK 的事件参数与预上屏语义另行设计,不应在当前示例里硬编码未经核实的接口。

最后还要给出可观察性口径。一次查询可以记录“发起代际—查询词摘要—回执代际—是否显示”的结构化诊断,不需要记录用户输入全文,也不需要声称某个构造延迟是线上耗时。发布后若用户反馈列表错配,可先检查是否所有入口都经过 scheduleQuery()、清空是否递增了代际、提交按钮是否绕过了过滤,再检查后端缓存键和搜索参数是否一致。这样定位范围会比直接怀疑 List 刷新机制小得多。

收束:防抖改善体验,响应归属保证可靠

搜索页最容易被忽略的不是组件如何画,而是结果究竟属于哪个关键词。Search.onChange 负责获取输入,280毫秒防抖避免每个字符都触发查询;立即递增的请求序号确保旧响应不会修改当前列表;@State 仅保存真正驱动页面的状态;销毁时清理延迟任务与代际,避免生命周期结束后的写回。

这个结构适合站内文章查找、会议日程搜索、展览目录筛选等前端异步检索场景。需要真正上线时,仍应根据业务规模加上分页、后端检索索引、权限、错误码及缓存失效策略。重要的是:即使将本地 ExhibitSource 换成网络请求,页面关于“何时发起、哪份响应能显示、错误如何收束”的责任边界无需推倒重来。

相关接口参考:华为 Search 组件文档、https://developer.huawei.com/consumer/en/doc/harmonyos-guides/arkts-state/@State 状态管理、自定义组件生命周期。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐