Design §7.5:补 H5 app_*.js 预注入文件机制(15 个变量),§3.4.1 polyfill 撤销
按 CLAUDE.md「先看原项目」规则深度审查 daoqi/msext NewRootVC.initJSdata /
gameController.initJSdata / AppDelegate 写入逻辑后发现:
**关键纠正**:H5 在 iOS 9+ 路径下的真实读值机制是 §4.2「写 app_*.js 文件
+ H5 <script src> 同步引入」,不是早期 commit 82bad8a 实现的
§3.4.1 window.settings.getXxx() polyfill(那是 §附录 A 旧桥 JSExport
路径,契约明示「新外壳如最低系统 ≥ iOS 14 可不实现」,本项目最低
iOS 15.6 → 无需实现)。
§3.4.1 处理:标题改为「⚠️ 撤销」+ 完整说明撤销原因;旧 polyfill 代码
骨架保留为 §3.4.1.legacy 作为数据源映射参考(不删除以保 git log 整洁,
也方便 Phase 2 实施者了解 9 项 getter 的等价数据来源)。
§7.5 新章节覆盖 15 个变量(用户列 14 + audit 发现 app_invitationcode):
- §7.5.1 完整映射表:变量名 / 大厅值 / 子游戏值 / 数据源 / 文件
- §7.5.2 4 个文件写入时机(一次性 vs 事件驱动)+ 为什么写文件不
evaluateJavaScript(保证 <script src> 同步引入时机)
- §7.5.3 AppDataWriter struct Swift 骨架(escape 4 种特殊字符避免
渠道字段含引号导致 H5 JS 解析错)
- §7.5.4 WebContainerViewController 接入点:loadFileURL 前 writeInitial
+ writeBattery + writeNetwork;持续监听 batteryLevelDidChange /
NWPathMonitor 触发增量重写
- §7.5.5 大厅 / 子游戏 / 弹层 4 维度差异(弹层不写 app_*)
- §7.5.6 与 msext 6 维度差异表(单一真相 / 转义 / 大小写严格)
- §7.5.7 §7.5 vs §3.4.1 vs §3.4 OverlayBridge vs §3.2 callback 四条
路径关系澄清
大小写严格契约:app_Launchtype(L 大写)/ app_getwifisignalLevel(wifi
小写、signal/Level 区分大小写)— 任何拼写错误会让 H5 业务读到 undefined。
至此 H5 端 JS 与原生通讯的完整 5 条路径全部覆盖:
1. WebViewJavascriptBridge 异步 callback(§5/§8/§3.1,22 项 handler)
2. Native→H5 反向 callback(§3.2,15 项)
3. app_*.js 文件预注入全局变量(§7.5,15 项)
4. 弹层 window.settings 同步 polyfill(§3.4,3 项 + §3.4.2 OpenurlTitleData)
5. WKUIDelegate alert / confirm(§3.7,2 项)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
33574a1cc2
commit
f68b0dba93
@@ -489,7 +489,19 @@ public enum OverlayBridge {
|
|||||||
|
|
||||||
3 个 `WKScriptMessageHandler` 在 OverlayViewController 内注册即可。
|
3 个 `WKScriptMessageHandler` 在 OverlayViewController 内注册即可。
|
||||||
|
|
||||||
#### 3.4.1 大厅 / 子游戏的 window.settings 同步 getter polyfill(契约 §附录 A 9 项 getter)
|
#### 3.4.1 ⚠️ 撤销:大厅 / 子游戏的 `window.settings.getXxx()` polyfill(无需实现)
|
||||||
|
|
||||||
|
> 本子节早期版本(commit `82bad8a`)曾设计 9 项同步 getter polyfill(`getchannelName` / `getmarketname` / `getothername` / `getbattery` / `getnetwork` / `getcompareCode` / `getGameinstall` / `getGameplay` / `getOther`),把 §附录 A 旧桥 JSExport 方法重新实现为 `window.settings.getXxx()` JS 函数。**经原项目深度审查后撤销**,理由:
|
||||||
|
>
|
||||||
|
> 1. **契约自身已明示可不实现**:Contract §3.3 标题"关于 iOS<9 旧桥的精确签名(**附录用,新外壳可不实现**)",§附录 A 同样声明"这是 iOS<9 路径使用的旧协议,新外壳如最低系统 ≥ iOS 14 可不实现"。本项目最低 iOS 15.6 → 不需要
|
||||||
|
> 2. **H5 端真实路径是 §4.2 的 `app_*.js` 文件预注入**:H5 业务代码通过 `<script src="app_data.js">` 同步引入预生成的 15 个 `app_xxx` 全局变量直接读值,**不调** `window.settings.getXxx()`。详见 **§7.5 H5 `app_*.js` 预注入文件机制**
|
||||||
|
> 3. **过度设计**:原 polyfill 把数据快照塞进 `window.__nativeSnapshot` 全局对象 + 写一遍 getter 函数,实际工作量比直接写 `var app_xxx = ...` 文件大;同时引入了"两条路径都存在但 H5 只读其中一条"的混淆
|
||||||
|
>
|
||||||
|
> **保留的部分**:§3.4 头部的 `OverlayBridge.polyfill`(3 项 `backgameData` / `browser` / `finishweb`)是**弹层 threeView 内**的契约硬约束(Contract §3.4 明示"与系统版本无关,iOS 9+ 也走这条"),与本子节撤销的旧桥 getter polyfill 不是同一回事,继续保留。
|
||||||
|
>
|
||||||
|
> Phase 2+ 实施时直接按 §7.5 落地 `app_*.js` 文件预写,本子节内容**不要照搬**。
|
||||||
|
|
||||||
|
#### 3.4.1.legacy 9 项 getter 的等价数据来源(仅供 §7.5 映射参考)
|
||||||
|
|
||||||
**背景**:契约 §附录 A 列出 28 项旧桥 JSExport 方法(iOS<9 路径),与 §3.1 异步 callback 主表去重后多出 **9 项 H5 同步只读 getter**:
|
**背景**:契约 §附录 A 列出 28 项旧桥 JSExport 方法(iOS<9 路径),与 §3.1 异步 callback 主表去重后多出 **9 项 H5 同步只读 getter**:
|
||||||
|
|
||||||
@@ -1956,6 +1968,223 @@ for channel in xianliao 360 qq tencent; do
|
|||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 7.5 H5 `app_*.js` 预注入文件机制(契约 §4.2 + 原项目全面审查)
|
||||||
|
|
||||||
|
**用途**:H5 业务代码通过 `<script src="app_data.js">` / `<script src="app_battery.js">` / `<script src="app_network.js">` / `<script src="app_gamesname.js">` 等同步 `<script>` 引入预先写好的 4 个 `.js` 文件,文件内是若干 `var app_xxx = "..."` 全局变量声明。**这是 H5 在 iOS 9+ 路径下读渠道 / 设备 / 启动信息的唯一真实路径**(§3.4.1 旧桥同步 getter 已撤销)。
|
||||||
|
|
||||||
|
#### 7.5.1 15 个 `app_*` 变量完整映射(按 daoqi/msext `NewRootVC.initJSdata`、`gameController.initJSdata`、`AppDelegate.applicationDidFinishLaunching` 审查得出)
|
||||||
|
|
||||||
|
| # | 变量名 | 大厅值(NewRootVC)| 子游戏值(gameController)| 数据源(新外壳) | 写入文件 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | `app_version` | `"1"` 硬编码 | `"1"` 硬编码 | 硬编码 string `"1"`(沿用历史,H5 不会读出不同值) | `app_data.js` |
|
||||||
|
| 2 | `app_gameconfig` | `self.gameconfig` | `self.gameconfig` | `BundleConfig.shared.gameConfig` | `app_data.js` |
|
||||||
|
| 3 | `app_gamedir` | `self.gamedir` | `self.gamefilepath` | 大厅 `BundleConfig.shared.gameDir` / 子游戏 `subGameDir` | `app_data.js` |
|
||||||
|
| 4 | `app_gamestart` | `self.gamestart` | `self.game_name` | 大厅 `BundleConfig.shared.gameStart` / 子游戏 `subGameName` | `app_data.js` |
|
||||||
|
| 5 | `app_agent` | `self.agentinfo` | `self.agentinfo` | `BundleConfig.shared.agent` | `app_data.js` |
|
||||||
|
| 6 | `app_appversion` | 远端 `result` 解析后的 appVersion | `self.result_state` | `ResolvedVersion.appVersion` 或 `BundleConfig.shared.appVersion`(启动早期 fallback)| `app_data.js` |
|
||||||
|
| 7 | `app_market` | `self.market` | `self.market` | `BundleConfig.shared.market` | `app_data.js` |
|
||||||
|
| 8 | `app_channel` | `self.channel_id` | `self.channel_id` | `BundleConfig.shared.channel` | `app_data.js` |
|
||||||
|
| 9 | `app_Launchtype` | `"0"` 硬编码 | `"1"` 硬编码 | 大厅 `"0"` / 子游戏 `"1"`(⚠️ L 大写) | `app_data.js` |
|
||||||
|
| 10 | `app_getwifisignalLevel` | `"1"` 硬编码 | `"1"` 硬编码 | 硬编码 string `"1"`(⚠️ wifi 小写、signal/Level 区分大小写) | `app_data.js` |
|
||||||
|
| 11 | `app_gamename` | `self.gamestart` | `self.game_name` | 大厅 = `gameStart` / 子游戏 = 子游戏名(与 `app_gamestart` 同值) | `app_data.js` |
|
||||||
|
| 12 | `app_invitationcode` | `self.tuiguang_id` | `self.tuiguang_id` | `BundleConfig.shared.other`(推广码 / 邀请码,沿用 other 字段;如未来独立字段再改)| `app_data.js` |
|
||||||
|
| 13 | `app_gamesname` | 已安装子游戏 JS Array | 同上 + 新增本子游戏 | `SandboxPaths.installedSubGameNames()` JS Array literal | `app_gamesname.js` |
|
||||||
|
| 14 | `app_getbattery` | `String(format: "%.2f", UIDevice.batteryLevel)` | 同 | `UIDevice.current.batteryLevel`,每次 viewWillAppear + battery 变化通知 | `app_battery.js` |
|
||||||
|
| 15 | `app_getnetwork` | `"1"`/`"2"`/`"3"` | 同 | `NWPathMonitor` 状态映射(1 无网 / 2 WiFi / 3 蜂窝),每次 viewWillAppear + network 变化通知 | `app_network.js` |
|
||||||
|
|
||||||
|
#### 7.5.2 4 个文件的写入时机与内容
|
||||||
|
|
||||||
|
```
|
||||||
|
{gamedir}/{gamestart}/app_data.js 一次性,loadFileURL 前(含 #1-#12 共 12 个 var)
|
||||||
|
{gamedir}/{gamestart}/app_gamesname.js 一次性 + 新装子游戏后增量重写(#13)
|
||||||
|
{gamedir}/{gamestart}/app_battery.js 每次 viewWillAppear / 前台 / batteryLevelDidChange(#14)
|
||||||
|
{gamedir}/{gamestart}/app_network.js 每次 viewWillAppear / NWPathMonitor 变化(#15)
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么是写文件而不是 `evaluateJavaScript`**:H5 业务页面用 `<script src="app_data.js">` **同步**引入,必须在 `loadFileURL` 之前文件已存在于磁盘;如果用 `evaluateJavaScript` 异步注入,会落在 `documentStart` 之前但 `<script src>` 已经发起请求,时序无保证。原 msext 30 个版本沿用"先写文件再 load"路径,不要照抄"WKUserScript 注入"。
|
||||||
|
|
||||||
|
> 注:`WKUserScript(.atDocumentStart)` 也是可行的现代化方式(不依赖磁盘 IO),与"写文件"语义等价。两条路径选一即可。本 Design 推荐**写文件**路径,与 msext 一致,减少 H5 端任何假设差异;如 Phase 2 实施时发现写文件 IO 太慢(实测应该 < 5ms 不会成为瓶颈),再切 WKUserScript。
|
||||||
|
|
||||||
|
#### 7.5.3 Swift 骨架
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Source/WebView/AppDataWriter.swift
|
||||||
|
@MainActor
|
||||||
|
public struct AppDataWriter {
|
||||||
|
|
||||||
|
let bundleConfig: BundleConfig
|
||||||
|
let resolvedVersion: ResolvedVersion? // 拉到远端后传入;首次 fallback nil
|
||||||
|
let containerRole: ContainerRole // .lobby / .subGame(name:)
|
||||||
|
|
||||||
|
public enum ContainerRole {
|
||||||
|
case lobby
|
||||||
|
case subGame(name: String, dir: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次性写 #1-#12 到 app_data.js(loadFileURL 前调一次)
|
||||||
|
/// 同时写 app_gamesname.js(#13)。
|
||||||
|
public func writeInitial() throws {
|
||||||
|
try writeAppData()
|
||||||
|
try writeGamesName()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写 #14 app_battery.js(电池变化 / viewWillAppear 触发)
|
||||||
|
public func writeBattery(_ level: Float) throws {
|
||||||
|
let line = "var app_getbattery = \"\(String(format: "%.2f", level))\";\n"
|
||||||
|
try line.write(to: filePath("app_battery.js"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写 #15 app_network.js(NWPath 变化 / viewWillAppear 触发)
|
||||||
|
public func writeNetwork(_ code: Int) throws {
|
||||||
|
let line = "var app_getnetwork = \"\(code)\";\n"
|
||||||
|
try line.write(to: filePath("app_network.js"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeAppData() throws {
|
||||||
|
let bc = bundleConfig
|
||||||
|
let launchtype: String
|
||||||
|
let gameName: String
|
||||||
|
let gameDir: String
|
||||||
|
let gameStart: String
|
||||||
|
|
||||||
|
switch containerRole {
|
||||||
|
case .lobby:
|
||||||
|
launchtype = "0"
|
||||||
|
gameName = bc.gameStart
|
||||||
|
gameDir = bc.gameDir
|
||||||
|
gameStart = bc.gameStart
|
||||||
|
case .subGame(let name, let dir):
|
||||||
|
launchtype = "1"
|
||||||
|
gameName = name
|
||||||
|
gameDir = dir
|
||||||
|
gameStart = name
|
||||||
|
}
|
||||||
|
|
||||||
|
let appVersion = resolvedVersion?.appVersion.description ?? bc.appVersion
|
||||||
|
|
||||||
|
// 注意:所有 var 都是 string 字面量(JS 端用 == 比较,不区分类型,
|
||||||
|
// 但 msext 沿用 string);大小写严格保持,不能改名
|
||||||
|
let lines = """
|
||||||
|
var app_version = "1";
|
||||||
|
var app_gameconfig = "\(escape(bc.gameConfig))";
|
||||||
|
var app_gamedir = "\(escape(gameDir))";
|
||||||
|
var app_gamestart = "\(escape(gameStart))";
|
||||||
|
var app_agent = "\(escape(bc.agent))";
|
||||||
|
var app_appversion = "\(escape(appVersion))";
|
||||||
|
var app_market = "\(escape(bc.market))";
|
||||||
|
var app_channel = "\(escape(bc.channel))";
|
||||||
|
var app_Launchtype = "\(launchtype)";
|
||||||
|
var app_getwifisignalLevel= "1";
|
||||||
|
var app_gamename = "\(escape(gameName))";
|
||||||
|
var app_invitationcode = "\(escape(bc.other))";
|
||||||
|
"""
|
||||||
|
try lines.write(to: filePath("app_data.js"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeGamesName() throws {
|
||||||
|
// JS Array literal: ["game1","game2",...]
|
||||||
|
let installed = SandboxPaths.installedSubGameNames()
|
||||||
|
let escaped = installed.map { "\"\(escape($0))\"" }.joined(separator: ",")
|
||||||
|
let line = "var app_gamesname = [\(escaped)];\n"
|
||||||
|
try line.write(to: filePath("app_gamesname.js"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filePath(_ name: String) -> URL {
|
||||||
|
// 与 msext NewRootVC 一致:{gamedir}/{gamestart}/app_*.js
|
||||||
|
let base: URL
|
||||||
|
switch containerRole {
|
||||||
|
case .lobby:
|
||||||
|
base = SandboxPaths.lobbyAssets // {Caches}/{gamedir}/{gamestart}/
|
||||||
|
case .subGame(_, let dir):
|
||||||
|
base = SandboxPaths.subGameAssets(dir: dir)
|
||||||
|
}
|
||||||
|
return base.appendingPathComponent(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JS string 转义:双引号、反斜杠、换行
|
||||||
|
private func escape(_ s: String) -> String {
|
||||||
|
s.replacingOccurrences(of: "\\", with: "\\\\")
|
||||||
|
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||||
|
.replacingOccurrences(of: "\n", with: "\\n")
|
||||||
|
.replacingOccurrences(of: "\r", with: "\\r")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7.5.4 WebContainerViewController 接入点
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private func runBootPipelineSteps() async throws {
|
||||||
|
// ... ensureReady / fetch / resolve / upgrade ...
|
||||||
|
|
||||||
|
// 6. loadFileURL 前写 4 个 app_*.js 文件(msext 沿用顺序)
|
||||||
|
let writer = AppDataWriter(
|
||||||
|
bundleConfig: .shared,
|
||||||
|
resolvedVersion: resolved,
|
||||||
|
containerRole: .lobby
|
||||||
|
)
|
||||||
|
try writer.writeInitial()
|
||||||
|
try writer.writeBattery(UIDevice.current.batteryLevel)
|
||||||
|
try writer.writeNetwork(networkMonitor.currentCode)
|
||||||
|
|
||||||
|
splash.update(text: "加载大厅...", progress: nil)
|
||||||
|
bridgedWebView.webView.loadFileURL(
|
||||||
|
SandboxPaths.lobbyIndex,
|
||||||
|
allowingReadAccessTo: SandboxPaths.lobbyRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
// 持续监听:battery / network 变化时重写对应文件
|
||||||
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: UIDevice.batteryLevelDidChangeNotification,
|
||||||
|
object: nil, queue: .main
|
||||||
|
) { _ in
|
||||||
|
try? writer.writeBattery(UIDevice.current.batteryLevel)
|
||||||
|
}
|
||||||
|
networkMonitor.stateDidChange = { code in
|
||||||
|
try? writer.writeNetwork(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ 持续重写文件**不会** trigger H5 端 `<script src>` 重新加载(浏览器只在页面 load 时引入一次)。这条路径只服务于"刷新 → reload → 读到最新值"的场景,与 §3.2 [2][3]的 `bridge.call("getBattery"|"getnetwork", ...)` 事件 callback **互为补充**:
|
||||||
|
> - 事件 callback = 业务期 H5 主动收到变化推送
|
||||||
|
> - 文件重写 = 下次 reload 时 H5 读到的初始值是最新的
|
||||||
|
>
|
||||||
|
> 两条路径并存,与原 msext 行为等价。
|
||||||
|
|
||||||
|
#### 7.5.5 大厅 / 子游戏 / 弹层差异
|
||||||
|
|
||||||
|
| 容器 | app_data.js 写入 | app_gamesname.js 写入 | app_battery.js | app_network.js | 备注 |
|
||||||
|
|------|---|---|---|---|---|
|
||||||
|
| 大厅(WebContainer.lobby) | `app_Launchtype="0"` + `gamedir = BundleConfig.gameDir` + `gamename = gameStart` | 写当前已安装子游戏列表 | ✓ | ✓ | `app_appversion` 用远端 ResolvedVersion |
|
||||||
|
| 子游戏(WebContainer.subGame) | `app_Launchtype="1"` + `gamedir = 子游戏目录` + `gamename = 子游戏名` | 同样写(与大厅同份内容) | ✓ | ✓ | 子游戏专属 `result_state` 字段,新外壳暂用同 appVersion |
|
||||||
|
| 弹层(OverlayViewController / threeView) | ✗ 不写 | ✗ | ✗ | ✗ | 弹层 H5 走 `window.settings.{backgameData/browser/finishweb}`(§3.4),不读 app_* 全局变量 |
|
||||||
|
|
||||||
|
#### 7.5.6 与原 msext 的差异
|
||||||
|
|
||||||
|
| 维度 | msext 现状 | 新外壳决策 |
|
||||||
|
|------|---|---|
|
||||||
|
| 写入主体 | `NewRootVC.initJSdata` / `gameController.initJSdata` 散落在 VC 内 | 抽象为 `AppDataWriter` struct,三个容器复用同一份代码 |
|
||||||
|
| 数据源 | `self.gameconfig` 等实例属性,跨多处赋值 | 统一从 `BundleConfig.shared` + `ResolvedVersion` 拉,单一真相 |
|
||||||
|
| 文件写入方式 | `[NSString writeToFile:atomically:YES encoding:UTF8 error:nil]` | `String.write(to:atomically:encoding:)`(等价) |
|
||||||
|
| battery / network 时机 | `viewWillAppear` + 前台通知双重保险 | 同一份机制 + `addObserver` 注册一次(避免双重写入) |
|
||||||
|
| 转义处理 | 直接 `stringWithFormat:@"var x=\"%@\""` 不转义 | 完整转义 4 种特殊字符(避免渠道字段含引号导致 H5 JS 解析错) |
|
||||||
|
| 大小写 | `app_Launchtype` / `app_getwifisignalLevel` 严格保持 | 同款,注释提示 |
|
||||||
|
|
||||||
|
#### 7.5.7 与 §3.4.1 撤销 polyfill 的关系
|
||||||
|
|
||||||
|
| 路径 | 状态 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| §7.5 `app_*.js` 文件预写 | ✓ **主路径** | iOS 9+ 全部 H5 业务读 `app_xxx` 全局变量 |
|
||||||
|
| §3.4.1 polyfill | ✗ **已撤销** | 仅 iOS<9 旧桥 `window.settings.getXxx()`,本项目最低 iOS 15.6 不需要 |
|
||||||
|
| §3.4 OverlayBridge polyfill | ✓ **保留** | 弹层 `window.settings.{backgameData/browser/finishweb}`,与系统版本无关 |
|
||||||
|
| §3.2 事件 callback `getBattery` / `getnetwork` / `appservice` | ✓ **保留** | 业务期 H5 主动收到的变化推送,与 §7.5 文件重写互为补充 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 能力模块详细设计
|
## 8. 能力模块详细设计
|
||||||
|
|||||||
Reference in New Issue
Block a user