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:
co-authored by
Claude Opus 4.8
parent
9280c00809
commit
206db9730a
+15
-1
@@ -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",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| 里程碑 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| **M0** 脚手架 + 契约类型 | ☑ 完成 | 7 任务全完成并真机/模拟器验证 |
|
||||
| **M1** 桥引擎 | ☐ 未开始 | 下一步;含 V1 阻塞性验证 |
|
||||
| **M1** 桥引擎 | ◐ 进行中 | 含 V1 阻塞性验证 |
|
||||
| **M2** 容器 + 启动 + 资源 | ☐ 未开始 | 含 V2 阻塞性验证 |
|
||||
| **M3** 本期能力 | ☐ 未开始 | — |
|
||||
| **M4** 通用网页容器 | ☐ 未开始 | — |
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<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.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<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.1,T-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.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<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 = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user