用户在“链路镜”里点击了“开始检测”,两秒后觉得没有必要,按下取消。页面立即回到可操作状态。没想到又过了几秒,原先那次 HTTP 请求完成,结果把“已取消”改成“可访问”。从界面上看,这像是一个小闪烁;从诊断工具的可信度看,它意味着应用把用户已经撤销的操作重新算作有效结果。

第一篇建立了固定检测目标 https://example.com/、idle/loading/ok/error 状态和 http.createHttp() 的一次请求生命周期。这一篇不另起炉灶,继续使用 entry/src/main/ets/pages/NetworkProbePage.ets,但把“取消”拆成两个不同问题:页面还要不要接收这个结果,以及底层 HTTP 传输是否真的结束。工程上必须先解决第一个问题,不能把按钮变灰或者销毁 UI 当成取消协议已经完成。

网络检测中取消与迟到响应的独立技术封面

一、取消不是一个布尔值,而是结果的所有权变更

最常见的写法是新增 @State cancelled = true。请求完成后读取这个字段,如果为真就不显示结果。问题在于用户可以“开始 A → 取消 A → 开始 B”:开始 B 时必须将 cancelled 重置成 false,这时 A 恰好返回,于是 A 再次获得更新页面的资格。它甚至可能把 B 的加载状态改为成功,让用户误认为刚开始的 B 已经结束。

一个能区分先后请求的方案,是把每次用户认为有效的操作赋予递增代次。开始 A 时编号是 1,取消时直接把当前代次推进至 2,开始 B 时推进至 3。每一个 await 之后,只有“请求发起时保存的编号仍等于当前编号”才允许改写 @State。取消也需要失效先前编号,不能只更换按钮文案。这套规则没有依赖某个网络接口的内部取消语义,因此很适合做最基础、可验证的页面级结果隔离。

当用户在屏幕上看到“已取消”,本文所说的是本次结果不再更新页面,而不是已证明 TCP 连接被立即切断。根据官方 HTTP API,destroy() 是任务使用完毕后释放资源的重要步骤;不要未经验证就在一个飞行中的 Promise 上调用它并宣传为跨版本保证的硬中断。真正需要节省带宽时,应另行评估 SDK 中明确支持的取消接口、请求流或网络层能力。

还要注意,所谓“当前操作”的所有权属于页面的意图,而不是 Promise 创建的先后时间。用户先按取消再快速开始,哪怕这两个动作都发生在同一事件循环中,也必须在按钮处理时同步推进 currentId,不能等 HTTP 回调再决定谁过期。对于诊断类工具,结论的时间顺序本身就是数据含义的一部分:只有用户当前认可的检测,才有资格形成一个可展示的网络结论。

二、让 NetworkProbePage 只接收当前代次

仍然使用 @kit.NetworkKit 的 HTTP 请求,每次 http.createHttp() 产生自己的任务对象。这里把状态联合类型扩为 cancelled,加上 currentId 和 inFlight 两个非界面状态。前者管理谁有权限提交结果,后者防止连续取消后积压过多尚未结束的请求。示例限制同时在途的请求不超过两项;这个阈值属于应用的保护策略,并不是 HTTP API 的限制。

import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';

type ProbeState = 'idle' | 'loading' | 'ok' | 'error' | 'cancelled';
const TARGET_URL: string = 'https://example.com/';

@Entry
@Component
struct NetworkProbePage {
  @State private phase: ProbeState = 'idle';
  @State private detail: string = '尚未发起检测';
  @State private lastCode: number = 0;
  private currentId: number = 0;
  private inFlight: number = 0;

  private cancelProbe(): void {
    if (this.phase !== 'loading') { return; }
    this.currentId += 1;  // 旧 Promise 失去提交资格
    this.phase = 'cancelled';
    this.detail = '本次结果已忽略,可重新检测';
    this.lastCode = 0;
  }

  private async startProbe(): Promise<void> {
    if (this.phase === 'loading') { return; }
    if (this.inFlight >= 2) {
      this.phase = 'cancelled';
      this.detail = '旧请求尚在清理,请稍后再试';
      return;
    }
    const id = ++this.currentId;
    this.inFlight += 1;
    this.phase = 'loading';
    this.detail = '正在检测 HTTPS 目标';
    this.lastCode = 0;
    const req = http.createHttp();
    try {
      const data: http.HttpResponse = await req.request(TARGET_URL, {
        method: http.RequestMethod.GET,
        connectTimeout: 8000,
        readTimeout: 8000
      });
      if (id !== this.currentId) { return; }
      this.lastCode = data.responseCode;
      const ok: boolean = data.responseCode >= 200 && data.responseCode < 300;
      this.phase = ok ? 'ok' : 'error';
      this.detail = ok ? `目标可访问 · HTTP ${data.responseCode}`
        : `目标返回 HTTP ${data.responseCode}`;
    } catch (e) {
      if (id !== this.currentId) { return; }
      const err = e as BusinessError;
      this.phase = 'error';
      this.detail = `请求异常 · 错误码 ${err.code}`;
    } finally {
      req.destroy();
      this.inFlight -= 1;
    }
  }

  onPageHide(): void {
    this.cancelProbe();
  }

  build() {
    Column({ space: 20 }) {
      Text('链路镜').fontSize(30).fontWeight(FontWeight.Bold)
        .fontColor('#183B58')
      Text('固定目标 · https://example.com/').fontSize(15)
        .fontColor('#64748B')
      Column({ space: 14 }) {
        Text(this.phase === 'idle' ? '待检测' :
          this.phase === 'loading' ? '检测中' :
          this.phase === 'ok' ? '可访问' :
          this.phase === 'error' ? '检测失败' : '已取消')
          .fontSize(28).fontWeight(FontWeight.Bold)
        Text(this.detail).fontSize(15).fontColor('#475569')
        if (this.lastCode > 0) {
          Text(`HTTP 状态码:${this.lastCode}`).fontSize(13)
        }
      }.width('100%').padding(24).borderRadius(22)
        .backgroundColor(Color.White)
      Button(this.phase === 'loading' ? '检测中…' : '开始检测')
        .width('100%').height(52).backgroundColor('#117D86')
        .enabled(this.phase !== 'loading')
        .onClick(() => { this.startProbe(); })
      Button('取消本次').width('100%').height(50)
        .enabled(this.phase === 'loading')
        .onClick(() => { this.cancelProbe(); })
      Text('取消仅停止本页接收旧结果,不等于中断网络传输')
        .fontSize(13).fontColor('#64748B')
    }.width('100%').height('100%').padding(24)
      .backgroundColor('#F2F8FA')
  }
}

onPageHide() 属于 @Entry 页面可用的页面生命周期回调;它让页面退到后台或跳转时旧结果失效。这里故意不把 req 放在一个可被任意位置销毁的全局字段里,以避免取消 A 后 B 开始,A 的清理动作误伤 B 的实例。每个任务在自己的 finally 中 destroy(),释放责任能够准确对应原请求。

链路镜取消后的完整竖屏界面:已取消、可重新检测

竖屏示例呈现取消之后的页面:检测目标仍然是 https://example.com/,状态是“已取消”,消息为“本次结果已忽略,可重新检测”。主按钮恢复可用,取消按钮不可用,并且不再保留上次的状态码。它不是“网络不可用”的结论,也不显示任何未经发送请求得到的 HTTP 200。颜色只做辅助识别,结论必须有文字。

三、八秒超时为何不能当作八秒倒计时

上篇和本篇都配置 connectTimeout: 8000、readTimeout: 8000。这两个字段由 HTTP 模块解释;它们不是一个由界面保证的“8.000 秒后一定返回”的定时器。连接、传输、系统调度、网络栈错误以及应用切后台,都可能影响用户观察到的结束时刻。为了在页面上表现“等待过长”,可以增加独立的 UI 等待提示,但不能把这个提示误写成 HTTP 请求已经终止。

最容易忽略的是超时本身也会与取消竞争。假设 A 在第七秒被取消,第八秒网络栈抛出超时异常;没有代次判定的 catch 会把“已取消”改写成“请求异常”。本文在 catch 与正常返回两条分支都检查 id,使两种结局互斥:只有仍属于当前操作的错误才显示。finally 不更新界面,因此即使过期的 A 最后执行清理,也不破坏 B 的 loading 状态。

取消、重试及超时的请求代次时序图

为了便于复现竞态,不建议对公网上的 example.com 人为假定“总是三秒后返回”,也不要使用真实生产接口施加故意的大量超时请求。测试时可以把请求函数改为一个受控的 Promise 延迟测试替身:第一次延迟返回 200,第二次快速返回 503;先取消第一次再发起第二次,确认屏幕保持“检测失败 · HTTP 503”,即使第一次稍后返回 200,也不能改变界面。再把第一次改为拒绝,验证旧 catch 同样没有显示权。测试替身只构造完成顺序,不代表真实 TLS、DNS、HTTP 超时行为。

四、从资源保护到可观测的验收

一次成功的验证不只是按钮能够点击。建议依次验证六组行为:初始态没有状态码;正常请求结束后释放实例;检测中重复点击不会新增请求;取消后 UI 立即进入 cancelled;取消 A、启动 B 后 A 的正常响应不能覆盖 B;取消 A、启动 B 后 A 的异常也不能覆盖 B。最后测试跳转其他页面再返回、联网断开、访问被代理拦截的情况,重点检查是否有旧回调更新已经不属于当前用户操作的页面。

inFlight 的上限是额外的现实约束:软取消并没有让网络传输立即消失,如果允许用户无限次“取消—开始”,短时间里可能堆积很多网络任务。这个计数在每次请求的 finally 中递减,不会因为取消按钮改变界面状态而提前归零;否则将失去限流意义。极端情况下,当两个请求都迟迟没有释放,用户会收到“旧请求尚在清理”的明确提示,而不是页面偷偷创建第三个任务。

错误码说明仍应保持克制:HTTP 404 是对端响应,和 Promise 拒绝产生的系统错误码不是同一层;超时、域名解析、证书验证等问题也不应全部叫“网络断开”。本例不读取响应正文,不上传响应头,不允许用户随意输入内网地址,也不在日志中记录凭证。若把工具用于实际运维,需要定义白名单、隐私约束、网络诊断报告格式及后台切换策略。

这次改造的边界十分明确:结果隔离已经由代次控制,底层网络中断尚未作为能力承诺。它比只依赖加载按钮禁用可靠,也比无条件提前 destroy() 更容易解释和测试。下一篇可以在此基础上增加错误类型与诊断建议,把“请求异常”进一步拆解为对用户有意义的处理步骤。

官方资料:HTTP 请求 API 与 destroy()、使用 HTTP 访问网络、页面与自定义组件生命周期。

Logo

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

更多推荐