一名开发者在“工程文档速查”里连续输入 n、na、nav。第三次请求最先返回,界面已经出现 Navigation、NavDestination、NavPathStack 三条建议;不久后,第一次请求才慢吞吞结束,却把结果覆盖成一组与当前搜索词无关的项目。页面没有崩溃,日志也未必报错,用户却会怀疑搜索框的质量。根因不是 TextInput 更新太快,而是我们默认异步完成顺序等于用户输入顺序。

这类问题在 HarmonyOS 的搜索建议、联系人筛选、远端地址补全和配置面板里都会发生。处理方法不是只加一个 300ms 防抖,因为防抖能减少请求次数,却不能保证旧请求一定早于新请求返回。下面构建一个可复现的 ArkUI 示例 QueryGuard:用本地异步仓储制造不同到达次序,先验证页面状态防护,再说明如何替换为真实网络请求。示例环境不需要网络权限,也不涉及不公开的新系统能力;代码使用 ArkTS 的 @State、TextInput、ForEach、Promise 和计时器。

搜索建议乱序保护与页面状态管理的技术封面

一、失败现场:两种“旧”都要防

用户在文本框里写入一个搜索词,可以拆成三个时刻:输入事件发生、请求发出、异步响应到达。TextInput.onChange 负责把输入值同步到 @State query,但仓储返回的是另一个时钟上的结果。即使一个请求在前,也可能在后结束;若每次都写 this.items = result,后完成的旧响应就会覆盖新响应。最典型的失效顺序是:输入 n 时发出 A,输入 nav 时发出 B,B 先完成,A 后完成——最终 query 和 items 所代表的词已经不一致。

第二种旧响应来自页面生命周期。用户输入后立刻从搜索页返回,原请求还在执行;当组件即将销毁时,继续对原来的状态赋值没有业务意义,而且会让以后引入组件复用、路由返回时的行为更难判断。我们需要一条规则:只有当前页面仍有效、请求序号仍是最新、查询词仍相同,才允许提交结果。这三个条件分别保护生命周期、请求顺序和输入一致性。取消计时器只是优化;请求已经发出以后,取消 UI 定时器并不能令网络包自动消失。

为了能重复观察乱序,先提供一个本地仓储。它不是云端接口,也没有伪装真实后端。示例数据全部写在项目里,可以独立检查每个查询返回什么;特别安排单字符查询慢一些,让旧响应晚到的情况更容易出现。等待时间属于测试输入参数,不是设备实测性能指标。

// entry/src/main/ets/model/SuggestionRepository.ets
export class SuggestionRepository {
  private readonly entries: string[] = [
    'Navigation · 路由栈',
    'NavDestination · 详情页面',
    'NavPathStack · 页面状态',
    'GridRow · 响应式栅格',
    'TextInput · 文本输入',
    'Scroller · 滚动位置'
  ];

  async search(keyword: string): Promise<string[]> {
    const normalized = keyword.trim().toLowerCase();
    const delayMs = normalized.length <= 1 ? 850 :
      normalized.length === 2 ? 460 : 140;
    return new Promise<string[]>((resolve) => {
      setTimeout(() => {
        resolve(this.entries.filter((entry: string) =>
          entry.toLowerCase().includes(normalized)));
      }, delayMs);
    });
  }
}

这里对 nav 返回 Navigation、NavDestination、NavPathStack,因为英文名称中都含有 nav。不应把 n 的大范围结果误当成 nav 的最终结果。实际接入 @ohos.net.http 时,仓储负责 URL、权限、异常与状态码,页面的序号与生命周期策略仍可保持不变;外部服务端是否支持取消请求,需要另查对应的请求对象接口。

二、用单调请求序号决定哪次结果能落地

为了便于完整复制,页面放在 entry/src/main/ets/pages/SearchPage.ets,并将其配置为入口页面,或从现有首页按路由进入。requestSerial 每次发起一次有效搜索就递增;latestQuery 表示这次请求绑定的关键字。一个旧请求即便成功拿到数据,也没有资格写入显示列表。

import { SuggestionRepository } from '../model/SuggestionRepository';

@Entry
@Component
struct SearchPage {
  @State query: string = '';
  @State items: string[] = [];
  @State loading: boolean = false;
  @State message: string = '输入关键字查找工程文档';
  private repo: SuggestionRepository = new SuggestionRepository();
  private requestSerial: number = 0;
  private debounceTimer: number = -1;
  private pageActive: boolean = true;

  aboutToAppear(): void { this.pageActive = true; }
  aboutToDisappear(): void {
    this.pageActive = false;
    ++this.requestSerial;
    if (this.debounceTimer >= 0) clearTimeout(this.debounceTimer);
  }

  private changeQuery(value: string): void {
    this.query = value;
    ++this.requestSerial; // 在等待防抖期间也令旧请求失效
    if (this.debounceTimer >= 0) clearTimeout(this.debounceTimer);
    const keyword = value.trim();
    if (keyword.length === 0) {
      this.items = [];
      this.loading = false;
      this.message = '输入关键字查找工程文档';
      return;
    }
    this.debounceTimer = setTimeout(() => {
      this.runSearch(keyword);
    }, 220);
  }

  private async runSearch(keyword: string): Promise<void> {
    const ticket = ++this.requestSerial;
    this.loading = true;
    this.message = '正在查找…';
    try {
      const data = await this.repo.search(keyword);
      if (!this.pageActive || ticket !== this.requestSerial ||
          keyword !== this.query.trim()) return;
      this.items = data;
      this.message = data.length ? '匹配结果 · ' + data.length + '条' : '没有匹配文档';
    } catch (_error) {
      if (!this.pageActive || ticket !== this.requestSerial) return;
      this.items = [];
      this.message = '查询失败,请修改关键字重试';
    } finally {
      if (this.pageActive && ticket === this.requestSerial) {
        this.loading = false;
      }
    }
  }

  build() {
    Column({ space: 16 }) {
      Text('工程文档速查').fontSize(26).fontWeight(FontWeight.Bold)
      TextInput({ placeholder: '搜索组件或接口,例如 nav', text: this.query })
        .onChange((value: string) => this.changeQuery(value))
      Text(this.message).fontSize(14).fontColor('#64748B')
      if (this.loading) { LoadingProgress().width(30).height(30) }
      List({ space: 10 }) {
        ForEach(this.items, (item: string) => {
          ListItem() { Text(item).fontSize(17).padding(16) }
            .backgroundColor('#F1F5F9').borderRadius(14)
        }, (item: string) => item)
      }.layoutWeight(1)
    }
    .height('100%').width('100%').padding(20)
  }
}

这里 ++requestSerial 被放在 changeQuery 开头,而不是仅放在 runSearch 里,这是容易被忽略的细节。假如旧请求恰好在新输入后的 220ms 防抖期内返回,新搜索尚未真正发出;如果只在发请求时递增序号,旧结果仍可能短暂覆盖新输入。输入一变化就先作废旧票据,才能确保交互一致。finally 也只清理由自己创建的最新 loading 状态,避免迟到请求关掉新请求的加载提示。

QueryGuard 的白色主题 DevEco 工程、核心请求校验与预览界面

三、把“不会乱序”拆成可以复现的检查

第一步慢慢输入 nav,停下后应看到三条以 nav 为子串的文档建议。第二步输入 n,等待搜索开始,再快速补成 nav。本地仓储对单字符请求设置更长的示例等待,因此有机会让 nav 的响应先到;最终的界面应只保留 nav 的三条,而不是退回 n 的结果。若手速与 220ms 防抖不匹配,某个查询可能根本不发出,这属于防抖的预期行为。为了稳定复现,也可以在调试时临时把防抖间隔调低,或调用两个仓储 Promise 并记录各自票据,但不应把展示延迟误写成真实网络 RTT。

第三步清空输入。query 变空的瞬间便应删除旧建议并显示占位提示,此前在途请求返回后不可“复活”旧列表。第四步输入后马上离开页面;在 aboutToDisappear 中失效序号、取消尚未触发的防抖,返回旧页面实例时不应重新应用先前请求的结果。第五步让仓储抛出异常,检查 catch 只处理最新请求的错误,不要让旧失败信息覆盖新成功数据。

旧请求迟到、新请求优先以及页面离开时失效票据的时序图

真实业务还要注意两个陷阱。其一是缓存:若把搜索结果按关键字缓存,必须对缓存命中也执行“当前关键字一致”验证;否则缓存回调同样可能乱序。其二是结果一致性:对于远端搜索,客户端序号只能保证展示顺序,不能保证服务器看到的就是最新索引;需要时还要带上数据版本、分页游标或搜索索引更新时刻。不要因为页面显示正确,就推断后端已经具备强一致搜索能力。

四、什么时候该取消请求,什么时候只该丢弃结果

在网络带宽紧张、搜索调用计费或单次查询非常昂贵的场景,物理取消请求能节省资源;但并非所有 Promise 都天然支持取消。Promise 的 await 语义只表示等待某项异步工作完成,并没有通用的“撤销已经发出的工作”机制。本例优先保证 UI 正确:旧请求可以结束,但过期结果被忽略。接入真实 HTTP 后,可研究请求对象的取消或销毁方法,作为单独的资源优化步骤,而不能把 clearTimeout 称为网络取消。

实际排障时,建议只记录请求序号、关键词长度、发起阶段以及“接受/丢弃”的决策,避免把用户输入的完整查询词直接写入长期保留的日志。对搜索接口而言,关键词可能包含个人姓名、内部系统编号或客户资料,调试便利不能自动凌驾于最小化采集原则之上。若团队需要比较优化前后的行为,可在测试工程中构造固定的输入序列与可控延迟,再统计被拒绝的旧响应个数;记录应注明这是可重复的测试条件。真正面向用户的性能数据,仍应来自可说明设备、SDK 和网络环境的采集过程。

这个方案也有边界。若进入搜索页又返回同一个实例,应明确何时把 pageActive 置回 true;若多个搜索入口共享一个仓储,票据应属于每个页面会话,不能用全局序号把另一个页面的合法请求意外作废;若结果要显示图片、富文本摘要,还要分别处理列表更新的稳定键及加载失败的空态。项目正式发布前,需在 DevEco Studio 对目标 API SDK 进行构建检查,并在设备上测试快速输入、返回手势、横竖屏和无网络异常路径。

这次把搜索问题归结为一个可以推广的规则:输入的最新意图,才有权改变当前页面的结果。防抖是降低请求密度,序号是拒绝过期写入,生命周期标志是阻断离页更新,它们负责不同层次。把这三个边界分清楚,搜索框、实时筛选乃至配置预览,都能少掉一类看似偶发、实则必然的异步竞态。

参考华为官方:TextInput 及 onChange、自定义组件生命周期、List 列表组件。示例不使用设备权限;替换为真实网络访问时,应另外依据通信 API 文档配置权限、域名策略和错误处理。

Logo

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

更多推荐