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:
co-authored by
Claude Opus 4.7
parent
5b67e414b3
commit
0427bb9542
@@ -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()
|
||||
|
||||
// MARK: - .subGameDidReturn 观察者(overlay pop 回子游戏时反向 callback getWebdata)
|
||||
|
||||
private var subGameReturnObserver: NSObjectProtocol?
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
public init(request: SubGameRequest) {
|
||||
@@ -239,6 +243,19 @@ public final class SubGameViewController: UIViewController {
|
||||
AppLifecycleObserver.shared.onForeground = {
|
||||
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() {
|
||||
@@ -246,6 +263,11 @@ public final class SubGameViewController: UIViewController {
|
||||
NetworkMonitor.shared.onChange = nil
|
||||
AppLifecycleObserver.shared.onBackground = nil
|
||||
AppLifecycleObserver.shared.onForeground = nil
|
||||
|
||||
if let token = subGameReturnObserver {
|
||||
NotificationCenter.default.removeObserver(token)
|
||||
subGameReturnObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error alert
|
||||
|
||||
Reference in New Issue
Block a user