Files
youle_app_ios_v2/ylgamehall/Source/WebView/OverlayViewController.swift
T
joywayerandClaude Opus 4.7 0427bb9542 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>
2026-06-22 21:30:03 +08:00

202 lines
7.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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-lockedorientation 字段保留但不主动旋转(如有 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 / arraydict/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)
}
}