From 82bad8a85db12ff3d1bebc20ba9afb758d77c887 Mon Sep 17 00:00:00 2001 From: joywayer Date: Mon, 22 Jun 2026 07:24:01 +0800 Subject: [PATCH] =?UTF-8?q?Design=20=C2=A73.4.1=EF=BC=9A=E8=A1=A5=20window?= =?UTF-8?q?.settings=20=E5=90=8C=E6=AD=A5=20getter=20polyfill=EF=BC=889=20?= =?UTF-8?q?=E9=A1=B9=E5=90=AB=20getothername=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audit 第二轮发现:Contract §附录 A 旧桥 JSExport 共 28 方法,与 §3.1 异步主桥去重后多出 9 项 H5 同步只读 getter。前一轮 audit 只覆盖 §3.1 主表的 22 项异步 handler,这 9 项 getter 在 Design 完全无覆盖: - getchannelName / getmarketname / getOther / getothername(name) - getcompareCode / getbattery / getnetwork - getGameinstall(name) / getGameplay(jsondata) 这些接口 H5 端形式是同步表达式(var ch = window.settings.getchannelName()), WKWebView 无法同步返回 JS 值。唯一不破契约(H5 端零修改)的路径: documentStart 注入数据快照 + 纯 JS getter polyfill,本地查询零延迟。 §3.4.1 新增覆盖: - 9 项 getter 业务语义 + 数据源映射表(驳到 BundleConfig / DeviceKit / SandboxPaths.installedSubGames) - 数据快照时机:静态字段(11 项渠道注入)启动期一次;动态字段 (battery / network / installedGames)每次 loadFileURL 前重新快照 - SettingsBridgePolyfill.makeUserScript 完整 JS 注入骨架 - WebContainerViewController 接入点(runBootPipelineSteps 末尾、 loadFileURL 前调 installSettingsPolyfill) - 与原 msext JSExport 5 维度差异表 待 Phase 2 实施时查清:getcompareCode 在 msext RootVC.m:1560 附近的真实 返回值语义(业务校验码,可能与 zip 版本号或固定值相关)。 Co-Authored-By: Claude Opus 4.7 --- docs/H5-Native-Implementation-Design.md | 166 ++++++++++++++++++++++++ 1 file changed, 166 insertions(+) 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)