WebView 与 PixUI 宿主通信:JSB、回调与生命周期

这篇只回答一个问题:WebView、Gamelet/PixUI 和 UGC2 PixUI 页面究竟怎样与游戏宿主通信。重点不是背 API 名,而是把传输、协议、路由、线程和生命周期讲成一套完整机制。

先背 30 秒结论

JSB 不是某一个 window 对象,而是页面与宿主之间的一套异步 RPC 和事件协议。WebView 依赖浏览器内核注入:JS 把 {functionName, callbackId, params} 交给 iOS 的 WKScriptMessageHandler、Android 的注入对象、鸿蒙的 javaScriptProxy 或 PC 的 UE 对象;C++ 按 WebView identifier 切回 Game Thread,再把 JSON 转成 Lua table 分发。宿主返回时按 identifier 在指定 WebView 执行 JS,页面用 callbackId 完成一次性回调,长期通知则走事件订阅;旧兼容层仍有代码字符串和 eval,固定结构化入口是演进目标。PixUI 不依赖浏览器内核:旧 Gamelet 通过 GameletAPI.callGame 和命令监听通信;UGC2 则用 PixUI message Delegate、C++ 队列和 PostPxMessage 建立页面、C++、Lua 的双向协议。三者抽象相似,底层通道和生命周期不能混为一谈。

一句话记忆:WebView 靠内核注入,Gamelet 靠容器 API,UGC2 靠 PixUI 消息 Delegate;JSB 负责把它们统一成请求、回调和事件语义。

先把四个概念分开

概念回答的问题典型实现
Transport字节怎样跨运行时WK message handler、JNI/Java 对象、ArkTS proxy、GameletAPI.callGame、PostPxMessage
Protocol字节表示什么method/type、payload、callbackId、error、version
Router消息应该交给谁WebView identifier、AppID、pageId/generation、Lua dispatcher
Lifecycle谁创建、取消和销毁listener owner、pending callback、View/Window、页面实例

window.external.sendMessage、window.__letsgo.dispatch_js_event 和 GameletAPI.callGame 都只是传输入口。只有再加上消息格式、回调关联、事件订阅、线程切换和销毁规则,才是一套可用的 JSB。

URL Scheme 也不是通用 JSB。它适合表达“打开某个页面或能力”的导航意图,不适合高频双向 RPC、持续事件和大数据传输。

三套链路不要混讲

场景页面运行时JS 到宿主宿主到 JS路由身份主要用途
WebView 页面系统或平台浏览器内核注入对象 / WK handler / JNI / ArkTS proxy按 identifier 执行 JS,进入页面回调或通知分发WebView identifier + callbackIdSSR、配置、运营和端内 Web 页面
旧 Gamelet/PixUIPixUI Runtime + Gamelet 容器GameletAPI.callGame(JSON)addOnGameCommandListenerAppID + type + callbackId多应用页面与通用游戏能力
UGC2 PixUI客户端内 PixUI Viewexternal.sendMessage -> PixUI DelegateC++ PostPxMessage,兼容通道兜底pageId + generation + domain/revision + seq编辑器多页面和领域状态同步

相同点是都只跨边界传纯值,不让页面持有 UObject 或 Lua 对象。不同点是 WebView 有浏览器和平台线程,Gamelet 有应用容器,UGC2 则把页面实例和编辑器领域状态纳入协议。

WebView:页面怎样调用宿主

页面侧封装

页面不应该到处直接访问平台对象,而是统一调用 callNativeFunction(functionName, params, callback)。底层旧 SDK 生成的请求大致是:

{
  MsdkMethod: 'jsCallNative',
  functionName: 'GetScreenSafeMargin',
  callbackId: 'cb_17',
  performance: { jsCallStart: 123.456 },
  // 业务参数会合并到同一个 JSON 对象
}

有回调时先登记 callbackId -> callback,再序列化并交给平台传输;没有回调时就是单向通知。上层 nativeBridge.ts 再负责 SSR 守卫、Promise 化、返回结构归一和具名业务接口,避免业务组件知道平台差异。

四个平台怎样把消息送进客户端

平台页面入口穿透路径
iOSwindow.__letsgo.dispatch_js_event(data)window.webkit.messageHandlers.jsEvent.postMessage -> WKScriptMessageHandler
Androidwindow.Letsgo_android.send(data)Java WebView 注入层 -> JNI sendMoeWebData(identifier, data)
OpenHarmonywindow.Letsgo_openharmony.send(data)ArkTS javaScriptProxy -> Worker -> Native 回调
PCwindow.ue.__letsgo.dispatch_js_event(data)UE WebBrowser 暴露对象 -> 客户端回调

iOS 在 document start 注入 window.__letsgo;OpenHarmony 给每个 WebView 创建带 identifier 的 JsProxy;Android 当前仓库能核实 JNI 入口和反向 executeJs,Java WebView 具体注入实现位于平台库边界,面试时不应假装全部由自己实现。

进入 C++ 和 Lua 后发生什么

sequenceDiagram
    participant P as Web 页面
    participant B as 平台 Bridge
    participant C as C++ MoeWebviewManager
    participant L as Lua 业务分发

    P->>P: 登记 callbackId
    P->>B: functionName + params + callbackId
    B->>C: identifier + JSON
    C->>C: RunOnGameThread
    C->>C: JSON 解码为 Lua table
    C->>L: OnWebViewManagerCallNativeTable(identifier, table)
    L-->>C: 处理结果
    C-->>B: ExecuteJS(identifier, callback code)
    B-->>P: callback / notification
    P->>P: take callback 或分发事件

C++ 的关键责任有三个:

  1. 保留 WebView identifier,保证多实例时不会把 A 页回包送进 B 页。
  2. 用 RunOnGameThread 进入合法线程,再访问 UnLua 和游戏对象。
  3. 把 JSON 解成 Lua table,统一调用 _MOE.OnWebViewManagerCallNativeTable,不让每个平台直接接业务。

这就是为什么 JS 看起来像“调用函数”,实际却是一次跨线程、跨语言的异步消息。

WebView:宿主怎样回调和推事件

一次性回调

宿主处理完请求后,通过 WebView identifier 找到具体实例,再用 evaluateJavaScript、Android executeJs 或鸿蒙 runJavaScript 进入页面。页面最终调用类似:

window.letsgo.callbackManager.handleCallback(callbackId, result)

handleCallback 查表、执行并立即删除,因此同一个 request 只消费一次。迟到或重复回包找不到 callbackId 时应被忽略,而不是再次修改 UI。

长期通知

长期状态变化不能伪装成永久 callback。页面通过 addNotify(type, handler) 登记订阅,宿主调用固定通知入口后,由 receiveNotification(type, data) 广播给同类型 handler。

模型基数结束条件适合场景
Request/Callback一问一答回包、超时或取消查询安全区、提交配置、请求数据
Command单向发出即结束打日志、打开窗口、无须结果的操作
Event/Notification一对多、多次显式 unsubscribe 或 owner 销毁前后台、登录变化、异步任务进度

旧 WebView SDK 的 addNotify 只有注册,没有通用反注册;普通 invoke 也没有统一超时。这不是可以忽略的小事:WebView 复用会累积 handler,宿主不回包会让 Promise 永久 pending。理想接口应该返回 disposer,并让所有请求都有可配置超时和取消信号。

MSDK Bridge 为什么是另一条兼容分支

MSDK WebView 使用 WebViewJavascriptBridge:iOS 通过 WVJBCallbacks 和隐藏 iframe 完成 bridge 初始化,页面用 callHandler('MSDKCall', data) 发消息;宿主通过 MSDKJSHandler 回到 NativeExecuteJS。旧非 iOS 分支还保留了 prompt 通道。

它与普通游戏 WebView 的区别是:MSDK 内置方法由 MSDK 自己处理,不一定继续转发 Lua;普通 callFunction 才是游戏业务协议。面试时说“WebView 都走一个 JSB”会把这两个责任域说错。

为什么旧实现里的 eval 是技术债

当前兼容层的 NativeExecuteJS(param) 会执行 eval(param.code),原因是旧 MSDK 只能回调 window 上的函数,历史上用代码字符串兼容不同入口。

它的问题不是“eval 一定会出事故”,而是能力边界过宽:

  1. 协议无法靠 Schema 校验,代码和数据混在一起。
  2. 转义错误会变成执行错误,日志也难结构化。
  3. 一旦页面来源、重定向或宿主输入控制不严,风险面显著扩大。
  4. 单元测试只能断言字符串,无法稳定验证方法和 payload。

更好的演进方式是宿主只执行一个固定入口:

window.NativeBridge.dispatch({
  kind: 'response',
  callbackId: 'cb_17',
  payload: result,
})

宿主仍可能需要 evaluateJavaScript 进入 WebView,但只拼固定函数名和经过安全序列化的数据,不下发任意代码。再进一步可使用平台提供的结构化 message port。

旧 Gamelet/PixUI:请求和回包怎样走

PixUI 不是浏览器 WebView,所以不会经过 WKScriptMessageHandler 或 Android WebView 注入。共享 SDK 依赖官方 gamelet-pixui-frame 提供的 GameletAPI。

sequenceDiagram
    participant P as PixUI 页面
    participant S as gameSDK
    participant G as GameletAPI / 容器
    participant H as 游戏宿主

    P->>S: callGameProtocol(method, content)
    S->>S: 生成 callbackId<br/>登记 owner + timeout
    S->>G: callGame(JSON envelope)
    G->>H: 按 AppID/type 分发
    H-->>G: callbackId + content
    G-->>S: onGameCommand(command)
    S->>S: 校验 AppID<br/>takeCallback(callbackId)
    S-->>P: resolve(content)

发送包的核心字段是:

{
  appId,
  type: method,
  content,
  callbackId, // 仅请求需要
}

宿主回包进入稳定的 command listener。SDK 先按 AppID 过滤;type === 'callback' 时按 callbackId 取出一次性回调,其余 type 进入事件总线。

为什么回调表是全局的

同一 PixUI 运行环境可能同时加载多个 bundle,每份 bundle 若都从 cb_0 自增,会冲突;某一页面若直接清空共享表,还会让其他页面的 Promise 永久挂起。

当前 SDK 的处理是:

  1. callback ID 由时间、全局序列和随机段组合,避免跨 bundle 冲突。
  2. pending callback 放到运行环境共享 Map。
  3. 每条记录带 bundle owner,销毁时默认只清理本 owner。
  4. takeCallback 先删除再执行,保证 one-shot。
  5. 默认协议超时 10 秒,超时取消回调并 reject。
  6. pending 上限为 200,避免宿主长期不回包造成无界增长。

这比“封装了几个平台 API”更能说明 SDK 的技术价值:它治理的是多页面条件下的身份、资源和失败语义。

事件为什么还要加 AppID 前缀

跨页面事件通过 ${APP_ID}_${eventName} 做应用级命名空间,注册前先去重,清理时用同一个函数引用反注册。否则多个应用使用 refresh、close 之类通用事件名时会串消息。

init() 也必须幂等并复用稳定 listener 引用。每次初始化都创建新箭头函数,会导致引擎侧无法按引用移除旧 listener,最终重复响应甚至报 function duplicated。

UGC2 PixUI:为什么没有直接复用旧 Gamelet RPC

UGC2 编辑器页面不是简单的远端活动应用。它随客户端交付,页面实例、编辑状态、预览世界和 Lua 服务有更强的生命周期关系,因此采用项目内的 typed transport。

TypeScript 到 Lua

TypeScript message
  -> window.external.sendMessage(JSON)
  -> PixUI DelegateOnMessage
  -> C++ Forwarder queue
  -> Game Thread frame-end drain
  -> Lua PageManager / domain service

页面把 {type, seq, data} 序列化为 JSON。C++ Delegate 收到后不在 PixUI 内部调用栈里直接重入 Lua,而是入队,在统一帧点消费。这样能明确线程、批处理和销毁边界,也能在页面失效后整批丢弃旧消息。

Lua 到 TypeScript

Lua domain state
  -> C++ Binding
  -> PixUI View.PostPxMessage(JSON)
  -> TypeScript receiver

主通道走 PostPxMessage;全局变量消息队列只作为不同平台或启动阶段的兼容兜底。双通道可能重复送达,所以必须共享 seq/ACK,而不是两边各自处理一次。

为什么这里需要更多身份字段

字段解决的问题
pageId消息属于哪个逻辑页面
generation同一个 pageId 重建后,拒绝旧实例迟到消息
domain状态属于对象树、属性、选择集还是其他领域
revision拒绝旧状态覆盖新状态
seq / ACK请求关联、重试和双通道去重

Gamelet 的 AppID 解决“哪个应用”,UGC2 的 generation/revision 进一步解决“哪个页面实例、哪一版状态”。这是业务复杂度不同,不是谁替代谁。

一套成熟 JSB 应该怎样分层

Business API
  getUserInfo / saveConfig / selectObject
        |
Typed SDK
  参数类型、错误模型、超时、取消、兼容版本
        |
Protocol Router
  request / response / event、callbackId、view/app/page identity
        |
Transport Adapter
  WebKit / JNI / ArkTS / UE object / GameletAPI / PixUI message
        |
Native Dispatcher
  Game Thread、Lua/C++ 服务、权限与生命周期

业务层只能看到稳定能力,不应该判断 iOS 还是 Android;Transport 只负责送达,不应该猜业务成功;Router 只负责找到请求和页面,不应该直接修改 UI。

协议包至少要能表达:

type BridgeMessage = {
  version: number;
  kind: 'request' | 'response' | 'event' | 'cancel';
  method?: string;
  requestId?: string;
  target: { viewId?: string; appId?: string; pageId?: string; generation?: number };
  payload?: unknown;
  error?: { code: string; message: string };
};

这是推荐的统一模型,不是说现有三套协议已经原样使用这些字段。面试中要区分“源码现状”和“我会怎样演进”。

线程、重入和时序怎样回答

为什么必须回 Game Thread

WebKit、Java、ArkTS、PixUI 回调和网络完成点不保证都在 Unreal Game Thread。只要后续访问 UObject、Widget、UnLua VM 或 World,就必须切到规定线程。当前 WebView C++ 管理器明确使用 RunOnGameThread 再调用 Lua。

为什么 PixUI Delegate 不直接重入 Lua

即使回调恰好在 Game Thread,也可能仍处于 PixUI 布局、脚本或销毁调用栈。同步调用 Lua 后若反向关闭当前 View,就会出现重入和悬空引用。入队到帧末让消息处理与 PixUI 内部栈解耦。

为什么回调到达不等于业务成功

Transport 只回答“有没有收到回包”;业务层才判断 errorCode、result 或业务字段。把两层混在一起会导致两种错误:收到业务失败却被当成 RPC 成功,或者 SDK 擅自吞掉业务原始返回。

生命周期必须对称

创建时运行时销毁时
分配 view/app/page identity所有请求绑定 owner停止接收新消息
注入或注册稳定 listenercallback 有超时与容量上限取消本 owner pending callback
建立 C++/Lua 路由event 可反注册移除 event/listener/delegate
View ready 后开放调用回包校验实例代数清空队列并释放 Lua ref
建立诊断上下文仅在合法线程操作对象关闭 View/Window 和平台对象

仅仅把 Widget 隐藏或把 React 组件卸载,不代表宿主资源已经释放。反过来,页面临时切换也不能总是销毁 View;隐藏与 dispose 必须是两种明确状态。

安全边界怎样讲才像客户端架构

  1. 能力白名单:页面只能调用注册过的 method,不能把任意 UObject 或 Lua 函数暴露出去。
  2. 来源控制:WebView 校验 URL、重定向、主 frame 和本地文件权限;如果 Bridge 注入所有 frame,更要明确子 frame 策略。
  3. 数据代码分离:宿主回注固定函数和 JSON,不接受任意代码字符串;逐步淘汰 eval。
  4. 输入限制:限制 payload 大小、嵌套深度和频率,JSON 解析失败不能污染 Lua 栈。
  5. 身份与权限:WebView identifier、AppID、page generation 都要参与校验,不能只信 callbackId。
  6. 日志脱敏:记录 method、ID、字节数、耗时和错误码,不打印 token、用户内容和完整响应。
  7. 宿主兜底:关闭、重试、错误页等逃生能力不能只依赖可能已经白屏的页面。

故障怎么定位

页面点击后完全没反应

按边界从近到远排:页面 handler 是否执行 -> bridge 是否存在 -> JSON 是否成功序列化 -> 平台入口是否收到 -> identifier 是否正确 -> C++ 是否切 Game Thread -> Lua dispatcher 和 method 是否存在。

宿主处理成功但页面没回调

检查 callbackId 是否原样返回、是否被超时取消、回包是否送错 WebView/AppID、listener 是否重复或已解绑、页面是否已经换 generation。不要先怀疑业务数据。

同一个事件执行两次

优先查重复 init、匿名函数导致无法反注册、新旧 SDK 并存、WebView 复用后 notify 未清理,以及 UGC2 主通道与 fallback 是否缺少 seq 去重。

Promise 永久 pending

说明请求没有统一终止条件。每个 RPC 都必须在回包、超时、取消、页面销毁四种路径之一结束;纯 Web/SSR 环境还要在 bridge 不存在时立即 reject 或返回明确降级结果。

多页面偶发串消息

检查三层身份是否齐全:容器级 identifier/AppID、实例级 generation、请求级 callbackId/seq。只靠 method 名无法路由并发页面。

测试和观测应该覆盖什么

层级必测内容
SDK 单测参数序列化、回调只执行一次、超时、取消、事件反注册、SSR 无 window
多实例测试两个 WebView、同 AppID 多 bundle、旧 generation 回包、销毁一页不影响另一页
平台集成iOS/Android/OpenHarmony/PC 的注入时机、线程和反向执行
故障注入宿主不回、重复回、乱序回、坏 JSON、超大包、关闭后迟到
安全测试非白名单 method、非可信 origin、子 frame、重定向和代码注入
观测method、route identity、callbackId、耗时、超时、丢弃原因、pending/queue 水位

一条请求最好能在页面、平台层、C++ 和 Lua 共享 trace/request ID。否则跨四层日志只能靠时间猜关联。

面试官连续追问

JSB 是同步还是异步?

业务语义按异步设计。即使某个平台注入函数表面同步返回,也不能依赖,因为宿主可能切线程、访问网络或等待 Lua。统一异步才能保持多平台一致。

为什么不用直接暴露 C++ 方法?

直接暴露会把 ABI、线程、对象生命周期和权限泄漏给页面。结构化协议虽然多一层序列化,却能做版本、校验、监控、超时和跨平台适配。

callbackId 为什么还不够?

它只能标识一次请求,不能证明回包属于当前页面实例。页面重建后旧请求可能迟到,所以还要校验 owner 或 generation。

页面 ready 之前宿主推消息怎么办?

建立握手。宿主在收到 ready(pageId, generation, protocolVersion) 前只缓存有界的最新快照或拒绝发送;ready 后先发全量基线,再发增量,不能无限堆事件。

WebView 和 PixUI 能否共用业务 SDK?

可以共用类型、业务 API 和错误模型,但 Transport Adapter 必须分开。强行让 WebView 伪装 GameletAPI 或让 PixUI 模拟浏览器注入,会把平台生命周期藏进兼容层。

为什么事件一定要 unsubscribe?

事件是长期引用。页面销毁后不解绑,既会持有闭包和状态,又会在新页面出现重复响应;回调表有 one-shot 删除,事件没有天然结束点。

evaluateJavaScript 就等于不安全吗?

不是。WebView 反向调用通常需要它,关键是只调用固定入口并安全序列化参数。危险的是让数据决定任意可执行代码,或对不可信页面开放高权限 Bridge。

URL Scheme 和 JSB 怎么选?

一次性外部跳转或深链用 Scheme;页面与当前宿主的请求回调、状态推送和高频交互用 JSB。Scheme 还要做允许列表和参数编码。

个人工作边界怎样表达

可以明确说:

我直接做得比较深的是页面侧共享 SDK 和宿主边界治理,包括 PixUI 多 bundle 的 callback 冲突、稳定 listener、owner 清理、超时和事件封装,也在 WebView 项目里收敛了 SSR 安全的 nativeBridge、具名接口和返回结构。UGC2.0 中,我进一步参与了 PixUI 页面宿主、TS/C++/Lua 消息和生命周期协议。iOS/Android/OpenHarmony 的 WebView 平台插件与旧 Gamelet 底层是团队已有基础设施;我通过源码把完整链路核清并在其上接入、排障和演进,不会说整套平台 SDK 都是我独立设计的。

有个人提交可以支撑的关键词是:公共包规范化、sdk 重新封装、修复 callback 重复、第一批协议审查、Web 业务接入与跨端缺陷修复。平台插件源码适合用来证明系统理解,不应改写成个人 ownership。

90 秒最终背诵稿

我会先区分 WebView 和 PixUI。WebView 使用浏览器内核,页面侧统一调用 nativeBridge,把 functionName、参数和 callbackId 序列化后交给平台注入对象。iOS 走 WKScriptMessageHandler,Android 经过 Java/JNI,鸿蒙走 javaScriptProxy,PC 走 UE 注入对象;平台回调都带 WebView identifier 进入 C++。C++ 切回 Game Thread,把 JSON 转成 Lua table,交给统一 dispatcher。宿主返回时按 identifier 在指定 WebView 执行 JS,再进入 callback 或 notification 分发;页面按 callbackId 完成并删除一次性回调,长期状态必须在销毁时反注册。旧兼容层仍有 eval,我会把它演进为固定入口和结构化数据。

PixUI 不经过浏览器 JSB。旧 Gamelet 用 GameletAPI.callGame 发 {appId,type,content,callbackId},回包从 command listener 进入;共享 SDK 解决跨 bundle callback 唯一、owner 清理、默认超时、容量上限和事件命名空间。UGC2 编辑器则用更强的页面协议:TS 经 external.sendMessage 到 PixUI Delegate,C++ 入队后在帧末转 Lua;反向由 Lua 经 C++ 调 PostPxMessage,并用 seq/ACK 处理兼容双通道,用 pageId、generation 和 revision 拒绝旧实例与乱序状态。

所以我理解的 JSB 不只是“JS 调原生”,而是 Transport、Protocol、Router 和 Lifecycle 四层。真正容易出问题的是线程重入、多页面路由、迟到回调、事件泄漏和任意代码回注;演进方向是固定入口、结构化消息、能力白名单、统一超时取消,以及端到端 trace。

白板只画这一张

WebView:
Page -> injected bridge -> WebKit/JNI/ArkTS/UE -> C++ GameThread -> Lua
Page <- callbackId/event <- NativeExecuteJS/dispatcher <- ExecuteJS(identifier) <- Host

Gamelet/PixUI:
Page -> GameletAPI.callGame(appId/type/callbackId) -> Gamelet Host
Page <- command listener <- callback/event ----------------------- Host

UGC2 PixUI:
TS -> external.sendMessage -> PixUI Delegate -> C++ queue -> Lua domain
TS <- PostPxMessage -------- C++ Binding <---------------- Lua state

Identity: WebView identifier / AppID / pageId+generation / callbackId+seq

讲解顺序固定为:先分运行时 -> 再讲正向传输 -> 再讲回调和事件 -> 再讲线程与销毁 -> 最后讲安全和个人边界。

关联阅读