这次的问题是从一条很怪的日志开始的:网页明明已经重新加载完成,按钮也能点,ArkTS 侧却偶尔收不到消息。再点一次“模拟重建”,有时又恢复了。最麻烦的是,页面没有报错,Web 组件也没有白屏,看起来像“偶发通信失败”。

Demo 叫 WebBridgeLab。我把 WebView 和应用侧之间的通信从简单 JS bridge 换成 WebMessagePort,为了模拟真实业务,又加了登录态变化导致的 Web 组件重建。问题很快出现:旧端口属于上一代 Web 页面,组件重建后如果继续拿它发消息,代码里那个 ports 数组还在,但它已经不是当前页面的通信通道。

这篇不讨论 ArkWeb 所有通信方案,只盯住一个工程边界:Web 组件重建前关闭上一代端口;新页面准备好后创建新的 port pair;每次重绑定增加 generation;所有异步回调都校验 generation,旧一代回调即使晚到也不能改当前状态。最终运行会话为 web_bridge_20261001_07,第三代 Web 生命周期状态是 RECREATED,通信状态 REBOUND,活动端口 2 个,累计关闭 4 个旧端口,待处理消息 0,最后一次 ACK 41 ms。

一、最容易误判的地方:变量还在,不等于通道还活着

最早的代码非常直接:页面加载完成后调用 createWebMessagePorts(),把 ports[0] 交给 H5,ArkTS 保留 ports[1];后面所有消息都通过 ports[1].postMessageEvent() 发送。

如果 Web 组件从创建到销毁只发生一次,这套写法足够演示 API。问题是实际项目里的 Web 页面经常重建:账号切换、容器 key 变化、业务切源、故障恢复,都可能让当前 Webview 生命周期进入下一代。应用对象里的数组并不会因为网页变了自动变成 null,于是“有对象”被误当成“可继续通信”。

HarmonyOS 当前官方指导明确说明,可以通过 createWebMessagePorts() 创建两个端口,一端由应用侧保留,另一端通过 postMessage() 发送给前端页面;端口使用完成后,或者 Webview 对象销毁前,应调用 close() 关闭端口。这个生命周期要求正好解释了我遇到的问题:重建 Web 不是继续用旧桥,而是结束上一代桥,再建立下一代桥。

二、我先把绑定动作收敛成一个完整事务

项目最后拆成四个文件:pages/WebBridgePage.ets 负责 Web 组件,bridge/WebPortBridge.ets 负责端口,bridge/BridgeMessage.ets 负责消息协议,rawfile/index.html 是测试页。

我不再在多个回调里零散地创建端口,而是把“关闭旧端口→generation + 1→创建新端口→注册回调→把另一端发给 H5”做成一个 rebind()。只要缺一步,这一代就不算完成。

下面这段代码解决的是 Web 重建以后旧端口仍被继续引用的问题,同时给每次绑定一个明确代号。

import { webview } from '@kit.ArkWeb'

export class WebPortBridge {
  private ports: webview.WebMessagePort[] = []
  private generation: number = 0
  private state: string = 'ATTACHED'

  constructor(private controller: webview.WebviewController) {}

  rebind(): void {
    this.closePorts()
    const currentGeneration = ++this.generation
    this.state = 'REBINDING'

    this.ports = this.controller.createWebMessagePorts()
    const appPort = this.ports[1]

    appPort.onMessageEvent((message: webview.WebMessage) => {
      if (currentGeneration !== this.generation) {
        return
      }
      this.handleMessage(message)
    })

    this.controller.postMessage('__init_port__', [this.ports[0]], '*')
  }
}

这里 generation 的作用和普通 requestId 很像,但语义更明确:它不是一条网络请求编号,而是一代 Web 通道。只要 Web 组件重建,这一代生命周期就结束。旧回调如果晚到,看到 generation 不一致直接退出。

我没有在 rebind() 开头只写 this.ports = []。清空数组只是丢掉 ArkTS 引用,不等于告诉消息端口结束工作。既然官方给了明确的 close() 生命周期动作,就应该让关闭成为桥接器内部的一等操作。

三、close 要关闭的是这一对端口,而不是“随手关一个”

官方示例里为了说明基本使用流程,会演示端口创建、回调注册、发送和关闭。到了工程封装,我更愿意把 pair 当成一个整体:重建时不猜哪个端口目前已经转移到网页侧,不把历史对象留在列表里;应用侧当前持有并可管理的端口统一进入 close 路径。

下面这段代码解决的是重复重建时端口对象不断累积的问题。

private closePorts(): void {
  for (const port of this.ports) {
    try {
      port.close()
      this.closedPorts++
    } catch (error) {
      hilog.warn(0x0000, 'WebBridgeLab',
        `port close failed: ${JSON.stringify(error)}`)
    }
  }
  this.ports = []
}

Demo 第三代页面出现时,前两代一共关闭了 4 个端口,所以运行图里 Closed Ports = 4。这个数字不是为了做 KPI,而是一个很实用的自检:generation 已经到 3,如果我还看到 Closed Ports = 0,基本可以确定旧通道没有走清理流程。

正式业务还要区分“主动重建”和“页面彻底销毁”。主动重建执行 close 后会马上 rebind;页面真正离开时只 close,不应该再创建下一代。否则就会出现页面已经退出,后台对象却又悄悄建了一对 port 的反向泄漏。

四、消息状态也要带 generation,否则旧 ACK 会污染新页面

修完端口后,我又遇到一个更隐蔽的问题:页面重建很快,旧页面发出的 ACK 有机会在新页面绑定过程中到达。如果 UI 只看 lastMessage = 'bridge_ready',就可能把旧 ACK 当成新通道已经就绪。

所以我的消息协议里增加了 generation。网页收到 __init_port__ 后先回 bridge_ready,应用侧只接受当前 generation 的 ready,再把状态从 REBINDING 改成 REBOUND。

这段代码解决的是异步消息晚到导致当前代状态被前一代覆盖的问题。

private handleMessage(message: webview.WebMessage): void {
  const text = typeof message === 'string' ? message : ''
  if (!text) {
    return
  }

  const payload: BridgePacket = JSON.parse(text) as BridgePacket
  if (payload.generation !== this.generation) {
    hilog.info(0x0000, 'WebBridgeLab',
      `drop stale packet, gen=${payload.generation}`)
    return
  }

  if (payload.type === 'bridge_ready') {
    this.pendingMessages = 0
    this.lastAck = Date.now() - this.bindStartAt
    this.state = 'REBOUND'
  }
}

到这里,State = REBOUND 才有了真正可验证的含义:不是“端口数组长度等于 2”,而是当前代端口已经创建、网页端已经拿到另一端,并且当前 generation 的 ready 包已经返回。

我还把 Pending Messages 暴露到了调试页。重建过程中 pending 可以暂时大于 0,进入 REBOUND 后应该回到 0。如果长期不归零,说明要继续检查网页是否拿到端口、回调是否注册、消息协议版本是否一致,而不是盲目重试 postMessageEvent()。

五、页面生命周期里最重要的代码反而最短

桥接器写得再完整,如果页面退出时没人调用 close,还是会留下生命周期缺口。所以页面层只保留两个动作:Web 准备好时 bind,页面消失时 dispose。

下面这段代码解决的是业务页面已经销毁但消息端口仍被持有的问题。

aboutToDisappear(): void {
  this.bridge.dispose()
}

// WebPortBridge.ets
public dispose(): void {
  this.generation++
  this.closePorts()
  this.state = 'CLOSED'
}

generation++ 放在 close 前后都可以设计,但我更习惯先让旧回调全部失效,再执行资源清理。这样即便关闭过程中触发某些异步尾声,回调也没有机会再修改当前 UI。

正式项目如果 Web 组件只是暂时不可见而不是销毁,是否 close 需要结合具体生命周期。不要机械地把每次 onPageHide 都当成 Webview 销毁;同样,也不要因为页面对象还在内存里就认为底层 Web 内容一定还是同一代。判断依据应该是 Web 组件真实创建/销毁边界。

六、我为什么保留“模拟重建”按钮

调试工具页里有两个按钮:发送消息 和 模拟重建。后者在上线前不会出现在正式 UI,但我会把这类故障注入入口留到开发构建里。

因为这类生命周期 Bug 最怕“靠操作碰”。如果只能通过登录退出、切配置、等待远端页面变化来触发一次 Web 重建,修一次问题要花很多时间。把重建变成一个确定按钮,连续点三次,很快就能看出 generation、关闭数量和回调是否成对。

本次最终状态是:Session web_bridge_20261001_07,Generation 3,Web Lifecycle RECREATED,State REBOUND,Active Ports 2,Closed Ports 4,Pending Messages 0,Last Ack 41 ms,最后消息 bridge_ready。状态流是 ATTACHED → BINDING → RECREATED → REBINDING → REBOUND。

从运行图看,最有价值的并不是绿色“运行中”,而是这组数字彼此能解释。Generation 到 3,对应前两代 4 个旧端口已关闭;当前活动 pair 恰好 2 个;pending 已归零;ACK 来自当前代,最终才进入 REBOUND。任何一个数字对不上,都说明通信链路还有模糊地带。

七、几个我现在会主动检查的边界

第一,createWebMessagePorts() 应该在 WebController 已经具备可用上下文以后调用。把桥接初始化抢到控制器真正附着之前,会把“生命周期问题”伪装成“偶发 API 失败”。项目里我把 rebind 放在确认 Web 已进入可建立通信的阶段,而不是组件构造函数。

第二,端口关闭后不再发送。不要 catch 一个发送异常以后继续把同一个 port 放回重试队列,重试应该基于新 generation 的新端口。

第三,消息协议要能辨别代际。只靠字符串 ready 在简单 Demo 里够用,业务里建议至少有 type / generation / requestId,这样重复消息、旧消息、当前消息才能在日志里区分。

第四,网页侧也要同步换端口。应用侧创建新 pair 并不意味着 H5 自动知道,仍要通过 postMessage('__init_port__', [ports[0]], '*') 把新端口转交给页面,并在前端替换旧的 h5Port。

第五,资源释放和业务状态不要混为一谈。用户输入内容可以在 Web 重建前保存并恢复,但 MessagePort 本身不应该序列化后“恢复使用”;它属于本次 Web 生命周期,下一代重新创建才是更清楚的边界。

八、最后的判断标准:不是消息能通,而是重建三次还能通

ArkWeb 和前端页面的消息通道,第一次建立成功并不难。真正接近工程可用的标准,是 Web 组件经历重建、页面切换、异常恢复以后,旧端口不会继续响应,新端口能重新绑定,旧 ACK 不会污染当前状态,离开页面时资源能收干净。

这次我最终保留了三个调试字段:Generation、Closed Ports、Pending Messages。它们比“连接成功”四个字更能说明问题。一个通道如果没有代际概念,就很难讨论重建;没有关闭计数,就很难确认旧资源是否结束;没有 pending,就很难判断消息究竟卡在哪里。

官方当前指南明确要求端口使用完毕后或 Webview 对象销毁前调用 close(),这条看起来很基础的说明,放到真实工程里其实就是整个生命周期设计的起点。

八、端口重绑之后,还要把“业务重放”限制在正确边界里

通信通道恢复以后,另一个很容易顺手做过头的事情是“把上一代所有消息重新发一遍”。我一开始也这么想:既然 Web 重建会丢连接,那就把发送队列保存起来,rebind 成功后全部重放。后来很快发现,这会把端口生命周期问题变成业务幂等问题。比如“提交订单”“领取权益”这类动作不能因为 Web 重建就自动执行第二次,而“同步主题色”“刷新当前用户信息”这类状态消息则适合重新下发。

所以 WebBridgeLab 最终把消息分成两种:状态同步消息可以在新 generation 建好后按当前值重新生成;一次性命令只在明确拿到业务 requestId 并确认未完成时才考虑恢复。桥接层本身不缓存业务指令,它只负责保证“当前代消息发到当前代端口”。这样 WebMessagePort 不会悄悄承担重试中心的职责。

我也没有把 ACK 超时直接等同于端口失效。41 ms 只是本次 Demo 的最后一次往返记录,正式项目里网络请求、H5 主线程繁忙、页面脚本执行都可能让 ACK 变慢。真正判断要不要重绑,应结合 Web 生命周期、控制器状态和连续失败次数,而不是写一个 100 ms 超时就立刻销毁通道。调试页展示 Last Ack 是为了观察,不是为了把它当成固定阈值。

九、我用四组故障注入检查这条桥有没有真的收干净

第一组是连续点击三次“模拟重建”。我期望 generation 从 1 增长到 3,第二、第三代建立前都能看到上一代端口关闭;最终 Active Ports 保持 2,Closed Ports 累积到 4,Pending Messages 回到 0。只要活动端口数量出现 4 或 6,说明我只是不断创建新 pair,没有真正关闭旧 pair。

第二组是在 REBINDING 阶段故意让上一代网页延迟发 bridge_ready。当前代必须把这个包丢掉,状态不能被旧 ACK 提前改成 REBOUND。第三组是在端口刚关闭后触发一次发送,预期桥接层直接拒绝,不把异常吞掉后继续使用旧对象。第四组是退出页面以后再触发一个定时器消息,日志里应该只能看到“bridge closed / message ignored”,不能出现新一代端口被偷偷创建。

这四组测试让我确认的不是 API 会不会调用,而是生命周期有没有闭环:创建一对、使用一对、结束一对;下一代重新开始。实际项目里我会把这些状态接到自动化测试或开发构建日志中,至少让 generation / activePorts / closedPorts / pending 四个指标可见。Web 容器的问题很多时候不是稳定复现的语法错误,而是某一次顺序变化,所以越能把顺序变成可观察数据,排查成本越低。

另外,网页侧也需要配合故障注入。比如收到新的 __init_port__ 后先把旧 h5Port.onmessage 解除,再替换变量;不要让两个端口都向同一个 UI 回调写数据。应用侧只做得干净,而 H5 还保留旧闭包,同样会出现“重复消息”。通信是两端的生命周期契约,任何一端把端口当永久对象,另一端都很难完全兜住。

十、参考资料与版本说明

本文按 2026 年 9 月公开文档核对 ArkWeb 数据通道行为。官方指南给出了 createWebMessagePorts()、onMessageEvent()、postMessage()、postMessageEvent() 和 close() 的基本组合,并明确提示端口在使用完毕或 Webview 销毁前关闭。实际项目仍应以当前 DevEco Studio 配套 SDK 的接口签名为准。

  • 建立应用侧与前端页面数据通道:https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/web-app-page-data-channel
  • ArkWeb 文档中心:https://developer.huawei.com/consumer/cn/doc/
Logo

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

更多推荐