From 206db9730af0fb784b78178361c91c4e366b1581 Mon Sep 17 00:00:00 2001 From: lanterngamescn Date: Thu, 25 Jun 2026 08:51:11 +0800 Subject: [PATCH] =?UTF-8?q?M1:=20=E6=A1=A5=E5=BC=95=E6=93=8E=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=EF=BC=88T-M1-01~04=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100% 复刻 lzyzsd/JsBridge(对照原 Android com.tagmae.jsbridge 逐字): - IWebController + WebviewControllerAdapter(emitter UI 线程守卫,§5.5) + FakeWebController(单测用) - Message(5字段,undefined省略) + MessageCodec(toJson/toArray + dispatchMessage 两次正则转义逐字复刻) - HandlerRegistry + 空实现 DefaultHandler 兜底(H5 调用永不报错最后防线) - BridgeController:onLoadIntercept(decode→yy://return/yy://)/callHandler/send/onPageEnd/ flushMessageQueue(_fetchQueue 经 yy://return 回传,非 runJavaScript 返回值)/handleReturnData/ 启动队列补发;对能力零感知、纯逻辑可测 - build 编译通过(ArkTS 严格) Co-Authored-By: Claude Opus 4.8 (1M context) --- build-profile.json5 | 16 +- docs/设计文档/Plan/01_任务分解WBS.md | 2 +- feature_bridge/Index.ets | 11 +- .../src/main/ets/core/BridgeController.ets | 193 ++++++++++++++++++ .../src/main/ets/core/HandlerRegistry.ets | 50 +++++ feature_bridge/src/main/ets/core/Message.ets | 29 +++ .../src/main/ets/core/MessageCodec.ets | 59 ++++++ .../src/main/ets/web/IWebController.ets | 95 +++++++++ 8 files changed, 452 insertions(+), 3 deletions(-) create mode 100644 feature_bridge/src/main/ets/core/BridgeController.ets create mode 100644 feature_bridge/src/main/ets/core/HandlerRegistry.ets create mode 100644 feature_bridge/src/main/ets/core/Message.ets create mode 100644 feature_bridge/src/main/ets/core/MessageCodec.ets create mode 100644 feature_bridge/src/main/ets/web/IWebController.ets diff --git a/build-profile.json5 b/build-profile.json5 index 709912b..9ab527e 100644 --- a/build-profile.json5 +++ b/build-profile.json5 @@ -1,6 +1,20 @@ { "app": { - "signingConfigs": [], + "signingConfigs": [ + { + "name": "default", + "type": "HarmonyOS", + "material": { + "certpath": "C:\\Users\\Joywayer\\.ohos\\config\\default_gamelobby_Tg3dZWT2uE_yyJID0XfKtliH7uuF1dWWNM21ecODP2A=.cer", + "keyAlias": "debugKey", + "keyPassword": "0000001BB88B945FF086A71E48E2000AC023983A9F3246CAC3CDD481C5ECCE32627C982DD892BD85A5FBC7", + "profile": "C:\\Users\\Joywayer\\.ohos\\config\\default_gamelobby_Tg3dZWT2uE_yyJID0XfKtliH7uuF1dWWNM21ecODP2A=.p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "C:\\Users\\Joywayer\\.ohos\\config\\default_gamelobby_Tg3dZWT2uE_yyJID0XfKtliH7uuF1dWWNM21ecODP2A=.p12", + "storePassword": "0000001B3D659D26D98202CB916B9A08D909956365FC114D150ECBBDDDDB9CC07E454FDF9E65AC7A5198EC" + } + } + ], "products": [ { "name": "default", diff --git a/docs/设计文档/Plan/01_任务分解WBS.md b/docs/设计文档/Plan/01_任务分解WBS.md index 506c872..6609367 100644 --- a/docs/设计文档/Plan/01_任务分解WBS.md +++ b/docs/设计文档/Plan/01_任务分解WBS.md @@ -13,7 +13,7 @@ | 里程碑 | 状态 | 说明 | |---|---|---| | **M0** 脚手架 + 契约类型 | ☑ 完成 | 7 任务全完成并真机/模拟器验证 | -| **M1** 桥引擎 | ☐ 未开始 | 下一步;含 V1 阻塞性验证 | +| **M1** 桥引擎 | ◐ 进行中 | 含 V1 阻塞性验证 | | **M2** 容器 + 启动 + 资源 | ☐ 未开始 | 含 V2 阻塞性验证 | | **M3** 本期能力 | ☐ 未开始 | — | | **M4** 通用网页容器 | ☐ 未开始 | — | diff --git a/feature_bridge/Index.ets b/feature_bridge/Index.ets index 45e403c..899128c 100644 --- a/feature_bridge/Index.ets +++ b/feature_bridge/Index.ets @@ -1,4 +1,13 @@ // feature_bridge HAR —— 桥引擎层(框架 §5,100% 复刻 lzyzsd/JsBridge 协议) -// M0:桥 JS 读取器。M1 将补 BridgeController/MessageCodec/HandlerRegistry/IWebController。 +// 桥核心 +export { BridgeController, ResponseCallback } from './src/main/ets/core/BridgeController'; +export { Message, MessageJson } from './src/main/ets/core/Message'; +export { MessageCodec } from './src/main/ets/core/MessageCodec'; +export { HandlerRegistry, BridgeHandler } from './src/main/ets/core/HandlerRegistry'; + +// Web 控制器抽象(UI 线程守卫下沉于此) +export { IWebController, WebviewControllerAdapter, FakeWebController } from './src/main/ets/web/IWebController'; + +// 桥 JS 读取器 export { BridgeJsLoader, BRIDGE_JS_RAWFILE } from './src/main/ets/assets/BridgeJsLoader'; diff --git a/feature_bridge/src/main/ets/core/BridgeController.ets b/feature_bridge/src/main/ets/core/BridgeController.ets new file mode 100644 index 0000000..2eb893e --- /dev/null +++ b/feature_bridge/src/main/ets/core/BridgeController.ets @@ -0,0 +1,193 @@ +/** + * 桥控制器(框架 §5.2,T-M1-04)。100% 复刻 lzyzsd/JsBridge 协议,是 H5 零改动命门。 + * + * 对外暴露 registerHandler / callHandler / send,与 Android 端同名同义。 + * 🔴 对能力零感知:只持 HandlerRegistry,能力启动时注册自己的 handler。 + * 🔴 UI 线程守卫:下沉到 IWebController 实现层(WebviewControllerAdapter),本类保持纯逻辑、可单测。 + * 🔴 队列回传不靠 runJavaScript 返回值:_fetchQueue 经 H5 把 iframe.src 置为 + * yy://return/_fetchQueue/<队列JSON>,再次经 onLoadIntercept → handleReturnData 路由。 + */ +import { IWebController } from '../web/IWebController'; +import { HandlerRegistry, BridgeHandler } from './HandlerRegistry'; +import { Message } from './Message'; +import { MessageCodec } from './MessageCodec'; + +const YY_SCHEMA: string = 'yy://'; +const YY_RETURN: string = 'yy://return/'; +const YY_FETCH_QUEUE: string = 'yy://return/_fetchQueue/'; +const FETCH_QUEUE_KEY: string = '_fetchQueue'; + +export type ResponseCallback = (data: string) => void; + +export class BridgeController { + private readonly web: IWebController; + private readonly registry: HandlerRegistry; + private readonly responseCallbacks: Map = new Map(); + private startupMessages: Message[] | null = []; + private uniqueId: number = 0; + private bridgeJs: string = ''; + + constructor(web: IWebController, registry?: HandlerRegistry) { + this.web = web; + this.registry = registry ?? new HandlerRegistry(); + } + + getRegistry(): HandlerRegistry { + return this.registry; + } + + /** 设置待注入的桥 JS 文本(由容器从 BridgeJsLoader 读入)。 */ + setBridgeJs(js: string): void { + this.bridgeJs = js; + } + + // —— H5 → 原生:在 Web().onLoadIntercept 调用,返回 true 表示拦截、阻断真实导航 —— + onLoadIntercept(rawUrl: string): boolean { + let url: string; + try { + url = decodeURIComponent(rawUrl); + } catch (e) { + url = rawUrl; + } + if (url.startsWith(YY_RETURN)) { + this.handleReturnData(url); + return true; + } + if (url.startsWith(YY_SCHEMA)) { + this.flushMessageQueue(); + return true; + } + return false; + } + + // —— 原生 → H5:调用 H5 注册的 handler —— + callHandler(name: string, data: string, cb?: ResponseCallback): void { + this.doSend(name, data, cb); + } + + /** 向 H5 默认 handler 发消息(无 handlerName)。 */ + send(data: string, cb?: ResponseCallback): void { + this.doSend(undefined, data, cb); + } + + private doSend(handlerName: string | undefined, data: string, cb?: ResponseCallback): void { + const m = new Message(); + if (data.length > 0) { + m.data = data; // 对齐 Android !TextUtils.isEmpty(data) + } + if (cb !== undefined) { + this.uniqueId += 1; + const id: string = `JAVA_CB_${this.uniqueId}_${Date.now()}`; + this.responseCallbacks.set(id, cb); + m.callbackId = id; + } + if (handlerName !== undefined && handlerName.length > 0) { + m.handlerName = handlerName; + } + this.queue(m); + } + + registerHandler(name: string, handler: BridgeHandler): void { + this.registry.put(name, handler); + } + + // —— 页面就绪:在 Web().onPageEnd 调用——注入桥 JS + 补发启动队列(顺序对齐 Android onPageFinished)—— + onPageEnd(): void { + if (this.bridgeJs.length > 0) { + this.web.runJavaScript(this.bridgeJs); + } + const pending = this.startupMessages; + this.startupMessages = null; + if (pending !== null) { + for (const m of pending) { + this.dispatch(m); + } + } + } + + // —— 触发 H5 交出待发队列;队列经 yy://return/_fetchQueue/ 回传到下方预登记的回调 —— + private flushMessageQueue(): void { + this.responseCallbacks.set(FETCH_QUEUE_KEY, (queueJson: string) => { + let list: Message[]; + try { + list = MessageCodec.toArray(queueJson); + } catch (e) { + return; + } + for (const m of list) { + const responseId = m.responseId; + if (responseId !== undefined && responseId.length > 0) { + // H5 对"原生 callHandler"的回执 + const fn = this.responseCallbacks.get(responseId); + if (fn !== undefined) { + fn(m.responseData ?? ''); + this.responseCallbacks.delete(responseId); + } + } else { + // H5 主动 callHandler → 分发到能力层 handler + const callbackId = m.callbackId; + const respFn: ResponseCallback = (callbackId !== undefined && callbackId.length > 0) + ? (d: string) => { + this.queue(Message.response(callbackId, d)); + } + : (_d: string) => { }; + const name = m.handlerName; + const handler: BridgeHandler = (name !== undefined && this.registry.has(name)) + ? this.registry.get(name)! + : this.registry.default(); + handler(m.data ?? '', respFn); + } + } + }); + this.web.runJavaScript('WebViewJavascriptBridge._fetchQueue();'); + } + + // —— H5 回执通道:yy://return// —— + private handleReturnData(url: string): void { + const fn = this.getFunctionFromReturnUrl(url); + const data = this.getDataFromReturnUrl(url); + if (fn !== undefined) { + const cb = this.responseCallbacks.get(fn); + if (cb !== undefined) { + cb(data ?? ''); + this.responseCallbacks.delete(fn); + } + } + } + + private queue(m: Message): void { + if (this.startupMessages !== null) { + this.startupMessages.push(m); + } else { + this.dispatch(m); + } + } + + private dispatch(m: Message): void { + const json = MessageCodec.escape(MessageCodec.toJson(m)); + this.web.runJavaScript(`WebViewJavascriptBridge._handleMessageFromNative('${json}');`); + } + + // —— BridgeUtil 等价:从回执 URL 解析 functionName / data —— + private getFunctionFromReturnUrl(url: string): string | undefined { + const temp = url.replace(YY_RETURN, ''); + const parts = temp.split('/'); + return parts.length >= 1 ? parts[0] : undefined; + } + + private getDataFromReturnUrl(url: string): string | undefined { + if (url.startsWith(YY_FETCH_QUEUE)) { + return url.replace(YY_FETCH_QUEUE, ''); + } + const temp = url.replace(YY_RETURN, ''); + const parts = temp.split('/'); + if (parts.length >= 2) { + let sb = ''; + for (let i = 1; i < parts.length; i++) { + sb += parts[i]; + } + return sb; + } + return undefined; + } +} diff --git a/feature_bridge/src/main/ets/core/HandlerRegistry.ets b/feature_bridge/src/main/ets/core/HandlerRegistry.ets new file mode 100644 index 0000000..55fa954 --- /dev/null +++ b/feature_bridge/src/main/ets/core/HandlerRegistry.ets @@ -0,0 +1,50 @@ +/** + * Handler 注册表 + 默认兜底(框架 §5.2 / §6.5,T-M1-03)。 + * + * 桥核心只持有本注册表,对具体能力零感知;能力层启动时把自己的 handler 注册进来。 + * 🔴 默认 handler 为空实现:任何**未注册**的 handler 名都落到它而**不抛错**—— + * 这是"H5 调用永不报错"的最后防线,也是暂缓能力(占位桩)的安全网底座。 + */ + +/** 入站 handler:处理 H5 调用,通过 callback 同步回传结果。 */ +export type BridgeHandler = (data: string, callback: (resp: string) => void) => void; + +export class HandlerRegistry { + private readonly handlers: Map = new Map(); + /** 默认兜底:空实现,永不抛错(对齐 Android BridgeWebView.defaultHandler / DefaultHandler)。 */ + private defaultHandler: BridgeHandler = (_data: string, callback: (resp: string) => void) => { + // 未注册名落此;若 H5 传了 responseCallback,回空串避免其永久挂起 + callback(''); + }; + + put(name: string, handler: BridgeHandler): void { + this.handlers.set(name, handler); + } + + get(name: string): BridgeHandler | undefined { + return this.handlers.get(name); + } + + /** 取默认兜底 handler。 */ + default(): BridgeHandler { + return this.defaultHandler; + } + + /** 自定义默认 handler(可选)。 */ + setDefault(handler: BridgeHandler): void { + this.defaultHandler = handler; + } + + remove(name: string): void { + this.handlers.delete(name); + } + + has(name: string): boolean { + return this.handlers.has(name); + } + + /** 取已注册 handler 数(调试/自检用)。 */ + size(): number { + return this.handlers.size; + } +} diff --git a/feature_bridge/src/main/ets/core/Message.ets b/feature_bridge/src/main/ets/core/Message.ets new file mode 100644 index 0000000..7aafed9 --- /dev/null +++ b/feature_bridge/src/main/ets/core/Message.ets @@ -0,0 +1,29 @@ +/** + * 桥消息结构(《契约规范》§2.4,对应 Android com.tagmae.jsbridge.Message)。 + * + * 5 个字段按需出现;序列化时 undefined 字段省略(对齐 org.json put(null) 移除 key)。 + */ +export class Message { + handlerName?: string; + data?: string; + callbackId?: string; + responseId?: string; + responseData?: string; + + /** 构造一条回执消息(对端 callbackId → responseId)。 */ + static response(responseId: string, responseData: string): Message { + const m = new Message(); + m.responseId = responseId; + m.responseData = responseData; + return m; + } +} + +/** JSON 解析中转结构(H5 待发队列元素)。 */ +export interface MessageJson { + handlerName?: string; + data?: string; + callbackId?: string; + responseId?: string; + responseData?: string; +} diff --git a/feature_bridge/src/main/ets/core/MessageCodec.ets b/feature_bridge/src/main/ets/core/MessageCodec.ets new file mode 100644 index 0000000..7028204 --- /dev/null +++ b/feature_bridge/src/main/ets/core/MessageCodec.ets @@ -0,0 +1,59 @@ +/** + * 消息编解码(框架 §5.1,T-M1-02)。与 lzyzsd/JsBridge 完全一致,是 H5 零改动命门。 + * + * - `toJson`:Message → JSON 字符串,仅含已定义字段(对齐 org.json `toJson` 省略 null)。 + * - `escape`:复刻 Android `BridgeWebView.dispatchMessage` 的**两次正则转义**,使 JSON 能安全 + * 嵌入 `_handleMessageFromNative('')` 的单引号字符串。 + * - `toArray`:H5 待发队列 JSON 数组 → Message[](对应 `Message.toArrayList`)。 + */ +import { Message, MessageJson } from './Message'; + +export class MessageCodec { + /** Message → JSON 字符串(undefined 字段省略)。 */ + static toJson(m: Message): string { + const obj: Record = {}; + if (m.handlerName !== undefined) { + obj.handlerName = m.handlerName; + } + if (m.data !== undefined) { + obj.data = m.data; + } + if (m.callbackId !== undefined) { + obj.callbackId = m.callbackId; + } + if (m.responseId !== undefined) { + obj.responseId = m.responseId; + } + if (m.responseData !== undefined) { + obj.responseData = m.responseData; + } + return JSON.stringify(obj); + } + + /** + * 与 Android `BridgeWebView.dispatchMessage` 逐字一致的二次转义: + * 1) messageJson.replaceAll("(\\)([^utrn])", "\\\\$1$2") —— 非 \u\t\r\n 的裸反斜杠序列再加倍 + * 2) messageJson.replaceAll("(?<=[^\\])(\")", "\\\"") —— 未转义的双引号前补反斜杠 + */ + static escape(messageJson: string): string { + let s: string = messageJson.replace(/(\\)([^utrn])/g, '\\\\$1$2'); + s = s.replace(/(?<=[^\\])(")/g, '\\"'); + return s; + } + + /** H5 待发队列 JSON 数组字符串 → Message[]。 */ + static toArray(jsonStr: string): Message[] { + const arr: MessageJson[] = JSON.parse(jsonStr) as MessageJson[]; + const list: Message[] = []; + for (const o of arr) { + const m = new Message(); + m.handlerName = o.handlerName; + m.data = o.data; + m.callbackId = o.callbackId; + m.responseId = o.responseId; + m.responseData = o.responseData; + list.push(m); + } + return list; + } +} diff --git a/feature_bridge/src/main/ets/web/IWebController.ets b/feature_bridge/src/main/ets/web/IWebController.ets new file mode 100644 index 0000000..34ae812 --- /dev/null +++ b/feature_bridge/src/main/ets/web/IWebController.ets @@ -0,0 +1,95 @@ +/** + * Web 控制器抽象(框架 §5.6 / §5.5,T-M1-01)。 + * + * 桥只依赖这个最小接口,不绑死 `webview.WebviewController`,便于单测 mock。 + * 出站下发是 fire-and-forget——不读 runJavaScript 返回值(队列经 `yy://return/` 回传)。 + * + * 🔴 UI 线程守卫(§5.5)下沉到此层:`WebviewControllerAdapter` 内部用 emitter 把 + * runJavaScript/loadUrl 投递到 UI 线程执行——能力可在任意线程经桥下发,最终都在 UI 线程 + * 调 `controller.runJavaScript`。如此 `BridgeController` 保持纯逻辑、可单测。 + */ +import { webview } from '@kit.ArkWeb'; +import { emitter } from '@kit.BasicServicesKit'; + +export interface IWebController { + /** 执行 JS(最终在 UI 线程,不关心返回值)。 */ + runJavaScript(script: string): void; + /** 加载 URL(最终在 UI 线程)。 */ + loadUrl(url: string): void; + /** 释放资源(解绑 UI 线程通道)。 */ + dispose(): void; +} + +/** 生产适配器:包 webview.WebviewController,内置 UI 线程守卫。**必须在 UI 线程构造**。 */ +export class WebviewControllerAdapter implements IWebController { + private static seq: number = 0; + private readonly controller: webview.WebviewController; + private readonly uiEventId: string; + + constructor(controller: webview.WebviewController) { + this.controller = controller; + WebviewControllerAdapter.seq += 1; + this.uiEventId = `web.ui.runjs.${WebviewControllerAdapter.seq}`; + // 构造发生在 UI 线程(容器内)→ 订阅回调即在 UI 线程执行 + emitter.on(this.uiEventId, (ev: emitter.EventData) => { + const d = ev.data; + if (d === undefined) { + return; + } + const script = d['script']; + if (typeof script === 'string') { + this.controller.runJavaScript(script).then(() => { }).catch((_e: Object) => { }); + return; + } + const url = d['loadUrl']; + if (typeof url === 'string') { + this.controller.loadUrl(url); + } + }); + } + + runJavaScript(script: string): void { + const payload: Record = { 'script': script }; + const ev: emitter.EventData = { data: payload }; + emitter.emit(this.uiEventId, ev); + } + + loadUrl(url: string): void { + const payload: Record = { 'loadUrl': url }; + const ev: emitter.EventData = { data: payload }; + emitter.emit(this.uiEventId, ev); + } + + dispose(): void { + emitter.off(this.uiEventId); + } +} + +/** + * 测试用假控制器:同步记录 runJavaScript 脚本与 loadUrl,供单测断言; + * 模拟 H5 回执由测试直接调 BridgeController.onLoadIntercept('yy://...')。 + */ +export class FakeWebController implements IWebController { + scripts: string[] = []; + loadedUrls: string[] = []; + + runJavaScript(script: string): void { + this.scripts.push(script); + } + + loadUrl(url: string): void { + this.loadedUrls.push(url); + } + + dispose(): void { + } + + lastScript(): string | undefined { + return this.scripts.length > 0 ? this.scripts[this.scripts.length - 1] : undefined; + } + + clear(): void { + this.scripts = []; + this.loadedUrls = []; + } +}