搜索框的体验问题,有时不是服务端慢,而是慢请求比快请求更晚回来。用户先输入“海”,紧接着输入“海边”;第二次请求先返回三条建议,第一次请求却在随后到达。界面如果无条件写入响应,就会把“海”的结果盖到“海边”下面。表面像搜索词跳动,实际是没有约束异步结果的归属。

本文把问题限制在一个可复现的架构示例 QueryGate:任务编号 QG-018,先发起 seq=6 / q=海,再发起 seq=7 / q=海边;演示回包顺序为先 7 后 6。期望界面保留 seq=7 的三条联想,并丢弃序号 6 的过期结果。这些是人为设定的测试数据,不是已经接入线上服务获得的实测日志。

一、把请求发出不等于允许请求改界面

对一个即时搜索功能来说,至少有三种时间:键盘事件发生的时间、请求从网络层返回的时间、界面状态真正写入的时间。它们不总是保持同一个顺序。如果只盯网络耗时,容易把问题归因于超时;但即使每次请求都成功,结果写入仍然可能错位。

本例采用第三方 @ohos/axios 做网络传输,借助 ArkTS 的局部序号门禁保证“只有当前搜索任务能写 UI”。axios 不是系统提供的 ArkUI 组件,也不应把它的行为与 HarmonyOS 系统请求能力混称。ohpm install @ohos/axios 是其项目文档中提供的安装方式;实际依赖版本、架构适配和传输能力必须以项目锁文件与当前维护仓库为准。

需要把三种情况区分清楚:旧请求成功属于过期回包,不是错误;当前请求失败属于当前状态的错误;页面关闭后不再接收回包属于生命周期门禁。三者走不同分支,可以避免“网络错误”提示被一个无关的旧请求触发。

二、让传输层尽量无状态

第一段代码只解决“请求接口与返回数据的边界”。它不负责把内容塞进 @State,也不决定哪次请求更新 UI;后者属于页面状态管理。数据协议只保留 id 和 label,把远端响应转成统一的联想列表。

import axios from '@ohos/axios';

export interface SuggestItem {
  id: string;
  label: string;
}
export interface SuggestPayload {
  items: SuggestItem[];
}

export class SuggestApi {
  constructor(private endpoint: string) {}

  async fetch(keyword: string): Promise<SuggestItem[]> {
    const response = await axios.get<SuggestPayload>(this.endpoint, {
      params: { q: keyword },
      timeout: 5000
    });
    return response.data.items;
  }
}

endpoint 由应用配置注入,而不是硬编码一个看似可用、其实未经验证的线上接口。接入真实服务前,要检查 HTTPS、身份认证、超时、数据字段和服务端限流。返回结构不符合协议时,应在传输层转换为明确错误;不能因为图片展示了三条联想,就假装某个 API 实际提供了这些字段。

正式联网还要在模块配置中按需声明 ohos.permission.INTERNET。该权限是网络访问条件,不代表请求必定成功,也不替代服务端认证或用户隐私告知。对生产环境而言,接口域名和密钥管理应遵守应用自身的安全流程,尤其不能在博客示例里塞真实 Token。

三、过期回包不要写状态

核心实现并不复杂:每次真正发出请求时序号加一,等待结果;结果返回以后,只有 alive=true 且响应序号等于当前序号,才允许更新 suggestions。这样即便网络层没有取消先前请求,老结果也不可能覆盖新结果。

下列代码解决“结果归属”和“离开页面后的异步写入”。为了方便阅读,只展示页面状态与方法;api 采用一个不可访问的演示域名初始化,正式项目应改为由配置注入实际服务地址。

@State private keyword: string = '海边';
@State private status: string = '等待搜索';
@State private suggestions: SuggestItem[] = [];
private currentSeq: number = 0;
private alive: boolean = true;
private typingTimer: number = -1;
private api: SuggestApi = new SuggestApi('https://example.invalid/suggest');

private searchNow(word: string): void {
  const seq: number = ++this.currentSeq;
  this.status = `请求中 #${seq}`;
  this.api.fetch(word).then((items: SuggestItem[]) => {
    if (!this.alive || seq !== this.currentSeq || word !== this.keyword) {
      console.info(`[QueryGate] drop=${seq} stale`);
      return;
    }
    this.suggestions = items;
    this.status = `已采纳 #${seq} / ${items.length}条`;
    console.info(`[QueryGate] accept=${seq} count=${items.length}`);
  }).catch((_error: Error) => {
    if (!this.alive || seq !== this.currentSeq || word !== this.keyword) { return; }
    this.status = `请求失败 #${seq}`;
  });
}

private queueSearch(word: string): void {
  if (this.typingTimer >= 0) { clearTimeout(this.typingTimer); }
  if (word.trim().length === 0) {
    this.currentSeq++;
    this.suggestions = [];
    this.status = '请输入关键词';
    this.typingTimer = -1;
    return;
  }
  this.typingTimer = setTimeout(() => {
    this.typingTimer = -1;
    if (this.alive) { this.searchNow(word); }
  }, 250);
}

注意 currentSeq 不是服务器任务 ID,而是这个页面实例里的本地递增计数。它只用来约束回包归属;应用重新打开后无需保持同一个值,也不需要发给服务端。代码里的 https://example.invalid/suggest 是明确不可用于生产的占位地址,故此片段不能被描述为已联网成功。测试时应注入可控的 Mock 适配器,正式运行再替换为项目自己的 HTTPS 接口。

250ms 防抖是降低输入噪声,不是解决请求乱序的根本机制。如果用户在间隔超过防抖窗口时输入“海”和“海边”,两次请求仍可能同时在途。真正避免覆盖的条件仍是 seq === currentSeq。此外,忽略旧响应不等于在网络层取消了旧请求,它仍可能消耗带宽或服务端配额;需要取消网络传输时,必须另行评估所锁定 axios 版本支持的取消 API。

四、输入事件、页面退出要分开管

搜索框调用 queueSearch,列表则只关心 suggestions。不要把网络逻辑直接写进 TextInput 的 onChange,否则输入、回包、异常与生命周期会耦在同一个回调里,后续难以定位“显示错误发生在哪一步”。

这段代码解决的是“页面已经不可见,旧定时器和回包还能不能继续修改它”。这里采用最小演示页面:输入框、状态栏与三条建议。aboutToAppear 负责让新页面实例可以再次处理事件,aboutToDisappear 则失效化序号并清除防抖计时器。

aboutToAppear(): void { this.alive = true; }

aboutToDisappear(): void {
  this.alive = false;
  this.currentSeq++;
  if (this.typingTimer >= 0) { clearTimeout(this.typingTimer); }
  this.typingTimer = -1;
}

build() {
  Column({ space: 12 }) {
    Text('QueryGate 搜索联想').fontSize(22)
    TextInput({ text: this.keyword, placeholder: '输入地点关键词' })
      .onChange((word: string) => {
        this.keyword = word;
        this.queueSearch(word);
      })
    Text(this.status)
    ForEach(this.suggestions, (item: SuggestItem) => {
      Text(item.label).padding(12).width('100%')
    }, (item: SuggestItem) => item.id)
  }.padding(16)
}

页面重入的处理还可以更严格:若一个页面对象在退场后又重新激活,之前发出的旧请求已经因为退场时 currentSeq++ 而失效;新输入会产生新的序号。但这并不意味着只要显示新页面就自动有搜索结果。是否重新请求、是否保留缓存,应由产品需求决定。

上面的 DevEco 图片是基于 QueryGate 数据绘制的 IDE 界面演示图,不是编译与实机证据。日志中的 17:40:22 [QueryGate] send seq=6 q=海、17:40:23 [QueryGate] send seq=7 q=海边、17:40:24 [QueryGate] accept=7 count=3、17:40:25 [QueryGate] drop=6 stale 是用于说明“后请求先返回”的预设顺序,不代表真实线上流量。

五、把状态和错误提示设计成可验收的东西

对于演示输入“海边”,Mock 数据返回三条建议:海边日落、海边步道、海边酒店。期望最终 UI 显示 QG-018、当前关键字:海边、已采纳 #7、已丢弃 #6、建议数 3。这个页面状态的业务含义是“当前关键字关联的三项展示已锁定”,不是“服务器查询耗时优秀”,更不是“所有网络错误都已解决”。

图里的状态同样属于演示。做真实验证时应在 Mock 适配器里用受控 Promise 人为让 seq=7 先完成,然后才让 seq=6 完成,断言界面仍保留三条“海边”建议。随后测试:当前请求失败时出现失败状态;老请求失败时不覆盖当前结果;页面离开以后不写状态;清空输入时不继续显示历史建议。这几项都可以用可重复的输入与明确的预期输出验收。

还有一个容易遗漏的边界:搜索词相同,但筛选条件、语言或用户身份发生变化,仅以关键词比较是不够的。局部递增序号应覆盖任何能够改变返回含义的输入。对于输入法组合态,不应该只靠简单字符变化就假设用户已完成输入;必要时应增加明确的“搜索”触发点。

六、第三方库要守住升级与异常边界

使用 axios 的价值在于统一 Promise 式请求和响应处理,而不是让 UI 自动具备并发正确性。业务层的序号门禁与库的升级节奏解耦;即使以后替换底层 HTTP 实现,也可以保留同样的“允许谁写状态”规则。

依赖管理需要格外具体。OpenHarmony-SIG 的旧 Gitee 仓库已经提示归档并给出了新维护地址,因此不能仅因为历史 README 能打开就认定它代表最新发布版本。团队应在项目里锁定实际安装版本,核对当前维护仓库的许可证、安全修复和取消请求支持,并在设备构建时验证。不能随意把 Axios 原库的所有浏览器功能当作适配库必然支持的功能。

错误处理也要区分层级。网络异常可提示重试,数据结构异常通常需要排查接口契约,过期回包则应该静默丢弃。日志应携带序号与查询条件,但不要直接记录敏感搜索内容、认证凭据或完整响应。示意日志为了容易理解打印了“海/海边”,生产环境应根据隐私要求脱敏。

最后,长列表建议与复杂联想 UI 还涉及性能、无障碍与焦点问题;本文只覆盖异步一致性。把“不会被旧请求覆盖”写成“搜索功能已经完成全部质量验收”,属于过度结论。

七、结语:把异步归属写成明确规则

QueryGate 的实现取舍可以概括为三句话:网络库负责传输,业务序号负责回包归属,页面生命周期负责使旧工作失效。对于搜索、筛选、远程校验和快速切换详情页,这条规则都适用,但不等于给系统新增了某种取消 API。

资料核对:华为开发者联盟 ohpm install 文档 https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-hmos-ohpm-install ;OpenHarmony-SIG ohos_axios 仓库 https://gitee.com/openharmony-sig/ohos_axios (该仓库已注明归档,须以新维护仓库确认实际版本)。当前文章只做文档级核对,未运行联网请求、SDK 编译或真机测试。

Logo

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

更多推荐