Files
youle_app_ios_v2/ylgamehall/Source/Bridge/BridgeBus.swift
T
2026-08-07 12:18:44 +08:00

290 lines
13 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.
//
// BridgeBus.swift
// ylgamehall
//
// BridgeProtocol 的具体实现 + WKScriptMessageHandler 适配。
// 详见 docs/H5-Native-Implementation-Design.md §3.3。
//
import Foundation
import WebKit
import os.log
/// 桥诊断日志。文件级 `let`Logger 是 Sendable)以便在 evaluateJavaScript
/// 完成回调等非 MainActor 上下文里也能直接用。
private let bridgeLog = Logger(subsystem: "ylgamehall", category: "Bridge")
/// ── 临时诊断输出(排查「子游戏收不到 getlocationinfo」,结束后整体移除)──
///
/// 真机上 `Logger.debug` 会被系统日志级别过滤掉:devicectl console / Xcode
/// console 都只能看到 `.error` 及以上。`print` 直写 stderr 必达,所以诊断期
/// 统一走这里;带毫秒时间戳以便与 CoreLocation 的 os_log 行对齐时序。
/// 同时落盘到 `Documents/diag.log`:用 Xcode 跑真机时 print 只进 Xcode console
/// 外部拿不到;落盘后可用
/// xcrun devicectl device copy from --domain-type appDataContainer \
/// --domain-identifier com.skyapp.ylgamehall --source Documents/diag.log ...
/// 把整轮日志取出来分析。
nonisolated func diagLog(_ message: String) {
let line = "[DIAG \(diagTimestamp())] \(message)"
print(line)
diagFileSink.append(line)
}
private nonisolated let diagFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f
}()
private nonisolated func diagTimestamp() -> String {
diagFormatter.string(from: Date())
}
/// 串行写入 `Documents/diag.log`。每个进程首次写入前插一行 session 分隔,
/// 便于区分多次启动。纯诊断设施,排查结束随 diagLog 一并移除。
// 工程默认 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor,需显式 nonisolated 才能在
// @Sendable 桥回调 / evaluateJavaScript 完成块里同步调用。
private nonisolated final class DiagFileSink: @unchecked Sendable {
private let queue = DispatchQueue(label: "ylgamehall.diag.log")
private let url: URL
private var wroteHeader = false
init() {
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
url = dir.appendingPathComponent("diag.log")
}
func append(_ line: String) {
queue.async { [self] in
var text = line + "\n"
if !wroteHeader {
wroteHeader = true
let stamp = ISO8601DateFormatter().string(from: Date())
text = "\n===== session \(stamp) pid=\(ProcessInfo.processInfo.processIdentifier) =====\n" + text
}
guard let data = text.data(using: .utf8) else { return }
let fm = FileManager.default
if !fm.fileExists(atPath: url.path) {
fm.createFile(atPath: url.path, contents: nil)
}
guard let handle = try? FileHandle(forWritingTo: url) else { return }
handle.seekToEndOfFile()
handle.write(data)
try? handle.close()
}
}
}
private nonisolated let diagFileSink = DiagFileSink()
/// H5 ↔ Native 消息总线。
///
/// **运行时约束**:必须挂在 WKWebView 上、@MainActor 单线程访问。
/// **生命周期**:构造时自动向 `WKUserContentController` 注册 `WVJBHandler` 通道,
/// 销毁时由 WebView 链路自然释放(无需手动 remove)。
@MainActor
public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
/// JS → Native 的 WKScriptMessageHandler 通道名。
/// JS 端通过 `window.webkit.messageHandlers.WVJBHandler.postMessage({...})` 发送。
public static let scriptMessageHandlerName = "WVJBHandler"
/// JS 端注入的全局桥对象名(约定 `window.WebViewJavascriptBridge`)。
public static let jsBridgeName = "WebViewJavascriptBridge"
/// 容器标识(`lobby` / `subGame` / …),仅用于诊断日志区分是哪个 WebView 的桥。
/// 不参与任何契约行为。
public let label: String
private weak var webView: WKWebView?
private var handlers: [String: BridgeHandler] = [:]
private var pendingCallbacks: [String: BridgeCallback] = [:]
private var nativeCallbackCounter: UInt64 = 0
/// 桥就绪前的消息暂存队列。等价 msext `WebViewJavascriptBridgeBase.startupMessageQueue`
/// `_queueMessage` 在 bridge 尚未 loaded 时入队,`injectJavascriptFile` 时按序 flush。
///
/// 早前的实现在 `window.WebViewJavascriptBridge` 不存在时**直接丢弃**消息 ——
/// 真机日志里每次启动都有一条 `sendToJS 'getnetwork' 未送达 H5no-bridge`
/// msext 里这条是会补发到 H5 的。子游戏 WebView 是全新建的、H5 很早就调
/// `startlocation`,反向 `getlocationinfo` 落在这个窗口里就会被静默吞掉。
private var startupMessageQueue: [[String: Any]] = []
/// H5 侧 WVJB.js 初始化完成后会 postMessage `{bridgeReady:true}`,收到即置 true 并 flush。
private var bridgeReady = false
/// 构造时绑定 WebView + 用户内容控制器,自动注册 `WVJBHandler` 消息处理器。
public init(webView: WKWebView,
controller: WKUserContentController,
label: String = "webview") {
self.webView = webView
self.label = label
super.init()
controller.add(self, name: Self.scriptMessageHandlerName)
}
// MARK: - BridgeProtocol
public func register(_ name: String, handler: @escaping BridgeHandler) {
handlers[name] = handler
diagLog("[\(self.label)] register handler '\(name)'")
}
public func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) {
var payload: [String: Any] = ["handlerName": name]
if let data {
payload["data"] = data.jsonObject
}
if let callback {
nativeCallbackCounter += 1
let cbId = "objc_cb_\(nativeCallbackCounter)"
pendingCallbacks[cbId] = callback
payload["callbackId"] = cbId
}
// 诊断:打出实际序列化后的字节,确认字段集合 / 类型 / 引号与 msext 一致
let wire = (try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]))
.flatMap { String(data: $0, encoding: .utf8) } ?? "<encode-failed>"
diagLog("[\(self.label)] → H5 callHandler '\(name)' wire=\(wire)")
sendToJS(payload: payload)
}
// MARK: - WKScriptMessageHandler
public func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
// body 可能是单条 dict 或一批 dict arrayWVJB 支持批处理)
if let batch = message.body as? [[String: Any]] {
for msg in batch { handleIncoming(msg) }
} else if let single = message.body as? [String: Any] {
handleIncoming(single)
} else {
print("[BridgeBus] unrecognized message body type: \(type(of: message.body))")
}
}
// MARK: - 私有
private func handleIncoming(_ msg: [String: Any]) {
// H5 侧 WVJB.js 初始化完成信号(等价 msext 的 `__bridge_loaded__`)。
// 每次导航都会重新注入 user script,因此会重复收到;每次都要重新 flush。
if msg["bridgeReady"] as? Bool == true {
bridgeReady = true
let queued = startupMessageQueue
startupMessageQueue.removeAll()
if !queued.isEmpty {
diagLog("[\(self.label)] 桥就绪,flush \(queued.count) 条暂存消息")
}
for payload in queued { dispatchToJS(payload: payload) }
return
}
// 诊断上报:H5 侧派发结果(有没有对应 handler、handler 有没有抛错)
if let dispatched = msg["diagDispatched"] as? String {
let had = msg["hadHandler"] as? Bool ?? false
let threw = msg["threw"] as? String
diagLog("[\(self.label)] H5 派发 '\(dispatched)'hadHandler=\(had) threw=\(threw ?? "nil")")
return
}
// 优先判 responseIdJS 响应 Native 早前的 callHandler
if let responseId = msg["responseId"] as? String {
let respData = msg["responseData"].flatMap { BridgeData(jsonObject: $0) }
if let cb = pendingCallbacks.removeValue(forKey: responseId) {
cb(respData)
} else {
print("[BridgeBus] stale responseId: \(responseId)")
}
return
}
// 否则是 JS → Native handler 调用
guard let name = msg["handlerName"] as? String else {
print("[BridgeBus] message missing handlerName: \(msg)")
return
}
let data = msg["data"].flatMap { BridgeData(jsonObject: $0) }
let callbackId = msg["callbackId"] as? String
// 构造一个回调闭包,handler 调用即把 responseData 异步派回 JS
let responseCallback: BridgeCallback? = makeResponseCallback(for: callbackId)
diagLog("[\(self.label)] ← H5 call '\(name)' data=\(String(describing: msg["data"]))")
guard let handler = handlers[name] else {
// msext `WebViewJavascriptBridgeBase.flushMessageQueue`
// if (!handler) { NSLog(@"WVJBNoHandlerException, ..."); continue; }
// `continue` 意味着 **responseCallback 一次都不会被调用**。
//
// 早前这里调了 `responseCallback?(nil)`,会给 H5 回一条 {responseId:...}。
// 现网 H5 的 `Func.getlocation()` 就是带 callback 调 `getlocationinfo` 的
// `bridge.callHandler('getlocationinfo',"",function(resp){})`,而
// `getlocationinfo` 两边都没有对应的原生 handler),于是这条在原工程里
// 永不触发的 JS 回调在我们这儿会被触发一次 —— 链条不一致。改为不回。
diagLog("[\(self.label)] no NATIVE handler registered for '\(name)'msext 同款:不回 responseCallback")
return
}
// handler 是 async,独立 Task 内 await;本方法保持 sync 返回
Task {
await handler(data, responseCallback)
}
}
private func makeResponseCallback(for callbackId: String?) -> BridgeCallback? {
guard let id = callbackId else { return nil }
return { [weak self] respData in
Task { @MainActor in
self?.sendResponseToJS(responseId: id, data: respData)
}
}
}
private func sendResponseToJS(responseId: String, data: BridgeData?) {
var payload: [String: Any] = ["responseId": responseId]
if let data {
payload["responseData"] = data.jsonObject
}
sendToJS(payload: payload)
}
/// 等价 msext `WebViewJavascriptBridgeBase._queueMessage:`
/// 桥未就绪则入 `startupMessageQueue`,就绪则直接派发。
private func sendToJS(payload: [String: Any]) {
guard bridgeReady else {
let what = (payload["handlerName"] as? String) ?? (payload["responseId"] as? String) ?? "?"
diagLog("[\(self.label)] '\(what)' 桥未就绪 → 入队(msext startupMessageQueue 等价,队列长 \(startupMessageQueue.count + 1)")
startupMessageQueue.append(payload)
return
}
dispatchToJS(payload: payload)
}
/// 等价 msext `_dispatchMessage:`:序列化 → 注入 `_handleMessageFromObjC`。
/// H5 侧派发是 `setTimeout` 异步的(msext dispatchMessagesWithTimeoutSafety=true),
/// 因此 evaluateJavaScript 的返回值**不代表** H5 handler 是否执行,不要据此判断送达。
private func dispatchToJS(payload: [String: Any]) {
guard let webView else {
diagLog("[\(self.label)] dispatchToJS 丢弃:webView 已释放")
return
}
guard let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8)
else {
diagLog("[\(self.label)] JSON encode failed for payload: \(String(describing: payload))")
return
}
// base64 包装避免 JSON 内单引号 / 反斜杠扰乱 JS 字符串字面量
// msext 是逐个 escape 反斜杠/引号/换行/U+2028/U+2029,等价)
let base64 = Data(json.utf8).base64EncodedString()
let js = "window.\(Self.jsBridgeName)._handleMessageFromObjC('\(base64)');"
let tag = label
let what = (payload["handlerName"] as? String) ?? (payload["responseId"] as? String) ?? "?"
webView.evaluateJavaScript(js) { _, error in
if let error {
diagLog("[\(tag)] dispatchToJS '\(what)' evaluateJavaScript 失败: \(error.localizedDescription)")
}
}
}
}