夜雨聆风学习资料网

ARTICLE · 1163621

App 和 H5 之间的那座桥,比地图本身难写

App 和 H5 之间的那座桥,比地图本身难写

技术栈:uni-app(Vue 3)+ 原生 web-view + Leaflet,覆盖 iOS / Android / HarmonyOS 三端。

引子:地图渲染出来了,按钮却全是死的

上一篇写了我们怎么用天地图把三端地图收口到一个 web-view。文末提到"桥是最难的部分",这篇专门讲它。

先讲一个真实的翻车现场。

地图页上线后自测,瓦片正常、气泡正常、双指缩放正常——看起来一切就绪。然后点"筛选",没反应;点"地铁找房",没反应;点右下角定位,还是没反应。地图明明活着,它上面所有的交互却像被冻住了。

我们第一反应是查 H5 的点击事件:addEventListener 写对了,z-index 也没被盖住。再查 App 的下发逻辑:payload 组装没问题,evalJS 也确实调了。

问题出在一行看起来最无害的代码上——我们用了 evalJSAsync。

它返回一个 Promise,await 一下拿到结果再决定后续,多自然。但它返回的 Promise 既不 resolve 也不 reject:.then 不进、.catch 也不进。于是"下发"这一步永远停在等待里,H5 从头到尾没收到任何数据。而它不报错,所以地图正常渲染、页面没有异常日志,只是所有依赖下发的 UI 全死了。

从那天起我们就认了一件事:在 web-view 场景里,App 与 H5 之间的这座桥,可靠性得自己兜,不能指望框架 API 在每个平台行为一致。

一、为什么 web-view 的桥天生不可靠

web-view 里的 H5 和外面的 App,是两个运行时。它们之间的通信要靠平台各自提供的一层桥:

  • • iOS / Android 上是 plus 那套子 webview;
  • • HarmonyOS(ArkWeb)上是完全另一套上下文,没有 plus。

三端的桥长得不一样,行为也不一样,而且大部分失败都是静默的:不抛异常、不返回错误码,就是消息没到。

更麻烦的是调试条件:鸿蒙真机上看不到 H5 的 console。H5 里的报错、console.log,在设备上全部黑洞。你只能靠"页面上打红字""App 侧打面包屑"这种原始手段定位。

所以这座桥的设计目标不是"优雅",而是在看不见、拿不到反馈的前提下,仍然能确定消息到底送没送到。

二、下行:App → H5

2.1 注入通道:同步盲投,不要异步

App 往 H5 里塞数据,靠的是往页面上下文执行一段脚本(把一份状态快照喂给 H5 的全局函数)。三端各有入口,但语义必须统一成"同步、盲投":

// 鸿蒙:createWebviewContext(id, this).evalJS(script)// App :当前页子 webview.evalJS(script)// ⚠️ 鸿蒙必须用 evalJS(同步、无返回值)// ⚠️ 不要用 evalJSAsync:它返回的 Promise 既不 resolve 也不 reject,会静默失效// ⚠️ 也不要用小写 s 的 evalJs:鸿蒙上已废弃,调用只告警、不执行functiontdEvalJS(script) {try {if (harmony) { ctx.evalJS(script); returntrue }if (childWebview) { childWebview.evalJS(script); returntrue }    } catch (e) { /* 记日志 */ }returnfalse}

同步盲投的代价:拿不到返回值,也就无法从这一步判断"H5 收到没有"。这直接把问题推向下一节。

2.2 解法:反复投递 + H5 回执

既然发出去之后没有回音,那就让接收方主动回一声。我们约定:

  • • App 投出 payload 后,每 500ms 重投一次,直到收到 H5 回传的 { type: 'ack' };
  • • 设 20s 上限,避免 H5 彻底没起来时无限空转。
functiontdPush(payload) {this._pending = payloadthis.sendPayload()      // 先投一次this.startFlush()       // 起定时器,每 500ms 重投}functionsendPayload() {var js = JSON.stringify(this._pending)tdEvalJS('window.rfUpdate && window.rfUpdate(' + js + ')')}functionstartFlush() {this._timer = setInterval(() => {if (++this._tries > 40) { this.stopFlush(); return } // 20s 上限this.sendPayload()    }, 500)}

H5 侧收到即回执:

window.rfUpdate = function (o) {if (!o) returnpostToApp({ type: 'ack' })   // 回执:App 据此停止盲投if (!map) { pendingOpts = o; return }  // Leaflet 还没就绪 → 先入队// ...应用快照}

App 收到 ack 就 stopFlush()。这条回执同时证明了两件事:下行注入送达了,上行通道也可用——一次往返把两条链路都验证了。

2.3 幂等,是"能重试"的前提

重投意味着同一个 payload 会被执行很多次。如果 H5 的 rfUpdate 不是幂等的,地图会反复抖动、气泡反复重绘。

所以 H5 侧用自增序号 + JSON 比对做幂等:

// 相机/动作类:序号变了才应用(同值不重复执行)if (o.cameraSeq != null && o.cameraSeq !== lastCameraSeq) {    lastCameraSeq = o.cameraSeq    map.setView([lat, lng], z)}// 数组类:比对 JSON 串,内容没变就跳过var json = JSON.stringify(o.markers)if (json !== lastMarkersJson) { lastMarkersJson = json; renderMarkers(o.markers) }

幂等和重试是一对:没有幂等,重试就是灾难;有了幂等,重试才敢做成"无脑每 500ms 投一次"。

2.4 快照式下发:粘性字段 + 启动期多轮重推

我们没有做"增量指令"(setCenter / addMarker 各一条消息),而是每次下发一份完整的地图状态快照:相机、气泡数组、画圈多边形、定位点、各个浮层的 view-model,全都在里面。

好处有三个:

  1. 1. 天然幂等——整份覆盖,投多少次结果一样;
  2. 2. 能自愈——任何一条消息丢了,下一次全量投递自动补齐,不需要单独补发机制;
  3. 3. 粘性——H5 未就绪时把整份快照存进队列,Leaflet 就绪后整体重放,不会只重放一半。

在此基础上,进页面后按 [600, 1500, 3000, 5000] ms 主动多推几次首帧。为什么?因为 H5 的脚本加载、Leaflet 初始化是异步的,早推的那几次大概率落在"H5 还没准备好"的窗口里——但早推是安全的,H5 会自己入队,等就绪再应用。

// 进页面后主动重推首帧:早推安全(H5 未就绪会入队),晚推才危险;[600, 1500, 3000, 5000].forEach(d => {setTimeout(() =>this.pushOptions(this.options), d)})

2.5 mapready 不能当作唯一判据

H5 初始化完成时会回一条 { type: 'mapready' },直觉上 App 应该"等收到 mapready 再开始下发"。

这是错的。 因为 mapready 是 H5 主动上行的首帧消息,而它发出时,上行的桥可能还没就绪——这条消息会被静默丢弃(实测收不到)。如果 App 死等 mapready 才下发,就永远等不到,所有下行(包括画圈动作)被拦在门外。

正确做法:

  • • App 不依赖 mapready,直接推(H5 会入队兜住);
  • • 把 mapready 降级为"恰好收到就提前做点事"的可选优化;
  • • 并且收到任何 H5 消息都视为"地图可用",不止 mapready 一种。
// 收到任何 H5 消息都置就绪:mapready 易丢,不能作为唯一判据if (!this.mapReady) this.mapReady = true

三、上行:H5 → App

3.1 三端通道不一致,而且有一个是"假通"

H5 往 App 发消息,有两个候选通道:uni.postMessage 和 __uniapp_x_.postMessage。

坑在于:在鸿蒙上,uni.postMessage 是存在但不通的——函数在、调用不报错、消息被静默丢弃。

更阴的是,uni 对象和 __uniapp_x_ 在鸿蒙上同时存在。所以那种"谁在就用谁"的写法(uni.postMessage || __uniapp_x_.postMessage)在鸿蒙上必然走错分支,消息全丢。我们的地图页"周边配套没数据、气泡出不来",一部分就是这个原因。

正确的判据是看运行时特征,而不是看 UA:

// 鸿蒙:有 __uniapp_x_ 且没有 plus(plus 只在 Android/iOS 有)functionisHarmonyUA() {return (typeof __uniapp_x_ !== 'undefined' && typeofwindow.plus === 'undefined') ||/harmony|arkweb/i.test(navigator.userAgent || '')}functionsendToApp(msg) {if (isHarmonyUA() && __uniapp_x_.postMessage) {        __uniapp_x_.postMessage(JSON.stringify({ data: [msg] })); returntrue    }if (typeof uni !== 'undefined' && uni.postMessage) {        uni.postMessage({ data: msg }); returntrue    }if (__uniapp_x_.postMessage) {   // 最后兜底        __uniapp_x_.postMessage(JSON.stringify({ data: [msg] })); returntrue    }returnfalse}

注意两个平台的消息外层结构不一样:鸿蒙这条路要 { data: [msg] }(双层数组),另一条是 { data: msg }。App 侧收到后统一摊平:

// e.detail.data 可能是 [[{...}]] 也可能是 [{...}],都摊平constflatten = (arr) => arr.forEach(it =>Array.isArray(it) ? flatten(it) : msgs.push(it))

3.2 桥未就绪:队列 + 重放 + 页面报错

H5 的消息可能早于桥就绪发出。发不出去就进队列,桥就绪后重放:

var _pendingMsg = []functionpostToApp(msg) {if (sendToApp(msg)) return    _pendingMsg.push(msg)setTimeout(flushPending, 500)}functionflushPending() {if (!_pendingMsg.length) returnvar rest = []    _pendingMsg.forEach(m => { if (!sendToApp(m)) rest.push(m) })    _pendingMsg = restif (rest.length && ++tries < 20) setTimeout(flushPending, 500)elseif (rest.length) showTip('上行通道不可用:' + rest[0].type)  // 重试耗尽 → 页面打红字}document.addEventListener('UniAppJSBridgeReady', flushPending)

最后那行 showTip 很重要:鸿蒙真机看不到 H5 的 console,所以重试耗尽后我们直接把错误显示在页面顶部。上线后线上排查,全靠这几行红字。

3.3 上行的消息形态

H5 上行的事件不只有"控制类"(点了什么),也有"数据类"(地图动了,新视野是什么):

{type:'mapready'}                                   // 就绪{type:'statuschange', zoom, center, bounds}         // 视野变化(App 据此重新拉数据){type:'markerclick', code, layer}                   // 点了气泡{type:'locateTap'}                                  // 点了定位按钮{type:'drawfinish', polygon:[[lng,lat],...]}        // 画圈抬手{type:'location', lng, lat} | {type:'location', failed:true}// 另有 mapclick / metroTap / sheetClose / houseClick / filter* 等

其中 statuschange 是量最大的一条——地图每停一次就上报一次,App 据此判断"要不要重新请求数据"。

四、一条消息的完整生命周期

把两边串起来,一次"点筛选"的完整往返是这样的:

App(Vue)  │  ① 组装完整快照(含筛选面板 view-model)→ JSON  │  ② evalJS('window.rfUpdate && window.rfUpdate({...})')   ← 同步盲投  │  ③ 起 500ms 定时器,持续重投  ▼H5(web-view 内)  │  ④ rfUpdate 执行 → 先 postToApp({type:'ack'})            ← 回执  │  ⑤ 幂等比对:seq/JSON 没变就跳过;变了才渲染  ▼App  │  ⑥ onTdMessage 收到 ack → stopFlush()                    ← 停止重投  │  ⑦ (顺带)通知 H5 按真实视口 invalidateSize 一次  ▼H5  │  ⑧ 用户点"确定" → postToApp({type:'filterConfirm', ...})  ← 上行  ▼App  │  ⑨ 重新请求数据 → 回到 ①,下发新快照

整条链路里,App 从不假设"消息一定送达":发出去就重试,收到回执才停;H5 从不假设"桥一定就绪":发不出去就入队,就绪后重放。

五、踩坑清单

如果你也在给 web-view 搭桥,这几条可以直接抄:

  1. 1. 地图在,但所有依赖下发的按钮都"点了没反应" → 典型的下行注入失败。先查有没有误用 evalJSAsync(鸿蒙上 Promise 永不 settle,静默失效),或小写 evalJs(已废弃,只告警不执行)。
  2. 2. 上行消息收不到 → 鸿蒙必须用 __uniapp_x_.postMessage;uni.postMessage 在鸿蒙上"存在但不通",会静默丢弃。判据要用"有 __uniapp_x_ 且无 plus",别只看 UA。
  3. 3. await 之后什么都没发生,也不报错 → 很可能在等一个永不 settle 的 Promise。
  4. 4. 重投导致地图抖动/重复渲染 → H5 侧幂等没做好。序号 + JSON 比对是底线。
  5. 5. 进页面第一屏永远没数据,动一下才有 → 在等 mapready 这条首帧消息,而它可能已经丢了。别依赖它,直接推。
  6. 6. 线上排查无从下手 → 鸿蒙真机看不到 H5 console。务必在桥失败时往页面打可见的错误,App 侧沿链路打面包屑日志。
  7. 7. 首屏偶发空白 → 检查是否只推了一次首帧。H5 脚本是异步加载的,要按时间点主动多推几次(我们推 600/1500/3000/5000ms)。

六、复盘

回头看,这座桥的复杂度几乎全部来自三个事实:

  1. 1. 三端运行时不一致——plus 有或没有、uni.postMessage 真通还是假通、消息外层是单层还是双层数组;
  2. 2. 失败是静默的——不抛错、不返回错误码,只能靠"回执"这种应用层协议来确认送达;
  3. 3. 设备上看不见——H5 的 console 在真机上是黑洞,必须自己造可见的错误通道。

对应三条设计原则,和框架无关,换任何 web-view 场景都成立:

  • • 下行盲投 + 回执:发出去不假设送达,收到 ack 才停;
  • • 上行队列 + 重放:桥未就绪先入队,就绪后重放,耗尽则页面报错;
  • • 幂等 + 快照:整份覆盖式下发,让"重试"和"自愈"免费获得。

一句话:别把 web-view 的桥当成一个可靠的 RPC,把它当成一个会丢消息、会静默失败、还看不见的通道来设计。 心态摆正了,代码反而简单——反正都要兜,那就兜得彻底一点。

你们在 web-view 里踩过哪些"静默失败"的坑?有没有遇到过框架 API 在某个平台上"存在但不生效"的情况?评论区聊聊。


如果你正在用 web-view 承载地图或富交互页面,这篇的桥接方案可以直接搬;下一篇我可能会写坐标系(BD09 / GCJ02 / WGS84)那一堆反直觉的偏移问题——感兴趣的话点个在看。

相关学习资料