- 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>
202 lines
7.8 KiB
Swift
202 lines
7.8 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|