diff --git a/docs/H5-Native-Implementation-Design.md b/docs/H5-Native-Implementation-Design.md index 31bfd21..b966e00 100644 --- a/docs/H5-Native-Implementation-Design.md +++ b/docs/H5-Native-Implementation-Design.md @@ -489,6 +489,172 @@ public enum OverlayBridge { 3 个 `WKScriptMessageHandler` 在 OverlayViewController 内注册即可。 +#### 3.4.1 大厅 / 子游戏的 window.settings 同步 getter polyfill(契约 §附录 A 9 项 getter) + +**背景**:契约 §附录 A 列出 28 项旧桥 JSExport 方法(iOS<9 路径),与 §3.1 异步 callback 主表去重后多出 **9 项 H5 同步只读 getter**: + +| getter | 返回类型 | 业务语义 | 数据源 | +|---|---|---|---| +| `getchannelName()` | string | 渠道 ID(11 项渠道注入之一)| `BundleConfig.shared.channel` | +| `getmarketname()` | string | 市场 ID(11 项渠道注入之一)| `BundleConfig.shared.market` | +| `getOther()` | string | 渠道 `other` 字段 | `BundleConfig.shared.other` | +| `getothername(name)` | string | 按 H5 传入 key 动态读 11 项渠道注入任意字段 | `BundleConfig.shared.value(forKey: name)` | +| `getcompareCode()` | int | 业务校验码(msext `RootVC.m` 沿用 zip 版本号或固定值) | 待原 msext 取值确认(Phase 2 实施时查 `RootVC.m:1560` 附近 `getcompareCode` 真实返回值,并对齐) | +| `getbattery()` | double | 当前电池电量 0.0–1.0 | `UIDevice.current.batteryLevel`(启动期 snapshot 一次) | +| `getnetwork()` | int | 当前网络类型(0 无 / 1 WiFi / 2 蜂窝) | `NWPathMonitor` 当前 path(loadFileURL 前 snapshot) | +| `getGameinstall(name)` | int | 子游戏目录是否存在(0/1) | 扫 `SandboxPaths.subGameRoot(name)` 后注入已安装列表 | +| `getGameplay(jsondata)` | void | 契约 §3.1 [19]**空实现**,H5 仍会调,原生 noop 即可 | — | + +**为什么不走 BridgeBus 异步 callback**:H5 端代码形式是 `var ch = window.settings.getchannelName()`、`var b = window.settings.getbattery()` 等**同步表达式**(取值后立即用于业务判断),WKWebView 时代 native 无法同步返回 JS 值(异步 evaluateJavaScript 改不了 H5 端代码 → 违反契约原则 A)。唯一不破契约的实现路径:**WebView 加载前在 `documentStart` 注入完整数据快照 + 同步 JS getter polyfill**,本地查询零延迟。 + +**数据快照时机**: +- 静态字段(渠道 / market / other / appVersion 等 11 项渠道注入):app 启动期读 `ChannelConfig.plist` 后即不变,全程一次即可 +- 动态字段(getbattery / getnetwork):**每次 `loadFileURL` 前重新 snapshot 注入**(精度足够,原 msext 自身也只在 `viewDidLoad` 取一次,H5 业务里"启动时刻电量值"被复用整个会话) +- 已安装子游戏列表(getGameinstall):每次 loadFileURL 前扫描沙盒 + 注入 → SwitchOverGameData 解压新子游戏后自然在下次 loadFileURL 刷新 + +**实现骨架**: + +```swift +// Source/WebView/SettingsBridgePolyfill.swift +// +// 把契约 §附录 A 的 9 项 H5 同步 getter 实现为 documentStart 注入的 JS polyfill, +// 数据全部本地查询、零延迟,与原 msext JSExport 同步行为等价。 +// +// 注入流程: +// WebContainerViewController.loadFileURL(lobbyIndex) 调用前 +// → SettingsBridgePolyfill.makeUserScript(BundleConfig + DeviceSnapshot + InstalledGames) +// → BridgedWebView 把 WKUserScript 加到 WKUserContentController +// → loadFileURL → H5 一加载即可同步读 window.settings.getXxx() + +@MainActor +public enum SettingsBridgePolyfill { + + /// 构造一段 documentStart 注入的 JS。data 是 Native 端拼好的快照。 + public static func makeUserScript(snapshot: Snapshot) -> WKUserScript { + let json = (try? JSONSerialization.data(withJSONObject: snapshot.jsonObject)) + .flatMap { String(data: $0, encoding: .utf8) } + ?? "{}" + let source = """ + (function() { + window.__nativeSnapshot = \(json); + window.settings = window.settings || {}; + + // ── 11 项渠道注入(静态,启动期一次性快照)──────────── + window.settings.getchannelName = function() { return window.__nativeSnapshot.channel || ""; }; + window.settings.getmarketname = function() { return window.__nativeSnapshot.market || ""; }; + window.settings.getOther = function() { return window.__nativeSnapshot.other || ""; }; + window.settings.getothername = function(name) { + if (!name) return ""; + return (window.__nativeSnapshot.channelConfig || {})[name] || ""; + }; + + // ── 业务校验码 + 设备动态字段(每次 loadFileURL 前刷新)── + window.settings.getcompareCode = function() { return window.__nativeSnapshot.compareCode || 0; }; + window.settings.getbattery = function() { return window.__nativeSnapshot.battery || 0.0; }; + window.settings.getnetwork = function() { return window.__nativeSnapshot.network || 0; }; + + // ── 子游戏安装查询(每次 loadFileURL 前快照已安装列表)─ + window.settings.getGameinstall = function(name) { + if (!name) return 0; + var list = window.__nativeSnapshot.installedGames || []; + return list.indexOf(name) >= 0 ? 1 : 0; + }; + + // ── 已知空实现(契约 §3.1 [19],H5 仍会调)──────────── + window.settings.getGameplay = function(_jsondata) { /* no-op */ }; + })(); + """ + return WKUserScript(source: source, + injectionTime: .atDocumentStart, + forMainFrameOnly: true) + } + + public struct Snapshot: Sendable { + public let channelConfig: [String: String] // 11 项渠道注入完整字典 + public let channel: String + public let market: String + public let other: String + public let compareCode: Int + public let battery: Double + public let network: Int // 0/1/2 + public let installedGames: [String] + + public var jsonObject: [String: Any] { + [ + "channelConfig": channelConfig, + "channel": channel, + "market": market, + "other": other, + "compareCode": compareCode, + "battery": battery, + "network": network, + "installedGames": installedGames + ] + } + + /// 从 BundleConfig + DeviceKit + SandboxPaths 拼装当前快照 + @MainActor + public static func make() -> Snapshot { + let bc = BundleConfig.shared + return Snapshot( + channelConfig: bc.asDictionary, + channel: bc.channel, + market: bc.market, + other: bc.other, + compareCode: CompareCodeProvider.current(), // 见下文 + battery: DeviceKit.batteryLevel(), + network: NetworkMonitor.shared.currentTypeCode, + installedGames: SandboxPaths.installedSubGames() + ) + } + } +} +``` + +**`WebContainerViewController` 接入点**: + +```swift +private func runBootPipelineSteps() async throws { + // ... ensureReady / fetch / resolve / upgrade ... + + // 6. loadFileURL 前注入 settings polyfill + splash.update(text: "加载大厅...", progress: nil) + let snapshot = SettingsBridgePolyfill.Snapshot.make() + bridgedWebView.installSettingsPolyfill(snapshot: snapshot) + bridgedWebView.webView.loadFileURL( + SandboxPaths.lobbyIndex, + allowingReadAccessTo: SandboxPaths.lobbyRoot + ) +} + +// Source/WebView/BridgedWebView.swift +extension BridgedWebView { + /// 在 contentController 重置后追加 polyfill UserScript, + /// 然后 loadFileURL 即可让 H5 在 documentStart 同步访问 window.settings.getXxx() + public func installSettingsPolyfill(snapshot: SettingsBridgePolyfill.Snapshot) { + let controller = webView.configuration.userContentController + // 注:WebViewJavascriptBridge.js 这条 atDocumentStart UserScript 在 init 时 + // 已加入并保持不变;这里只追加 settings polyfill,互不影响 + controller.addUserScript(SettingsBridgePolyfill.makeUserScript(snapshot: snapshot)) + } +} +``` + +**与原 msext 的差异**: + +| 维度 | msext 现状 | 新外壳决策 | +|------|----------|---------| +| JS↔Native 桥 | iOS 9+ JSContext + JSExport(同步原生返回值) | WKUserScript 注入 + 纯 JS polyfill(同步本地返回) | +| 数据传递 | JS 每次调用 selector → 进 ObjC runtime → 返回 | 启动期一次性 snapshot 注入 JS 全局,业务期纯 JS 查询 | +| 渠道注入读取 | `[FuncPublic getFilePath:@"other" PathType:3]` 扫目录 | 直接读 `BundleConfig.shared.channelConfig` 字典 | +| 性能 | 每次 H5 调用都有 JSContext 跨域开销(µs 级) | 业务期纯 JS 查询(ns 级) | +| 跨容器一致性 | 大厅 / 子游戏 / 弹层各自暴露 selector,要同步维护 | 同一份 `SettingsBridgePolyfill` 多处复用,单点定义 | + +**注意事项**: +- `compareCode` 业务语义需对照 msext `RootVC.m:1560` 附近 `getcompareCode` 真实返回逻辑(Phase 2 实施前查清);当前 Design 暂列字段,实现时补真值 +- 验收:契约 §10 验收清单里所有"H5 同步取渠道值 / 设备状态"的项目(如 `window.settings.getchannelName() === channel注入值`)通过即视为契约等价 +- 子游戏容器(SubGame WebContainer)也走同一份 polyfill,仅 `installedGames` 字段在子游戏内意义不同(一般不会再调 getGameinstall) + ### 3.5 性能优化 - **ProcessPool 复用**:大厅、子游戏、弹层共用同一个 `WKProcessPool`,Cookie/Cache 共享,避免重复初始化(800 ms → 50 ms)