**

QueryEpoch / IMG-2505。

一、先看一个容易误判的页面

文搜图页面有一个特别迷惑人的现象:用户先输入“海边”,紧接着改搜“雪山”,屏幕上明明已经出现了雪山卡片,却又突然跳回两张海边照片。组件没有报错,网络也都返回成功,问题出在请求发出顺序与返回顺序并不相等。第一条请求可能在弱网中停留更久,第二条请求反而先结束。若页面只在异步函数末尾把结果赋给状态变量,晚到的数据就能覆盖更新的用户意图。本文用一个仅含本地模拟数据的小项目,把“页面最新意图”变成可检查的数字凭证,而不是尝试猜测哪个请求应该更快。

二、确定数据与演示范围

项目叫 QueryEpoch,场景编号 IMG-2505。演示约定第一次查询为“海边”,返回 IMG-501、IMG-502;第二次查询为“雪山”,返回 IMG-511、IMG-512、IMG-513。前者故意等待九百毫秒,后者只等待一百八十毫秒,两次触发间隔六十毫秒。这样的时间配置能够稳定放大竞态,但它只是模拟网络,不意味着真实搜索接口存在或具备这样的响应速度。界面最终应停在请求 Q-0502、阶段 READY、结果三张。照片标题用文字卡片表示,避免把图片下载失败与请求代际问题混在一起。

图01:文章主题示意封面,合成设计。

三、从返回值开始拆分责任

请求适配器只负责根据关键词形成 SearchBatch;它不接触 ArkUI 页面,也不知道当前用户正在看哪一页。第一段代码规定每张卡片必须携带稳定业务ID,响应同时带回原查询词,方便日志检查来源。这种模型不会为网络重试提供幂等保证,但足以解释本次问题:一条响应究竟属于哪个输入,必须能从数据上辨认,而不能凭当前搜索框的文字推断。演示适配器使用 setTimeout 返回 Promise,后续接入真正后端时,替换的是数据获取实现,而不是页面判定规则。

下面是第一段核心代码,先划清数据入口和格式责任。

interface SearchPhoto {
  id: string;
  title: string;
}
interface SearchBatch {
  query: string;
  cards: SearchPhoto[];
}
function searchFixture(query: string): Promise<SearchBatch> {
  const waitMs: number = query === '海边' ? 900 : 180;
  const cards: SearchPhoto[] = query === '海边' ? [
    { id: 'IMG-501', title: '海浪' },
    { id: 'IMG-502', title: '沙滩' }
  ] : [
    { id: 'IMG-511', title: '雪山晨光' },
    { id: 'IMG-512', title: '高山湖泊' },
    { id: 'IMG-513', title: '山谷云海' }
  ];
  return new Promise<SearchBatch>((resolve) => {
    setTimeout(() => resolve({ query, cards }), waitMs);
  });
}

SearchBatch 的 query 用于追踪响应的来源,cards 中的 id 是展示层稳定键;setTimeout 只承担演示延时。Promise 完成后不会自动判断用户意图,正确性由下一层负责。

四、晚到不是失败,失去资格才该丢弃

真正关键的是 QueryEpochGate。它不取消 Promise,也不假设底层传输支持取消;每次提交时发出一个严格递增的代际 ticket。响应回来后,只有 ticket 仍然等于当前代际,并且页面仍处于有效生命周期,才有资格更新 @State。用户连续点击相同关键词也会得到不同票据,因为同一个词对应的旧请求未必拥有最新筛选条件。把 gate 设计成页面私有对象,避免两个页面实例之间共享代际。对于多个独立搜索框,应分别配置门禁,不能一个全局数字覆盖所有业务。

五、组件负责发起动作,也负责结束动作

页面代码负责呈现输入、发起异步请求、更新错误状态,并在离开时关闭门禁。TextInput 的 onChange 更新当前输入文本;onSubmit 触发查询。为了便于复现,另设“复现连发:海边 → 雪山”按钮。按钮先提交海边,再安排六十毫秒后提交雪山。这一设计有一个边界:离开页面后计时器依然可能触发,但旧页面的 gate 已关闭,此时不能再发起真正的请求。示例特意保留了这个问题,工程落地应保存计时器句柄,在 aboutToDisappear 里取消,或者给模拟按钮加活动状态判断;生产页面不要把诊断按钮当作通用请求队列。

第二段代码收拢跨时序的判断,避免把比较逻辑散落在多个 UI 回调中。

class QueryEpochGate {
  private generation: number = 0;
  private active: boolean = true;

  issue(): number {
    return ++this.generation;
  }
  canApply(ticket: number): boolean {
    return this.active && ticket === this.generation;
  }
  invalidate(): void {
    this.generation++;
  }
  open(): void {
    this.active = true;
  }
  isActive(): boolean { return this.active; }
  close(): void {
    this.active = false;
    this.invalidate();
  }
}

generation 只增不减;invalidate 让已经发出的异步回调失去写入资格,close 则同时关闭页面的有效窗口。门禁不持有图片资源,也无需手动释放网络连接。

六、为什么不能直接靠取消请求解决

取消传输能节省带宽,却不能单独保证界面结果正确。取消发出后,某些响应回调可能已经排队;页面离开、接口层命中缓存、回调封装二次派发,都可能让完成顺序与发起顺序产生差异。代际校验属于最后一层业务判定,即使将来接入 Request 或第三方 HTTP 客户端也值得保留。对搜索建议、分页结果和图片缩略图分别建立门禁,才能避免“搜索词更新了,旧页第二页还在追加”的细分错误。这里没有宣称完成了网络取消,也没有构造不存在的系统 SDK 入口。

第三段代码将独立校验组合为实际页面或命令行入口。

@Entry
@Component
struct QueryEpochPage {
  @State keyword: string = '雪山';
  @State phase: string = 'IDLE';
  @State requestId: string = 'Q-0500';
  @State cards: SearchPhoto[] = [];
  private gate: QueryEpochGate = new QueryEpochGate();

  aboutToAppear(): void { this.gate.open(); }
  aboutToDisappear(): void { this.gate.close(); }

  private async submit(term: string): Promise<void> {
    if (!this.gate.isActive()) return;
    const normalized: string = term.trim();
    const ticket: number = this.gate.issue();
    this.keyword = normalized;
    this.requestId = `Q-${(500 + ticket).toString().padStart(4, '0')}`;
    if (!normalized) { this.phase = 'EMPTY'; this.cards = []; return; }
    this.phase = 'LOADING';
    console.info(`[QueryEpoch] ${this.requestId} LOADING query=${normalized}`);
    try {
      const result: SearchBatch = await searchFixture(normalized);
      if (!this.gate.canApply(ticket)) {
        console.info(`[QueryEpoch] discard ${result.query} ticket=${ticket}`);
        return;
      }
      this.cards = result.cards;
      this.phase = 'READY';
      console.info(`[QueryEpoch] apply ${this.requestId} count=${this.cards.length}`);
    } catch (err) {
      if (this.gate.canApply(ticket)) {
        this.cards = [];
        this.phase = 'ERROR';
      }
    }
  }

  build() {
    Column({ space: 16 }) {
      Text('QueryEpoch · IMG-2505').fontSize(22).fontWeight(FontWeight.Bold)
      TextInput({ text: this.keyword, placeholder: '图片关键词' })
        .onChange((value: string) => { this.keyword = value; })
        .onSubmit(() => { this.submit(this.keyword); })
      Button('复现连发:海边 → 雪山').onClick(() => {
        this.submit('海边');
        setTimeout(() => { this.submit('雪山'); }, 60);
      })
      Text(`${this.requestId} · ${this.phase} · ${this.cards.length}张`)
      ForEach(this.cards, (item: SearchPhoto) => {
        Text(`${item.id}  ${item.title}`).fontSize(17)
      }, (item: SearchPhoto) => item.id)
    }.width('100%').padding(20)
  }
}

@State 只在最新请求完成时赋值。若要在真实工程使用,需把“复现连发”诊断计时器改为可清理的句柄,并按当前ArkUI版本验证组件生命周期。

图02:DevEco Studio 风格界面。代码、目录与模拟器区域用于说明设计。

七、按时间轴核对界面

建议把界面和日志共同当成观察对象,而不是只盯最终三张卡片。零毫秒触发海边后,Q-0501 进入 LOADING;约六十毫秒以后雪山提交,Q-0502 再次进入 LOADING;约二百四十毫秒以后雪山返回,三张卡片与 READY 同步显示;约九百毫秒以后海边返回,它带着旧 ticket,应该输出 discard,而不能触碰 cards 或 phase。日志次序应出现 apply Q-0502 count=3,随后是 discard 海边 ticket=1。若看到了海边卡片,优先检查 await 之后是否遗漏 canApply,而非立即怀疑 ForEach。

图03:9:16 手机状态画面,展示预期业务结果。

八、异常、空词和页面销毁

输入只包含空格时,应当产生新代际以压过旧查询,并清空卡片,将状态改为 EMPTY。服务端报错要区分最新请求和过期请求:旧请求失败不应该让新的成功结果突然变成 ERROR。离开页面时 active 变成 false,同时递增代际;下一次进入应重新打开 gate,但实际生产项目最好建立新页面实例或主动重置关键状态。若页面需要保留搜索历史,可在业务存储层保存最后一次确认的 query 与结果快照,不要持久化异步函数和计时器对象。列表卡片的业务ID适合做 ForEach key,但 key 稳定不能替代代际检查。

九、测试清单与适用局限

检查应覆盖四种情况:慢请求先发快请求后发、相同关键词快速连续提交、空白输入覆盖正在执行的请求、离开页面后回调返回。再加一条错误注入:让第二条请求失败,确认旧第一条即使成功也不能偷偷填充数据。演示里 900ms 和 180ms 是确定性的固定延迟,真实移动网络具有重试、超时和缓存,更需要把 token 打进可观测日志。这个方案不涉及文搜图模型推理或图片语义匹配算法,解决的是结果提交资格,不能把 READY 解读成搜索质量达标。还应在调试时区分“用户最后一次输入”与“用户最后一次提交”:TextInput 的 onChange 会频繁执行,而真正的查询提交发生在 onSubmit 或按钮操作。若产品采用防抖自动检索,延迟调度的回调也必须拿到提交时的关键词快照;否则虽然 ticket 正确,词却可能在排队期间改变。图片展示层另有解码失败、占位图切换和缓存命中问题,它们与本次门禁不同,应该由每张卡片自己的状态机处理,而不能全局改成 ERROR。对高并发搜索,进一步监测每次 discard 的请求ID、耗时与查询条件,才有办法区分正常丢弃和网络性能退化。

十、收束到一个工程判断

文搜图看上去是“输入词、显示图片”,实际上至少存在输入意图、网络响应、页面生命周期三个时钟。最稳妥的办法不是命令所有时钟同步,而是在最后一步明确谁有资格写入画面。QueryEpoch 的价值在于把这种资格变成可复查的条件。后续如果增加标签筛选、排序、翻页和相册切换,应让代际票据覆盖完整查询条件,再按业务维度拆分局部状态。这样排查时看到的不是“偶尔闪一下”,而是可以定位的过期响应。

十一、参考资料与核验边界

  • 华为 ArkUI 官方概览:https://developer.huawei.com/consumer/cn/arkui/
  • 华为 ArkUI 自定义键盘与 TextInput 官方示例:https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-customize-keyboard
Logo

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

更多推荐