M1: 桥引擎核心(T-M1-01~04)

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) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-25 08:51:11 +08:00
co-authored by Claude Opus 4.8
parent 9280c00809
commit 206db9730a
8 changed files with 452 additions and 3 deletions
+15 -1
View File
@@ -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",
+1 -1
View File
@@ -13,7 +13,7 @@
| 里程碑 | 状态 | 说明 |
|---|---|---|
| **M0** 脚手架 + 契约类型 | ☑ 完成 | 7 任务全完成并真机/模拟器验证 |
| **M1** 桥引擎 | ☐ 未开始 | 下一步;含 V1 阻塞性验证 |
| **M1** 桥引擎 | ◐ 进行中 | 含 V1 阻塞性验证 |
| **M2** 容器 + 启动 + 资源 | ☐ 未开始 | 含 V2 阻塞性验证 |
| **M3** 本期能力 | ☐ 未开始 | — |
| **M4** 通用网页容器 | ☐ 未开始 | — |
+10 -1
View File
@@ -1,4 +1,13 @@
// feature_bridge HAR —— 桥引擎层(框架 §5100% 复刻 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';
@@ -0,0 +1,193 @@
/**
* 桥控制器(框架 §5.2T-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<string, ResponseCallback> = new Map<string, ResponseCallback>();
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/<functionName>/<data> ——
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;
}
}
@@ -0,0 +1,50 @@
/**
* Handler 注册表 + 默认兜底(框架 §5.2 / §6.5T-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<string, BridgeHandler> = new Map<string, BridgeHandler>();
/** 默认兜底:空实现,永不抛错(对齐 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;
}
}
@@ -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;
}
@@ -0,0 +1,59 @@
/**
* 消息编解码(框架 §5.1T-M1-02)。与 lzyzsd/JsBridge 完全一致,是 H5 零改动命门。
*
* - `toJson`Message → JSON 字符串,仅含已定义字段(对齐 org.json `toJson` 省略 null)。
* - `escape`:复刻 Android `BridgeWebView.dispatchMessage` 的**两次正则转义**,使 JSON 能安全
* 嵌入 `_handleMessageFromNative('<here>')` 的单引号字符串。
* - `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<string, string> = {};
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;
}
}
@@ -0,0 +1,95 @@
/**
* Web 控制器抽象(框架 §5.6 / §5.5T-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<string, Object> = { 'script': script };
const ev: emitter.EventData = { data: payload };
emitter.emit(this.uiEventId, ev);
}
loadUrl(url: string): void {
const payload: Record<string, Object> = { '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 = [];
}
}