Phase 1.F: WebView 加载从 file:// 迁到 ylgame://h5 自定义 scheme

为未来 Cocos 子游戏 H5 build 适配铺路(file:// 下 Cocos XHR/fetch 受
null-origin 限制几乎必踩坑),同时保留大厅 + 子游戏跨页 localStorage
共享语义(用单虚拟 host `h5` 让所有 H5 same-origin)。

变更:
- 新增 AppSchemeHandler(WKURLSchemeHandler 单例 + Range/MIME/异步 IO/
  取消语义;闭包只携 Sendable ObjectIdentifier,不捕获 task)
- SandboxPaths 加 lobbyIndexAppURL / subGameIndexAppURL builders
- BridgedWebView 注册 scheme handler(WKWebView init 前)
- WebContainerViewController / SubGameViewController 的 loadFileURL
  → webView.load(URLRequest),OverlayViewController 不变

文档:
- Plan 新增 Phase 1.F (1.18-1.21) + ADR-010 决策记录 + 进度勾选
- Design 新增 §7.6 包含 URL 结构 / 实现要点 / 等价性表 / Cocos 预检脚本
- Contract §0.2 / §4.1 / §10 验收清单同步切换说明(H5 可观察差异:
  location.protocol "file:" → "ylgame:",项目方已 grep 确认现网 H5
  不依赖此字面)

存量影响:file:// → ylgame:// origin 切换时老用户 localStorage 一次性
清零,已与项目方确认业务可接受、不做迁移补偿。

BuildProject 通过。Plan 进度已勾选 1.18/1.19/1.20。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-29 08:58:00 +08:00
co-authored by Claude Opus 4.7
parent 960adc5491
commit 04e702a99b
8 changed files with 686 additions and 17 deletions
+127
View File
@@ -2400,6 +2400,133 @@ console.log("app_gamesname =", app_gamesname); // 期望 Array
---
### 7.6 WebView 加载方式:`WKURLSchemeHandler`Phase 1.FADR-010
> Phase 1 / Phase 6 初版用 `webView.loadFileURL(_:allowingReadAccessTo:)` 加载 H5。Phase 1.F 把这一路径迁到自定义 scheme `ylgame://h5/...` + `AppSchemeHandler`。三条动因:① 为未来 Cocos 子游戏 H5 build 适配铺路;② 大厅 + 子游戏共用单虚拟 host (`ylgame://h5`) → same-origin → **保留跨页 localStorage 共享**(业务依赖现状);③ 给 `<audio>` / `<video>` 提供 Range 请求支持。详细决策见 `docs/Development-Plan.md` ADR-010。
#### 7.6.1 URL 结构
| 入口 | URL | 反向映射 |
|------|-----|---------|
| 大厅 | `ylgame://h5/lobby/<gameStart>/index.html` | `{Caches}/{gameDir}/<gameStart>/index.html` |
| 子游戏 | `ylgame://h5/subgame/<dir>/<start>/index.html` | `{Caches}/<dir>/<start>/index.html` |
| `app_*.js` 同步引入 | `ylgame://h5/lobby/<gameStart>/app_data.js` | `{Caches}/{gameDir}/<gameStart>/app_data.js` |
| 跨目录引用(H5 `../foo.js` | `ylgame://h5/lobby/foo.js` | `{Caches}/{gameDir}/foo.js`(等价 file:// 时代 allowingReadAccessTo: lobbyRoot 语义) |
| 弹层外链(不变) | http(s)://... | 走 WebKit 默认网络栈,不经 handler |
**关键约束:scheme + host 必须完全一致**。大厅与所有子游戏都用 `ylgame://h5/...`,否则 origin 拆分破坏 localStorage 共享。
#### 7.6.2 `AppSchemeHandler` 实现要点
```swift
// Source/WebView/AppSchemeHandler.swift ~330
@MainActor
public final class AppSchemeHandler: NSObject, WKURLSchemeHandler {
public static let shared = AppSchemeHandler()
private let ioQueue = DispatchQueue(
label: "ylgamehall.app-scheme-handler.io",
qos: .userInitiated,
attributes: .concurrent
)
// Sendable ObjectIdentifier
// Sendable any WKURLSchemeTask @Sendable
private var tasksByKey: [ObjectIdentifier: any WKURLSchemeTask] = [:]
private var cancelledKeys: Set<ObjectIdentifier> = []
public func webView(_ webView: WKWebView, start task: any WKURLSchemeTask) {
let key = ObjectIdentifier(task)
tasksByKey[key] = task
// 1) + 线
let resolved = try? Self.resolveRequest(url: task.request.url!, ...)
// 2) IO deliver MainActor
ioQueue.async { [weak self] in
let result = Self.readAndBuildResponse(resolved: resolved!)
Task { @MainActor in
self?.deliver(result: result, forKey: key)
}
}
}
public func webView(_ webView: WKWebView, stop task: any WKURLSchemeTask) {
// tasksByKey IO
cancelledKeys.insert(ObjectIdentifier(task))
}
}
```
#### 7.6.3 关键设计抉择
1. **单虚拟 host = `h5`**:所有 H5 页共用此 host,保证 same-origin → localStorage / IndexedDB / sessionStorage 全局共享。**不要**用 `app://lobby` / `app://subgame` 区分,那会拆 origin
2. **`nonisolated` 常量**:项目启用了 MainActor-by-default`AppScheme` enum 所有 `static let` + `MimeMap` 函数必须显式 `nonisolated`,否则背景 IO 队列无法访问(编译期 actor 隔离错)
3. **Sendable 闭包安全**`ioQueue.async``@Sendable` 边界,闭包只能携带 Sendable 类型。`any WKURLSchemeTask` 不是 Sendable → 用 `ObjectIdentifier` key 跨边界,主线程持表查 task 派发回调
4. **取消语义严格性**WKURLSchemeTask 协议规定 `stop` 后**禁止**再向 task 发任何消息(否则 NSException 闪退)。`cancelledKeys` Set + `deliver` 内短路防御
5. **Range 请求**:解析 `Range: bytes=...` → 206 + `Content-Range`Cocos `<audio>` seek 必需,HTML5 `<video>` 同样依赖
6. **MIME 表覆盖 Cocos 常用扩展**`.atlas` / `.fnt` / `.plist` / `.wasm` / `.webp` / `.ogg` / `.m4a` / `.woff2` 全打表,未命中走 `application/octet-stream`(WKWebView 仍能下载,只是不会按内容类型行为)
7. **响应头策略**
- `Cache-Control: no-cache`:沙盒升级(`LobbyZipUpgrader` / `SubGameDownloader`)会原地覆盖文件,禁强缓存避免错版
- `Access-Control-Allow-Origin: *`:防御部分 Cocos build 的 fetch 默认 `mode: 'cors'` 走 preflight
- `Accept-Ranges: bytes`:告诉 H5 媒体元素本资源支持 Range
8. **路由 fail-fast**:未知前缀(既不是 `/lobby/` 也不是 `/subgame/`)直接 404,避免误读到沙盒任意目录
#### 7.6.4 BridgedWebView 注册时机
```swift
// Source/WebView/BridgedWebView.swift init()
let configuration = WKWebViewConfiguration()
// ... ...
// WKWebView(configuration:)
configuration.setURLSchemeHandler(
AppSchemeHandler.shared,
forURLScheme: AppScheme.scheme // "ylgame"
)
let webView = WKWebView(frame: .zero, configuration: configuration)
```
注意:WKWebViewConfiguration 是 **value copy** 但内部状态共享。注册一次后所有由这份 configuration 派生的 WKWebView 都会用同一 handler 实例。BridgedWebView 每次 init 都重新创建 configuration,但 handler 是 singleton,并发安全(主线程访问 + Sendable 闭包边界)。
#### 7.6.5 与 file:// 旧路径的等价性表
| 行为 | file:// 现状 | ylgame://h5 新方案 | 等价性 |
|------|------|------|------|
| 大厅 / 子游戏加载 | `loadFileURL(_:allowingReadAccessTo:)` | `load(URLRequest(url:))` | ✅ |
| 跨页 localStorage | null origin 共享 | host=`h5` same-origin 共享 | ✅ 业务可观察等价 |
| `app_*.js` `<script src>` 同步引入 | 沙盒文件同目录 | handler 透明读相同物理路径 | ✅ 时序等价 |
| XHR/fetch 资源 | null origin 受限 | 同源直通 | 🆙 **改善**Cocos 适配前提) |
| `<audio>` Range seek | WKWebView 原生 | handler 实现 206 | ✅ 行为等价 |
| 跨目录 `../foo.js` | allowingReadAccessTo 放行 | path 自然落回 lobbyRoot | ✅ |
| WKWebsiteDataStore | `.default()` 持久化 | 同左 | ✅ |
| `location.protocol` | `"file:"` | `"ylgame:"` | ⚠️ H5 可观察差异(项目方已 grep 验证现网 H5 不踩此红线) |
#### 7.6.6 接入 Cocos 子游戏的预检脚本(建议加入 SDK-Integration-Guide
```bash
# 子游戏 zip 解开后
grep -RnE "location\.protocol|window\.location\.(protocol|host)" $SUBGAME_DIR
grep -RnE "['\"](file|https?)://[^'\"]" $SUBGAME_DIR
grep -Rn "serviceWorker\.register" $SUBGAME_DIR
grep -Rn "document\.cookie" $SUBGAME_DIR
```
四条全为空 / 仅命中无害日志 → 该子游戏可直接接入;命中关键路径 → 评估是否单游戏开 http://localhost 逃生门(per-game 配置)。
#### 7.6.7 故障排查矩阵
| 症状 | 可能原因 |
|------|---------|
| H5 不加载,splash 永不淡出 | scheme handler 未注册 / configuration 注册时序错(必须在 WKWebView init 前) |
| H5 加载但黑屏 | 路由失败(404)→ Xcode console 看 `✗ file not found:`;检查 lobbyRoot 实际存在的文件名是否与 URL path 段对应 |
| `app_data.js` 未生效(H5 用默认值) | AppDataWriter 写盘失败 / 写入路径与 handler 读路径不一致;检查 `SandboxPaths.lobbyIndex.deletingLastPathComponent()` 是否等于 `lobbyRoot/<gameStart>/` |
| 子游戏切换后大厅 localStorage 丢失 | 子游戏与大厅 URL host 不一致 → origin 拆分;检查 `subGameIndexAppURL` 是否仍是 `ylgame://h5/...` |
| `<audio>` 中段 seek 失败 | handler Range 处理错;检查 `parseRange` 是否正确返回 206 + `Content-Range` |
| Cocos XHR 仍失败 | 个别 Cocos build 自检 `location.protocol === 'http:'`;按 7.6.6 预检脚本定位字面命中点 |
| `NSInternalInconsistencyException` "Completed task" | handler 对 stopped task 仍调了 `didReceive/didFinish`;检查 `cancelledKeys` 短路逻辑 |
---
## 8. 能力模块详细设计
### 8.1 AudioKit