Phase 7 完整实现:OpenurlTitleData 弹层(OverlayViewController + 3 个 settings)

- AppCoordinator 扩 OverlayRequest + lastOverlayAt(3s 节流 / 栈深 < 3 守门) +
  showOverlay / popOverlay(data==nil 仅 pop 即 finishweb 路径;
  data!=nil 同时发 .subGameDidReturn 即 backgameData 路径)
- 新增 Source/WebView/OverlayViewController.swift:独立 WKWebView,
  WKWebViewConfiguration.websiteDataStore = .nonPersistent() 隔离 Cookie / 缓存;
  .atDocumentEnd 注入 polyfill:window.settings = {browser/finishweb/backgameData}
  → webkit.messageHandlers.<name>(与 WVJB 不同 channel 可同名共存);
  ScriptMessageProxy weak target 切断 retain cycle
- OpenurlTitleDataHandler 升级为真实 push:解 url / "title "(末尾空格契约)/
  data / orientation → AppCoordinator.showOverlay,cb 字面始终回 "OpenurlTitleData"
- SubGameViewController.setupExternalSubscriptions 也订阅 .subGameDidReturn
  (overlay 可从子游戏 push,pop 回子游戏时需要 callback 子游戏 H5 的 getWebdata;
  与 viewWillAppear/Disappear 生命周期对齐,栈顶才挂钩避免双发)
- orientation 字段保留契约但 landscape-only app 暂不实施旋转(msext 旋转 transform
  在我们项目里无意义,记注释,如有 H5 反馈再补)
- Plan §5.7.1-7.7 / §8 进度已勾选

至此 SwitchOverGameData → push 子游戏 / OpenurlTitleData → push 弹层 / backgameData ↔
finishweb → pop + getWebdata 反向闭环全部接通。栈深永远 ≤ 3。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 21:30:03 +08:00
co-authored by Claude Opus 4.7
parent 5b67e414b3
commit 0427bb9542
5 changed files with 332 additions and 23 deletions
+23 -12
View File
@@ -634,13 +634,23 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
#### 任务清单 #### 任务清单
- [ ] **7.1** `Source/Containers/OverlayViewController.swift`:继承 WebContainer,但 WKWebsiteDataStore 用 `.nonPersistent()` 隔离 - [x] **7.1** `Source/WebView/OverlayViewController.swift`:独立 WKWebView,`WKWebViewConfiguration.websiteDataStore = .nonPersistent()`
- [ ] **7.2** `Source/WebView/OverlayBridge.swift` polyfill JS:`window.settings = {backgameData, browser, finishweb}` → `webkit.messageHandlers.*` 隔离弹层 Cookie / 缓存(不污染大厅 / 子游戏会话)
- [ ] **7.3** `WKScriptMessageHandler` 注册三 handler:`overlayBackgameData` / `overlayBrowser` / `overlayFinishweb` - [x] **7.2** polyfill JS 内联(不抽独立 .js 文件):`.atDocumentEnd` 注入
- [ ] **7.4** `OpenurlTitleData` handler(大厅 + 子游戏都注册):解参(注意 `"title "` 末尾空格契约) → AppCoordinator.showOverlay → 3 秒节流 `window.settings={browser,finishweb,backgameData}` → `webkit.messageHandlers.*`
- [ ] **7.5** Overlay backgameData → 发 `.subGameDidReturn` → 上层 H5 收 `getWebdata` (msext threeView.m:223-241 在 webViewDidFinishLoad 注入 `context[@"settings"]=jo` 等价时机)
- [ ] **7.6** Overlay `finishweb` → coordinator.popOverlay - [x] **7.3** `WKScriptMessageHandler` 通过 `ScriptMessageProxy`(weak target 切断 retain cycle)
- [ ] **7.7** Overlay `browser` → 系统 Safari 打开 注册 3 handler:`browser` / `finishweb` / `backgameData`(messageHandler 与 WVJB 不同 channel,
可与大厅 WVJB 同名共存不冲突)
- [x] **7.4** `OpenurlTitleData` handler(大厅 + 子游戏都注册):解参(`"title "` 末尾空格契约硬约束) →
`AppCoordinator.showOverlay` → 3 秒节流由 Coordinator 守门(msext first_Time 等价)
- [x] **7.5** Overlay backgameData → `AppCoordinator.popOverlay(returningData:)` → 发 `.subGameDidReturn`
→ 父层(大厅 / 子游戏)观察后 callback H5 `getWebdata`
- [x] **7.6** Overlay `finishweb` → `popOverlay(returningData: nil)` 仅 pop 不发通知
- [x] **7.7** Overlay `browser` → `UIApplication.shared.open` Safari 外链
- [x] **额外**:SubGameViewController.setupExternalSubscriptions 也订阅 `.subGameDidReturn`
(overlay 可从子游戏 push,pop 回子游戏时需要 callback 子游戏 H5 的 getWebdata;
与大厅 / 子游戏的 viewWillAppear/Disappear 生命周期对齐,栈顶才挂钩避免双发)
#### 验收 #### 验收
@@ -942,11 +952,12 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
- [x] 6.7 SubGameDownloader(actor,URLSession + ZIPFoundation + staging + 原子 rename) - [x] 6.7 SubGameDownloader(actor,URLSession + ZIPFoundation + staging + 原子 rename)
### Phase 7 弹层 ### Phase 7 弹层
- [ ] 7.1 OverlayViewController + 私有 dataStore - [x] 7.1 OverlayViewController + 私有 dataStore(.nonPersistent)
- [ ] 7.2 window.settings polyfill - [x] 7.2 window.settings polyfill(.atDocumentEnd 注入)
- [ ] 7.3 3 个 WKScriptMessageHandler - [x] 7.3 3 个 WKScriptMessageHandler(ScriptMessageProxy 切断 retain cycle)
- [ ] 7.4 OpenurlTitleData handler("title " 末尾空格) - [x] 7.4 OpenurlTitleData handler("title " 末尾空格 + 3s 节流由 Coordinator 守门)
- [ ] 7.5 Overlay backgameData - [x] 7.5 Overlay backgameData(→ popOverlay 发 .subGameDidReturn → 父层 getWebdata)
- [x] 额外 SubGameViewController 也订阅 .subGameDidReturn(覆盖 overlay→sub-game 返回)
- [ ] 7.6 Overlay finishweb - [ ] 7.6 Overlay finishweb
- [ ] 7.7 Overlay browser - [ ] 7.7 Overlay browser
@@ -2,14 +2,17 @@
// OpenurlTitleDataHandler.swift // OpenurlTitleDataHandler.swift
// ylgamehall // ylgamehall
// //
// H5 → Native handler stub:OpenurlTitleData — 打开内嵌 WebView 弹层(threeView) // H5 → Native handler:OpenurlTitleData — 打开内嵌 WebView 弹层(msext threeView 等价)。
// 契约:docs/H5-Native-Contract.md §3.1 [15] // 契约:docs/H5-Native-Contract.md §3.1 [15]
// //
// Phase 7 完整实现需要 OverlayViewController + window.settings polyfill // ⚠️ 入参 key "title " 末尾有空格(msext 历史契约硬约束):
// + 3 秒节流。当前为 stub(仅 cb 维持契约),让 H5 调时不报 "no handler"。 // 必须 `data["title "]?.asString` 而不是 `data["title"]?.asString`,
// H5 端 callHandler 时也是 `{"title ": "..."}`(带空格的字符串字面)
// //
// ⚠️ 注意入参 key "title " 末尾有空格(msext 历史契约硬约束),完整实现时 // 与 msext NewRootVC.m / gameController.m 等价:
// 必须 obj["title "]?.asString 而不是 obj["title"]?.asString。 // - 大厅 + 子游戏页都注册(msext 两处都有),用户在哪一页调都能打开弹层
// - 3 秒节流由 AppCoordinator.showOverlay 守门(msext first_Time 等价)
// - callback 字面始终回 "OpenurlTitleData",无论节流是否命中(msext 行为)
// //
import Foundation import Foundation
@@ -20,8 +23,25 @@ public enum OpenurlTitleDataHandler {
// 【15】OpenurlTitleData // 【15】OpenurlTitleData
// 入参 url / "title "(末尾空格!) / data / orientation // 入参 url / "title "(末尾空格!) / data / orientation
// cb: "OpenurlTitleData" // cb: "OpenurlTitleData"
// 完整实现需 push OverlayViewController + 3 秒节流(first_Time 标志) bridge.register("OpenurlTitleData") { data, callback in
bridge.register("OpenurlTitleData") { _, callback in await MainActor.run {
// ⚠️ "title " 末尾空格不可改;orientation 在 landscape-only app 中保留契约字段
let url = data?["url"]?.asString ?? ""
let title = data?["title "]?.asString ?? ""
let webData = data?["data"]?.asString ?? ""
let orientation = data?["orientation"]?.asInt
?? Int(data?["orientation"]?.asString ?? "")
?? 0
let request = OverlayRequest(
url: url,
title: title,
webData: webData,
orientation: orientation
)
AppCoordinator.shared.showOverlay(request)
}
// msext 行为:无论节流命中 / 入参缺失,都回 cb,避免 H5 等待
callback?(.string("OpenurlTitleData")) callback?(.string("OpenurlTitleData"))
} }
} }
@@ -10,7 +10,8 @@
// //
// 与 msext 一对一行为: // 与 msext 一对一行为:
// - SwitchOverGameData 节流:2s 内不重复触发 // - SwitchOverGameData 节流:2s 内不重复触发
// - subGameDidReturn 通知:子游戏 pop 时发,大厅监听并 callback getWebdata // - OpenurlTitleData 节流:3s 内不重复触发(msext first_Time 等价)
// - subGameDidReturn 通知:子游戏 / 弹层 pop 时发,父层(大厅 / 子游戏)监听并 callback getWebdata
// //
import UIKit import UIKit
@@ -30,9 +31,26 @@ public struct SubGameRequest: Sendable {
} }
} }
/// 子游戏 pop 回大厅通知(msext .subGameDidReturn 等价) /// 弹层 push 请求(从 H5 OpenurlTitleData 入参解析)
public struct OverlayRequest: Sendable {
public let url: String // 待加载 HTTP URL
public let title: String // 弹层标题(msext 历史:nav 默认隐藏,title 仅信息性)
public let webData: String // data:业务数据透传给 threeView 内 H5
public let orientation: Int // 0=竖屏 / 1=横屏(landscape-only app 忽略,预留契约字段)
public init(url: String, title: String, webData: String, orientation: Int) {
self.url = url
self.title = title
self.webData = webData
self.orientation = orientation
}
}
/// 子容器(子游戏 / 弹层)pop 回父层通知。
/// msext 同款 "backgameDatatwo":threeView 和 gameController 共用一个名字,
/// 父层(大厅 / 子游戏)监听后转 `getWebdata` 反向 callback。
public extension Notification.Name { public extension Notification.Name {
/// 子游戏 pop 返回时发出,userInfo["data"] = 子游戏 backgameData 入参字符串 /// 子游戏 / 弹层 pop 返回时发出,userInfo["data"] = 调 backgameData 时传入的字符串
static let subGameDidReturn = Notification.Name("ylgamehall.subGameDidReturn") static let subGameDidReturn = Notification.Name("ylgamehall.subGameDidReturn")
} }
@@ -41,8 +59,10 @@ public final class AppCoordinator {
public static let shared = AppCoordinator() public static let shared = AppCoordinator()
/// 节流:2 秒内连续 SwitchOverGameData 视为重复(msext first_Time 等价) /// SwitchOverGameData 节流:2 秒内连续触发视为重复(msext first_Time 等价)
private var lastSwitchAt: Date? private var lastSwitchAt: Date?
/// OpenurlTitleData 节流:3 秒内连续触发视为重复(契约 §3.1 [15] first_Time 等价)
private var lastOverlayAt: Date?
/// 当前可见 WebContainer(大厅)— 用于 push 子游戏 /// 当前可见 WebContainer(大厅)— 用于 push 子游戏
public weak var navigationController: UINavigationController? public weak var navigationController: UINavigationController?
@@ -80,4 +100,39 @@ public final class AppCoordinator {
userInfo: ["data": data] userInfo: ["data": data]
) )
} }
/// 大厅 / 子游戏触发 OpenurlTitleData。节流命中或栈深已满(3 层)时不触发。
/// 返回 true 表示 push 成功,false 表示被忽略。
@discardableResult
public func showOverlay(_ request: OverlayRequest) -> Bool {
// 节流:3s 内重复调用直接吞(msext first_Time 等价)
if let last = lastOverlayAt, Date().timeIntervalSince(last) < 3 {
return false
}
// 栈深约束:最多 Lobby + SubGame + Overlay = 3 层
guard let nav = navigationController,
nav.viewControllers.count < 3
else {
return false
}
lastOverlayAt = Date()
let overlay = OverlayViewController(request: request)
nav.pushViewController(overlay, animated: true)
return true
}
/// 弹层 pop 回父层(大厅 / 子游戏)。
/// - data == nil:`settings.finishweb()` 路径,仅 pop,不发 backgameDatatwo
/// - data != nil:`settings.backgameData(data)` 路径,pop + 发 .subGameDidReturn 通知
public func popOverlay(returningData data: String?) {
navigationController?.popViewController(animated: true)
if let data {
NotificationCenter.default.post(
name: .subGameDidReturn,
object: nil,
userInfo: ["data": data]
)
}
}
} }
@@ -0,0 +1,201 @@
//
// OverlayViewController.swift
// ylgamehall
//
// 弹层(msext threeView 等价):WKWebView 加载外部 HTTP 页面,
// H5 通过 `window.settings.{browser,finishweb,backgameData}` 三接口与原生交互。
//
// 契约:docs/H5-Native-Contract.md §3.4 弹层桥
// Design:§3.4.2 / §2.4.3 栈深 ≤ 3
//
// 与 msext threeView.m 的差异:
// - msext 用 UIWebView + JSExport (Bridgetwo);新外壳用 WKWebView + WKScriptMessageHandler,
// 在 .atDocumentEnd 注入 `window.settings = {...}` polyfill 桥到 messageHandlers
// - msext orientation=1 时 CGAffineTransformMakeRotation(±π/2) 旋转 webView;
// 本项目 landscape-locked,orientation 字段保留但不主动旋转(如有 H5 反馈需要再补)
// - msext 用全局 sharedHTTPCookieStorage;本项目用 WKWebsiteDataStore.nonPersistent()
// 隔离弹层 Cookie / 缓存,避免污染主业务态(Design §3.4.2 风险条目)
//
import UIKit
import WebKit
public final class OverlayViewController: UIViewController {
// MARK: - 入参
private let request: OverlayRequest
// MARK: - UI
private var webView: WKWebView!
/// WKScriptMessageHandler 名字(与 H5 端 polyfill 内 `webkit.messageHandlers.<name>` 一致)。
/// 弹层 WKWebView 与大厅 WVJB 在不同 channel,可以与 WVJB 同名 handler 共存不冲突。
/// fileprivate 让同文件下的 ScriptMessageProxy 可用 `OverlayViewController.MessageName(rawValue:)`
fileprivate enum MessageName: String, CaseIterable {
case browser
case finishweb
case backgameData
}
// MARK: - Init
public init(request: OverlayRequest) {
self.request = request
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("OverlayViewController requires OverlayRequest")
}
// MARK: - Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
title = request.title
setupWebView()
loadRequest()
}
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .landscape }
public override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { .landscapeRight }
public override var prefersStatusBarHidden: Bool { false }
// MARK: - Setup
private func setupWebView() {
let config = WKWebViewConfiguration()
// 关键:弹层 Cookie / 缓存隔离,第三方外链不污染大厅 / 子游戏的 H5 会话
config.websiteDataStore = .nonPersistent()
config.defaultWebpagePreferences.allowsContentJavaScript = true
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.allowsInlineMediaPlayback = true
// .atDocumentEnd 注入 polyfill:与 msext threeView.webViewDidFinishLoad 等价时机
// 用 documentEnd 而不是 documentStart,是为了让外链页自带的 JS 框架先初始化,
// 再覆盖 / 创建 window.settings — msext 在 didFinishLoad 才注入,行为等价。
let polyfill = MessageName.allCases.map { name in
switch name {
case .browser:
return "browser:function(u){webkit.messageHandlers.browser.postMessage(String(u||''));}"
case .finishweb:
return "finishweb:function(){webkit.messageHandlers.finishweb.postMessage('');}"
case .backgameData:
return "backgameData:function(d){webkit.messageHandlers.backgameData.postMessage(String(d==null?'':d));}"
}
}.joined(separator: ",")
let source = "window.settings=window.settings||{};Object.assign(window.settings,{\(polyfill)});"
let userScript = WKUserScript(
source: source,
injectionTime: .atDocumentEnd,
forMainFrameOnly: true
)
config.userContentController.addUserScript(userScript)
// 3 个 MessageHandler 名字与 polyfill 内的 messageHandlers.<name> 一致
for name in MessageName.allCases {
config.userContentController.add(
ScriptMessageProxy(target: self),
name: name.rawValue
)
}
webView = WKWebView(frame: .zero, configuration: config)
webView.scrollView.bounces = false
webView.scrollView.contentInsetAdjustmentBehavior = .never
webView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(webView)
NSLayoutConstraint.activate([
webView.topAnchor.constraint(equalTo: view.topAnchor),
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
}
private func loadRequest() {
guard let url = URL(string: request.url) else {
showFatalAlert(message: "URL 无效:\(request.url)")
return
}
webView.load(URLRequest(url: url))
}
// MARK: - Message handling
fileprivate func handleMessage(_ name: MessageName, body: Any) {
switch name {
case .browser:
// 系统 Safari 打开(msext threeView.browser: 等价)
let raw = (body as? String) ?? ""
guard !raw.isEmpty,
let target = URL(string: raw)
?? raw.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
.flatMap(URL.init(string:))
else { return }
Task { @MainActor in
_ = await UIApplication.shared.open(target)
}
case .finishweb:
// 仅 pop,不带数据(settings.finishweb())
AppCoordinator.shared.popOverlay(returningData: nil)
case .backgameData:
// pop + 发 .subGameDidReturn,父层(大厅 / 子游戏)观察后回调 H5 getWebdata
let payload = serialize(body)
AppCoordinator.shared.popOverlay(returningData: payload)
}
}
/// 兼容 string / dict / array:dict/array 走 JSON 序列化透传给父层 H5
private func serialize(_ body: Any) -> String {
if let s = body as? String { return s }
if JSONSerialization.isValidJSONObject(body),
let bytes = try? JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed]),
let s = String(data: bytes, encoding: .utf8) {
return s
}
return ""
}
private func showFatalAlert(message: String) {
let alert = UIAlertController(
title: "加载失败",
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "返回", style: .default) { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
present(alert, animated: true)
}
}
// MARK: - WKScriptMessageHandler proxy
//
// WKScriptMessageHandler 协议默认 retain 持有者,会跟 WebView 互相强引用导致 VC 不释放。
// 用一个 weak 持有 target 的 proxy 切断循环引用 — msext 时代手动 MRC release 等价做法。
private final class ScriptMessageProxy: NSObject, WKScriptMessageHandler {
private weak var target: OverlayViewController?
init(target: OverlayViewController) {
self.target = target
super.init()
}
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
guard let target,
let name = OverlayViewController.MessageName(rawValue: message.name)
else { return }
target.handleMessage(name, body: message.body)
}
}
@@ -30,6 +30,10 @@ public final class SubGameViewController: UIViewController {
private let shakeHandler = ShakeHandler() private let shakeHandler = ShakeHandler()
// MARK: - .subGameDidReturn 观察者(overlay pop 回子游戏时反向 callback getWebdata)
private var subGameReturnObserver: NSObjectProtocol?
// MARK: - Init // MARK: - Init
public init(request: SubGameRequest) { public init(request: SubGameRequest) {
@@ -239,6 +243,19 @@ public final class SubGameViewController: UIViewController {
AppLifecycleObserver.shared.onForeground = { AppLifecycleObserver.shared.onForeground = {
bridge.call("appservice", data: .string("2"), callback: nil) bridge.call("appservice", data: .string("2"), callback: nil)
} }
// overlay(Phase 7)pop 回子游戏时反向 callback getWebdata。挂在子游戏栈顶
// 期间生效;overlay push 上来时 viewWillDisappear → teardown 自动解绑,避免双发。
subGameReturnObserver = NotificationCenter.default.addObserver(
forName: .subGameDidReturn,
object: nil,
queue: .main
) { note in
let data = (note.userInfo?["data"] as? String) ?? ""
MainActor.assumeIsolated {
bridge.call("getWebdata", data: .string(data), callback: nil)
}
}
} }
private func teardownExternalSubscriptions() { private func teardownExternalSubscriptions() {
@@ -246,6 +263,11 @@ public final class SubGameViewController: UIViewController {
NetworkMonitor.shared.onChange = nil NetworkMonitor.shared.onChange = nil
AppLifecycleObserver.shared.onBackground = nil AppLifecycleObserver.shared.onBackground = nil
AppLifecycleObserver.shared.onForeground = nil AppLifecycleObserver.shared.onForeground = nil
if let token = subGameReturnObserver {
NotificationCenter.default.removeObserver(token)
subGameReturnObserver = nil
}
} }
// MARK: - Error alert // MARK: - Error alert