- module.json5:landscape → auto_rotation_landscape(横屏正/反向 180° 跟随重力 感应自动旋转,但不进入竖屏),build 校验取值合法、安装启动通过 - 同步契约 §7 与框架 §7.4:'不跟随传感器' 修正为 '横屏两朝向内跟随旋转' - H5 显式方向控制(含竖屏)仍由 setPreferredOrientation 保留,零改动不变 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
775 lines
54 KiB
Markdown
775 lines
54 KiB
Markdown
# TSGame HarmonyOS 应用框架设计与开发指南
|
||
|
||
> **配套文档**:本设计严格落地《[TSGame_原生与H5接口契约总规范](./TSGame_原生与H5接口契约总规范.md)》(下称《契约规范》)。**契约规范定义"对外必须一致的行为",本文定义"HarmonyOS 侧如何优雅、高效地实现它"。** 二者配合即可让现有 H5 **零改动**运行。
|
||
>
|
||
> **设计目标**:现代化(ArkTS / ArkUI 声明式 / Stage 模型)、高性能(Web 保活、并发解压下载、零主线程阻塞)、架构优雅(分层 + 依赖倒置 + 能力插件化)、专业成熟(清晰边界、可测试、可观测、可演进)。
|
||
>
|
||
> **目标平台**:HarmonyOS NEXT(API 12+,建议 API 17/23),ArkWeb(方舟 Web)组件。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [设计原则](#1-设计原则)
|
||
2. [Android → HarmonyOS 能力映射总表](#2-android--harmonyos-能力映射总表)
|
||
3. [总体架构(分层 + 模块)](#3-总体架构分层--模块)
|
||
4. [工程结构(模块化拆分)](#4-工程结构模块化拆分)
|
||
5. [核心一:JsBridge 桥引擎设计](#5-核心一jsbridge-桥引擎设计)
|
||
6. [核心二:能力插件框架(CapabilityProvider)](#6-核心二能力插件框架capabilityprovider)
|
||
7. [容器设计:双容器模型](#7-容器设计双容器模型)
|
||
8. [启动编排与配置/资源子系统](#8-启动编排与配置资源子系统)
|
||
9. [性能架构](#9-性能架构)
|
||
10. [横切关注点](#10-横切关注点)
|
||
11. [契约可追溯性矩阵](#11-契约可追溯性矩阵)
|
||
12. [实施路线图(里程碑)](#12-实施路线图里程碑)
|
||
13. [验收标准](#13-验收标准)
|
||
- [附录 A:ArkTS / ArkUI 实现适配清单](#附录-aarkts--arkui-实现适配清单)
|
||
- [附录 B:生产加固与合规清单](#附录-b生产加固与合规清单)
|
||
|
||
---
|
||
|
||
## 1. 设计原则
|
||
|
||
| 原则 | 说明 | 在本框架的体现 |
|
||
|---|---|---|
|
||
| **契约优先(Contract-First)** | H5 看到的协议是不可变契约,原生实现围绕契约展开 | 桥协议、handler 名、数据结构以《契约规范》为单一事实来源(SSOT),用 TS 类型固化 |
|
||
| **依赖倒置(DIP)** | 上层依赖抽象,不依赖具体平台实现 | 桥引擎依赖 `ICapability`/`IPlatformService` 接口,能力模块按需注入 |
|
||
| **能力插件化(Plugin)** | 每个原生能力是一个自注册插件 | `CapabilityProvider` 注册表,新增能力 = 新增一个 Provider,零侵入桥核心 |
|
||
| **单一职责 + 分层** | 桥、能力、配置、资源、平台服务各司其职 | 见 §3 分层 |
|
||
| **异步非阻塞** | IO/CPU 重活不上 UI 线程 | TaskPool/Worker 跑下载、解压、加解密;UI 线程只做编排与渲染 |
|
||
| **可观测(Observability)** | 全链路可日志、可埋点、可诊断 | 统一 `Logger` + `BridgeTracer`,每条桥消息可追踪 |
|
||
| **可演进** | 旧契约保留,新能力可叠加 | 契约层与实现层解耦;补齐项(QQ/抖音回调等)以新增 Provider 落地 |
|
||
|
||
---
|
||
|
||
## 2. Android → HarmonyOS 能力映射总表
|
||
|
||
> 这是把《契约规范》的 Android 机制翻译到 HarmonyOS 的"罗塞塔石碑",框架的所有实现都基于此映射。
|
||
|
||
| 契约机制(Android) | HarmonyOS ArkWeb 等价物 | 说明 |
|
||
|---|---|---|
|
||
| `WebView`(X5/BridgeWebView) | `Web({ src, controller })` + `webview.WebviewController` | 声明式组件,Controller 控制行为 |
|
||
| `shouldOverrideUrlLoading` 拦截 `yy://` | **`onLoadIntercept`**(返回 `true` 拦截) | 桥协议核心;`onLoadIntercept` 在 loadUrl 与 iframe 加载时均触发,正好匹配 H5 用 iframe.src 发起 `yy://` |
|
||
| `loadUrl("javascript:...")` / `evaluateJavascript` | **`controller.runJavaScript(script)`**(带 Promise 回调) | 原生→H5 下发 |
|
||
| `onPageFinished` 注入桥 JS | **`onPageEnd`** 中 `runJavaScript(桥JS)` | 注入 `WebViewJavascriptBridge.js` |
|
||
| `addJavascriptInterface(obj, name)`(@JavascriptInterface) | **`javaScriptProxy` 属性** 或 `controller.registerJavaScriptProxy()` | 仅通用网页容器 `settings` 用 |
|
||
| `onProgressChanged` | **`onProgressChange`** | 进度=100% 触发 `appservice`/`setPostUrl` |
|
||
| `onResume/onPause/onStop`(前后台) | `UIAbility.onForeground/onBackground` + 页面 `onPageShow/onPageHide` | 推送 `appservice` |
|
||
| WebSettings.setJavaScriptEnabled | `.javaScriptAccess(true)` | |
|
||
| setDomStorageEnabled | `.domStorageAccess(true)` | |
|
||
| setAllowFileAccess | `.fileAccess(true)`(默认开) | 访问本地文件系统 |
|
||
| setAllowUniversalAccessFromFileURLs(file:// 跨域) | **无直接等价属性**;用官方跨域方案:`onInterceptRequest`/`WebSchemeHandler` 自定义协议接管本地资源,或以自定义 scheme 加载首页 | 见 §7.3;这是 file:// 加载下 H5 发 XHR/fetch 取本地资源的关键,**M2 必须验证** |
|
||
| setMixedContentMode(ALWAYS_ALLOW) | `.mixedMode(MixedMode.All)` | |
|
||
| setCacheMode(LOAD_NO_CACHE) | `.cacheMode(CacheMode.None)` | 主容器禁缓存 |
|
||
| setGeolocationEnabled | `.geolocationAccess(true)` | |
|
||
| setSupportZoom(false) | `.zoomAccess(false)` | |
|
||
| setWebContentsDebuggingEnabled | `webview.WebviewController.setWebDebuggingAccess(true)` | 在 `aboutToAppear` 设置 |
|
||
| `startActivityForResult`/`setResult`(容器间回传) | 路由参数 + 回调 / `emitter` 事件 / `AppStorage` | 通用网页容器回传 `data`(结果码 101 语义)见 §7.2 |
|
||
| Intent extra | `router`/`Navigation` 参数对象 | |
|
||
| SharedPreferences | `@ohos.data.preferences`(KV) | `urlpath`/`upurlpath` 等 |
|
||
| 本机 HTTP server(截图上传) | **`@ohos.net.http` 是客户端、无内置服务端**;用 `@ohos.net.socket`(TCP) 自建轻量 HTTP 服务,或 NAPI 原生 server(如 cpp-httplib) | 监听本机端口,地址经 `setPostUrl` 告知 H5 |
|
||
| OkHttp | `@ohos.net.http` / `rcp`(Remote Communication Kit) | 远程配置、下载 |
|
||
| zip 解压 | `@ohos.zlib`(`decompressFile`) | TaskPool 内执行 |
|
||
| 友盟/Bugly | HUAWEI Analytics Kit / APM / Crash Service | 可观测,非契约 |
|
||
| 微信/QQ/抖音/高德 SDK | 各厂商 HarmonyOS SDK | 能力 Provider 内对接 |
|
||
|
||
---
|
||
|
||
## 3. 总体架构(分层 + 模块)
|
||
|
||
采用**自上而下五层 + 横切层**,依赖方向单向向下,跨层只依赖接口。
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────────────────┐
|
||
│ ① 应用/编排层 App & Orchestration │
|
||
│ EntryAbility · StartupOrchestrator · 路由(Navigation) · 全局DI容器 │
|
||
├────────────────────────────────────────────────────────────────────────┤
|
||
│ ② 容器层 Containers (ArkUI Pages) │
|
||
│ BridgeGameContainer(大厅/子游戏) │ GenericWebContainer(通用网页) │
|
||
├────────────────────────────────────────────────────────────────────────┤
|
||
│ ③ 桥引擎层 Bridge Engine ④ 能力层 Capabilities (插件) │
|
||
│ BridgeController · MessageCodec │ Share/Login/Pay/Location/Audio/ │
|
||
│ HandlerRegistry · QueueFlusher │ Shake/Device/Clipboard/Net/... │
|
||
│ SettingsProxy(通用容器) │ 每个 = 一个 CapabilityProvider │
|
||
├────────────────────────────────────────────────────────────────────────┤
|
||
│ ⑤ 领域服务层 Domain Services │
|
||
│ ConfigManager · ResourceManager · AppDataInjector · VersionResolver │
|
||
├────────────────────────────────────────────────────────────────────────┤
|
||
│ ⑥ 平台服务层 Platform Services (对 SDK 的薄封装) │
|
||
│ HttpClient · Downloader · Unzipper · KvStore · PermissionGuard · │
|
||
│ LocalUploadServer · FileSystem · TaskScheduler(TaskPool/Worker) │
|
||
└────────────────────────────────────────────────────────────────────────┘
|
||
横切层 Cross-cutting: Logger/Tracer · ErrorCenter · EventBus · DIContainer · Contracts(TS类型)
|
||
```
|
||
|
||
**关键约束**:
|
||
- 桥引擎层(③)**不认识任何具体能力**,只持有 `HandlerRegistry`;能力层(④)启动时把自己的 handler 注册进去 → **桥核心对能力数量零感知**。
|
||
- 能力层(④)通过平台服务层(⑥)使用系统/厂商 SDK,**绝不**直接被容器层调用(保持单向)。
|
||
- `Contracts` 横切层用 TypeScript `interface`/`enum`/常量固化《契约规范》的 handler 名与数据结构,**全工程唯一来源**。
|
||
|
||
---
|
||
|
||
## 4. 工程结构(模块化拆分)
|
||
|
||
按 HarmonyOS **HAR/HSP 多模块**组织,强边界、可独立编译、可并行开发:
|
||
|
||
```
|
||
TSGameHarmony/
|
||
├── entry/ # 入口 HAP(应用/编排层 ①②)
|
||
│ └── src/main/ets/
|
||
│ ├── entryability/EntryAbility.ets
|
||
│ ├── startup/StartupOrchestrator.ets
|
||
│ ├── pages/
|
||
│ │ ├── SplashPage.ets # 启动引导(对应 weclomeactivity1)
|
||
│ │ ├── BridgeGameContainer.ets # 大厅/子游戏容器
|
||
│ │ └── GenericWebContainer.ets # 通用网页容器(对应 openwebActivity1)
|
||
│ └── di/AppModule.ets # 组装根:把各 Provider 注入桥
|
||
│
|
||
├── feature_bridge/ (HAR) # 桥引擎层 ③ —— 与业务解耦的可复用桥
|
||
│ └── ets/
|
||
│ ├── BridgeController.ets
|
||
│ ├── MessageCodec.ets · Message.ets
|
||
│ ├── HandlerRegistry.ets
|
||
│ ├── SettingsProxy.ets # 通用容器 @javaScriptProxy 对象
|
||
│ └── assets/WebViewJavascriptBridge.js # 直接复用 lzyzsd 原文件
|
||
│
|
||
├── feature_capabilities/ (HAR) # 能力层 ④ —— 每个能力一个文件夹
|
||
│ └── ets/
|
||
│ ├── CapabilityProvider.ets # 插件接口 + 注册表
|
||
│ ├── share/ login/ pay/ location/ audio/ shake/
|
||
│ ├── device/ clipboard/ network/ vibrate/ scan/ camera/ photo/ room/
|
||
│ └── index.ets # 汇总导出 provideAll()
|
||
│
|
||
├── domain_resource/ (HAR) # 领域服务层 ⑤
|
||
│ └── ets/ ConfigManager · ResourceManager · AppDataInjector · VersionResolver
|
||
│
|
||
├── platform/ (HAR) # 平台服务层 ⑥
|
||
│ └── ets/ HttpClient · Downloader · Unzipper · KvStore · PermissionGuard ·
|
||
│ LocalUploadServer · FileSystem · TaskScheduler
|
||
│
|
||
├── contracts/ (HAR) # 横切:契约类型(SSOT)
|
||
│ └── ets/ Handlers.ets(枚举所有handler名) · dto/*.ets(数据结构) · Errors.ets
|
||
│
|
||
└── common/ (HAR) # 横切:Logger/Tracer/EventBus/DI/Result<T>
|
||
```
|
||
|
||
> **为何这样切**:`feature_bridge` 与 `contracts` 不含任何业务,可被未来其他 H5 壳复用;`feature_capabilities` 可按渠道裁剪(如海外版去掉微信支付);`domain_resource`/`platform` 可单测。
|
||
|
||
---
|
||
|
||
## 5. 核心一:JsBridge 桥引擎设计
|
||
|
||
> 这是"H5 零改动"的命门。目标:**100% 复刻 lzyzsd/JsBridge 协议**(《契约规范》§2),但用 ArkTS 优雅实现。
|
||
|
||
### 5.1 协议落地(与契约逐条对应)
|
||
|
||
| 协议要素 | 实现 |
|
||
|---|---|
|
||
| 注入 `WebViewJavascriptBridge.js` | 在 `Web().onPageEnd` 回调里 `controller.runJavaScript(bridgeJs)`;JS 文件**直接复用库原文件**,保证 `window.WebViewJavascriptBridge` API 与事件 `WebViewJavascriptBridgeReady` 完全一致 |
|
||
| H5→原生(`yy://`) | `Web().onLoadIntercept`(拦截范围含 **iframe 导航**,正好匹配 H5 用 `iframe.src='yy://…'` 发起调用)中:`URLDecode` 后,`yy://return/` 前缀→`handleReturnData`(回执/队列);其余 `yy://` 前缀→`flushMessageQueue()`;返回 `true` 拦截,其余返回 `false` 放行 |
|
||
| 取队列 `_fetchQueue()` | ⚠️ **队列不靠 `runJavaScript` 返回值取回**。`flushMessageQueue` 先以函数名 `_fetchQueue` 预登记回调,再 `runJavaScript("WebViewJavascriptBridge._fetchQueue();")`(不读返回值);H5 内 `_fetchQueue` 把 `iframe.src` 置为 `yy://return/_fetchQueue/<队列JSON>`,**再次经 `onLoadIntercept` → `handleReturnData`** 路由到该回调。与 Android `BridgeWebView` 实现完全一致 |
|
||
| 原生→H5 单条 | `controller.runJavaScript("WebViewJavascriptBridge._handleMessageFromNative('"+json+"');")` |
|
||
| Message 结构 | `{handlerName, data, callbackId, responseId, responseData}`(§2.4),`MessageCodec` 负责与 lzyzsd 完全一致的转义(注意原库对 `\` 与 `"` 的二次转义) |
|
||
| callbackId 生成 | `JAVA_CB_<自增>_<时间戳>`(格式可自定,H5 只原样回带) |
|
||
| 启动消息队列 | 页面 `onPageEnd` 前 native 若 callHandler,先入 `startupMessage` 队列,注入桥 JS 后补发(§2.6) |
|
||
|
||
> ⚠️ **M1 必须先验证的高风险点**:不同 ArkWeb 版本对**自定义 scheme(`yy://`)的 iframe 导航**是否稳定触发 `onLoadIntercept` 存在差异。M1 桥引擎自测时,**首先**用最小回显 H5 确认 `yy://` 能被 `onLoadIntercept` 捕获;若个别版本不触发,回退方案优先级:① `onInterceptRequest` + `WebSchemeHandler`(注册自定义 scheme,能力更强、可取 POST 体);② 极端情况下改桥协议为 `javaScriptProxy` 注入一个 `_bridgeNative.postMessage(json)` 同步方法替代 `yy://` 通道(**此法需同步改写注入的 `WebViewJavascriptBridge.js` 的 `_doSend`/`_fetchQueue`,但对 H5 仍透明,`window.WebViewJavascriptBridge` 对外 API 不变**)。三种方案对 H5 均零改动。|
|
||
|
||
### 5.2 BridgeController(桥控制器,每个 Bridge 容器持有一个)
|
||
|
||
职责:管 WebviewController、URL 拦截分发、handler 注册、出入站消息编解码与回调表。**对外暴露 `registerHandler` / `callHandler`,与 Android 端 API 同名同义**。
|
||
|
||
```typescript
|
||
// feature_bridge —— 设计级伪代码(ArkTS 风格,省略 import)
|
||
export type BridgeHandler = (data: string, callback: (resp: string) => void) => void;
|
||
|
||
export class BridgeController {
|
||
private controller: webview.WebviewController;
|
||
private registry: HandlerRegistry; // 注入:能力层填充
|
||
private responseCallbacks = new Map<string, (d: string)=>void>();
|
||
private startupMessages: Message[] | null = []; // 页面就绪前的积压
|
||
private uniqueId = 0;
|
||
|
||
// —— H5 → 原生:在 Web().onLoadIntercept 调用 ——
|
||
onLoadIntercept(url: string): boolean {
|
||
const u = decodeURIComponent(url);
|
||
if (u.startsWith('yy://return/')) { this.handleReturnData(u); return true; }
|
||
if (u.startsWith('yy://')) { this.flushMessageQueue(); return true; }
|
||
return false; // 正常导航
|
||
}
|
||
|
||
// —— 原生 → H5:能力层/容器调用 ——
|
||
callHandler(name: string, data: string, cb?: (resp: string)=>void): void {
|
||
const m = new Message(); m.handlerName = name; m.data = data;
|
||
if (cb) { const id = `JAVA_CB_${++this.uniqueId}_${SystemClock.now()}`;
|
||
this.responseCallbacks.set(id, cb); m.callbackId = id; }
|
||
this.queue(m);
|
||
}
|
||
|
||
registerHandler(name: string, h: BridgeHandler): void { this.registry.put(name, h); }
|
||
|
||
// —— 页面就绪:在 Web().onPageEnd 调用 ——
|
||
onPageEnd(): void {
|
||
this.controller.runJavaScript(BRIDGE_JS); // 注入桥
|
||
if (this.startupMessages) { this.startupMessages.forEach(m => this.dispatch(m));
|
||
this.startupMessages = null; }
|
||
}
|
||
|
||
// —— ⚠️ 关键:触发 H5 交出待发队列。注意队列【不是】靠 runJavaScript 返回值取回,
|
||
// 而是 _fetchQueue() 在 H5 内把 iframe.src 置为 'yy://return/_fetchQueue/<data>',
|
||
// 再次被 onLoadIntercept 捕获 → handleReturnData 路由到这里注册的 '_fetchQueue' 回调。
|
||
private flushMessageQueue(): void {
|
||
// 以函数名 '_fetchQueue' 作为 key 预登记回调(与 callbackId 共用同一张 responseCallbacks 表)
|
||
this.responseCallbacks.set('_fetchQueue', (queueJson: string) => {
|
||
const list = MessageCodec.toArray(queueJson); // H5 待发消息数组
|
||
for (const m of list) {
|
||
if (m.responseId) { // 是 H5 对"原生 callHandler"的回执
|
||
this.responseCallbacks.get(m.responseId)?.(m.responseData);
|
||
this.responseCallbacks.delete(m.responseId);
|
||
} else { // 是 H5 主动 callHandler
|
||
const respFn = m.callbackId
|
||
? (d: string) => this.queue(Message.response(m.callbackId!, d))
|
||
: (_: string) => {};
|
||
const handler = this.registry.get(m.handlerName) ?? this.registry.default();
|
||
handler(m.data, respFn); // ← 分发到能力层
|
||
}
|
||
}
|
||
});
|
||
// 仅触发,不读取返回值
|
||
this.controller.runJavaScript('WebViewJavascriptBridge._fetchQueue();');
|
||
}
|
||
|
||
// —— H5 → 原生 的回执通道:yy://return/<functionName>/<data> ——
|
||
// 既处理 _fetchQueue(队列回传),也处理普通 callbackId 回执
|
||
private handleReturnData(url: string): void {
|
||
const fn = parseFunctionFromReturnUrl(url); // 如 '_fetchQueue' 或 'JAVA_CB_x_y'
|
||
const data = parseDataFromReturnUrl(url);
|
||
const cb = this.responseCallbacks.get(fn);
|
||
if (cb) { cb(data); this.responseCallbacks.delete(fn); }
|
||
}
|
||
|
||
private queue(m: Message): void {
|
||
if (this.startupMessages) this.startupMessages.push(m); else this.dispatch(m);
|
||
}
|
||
private dispatch(m: Message): void {
|
||
const json = MessageCodec.escape(m.toJson());
|
||
this.controller.runJavaScript(`WebViewJavascriptBridge._handleMessageFromNative('${json}');`);
|
||
}
|
||
}
|
||
```
|
||
|
||
> 设计要点:
|
||
> - `BridgeController` **完全不 import 任何能力**;能力通过 `registry` 注入。桥引擎成为独立 HAR,可复用、可单测。
|
||
> - **务必忠实复刻"队列经 `yy://return/_fetchQueue/` 回传"的机制**,不要图省事改读 `runJavaScript` 的返回值——除非你同时改写注入的 `WebViewJavascriptBridge.js`(不推荐,破坏与成熟协议的一致性,易在边界场景出错)。`responseCallbacks` 一张表同时承载"函数名 key(`_fetchQueue`)"与"callbackId key(`JAVA_CB_*`)"两类回执,与 Android 端实现完全一致。
|
||
> - 上文 `this.controller: webview.WebviewController` 仅为示意。**为可测试性,桥应依赖抽象 `IWebController`(见 §5.6),由容器注入真实 `WebviewController` 适配器**,单测时注入 mock。
|
||
> - **必须设置 `DefaultHandler`(空实现)**:`flushMessageQueue` 分发时,`handler = registry.get(name) ?? registry.default()`。任何**未注册**的 handler 名都落到默认空实现而**不抛错**(对齐 Android `BridgeWebView.defaultHandler`)。这是"H5 调用永不报错"的最后防线,也是暂缓能力(§6.5)的安全网底座。
|
||
|
||
### 5.5 线程模型(P0,必须遵守)⚠️
|
||
|
||
> Android 原版 `BridgeWebView.dispatchMessage` 明确要求**只有在主线程才下发消息**(源码 `Thread.currentThread()==Looper.getMainLooper()` 才 `loadUrl`)。HarmonyOS 同理:**`runJavaScript` 只能在 UI(主)线程调用**。而能力回调(定位 `locationChange`、支付 SDK 回调、传感器、各类系统广播、TaskPool 完成)**经常发生在非 UI 线程**,若直接 `callHandler` → `runJavaScript` 会抛异常或行为异常。
|
||
|
||
**强制规则**:
|
||
1. **所有出站下发(`dispatch`/`runJavaScript`)必须在 UI 线程执行**。`BridgeController` 内部对 `dispatch` 做线程守卫:非 UI 线程则投递到 UI 线程任务队列。
|
||
2. **入站分发(`flushMessageQueue` 触发的 handler 回调)默认就在 UI 线程**(`onLoadIntercept` 在 UI 线程回调),handler 内若开了子线程做重活,回主线程再 `callback`/`callHandler`。
|
||
3. 能力 Provider 的异步事件回传统一经 `bridge.callHandler`,由桥内部保证线程切换,**Provider 不需自己关心线程**。
|
||
|
||
```typescript
|
||
// BridgeController 内:UI 线程守卫(示意)
|
||
private uiContext: UIContext; // 由容器在 aboutToAppear/onPageShow 注入
|
||
private dispatch(m: Message): void {
|
||
const run = () => {
|
||
const json = MessageCodec.escape(m.toJson());
|
||
this.controller.runJavaScript(`WebViewJavascriptBridge._handleMessageFromNative('${json}');`);
|
||
};
|
||
if (isOnUiThread()) run();
|
||
else this.uiContext.runScopedTask(run); // 或 emitter/AppStorage 投递回 UI 线程
|
||
}
|
||
```
|
||
|
||
> 实现方式可选:① UIContext 的 UI 线程任务;② `@ohos.events.emitter` 在 UI 线程订阅、能力线程 emit;③ `AppStorage`/`LocalStorage` 状态驱动。无论哪种,**对外保证:能力随便在哪个线程 `callHandler`,最终都在 UI 线程 `runJavaScript`**。
|
||
|
||
### 5.6 IWebController 抽象(P2,支撑单测)
|
||
|
||
桥不直接绑死系统类,依赖最小接口,便于 mock 与未来替换内核:
|
||
|
||
```typescript
|
||
export interface IWebController { // 桥只用到这几个能力
|
||
runJavaScript(script: string): void;
|
||
runJavaScript(script: string, cb: (err: Error|null, ret: string)=>void): void;
|
||
loadUrl(url: string): void;
|
||
}
|
||
// 生产:WebviewControllerAdapter implements IWebController(包一层 webview.WebviewController)
|
||
// 测试:FakeWebController implements IWebController(记录脚本、模拟 yy:// 回执)
|
||
```
|
||
> 这样 `feature_bridge` HAR 不 import `@kit.ArkWeb` 的具体类,`BridgeController`/`MessageCodec`/`VersionResolver` 均可纯逻辑单测,§13 的 ≥80% 覆盖率目标方可达成。
|
||
|
||
### 5.3 同步返回 vs 异步推送
|
||
|
||
《契约规范》区分两类返回,框架用同一套 `callback` 表达:
|
||
- **同步返回**(如 `getTime`/`getnetwork`/`getlocationinfo` 主动取):handler 内立即 `callback(value)`。
|
||
- **异步推送**(如定位结果、支付回调):能力层在事件到达时调用 `bridge.callHandler('出站名', data)`。
|
||
|
||
### 5.4 数据载荷的"裸字符串 vs JSON"陷阱
|
||
|
||
`MessageCodec` 不擅自 JSON 化 data。能力层按《契约规范》§8/§9 **逐 handler** 决定:`getTime` 回裸串、`getwifiLevel` 回 JSON 串。`contracts` 层为每个 handler 声明 `payloadType: 'raw' | 'json'` 与 DTO,编译期约束,杜绝写错。
|
||
|
||
---
|
||
|
||
## 6. 核心二:能力插件框架(CapabilityProvider)
|
||
|
||
> 让"新增/裁剪一个原生能力"成为零侵入操作,是框架成熟度的体现。
|
||
|
||
### 6.1 插件接口
|
||
|
||
```typescript
|
||
export interface CapabilityProvider {
|
||
readonly name: string; // 如 'location'
|
||
// 把本能力的入站 handler 注册到桥;并持有 bridge 以便异步推送
|
||
register(bridge: BridgeController, ctx: CapabilityContext): void;
|
||
onForeground?(): void; onBackground?(): void; // 生命周期联动
|
||
onDestroy?(): void;
|
||
}
|
||
export interface CapabilityContext {
|
||
uiAbilityContext: common.UIAbilityContext;
|
||
config: ConfigManager; platform: PlatformServices; log: Logger;
|
||
}
|
||
```
|
||
|
||
### 6.2 注册表 + 组装根(DI)
|
||
|
||
```typescript
|
||
// entry/di/AppModule.ets —— 组装根:唯一知道"全部能力"的地方
|
||
export function buildCapabilities(): CapabilityProvider[] {
|
||
return [
|
||
new ShareProvider(), new LoginProvider(), new PayProvider(),
|
||
new LocationProvider(), new AudioProvider(), new ShakeProvider(),
|
||
new DeviceProvider(), new ClipboardProvider(), new NetworkProvider(),
|
||
new VibrateProvider(), new ScanProvider(), new CameraProvider(),
|
||
new PhotoProvider(), new NavProvider(), // OpenurlTitleData/SwitchOverGameData/backgameData
|
||
new AppSystemProvider(), // orientation/browser/finsh/openApplyDownloadpath/notification
|
||
// —— 本期暂不集成:用占位桩注册,保证 H5 调用不报错(见 §6.5),未来换回真实 Provider ——
|
||
new RoomStubProvider(), // 视频房(声网)暂缓 → 真实版 RoomProvider
|
||
new PayStubProvider(), // 支付暂缓 → 真实版 PayProvider
|
||
// 闲聊:无 H5 桥接口,无需注册(默认 handler 兜底)
|
||
];
|
||
}
|
||
// 容器初始化时:
|
||
capabilities.forEach(p => p.register(bridge, ctx));
|
||
```
|
||
|
||
#### 44 个入站 handler → Provider 归属(确保零孤儿、可逐条核对《契约规范》§8)
|
||
|
||
| Provider | 负责的入站 handler |
|
||
|---|---|
|
||
| `ShareProvider` | `friendsSharetypeUrlToptitleDescript` |
|
||
| `LoginProvider` | `accreditlogin` |
|
||
| `PayProvider` (本期=`PayStubProvider` 桩,§6.5) | `paybrowser`、`getGameplay`(旧版可选) |
|
||
| `LocationProvider` | `startlocation`、`getlocationinfo` |
|
||
| `AudioProvider` | `prepareaudio`、`mediaTypeAudio`、`srcIsloop`、`voicePlaying` |
|
||
| `ShakeProvider` | `startshake`、`SwitchShake`、`stopshake` |
|
||
| `DeviceProvider` | `getTime`、`getphonestate`、`getbattery`、`getwifiLevel`、`getcompareCode`、`getphoneInfo`、`getmarketname`、`getothername`、`getOther` |
|
||
| `ClipboardProvider` | `gameCopytext`、`gamepastetext` |
|
||
| `NetworkProvider` | `getnetwork` |
|
||
| `VibrateProvider` | `vibrator`、`repeatvibrator`、`canclevibrator` |
|
||
| `ScanProvider` | `opensaoma` |
|
||
| `CameraProvider` | `opencamera` |
|
||
| `PhotoProvider` | `getphoto` |
|
||
| `RoomProvider` (本期=`RoomStubProvider` 桩,§6.5) | `createRoom`、`exitRoom`、`getVideoinfo`、`DragViewvideoIsshow` |
|
||
| `NavProvider` | `OpenurlTitleData`、`SwitchOverGameData`、`getGameinstall`、`backgameData`(New) |
|
||
| `AppSystemProvider` | `orientation`、`browser`、`finsh`、`openApplyDownloadpath`、`notification`(空实现) |
|
||
|
||
> 合计 **44 个入站 handler 全部有且仅有一个归属**。出站 handler(§9)由对应 Provider 在事件到达时 `bridge.callHandler` 推送(如 `LocationProvider→getlocationinfo`、`PayProvider→PayuserPaytypePaystate`、`AudioProvider→getaudiourl/gameui_*`、`DeviceProvider→getBattery/getwifiLevel/phonestate`、`ScanProvider→getsaomaData`、`CameraProvider→getcameraaAddress`、`PhotoProvider→getphoto`、`ShareProvider→sharesuccess`、`LoginProvider→sharelogin`、`ShakeProvider→shakeEnd`、`NavProvider→getWebdata`、容器→`appservice/setPostUrl`)。
|
||
|
||
### 6.3 一个 Provider 的样板(以定位为例)
|
||
|
||
```typescript
|
||
export class LocationProvider implements CapabilityProvider {
|
||
readonly name = 'location';
|
||
private bridge!: BridgeController; private ctx!: CapabilityContext;
|
||
|
||
register(bridge: BridgeController, ctx: CapabilityContext): void {
|
||
this.bridge = bridge; this.ctx = ctx;
|
||
bridge.registerHandler(H.startlocation, (data, _cb) => this.start(data === '1'));
|
||
bridge.registerHandler(H.getlocationinfo, (_d, cb) => cb(this.lastOrError()));
|
||
}
|
||
private start(continuous: boolean): void {
|
||
geoLocationManager.on('locationChange', loc => {
|
||
this.bridge.callHandler(H.getlocationinfo, JSON.stringify(toMapLocationInfo(loc)));
|
||
if (!continuous) geoLocationManager.off('locationChange');
|
||
});
|
||
}
|
||
private lastOrError(): string { /* 返回 MaplocationInfo JSON 或 {errorCode:-1,...} */ }
|
||
}
|
||
```
|
||
|
||
> 同理:`ShareProvider`/`LoginProvider`/`PayProvider` 对接微信/QQ/抖音/支付 HarmonyOS SDK;`AudioProvider` 用 `AVPlayer`/`SoundPool`;`ScanProvider` 用 Scan Kit;`CameraProvider` 用 Camera Picker;`DeviceProvider` 用 deviceInfo/telephony/`@ohos.batteryInfo`/`@ohos.wifiManager`。**所有 handler 名、参数、回传结构严格照《契约规范》§8/§9/§12。**
|
||
>
|
||
> **`ShareProvider` 的"截图分享"内部流程**(契约 §10.1 的 `type=="2"`):`friendsSharetypeUrlToptitleDescript` 收到 `type=="2"` 时,由 `ShareProvider` 内部 `runJavaScript("...canvas.toDataURL...")` 取当前页 Canvas 的 base64(失败可回退原生截图),再经平台层 `LocalUploadServer`(其地址此前已由 `setPostUrl` 告知 H5)上传/或直接交分享 SDK。此为**内部实现**,不新增对外 handler,对 H5 透明。
|
||
|
||
### 6.4 补齐项以"新增 Provider/增强"落地(《契约规范》§14.2)
|
||
|
||
| 待补齐 | 设计方案 |
|
||
|---|---|
|
||
| QQ/抖音分享结果回传 | `ShareProvider` 统一在分享 SDK 回调里 `callHandler('sharesuccess', {success,type})`,补齐三端一致 |
|
||
| 抖音/快手登录 | 新增 `DouyinLoginProvider`,对接开放平台,**回传 code**(而非用户资料),由服务端换 token |
|
||
| 主动截图 | 新增入站 handler `getCanvasBase64`(向后兼容、H5 可选用),`runJavaScript` 调 `canvas.toDataURL` 取回 base64 |
|
||
| AppSecret/API_KEY 明文 | **下沉服务端**:登录走 code 模式,支付签名由服务端出,客户端不存密钥 |
|
||
|
||
### 6.5 暂不实现的能力:占位桩(No-op Stub),保证 H5 调用不报错/不卡死
|
||
|
||
> **范围**:本期**视频房**、**支付**、**闲聊**先不集成。但**接口必须"留空"**——即仍把对应 handler 注册进桥,只是实现为空操作。否则 H5 调用时行为不确定,且若 H5 传了 `responseCallback` 还可能**永久挂起**。
|
||
|
||
**两道安全网(缺一不可)**:
|
||
1. **桥默认 handler 兜底**:`BridgeController` 必须设置一个 `DefaultHandler`(空实现),任何**未注册**的 handler 名都路由到它,不抛错(对齐 Android `BridgeWebView.defaultHandler`)。这是最后防线。
|
||
2. **显式占位 Provider**:对"明确暂缓"的能力,提供 `StubProvider` 显式注册占位 handler——行为确定、可日志、可灰度开启,优于依赖默认兜底。
|
||
|
||
**占位桩的实现规则**:
|
||
- **void 型 handler**(H5 不期望同步返回):空实现(可选 `log`/轻提示"功能暂未开放"),**不要**调用任何 `callHandler` 出站。
|
||
- **有同步返回的 handler**(H5 传了 `responseCallback`):**必须回一个安全默认值**,否则 H5 卡死。如返回 `'0'`、空串 `''` 或合法空 JSON。
|
||
- **绝不**触发该能力的出站 handler(如不 `callHandler('getVideoinfo')`、不 `callHandler('PayuserPaytypePaystate')`),H5 收不到推送即按"无事发生"处理。
|
||
|
||
**本期占位清单**:
|
||
|
||
| 能力 | 占位 Provider | 入站 handler(注册为桩) | 桩行为 | 关联出站(**不触发**) |
|
||
|---|---|---|---|---|
|
||
| 视频房(声网) | `RoomStubProvider` | `createRoom`、`exitRoom`、`getVideoinfo`、`DragViewvideoIsshow` | 全部 void,空实现 + 日志 | `getVideoinfo`(uid 推送) |
|
||
| 支付 | `PayStubProvider` | `paybrowser`、`getGameplay`(旧) | void,空实现(可轻提示"支付暂未开放") | `PayuserPaytypePaystate` |
|
||
| 闲聊 | —— | **无 H5 接口**(《契约规范》§10.4:`sgapi` 是闲聊且全注释、无 `registerHandler`) | 无需桩;H5 若误调,走默认 handler 兜底 | —— |
|
||
|
||
```typescript
|
||
// 占位 Provider 样板:注册即留空,H5 调用安全无副作用
|
||
export class RoomStubProvider implements CapabilityProvider {
|
||
readonly name = 'room(stub)';
|
||
register(bridge: BridgeController, ctx: CapabilityContext): void {
|
||
const noop: BridgeHandler = (_d, _cb) => { ctx.log.i('room not integrated yet'); };
|
||
bridge.registerHandler(H.createRoom, noop);
|
||
bridge.registerHandler(H.exitRoom, noop);
|
||
bridge.registerHandler(H.getVideoinfo, noop);
|
||
bridge.registerHandler(H.DragViewvideoIsshow, noop);
|
||
}
|
||
}
|
||
export class PayStubProvider implements CapabilityProvider {
|
||
readonly name = 'pay(stub)';
|
||
register(bridge: BridgeController, ctx: CapabilityContext): void {
|
||
bridge.registerHandler(H.paybrowser, (_d, _cb) => ctx.log.i('pay not integrated yet')); // 可 Toast 提示
|
||
}
|
||
}
|
||
```
|
||
|
||
> **组装根切换**:`buildCapabilities()` 里把 `new RoomProvider()`/`new PayProvider()` 暂时替换为 `new RoomStubProvider()`/`new PayStubProvider()`。**契约 handler 名/数量不变**,未来集成时换回真实 Provider 即可,**对 H5 始终零改动、零报错**。
|
||
> **闲聊**:本就没有 H5 桥接口,无需任何桩;默认 handler 兜底足以保证任何误调用不报错。
|
||
|
||
---
|
||
|
||
## 7. 容器设计:双容器模型
|
||
|
||
《契约规范》明确有**两套**互不相同的 H5↔原生机制,框架对应**两类容器组件**。
|
||
|
||
### 7.1 BridgeGameContainer(大厅 + 子游戏)
|
||
|
||
对应 `webviewActivity`/`NewwebviewActivity`。基于桥协议。
|
||
|
||
```typescript
|
||
@Component
|
||
export struct BridgeGameContainer {
|
||
private controller = new webview.WebviewController();
|
||
private bridge = new BridgeController(this.controller);
|
||
|
||
// ⚠️ 仅 debug 包开启远程调试;release 包必须关闭(安全加固,见附录 B)
|
||
aboutToAppear(): void { if (BuildProfile.DEBUG) webview.WebviewController.setWebDebuggingAccess(true); }
|
||
|
||
build() {
|
||
Web({ src: this.entryUrl(), controller: this.controller })
|
||
.javaScriptAccess(true).domStorageAccess(true).fileAccess(true)
|
||
.mixedMode(MixedMode.All).cacheMode(CacheMode.None)
|
||
.geolocationAccess(true).zoomAccess(false)
|
||
.onControllerAttached(() => { /* 注入对象(本容器不需要)、设 UA(默认即可) */
|
||
buildCapabilities().forEach(p => p.register(this.bridge, ctx)); })
|
||
.onLoadIntercept((e) => this.bridge.onLoadIntercept(e.data.getRequestUrl()))
|
||
.onPageEnd(() => { this.bridge.onPageEnd(); }) // 仅注入桥 + 补发启动队列
|
||
.onProgressChange((e) => { if (e.newProgress === 100 && !this.firstDone) {
|
||
this.firstDone = true; // 首次到 100% 才推
|
||
this.bridge.callHandler('appservice', '1'); // 前台
|
||
this.bridge.callHandler('setPostUrl', uploadUrl()); } })
|
||
}
|
||
// 子游戏切换 SwitchOverGameData → 仅 controller.loadUrl(新目录 index.html?Launchtype=1)
|
||
}
|
||
```
|
||
|
||
> ⚠️ **时序铁律**:`app_data.js` 必须在本容器**加载页面之前**就由 `AppDataInjector` 写入大厅目录(见 §8.4,发生在 `StartupOrchestrator` 的 `INJECT_APP_DATA` 步、早于进入本容器)——因为 H5 首页用 `<script src="app_data.js">` 在**加载时同步读取**。**切勿**放到 `onPageEnd`/`onProgressChange` 里推,那时页面已加载完、H5 早已读过 `app_data.js`,为时已晚。`setPostUrl`/`appservice` 则按契约在首次进度 100% 时下发(与 Android `onProgressChanged==100` 一致)。
|
||
|
||
- **入口 URL**:`weburl` 空 → `file://<解压根>/gamehall/index.html?Launchtype=0`;非空 → `http://<weburl 去-换/>?Launchtype=0`(《契约规范》§3/§5)。
|
||
- **前后台**:`UIAbility.onForeground/onBackground` → `callHandler('appservice','1'|'2')`。
|
||
- **子游戏切换**(路径 A,`SwitchOverGameData`):同容器内 `controller.loadUrl()` 到目标目录,桥与 handler 不变。
|
||
|
||
### 7.2 GenericWebContainer(通用网页容器)
|
||
|
||
对应 `openwebActivity1`,**不挂桥**,用 `javaScriptProxy` 注入 `settings` 对象 + `runJavaScript` 直调。
|
||
|
||
```typescript
|
||
@Component
|
||
export struct GenericWebContainer {
|
||
private controller = new webview.WebviewController();
|
||
// settings:H5→原生 6 方法(《契约规范》§11.3)
|
||
private settings = new SettingsProxy(this.controller, /*onReturnData*/ (data)=> this.finishWith101(data));
|
||
|
||
build() {
|
||
Web({ src: this.params.url, controller: this.controller })
|
||
.javaScriptAccess(true).domStorageAccess(true).fileAccess(true)
|
||
.geolocationAccess(true).zoomAccess(false)
|
||
.cacheMode(this.online ? CacheMode.Default : CacheMode.Only) // 有网:按HTTP缓存策略(≈LOAD_DEFAULT);无网:只用缓存(≈LOAD_CACHE_ELSE_NETWORK)。非关键项
|
||
.javaScriptProxy({
|
||
object: this.settings, name: 'settings',
|
||
methodList: ['backgameData','loadurl','browser','finishweb','isexitdialogeshow','isbackfinishweb'],
|
||
controller: this.controller,
|
||
})
|
||
.onProgressChange((e) => { if (e.newProgress === 100 && !this.loaded) {
|
||
this.loaded = true;
|
||
this.controller.runJavaScript(`getWebdata('${this.params.data}')`); } }) // 原生→H5 直调
|
||
}
|
||
// 返回键:若 H5 调过 isbackfinishweb() → runJavaScript('gamebackkeydown()');退出确认 → runJavaScript('backgameData()')
|
||
private finishWith101(data: string) { /* 把 data 经路由/emitter 回传上层 Bridge 容器 → 上层 callHandler('getWebdata', data) */ }
|
||
}
|
||
```
|
||
|
||
- `settings` 的 6 方法、`getWebdata`/`gamebackkeydown`/`backgameData` 三个直调函数严格照《契约规范》§11.3。
|
||
- **回传上层**:用路由返回值或 `emitter` 事件把 `data` 投递回打开它的 Bridge 容器,由后者 `callHandler('getWebdata', data)`(复刻 Android 结果码 101 语义)。
|
||
|
||
### 7.3 file:// 跨域
|
||
|
||
H5 以 `file://` 加载并请求本地/远程资源。HarmonyOS 侧采用官方"Web 页面跨域解决方案":用 `onInterceptRequest` 自定义本地资源响应,或为本地页配置自定义协议/响应头,避免 file 同源限制。**对 H5 透明**。
|
||
|
||
### 7.4 屏幕方向(横屏项目)
|
||
|
||
本项目为**横屏项目**:应用**默认横屏、不跟随设备传感器自动旋转**;同时**遵守 H5 零改动铁律**,完整保留 H5 经方向接口显式切换(含竖屏)的能力,与原 Android 一致(《契约规范》§7 屏幕方向约束 / §8.4 / §11)。落地方式:
|
||
|
||
- **横屏自动旋转**:`EntryAbility` 在 `module.json5` 配 `"orientation": "auto_rotation_landscape"`(在横屏两朝向间跟随重力传感器自动旋转,但不进入竖屏)。
|
||
- **H5 动态切换(保留竖屏能力)**:方向接口收到请求时用窗口 API **动态覆盖**,不写死锁定——
|
||
- `AppSystemProvider` 的 `orientation` handler:`data === '1'` → 横屏,否则 → 竖屏(对齐 Android `setRequestedOrientation`);
|
||
- 子游戏 `SwitchOverGameData`:按 `webtype`(`"3"` 横 / `"2"` 竖)设方向后再 `loadUrl`;
|
||
- `GenericWebContainer`:按打开参数 `orientation`(`"1"` 竖 / 其他 横)设方向。
|
||
- **实现 API**:运行时 `window.Window.setPreferredOrientation(orientation: window.Orientation)` 动态切换,`window.Orientation` 取 `LANDSCAPE` / `PORTRAIT`(编码前用 `devecocli docs` 核对签名)。
|
||
- **要点**:`module.json5` 的 `auto_rotation_landscape` 提供"横屏内跟随传感器旋转(不进竖屏)",运行时 `setPreferredOrientation` 提供"H5 显式切换"——二者叠加既满足横屏项目定位,又守住 H5 零改动。
|
||
|
||
---
|
||
|
||
## 8. 启动编排与配置/资源子系统
|
||
|
||
### 8.1 StartupOrchestrator(启动状态机)
|
||
|
||
把《契约规范》§3 时序实现为**显式状态机**,每步可重试、可观测、可降级:
|
||
|
||
```
|
||
INIT → LOAD_LOCAL_CONFIG → REQUEST_PERMISSIONS → FETCH_REMOTE_CONFIG
|
||
→ RESOLVE_VERSION → [PREPARE_RESOURCE: 内置拷贝/zip下载解压] → INJECT_APP_DATA
|
||
→ ENTER_HALL (跳 BridgeGameContainer)
|
||
任一步失败 → FALLBACK(本地缓存) 或 BLOCK(showmessage 公告)
|
||
```
|
||
|
||
- `showmessage` 非空 → 阻断弹窗(§3.2)。
|
||
- 远程配置长度 <30 视为错误文案直接提示。
|
||
|
||
### 8.2 ConfigManager(配置)
|
||
|
||
- **本地配置**:弃用"目录名编码",改为随包内置 `resources/rawfile/app_config.json`(KV),提供《契约规范》§4.2 全部键:`agent/channel/gamedir/gamestart/appversion/market/gameid/weburl/gameconfig/other/tuiguang`。对 H5 无感知。
|
||
- **远程配置**:`HttpClient.get("http://"+gameconfig.replace(/-/g,'/')+".txt?a="+ts)`,禁缓存。
|
||
- **分层覆盖**:`VersionResolver` 实现 agent→channel→market→game 后层覆盖(§4.4),含 `agentlist`/`gamelist` 两棵树与二级 `url` 二次请求。用纯函数实现,便于单测。
|
||
|
||
### 8.3 ResourceManager(资源)
|
||
|
||
```
|
||
路径(@ohos.file.fs + context.filesDir):
|
||
解压根 urlpath = <filesDir>/tsgames/<bundle>/<时间戳>/<gamedir>
|
||
游戏目录 = <urlpath>/gamehall
|
||
入口 = file://<urlpath>/gamehall/index.html
|
||
KV 持久化 urlpath / upurlpath(@ohos.data.preferences)
|
||
流程:
|
||
首启 → 拷贝内置包(rawfile) → @ohos.zlib.decompressFile 解压
|
||
非首启 → 比较 version.xml<version value> 与远程 game_version
|
||
需更新 → Downloader 下载 game_download(?a=ts) → 删旧 → decompressFile → 收尾
|
||
```
|
||
|
||
- 下载、解压在 **TaskPool/Worker** 执行(§9),UI 线程只收进度回调。
|
||
- `version.xml` 解析用 `@ohos.xml`(XmlPullParser),取 `agent/game/channel/version`。
|
||
|
||
### 8.4 AppDataInjector
|
||
|
||
加载大厅前,在 `<urlpath>/gamehall/app_data.js` 写入《契约规范》§6 的全局变量(`app_version/app_agent/app_market/...`)。用 `fs` 写文件即可;**注意必须在 `Web` 加载该页面之前完成**(在 StartupOrchestrator 的 INJECT_APP_DATA 步,先于 ENTER_HALL)。
|
||
|
||
---
|
||
|
||
## 9. 性能架构
|
||
|
||
> "高性能"落在四个可量化抓手上。
|
||
|
||
| 抓手 | 设计 | 收益 |
|
||
|---|---|---|
|
||
| **Web 保活 / 预热** | 用 `NodeController` + `BuilderNode` 离屏预创建并保活 Web 组件;大厅↔子游戏切换走 `loadUrl` 而非重建组件;可在启动期预热一个空 Web 实例 | 子游戏进入<300ms,避免内核重启白屏 |
|
||
| **并发卸载重活** | 下载/解压/解密/MD5 校验放 **TaskPool**(短任务)或 **Worker**(长驻);UI 线程零阻塞 | 启动期不卡顿,进度流畅 |
|
||
| **JSBridge 批处理** | 沿用 lzyzsd 队列语义:多条出站消息可在一次 `flushMessageQueue` 内聚合;高频 handler(定位/电量)做节流 | 减少 `runJavaScript` 跨引擎调用次数 |
|
||
| **资源就绪即渲染** | 本地 `file://` 优先;首屏资源预解压;`onPageEnd` 后再注桥,避免阻塞首屏;图片/音频懒加载 | 首帧更快 |
|
||
|
||
补充:
|
||
- **内存**:容器 `aboutToDisappear` 解绑 Controller、`onRenderExited` 兜底重载(防渲染子进程崩溃白屏)。
|
||
- **冷启动**:`StartupOrchestrator` 并行化"权限申请"与"内置包拷贝"等无依赖步骤。
|
||
- **包体**:能力 Provider 按渠道裁剪(HSP 动态特性)。
|
||
|
||
### 9.1 TaskPool 并发的 Sendable 约束(P1,必须遵守)⚠️
|
||
|
||
HarmonyOS `TaskPool` 的 `@Concurrent` 任务**跨线程传参/返回值必须是 Sendable**(基本类型、`@Sendable` class、可序列化对象),**不能传**:闭包回调、`WebviewController`、`UIAbilityContext`、复杂业务对象。因此下载/解压的"进度回传"不能直接传 callback 进 TaskPool。落地范式:
|
||
|
||
```typescript
|
||
// 1) 重活在 @Concurrent 函数里跑,只接收/返回 Sendable 数据
|
||
@Concurrent
|
||
function unzipTask(zipPath: string, destDir: string): boolean { /* @ohos.zlib.decompressFile */ return true; }
|
||
|
||
// 2) 进度/完成经 emitter 回 UI 线程(不跨线程传函数)
|
||
@Concurrent
|
||
function downloadTask(url: string, savePath: string, eventId: number): void {
|
||
// 边下边 emitter.emit({eventId}, { data: percent }) ← 仅传 Sendable 数字
|
||
}
|
||
// UI 线程:emitter.on({eventId}, e => updateProgressUI(e.data.percent));
|
||
// taskpool.execute(downloadTask, url, savePath, eventId);
|
||
```
|
||
|
||
> 要点:**TaskPool 只搬 Sendable 数据,UI 反馈一律走 `emitter`/`AppStorage`**。这条不遵守,编译期/运行期必报错。`context.filesDir` 等路径应在 UI 线程取好后以字符串传入。
|
||
|
||
---
|
||
|
||
## 10. 横切关注点
|
||
|
||
| 关注点 | 设计 |
|
||
|---|---|
|
||
| **契约类型(SSOT)** | `contracts` HAR:`enum Handlers`(所有 handler 名常量,杜绝拼写漂移如 `finsh`/`getcameraaAddress`/`backgameData-`);每个 DTO 一个 interface(§12 结构);编译期保证 |
|
||
| **日志/追踪** | `Logger`(分级)+ `BridgeTracer`:每条桥消息打 `traceId`,可还原"H5调用→handler→回传"全链路 |
|
||
| **错误中心** | `Result<T>` 统一返回;`ErrorCenter` 收敛异常并按策略(弹窗/静默/上报) |
|
||
| **事件总线** | `EventBus`/`emitter`:前后台、网络变化、容器间回传等解耦 |
|
||
| **依赖注入** | 轻量 `DIContainer`(组装根 `AppModule` 集中装配),避免散落 new |
|
||
| **权限** | `PermissionGuard` 统一申请存储/电话/定位/相机/麦克风,对齐《契约规范》§13 |
|
||
| **安全** | 密钥下沉服务端;`file://` 跨域按官方安全实践;自定义协议白名单 |
|
||
| **可测试** | 桥引擎、VersionResolver、MessageCodec 纯逻辑可单测;能力 Provider 以 mock bridge 测注册与回传 |
|
||
|
||
---
|
||
|
||
## 11. 契约可追溯性矩阵
|
||
|
||
> 保证"设计覆盖契约 100%",无遗漏。
|
||
|
||
| 契约规范条目 | 落地模块 | 验收点 |
|
||
|---|---|---|
|
||
| §2 桥协议(yy://、_handleMessageFromNative、_fetchQueue、Message、注入时机) | `feature_bridge/BridgeController + MessageCodec + WebViewJavascriptBridge.js` | H5 `registerHandler/callHandler` 全部生效 |
|
||
| §8 入站 44 handler | `feature_capabilities/*Provider.register()` | 逐一对照名称/参数/同步返回 |
|
||
| §9 出站 ~21 handler | 各 Provider 在事件时 `bridge.callHandler` | 名称/data 结构/时机一致 |
|
||
| §11.1 路径 A `SwitchOverGameData` | `BridgeGameContainer` + `NavProvider` | 同容器 loadUrl 切换 |
|
||
| §11.2/§11.3 通用网页容器 | `GenericWebContainer + SettingsProxy` | `settings` 6 方法 + 3 直调函数 + 101 回传 |
|
||
| §3 启动时序 | `StartupOrchestrator` | 状态机逐步 |
|
||
| §4 配置/分层覆盖 | `ConfigManager + VersionResolver` | 两棵树 + 二级 url + 后层覆盖 |
|
||
| §5 资源管理 | `ResourceManager + Unzipper + Downloader` | 路径/版本比较/下载解压 |
|
||
| §6 app_data.js | `AppDataInjector` | 全局变量同名同义、加载前注入 |
|
||
| §7 WebView 能力 | `BridgeGameContainer` 属性 | JS/DOM/file/mixed/cache/geo |
|
||
| §10 设备能力 | 各 Provider | 分享/登录/支付/定位/音频/摇一摇/扫码/相机/震动/剪贴板/网络/电量/WiFi/电话 |
|
||
| §13 常量/权限 | `ConfigManager + PermissionGuard` | 渠道账号、权限齐备 |
|
||
| §14.2 补齐项 | 新增/增强 Provider | QQ/抖音回调、抖音登录、主动截图、密钥下沉 |
|
||
|
||
---
|
||
|
||
## 12. 实施路线图(里程碑)
|
||
|
||
| 阶段 | 交付 | 关键验收 |
|
||
|---|---|---|
|
||
| **M0 脚手架** | `devecocli create` 多模块工程、CI、签名 | 空壳可跑 |
|
||
| **M1 桥引擎(最高优先)** | `feature_bridge` + `IWebController` + 线程守卫 + 回显 handler;注入桥 JS | ① 最小回显 H5:`callHandler('getTime')` 能返回;**② 阻塞性验证:`yy://` 的 iframe 导航确被 `onLoadIntercept` 捕获**(否则立即切 §5.1 回退方案) |
|
||
| **M2 容器 + 启动 + 资源** | 双容器、StartupOrchestrator、Config/Resource/AppData | 真机加载现有大厅 H5 `index.html`,能进子游戏;**阻塞性验证:`file://` 加载下 H5 的 XHR/fetch 取本地资源可用**(否则启用 §7.3 自定义协议方案) |
|
||
| **M3 能力(本期范围)** | 分享/登录/定位/音频/摇一摇/扫码/相机/震动/剪贴板/网络/设备 等 Provider;**视频房、支付用占位桩(§6.5)**;闲聊不涉及 | 用真实 H5 跑通本期业务;**视频房/支付被调用时无报错、无卡死**(桩 + 默认 handler 兜底);逐条对照可追溯性矩阵 |
|
||
| **M4 通用网页容器** | `GenericWebContainer + SettingsProxy` | 活动页/收银台/客服打开、回传正常 |
|
||
| **M5 性能 & 加固** | Web 保活、TaskPool、密钥下沉、可观测 | 子游戏切换流畅、冷启动达标、安全审查通过 |
|
||
| **M6 暂缓能力集成(按需)** | 把 `RoomStubProvider`/`PayStubProvider` 换回真实 `RoomProvider`/`PayProvider`;如需再加抖音登录等补齐项 | 真实视频房/支付打通,**H5 仍零改动** |
|
||
|
||
> **强烈建议**:M1 桥引擎必须先用一个**最小回显 H5** 单独验证,再接真实 H5;桥不稳,一切白搭。**两个"阻塞性验证"(`yy://` 拦截、`file://` 跨域)必须在 M1/M2 通过**——它们是整个适配最底层的两个不确定性,越早证伪越好。
|
||
|
||
---
|
||
|
||
## 13. 验收标准
|
||
|
||
**功能验收(H5 零改动)**:
|
||
- [ ] 现有大厅 H5 与全部子游戏 H5 **一行不改**即可运行。
|
||
- [ ] 《契约规范》§8 全部入站 handler、§9 全部出站 handler 行为一致(名称/参数/数据结构/时机)。
|
||
- [ ] 通用网页容器 `settings` 6 方法 + 3 直调函数 + 101 回传一致。
|
||
- [ ] `app_data.js` 全局变量、入口 URL、`?Launchtype=` 参数一致。
|
||
- [ ] **暂缓能力(视频房 `createRoom`/`exitRoom`/`getVideoinfo`/`DragViewvideoIsshow`、支付 `paybrowser`/`getGameplay`)被 H5 调用时:不报错、不卡死、不误触发出站推送**(占位桩 + 默认 handler 兜底,§6.5)。
|
||
|
||
**质量验收**:
|
||
- [ ] 桥引擎、VersionResolver、MessageCodec 单测覆盖率 ≥80%。
|
||
- [ ] 冷启动到大厅首帧、子游戏切换时延达标(建议 <2s / <300ms,按机型基线)。
|
||
- [ ] 下载/解压全程不阻塞 UI;渲染崩溃可自恢复。
|
||
- [ ] 无客户端硬编码密钥;权限按需申请、合规。
|
||
- [ ] 全链路日志可追踪一条桥消息。
|
||
|
||
---
|
||
|
||
> **一句话总结**:以 `feature_bridge`(100% 复刻 lzyzsd 协议)为基石,能力以 `CapabilityProvider` 插件化挂载,配置/资源/启动以领域服务编排,双容器分别承载"桥协议"与"settings 注入"两套契约——这套现代化 ArkTS 分层框架能在不改任何 H5 的前提下,优雅、高效地承载 TSGame 全部业务,并为后续能力补齐与多渠道演进留足空间。
|
||
|
||
---
|
||
|
||
## 附录 A:ArkTS / ArkUI 实现适配清单
|
||
|
||
> 前文为"设计级"。本附录把设计落到 ArkTS/ArkUI 的**现实工程约束**上,照此即可从"设计就绪"过渡到"编码就绪"。
|
||
|
||
### A.1 ArkUI `@Component struct` 与桥/控制器的协作
|
||
- **持有方式**:`WebviewController` 与 `BridgeController` 作为 struct 的**普通成员变量**持有即可(非响应式,不要加 `@State`;它们的变化不应驱动 UI 重渲染)。需要驱动 UI 的状态(加载进度、loading 显隐)才用 `@State`/`@Local`。
|
||
- **注入时机**:`onControllerAttached` 回调里才能安全调用 Controller 相关接口;**能力注册(`provider.register`)放这里**。`aboutToAppear` 只做无 Controller 依赖的设置(如 debug 开关)。
|
||
- **生命周期解绑**:`aboutToDisappear` 中停止能力(`provider.onDestroy`)、清空 `responseCallbacks`/`registry`、解绑 Controller,防泄漏(对应 Android `onDestroy` 的 WebView 清理)。
|
||
- **前后台**:在 `UIAbility.onForeground/onBackground` 或页面 `onPageShow/onPageHide` 调 `provider.onForeground/onBackground` 并 `callHandler('appservice','1'|'2')`。
|
||
|
||
### A.2 ArkTS 严格语法注意
|
||
- 全量类型标注,**禁用 `any`**;DTO 用 `interface`,handler 名用 `enum`/`const`(`contracts` HAR)。
|
||
- 闭包/箭头函数可用,但**跨线程(TaskPool)的函数须 `@Concurrent` 且仅收发 Sendable**(见 §9.1)。
|
||
- 动态对象访问受限:解析 H5 传入 JSON 后,**显式映射到 DTO**,不要依赖结构化鸭子类型。
|
||
- 单例/DI:用模块级实例或轻量 `DIContainer`;避免在 struct 内散落 `new` 业务对象。
|
||
|
||
### A.3 线程与并发(呼应 §5.5 / §9.1)
|
||
| 场景 | 线程要求 | 手段 |
|
||
|---|---|---|
|
||
| `runJavaScript` 下发出站消息 | **必须 UI 线程** | `BridgeController.dispatch` 内置 UI 线程守卫 |
|
||
| 能力异步回调(定位/支付/传感器/广播) | 任意线程 → 切 UI | `emitter`/UIContext 任务回 UI 线程再 `callHandler` |
|
||
| 下载/解压/MD5/解密 | 子线程 | `TaskPool @Concurrent`,仅传 Sendable,进度经 `emitter` |
|
||
| 大文件 IO | 子线程 | `@ohos.file.fs` 异步 API |
|
||
|
||
### A.4 关键系统能力对照(编码备查)
|
||
| 用途 | 模块 |
|
||
|---|---|
|
||
| Web 组件/控制器 | `@kit.ArkWeb`(`webview.WebviewController`、`Web`) |
|
||
| 偏好存储(urlpath 等) | `@ohos.data.preferences` |
|
||
| 文件 IO | `@ohos.file.fs` |
|
||
| 解压 | `@ohos.zlib`(`decompressFile`) |
|
||
| XML(version.xml) | `@ohos.xml`(`XmlPullParser`) |
|
||
| 网络请求/下载 | `@ohos.net.http` 或 `@kit.RemoteCommunicationKit`(`rcp`) |
|
||
| 本机端口服务(截图上传) | `@ohos.net.socket`(TCP) 自建 / NAPI 原生 server |
|
||
| 并发 | `@ohos.taskpool` / `Worker` |
|
||
| 事件 | `@ohos.events.emitter` |
|
||
| 定位 | `@ohos.geoLocationManager` |
|
||
| 电量/WiFi/网络 | `@ohos.batteryInfo` / `@ohos.wifiManager` / `@kit.NetworkKit` |
|
||
| 设备/电话 | `@ohos.deviceInfo` / `@kit.TelephonyKit` |
|
||
| 扫码/相机 | Scan Kit / Camera Picker |
|
||
| 震动/传感器(摇一摇) | `@ohos.vibrator` / `@ohos.sensor` |
|
||
| 剪贴板 | `@ohos.pasteboard` |
|
||
| 音频 | `@kit.MediaKit`(`AVPlayer`/`SoundPool`) |
|
||
|
||
> 注:具体 API 名以目标 SDK 版本为准,编码前用 `devecocli docs` 核对签名。
|
||
|
||
---
|
||
|
||
## 附录 B:生产加固与合规清单
|
||
|
||
| 项 | 要求 |
|
||
|---|---|
|
||
| **远程调试** | `setWebDebuggingAccess(true)` **仅 debug 包**(`BuildProfile.DEBUG` 守卫);release 必关 |
|
||
| **密钥** | 微信 `AppSecret`/支付 `API_KEY` **不入客户端**;登录走 code、支付签名走服务端(《契约规范》§13) |
|
||
| **明文 HTTP** | 远程配置/资源走 http 需在 `module.json5` 网络安全配置中显式放行域名;逐步迁 https |
|
||
| **file:// 跨域** | 用官方方案(自定义协议/`onInterceptRequest`)而非放开全局,最小授权(§7.3) |
|
||
| **自定义 scheme 注册** | `gamepaywelcome` 等外部唤起在 `module.json5` 的 `abilities.skills.uris` 声明 |
|
||
| **权限合规** | 定位/相机/麦克风/电话**用时申请 + 文案说明**;高德等 SDK 隐私合规初始化(`updatePrivacyShow/Agree`)先于使用 |
|
||
| **渲染兜底** | `onRenderExited` 重载页面,防子进程崩溃白屏 |
|
||
| **数据安全** | KV/文件按需加密;日志脱敏(不打 openid/token 明文) |
|
||
| **上架审核** | 准备隐私声明、权限用途清单,符合 HarmonyOS 应用市场审核要求 |
|