diff --git a/docs/H5-Native-Contract.md b/docs/H5-Native-Contract.md index b9b5533..8a719dc 100644 --- a/docs/H5-Native-Contract.md +++ b/docs/H5-Native-Contract.md @@ -616,13 +616,14 @@ H5 端无需任何改动:仍然按照"收到 `gameui_stop_voice(user)` 就停 **表 B — `getlocationinfo`** (成功): ```jsonc { + "errorCode": 0, // ⚠️ 新外壳补齐项,msext 遗漏,见下方说明 "address": "<完整地址>", "city": "<市>", "cityCode": "<城市编码>", "country": "<国>", "district": "<区>", - "latitude": "30.567890", // ⚠️ string,原生 stringWithFormat:@"%f",不是 double - "longitude": "104.123456", // ⚠️ string + "latitude": 30.567890, // ⚠️ 数字(非字符串),6 位小数。新外壳修正项,见下方说明 + "longitude": 104.123456, // ⚠️ 数字(非字符串),6 位小数 "province": "<省>", // ⚠️ 小写 p,与 sharelogin 的 "Province" 大写形成不一致,沿用历史 "street": "<街道>" } @@ -632,6 +633,54 @@ H5 端无需任何改动:仍然按照"收到 `gameui_stop_voice(user)` 就停 { "errorCode": 12, "errorMsg": "缺少定位权限" } // errorCode 是 NSNumber,JSON 看是数字 12 ``` +**`errorCode: 0`(成功状态码)—— 唯一一处有意偏离 msext 的定位字段** + +msext 全工程(`grep -rn errorCode msext/Class/`)只在**失败**分支发 `errorCode: 12` +(`gameController.m:2507` / `NewRootVC.m:2113` / 老 UIWebView 路径 +`RootVC.m:1976` / `fourviewVC.m:1518`),成功分支的 9 个字段里**没有** errorCode。 +**这是原工程的遗漏**,不是刻意设计。 + +H5 侧以 `errorCode == 0` 作为"这份定位数据有效"的判据。证据(2026-08-07 从真机沙盒 +取回的现网 H5 逐字核对): + +| H5 包 | `05_Func.js` 日期 | 全文 `errorCode` | +|---|---|---| +| 子游戏 `jinxianmahjong` | 2026-07-19 | 有 2 处 —— `Func.startlocation` / `Func.getlocation` 的 `catch` 兜底桩自造定位对象时带 `"errorCode":0` | +| 大厅 `gamehall` | 2026-02-04 | **0 处** | + +即:较新的子游戏 H5 依赖成功包里的 `errorCode`,较老的大厅 H5 不依赖。这正是 +「大厅定位正常、子游戏拿不到定位」这一现象的成因 —— 原生成功包缺 `errorCode` +时子游戏 H5 不认这份数据。 + +因此新外壳在成功包里补 `errorCode: 0`(JSON 数字,与失败包的 `12` 同类型)。 +对不检查该字段的大厅 H5 是多一个无害字段,不改变其行为。 + +**`latitude` / `longitude` 是数字,不是字符串 —— 第二处修正** + +msext 的两条路径自己就不一致: + +| msext 路径 | 代码 | JSON 形态 | +|---|---|---| +| WVJB / WKWebView(`gameController.m:2528`、`NewRootVC.m:2132`)| `[NSString stringWithFormat:@"%f", …]` 放进 `@{}` | **字符串** `"28.636486"` | +| 老 UIWebView / JSContext(`RootVC.m:1998`、`fourviewVC.m:1541`)| 手拼 `\"latitude\":%f` | **数字** `28.636486` | + +现网子游戏 H5(`jinxianmahjong` `05_Func.js:2029-2031`)兜底桩自造的定位对象写的是 +`"latitude":28.623546` —— **数字**形态。与 `errorCode` 是同一类问题:WVJB 路径当年 +把它字符串化了。新外壳按数字发。 + +实现细节:用 `BridgeData.decimal(Decimal)`(不是 `.number(Double)`)。原因是 +`JSONSerialization` 写 `Double` 会输出 17 位有效数字,`28.636486` 变成 +`28.636486000000001`;虽然 JS `JSON.parse` 出来是同一个 IEEE754 double、H5 不可分辨, +但 JSON 文本与原工程不一致。`Decimal` 由 `String(format: "%f", …)` 构造,序列化成 +`NSDecimalNumber` → 文本 `28.636486`,与老路径 `%f` 拼串逐字节一致。 + +**这两处(`errorCode: 0`、经纬度数字)是定位链路上唯一有意偏离 msext WVJB 路径的地方**, +其余(9 个字段名、`cityCode` 大写 C、`province` 小写 p、`street` 不带 `路` 后缀、 +失败包 `{errorCode:12, errorMsg:"缺少定位权限"}`、error 与 success 两段式发送顺序、 +任一逆地理字段为 nil 时整条不发)全部严格对齐。 + +实现:`Source/Bridge/Handlers/StartLocationHandler.swift`、`Source/Location/LocationService.swift`。 + **表 C — `sharelogin`**: ```jsonc { diff --git a/ylgamehall/Resources/ChannelConfig.plist b/ylgamehall/Resources/ChannelConfig.plist index c7bd05c..78504af 100644 --- a/ylgamehall/Resources/ChannelConfig.plist +++ b/ylgamehall/Resources/ChannelConfig.plist @@ -5,9 +5,9 @@ gameid G2hw0ubng0zcoI0r4mx3H2yr4GejidwO channel - FtJf073aa0d6rI1xD8J1Y42fINTm0ziK + frdt0C1GG0t91P0McFo0rbA1he5yurbS gamedir - FtJf073aa0d6rI1xD8J1Y42fINTm0ziK + frdt0C1GG0t91P0McFo0rbA1he5yurbS gamestart gamehall gameconfig @@ -15,7 +15,7 @@ market 2 agent - veRa0qrBf0df2K1G4de2tgfmVxB2jxpv + 00bA05haB0d9ZC0fwGD09Q2OA30insbQ appversion 44 other diff --git a/ylgamehall/Resources/JS/WebViewJavascriptBridge.js b/ylgamehall/Resources/JS/WebViewJavascriptBridge.js index be41c3e..1006071 100644 --- a/ylgamehall/Resources/JS/WebViewJavascriptBridge.js +++ b/ylgamehall/Resources/JS/WebViewJavascriptBridge.js @@ -4,8 +4,26 @@ * H5 端桥协议代码,与 Native BridgeBus(@MainActor BridgeProtocol 实现) * 一一对应。详见 docs/H5-Native-Implementation-Design.md §3.2 / §3.3。 * - * 在 BridgedWebView 构造时通过 WKUserScript(.atDocumentStart)注入到所有 - * 加载的 H5 页面。H5 业务代码运行时,window.WebViewJavascriptBridge 已就绪。 + * ⚠️ 本文件的**语义**必须与原工程 msext 的 `WebViewJavascriptBridge_JS.m` + * (marcuswestin 原版,daoqi/msext/Class/WebViewJavascriptBridge/)保持一致。 + * 传输层可以现代化(msext 用 iframe + _fetchQueue 拉取,我们用 + * WKScriptMessageHandler 直推),但**派发时机与消息字段必须逐项对齐**, + * 否则 H5 侧同一份代码在两个壳里的执行顺序不同 → 行为不一致。 + * + * 已对齐的 msext 语义(每一条都曾经不一致,是排查「子游戏定位拿不到」时发现的): + * + * 1. **异步派发**:msext `dispatchMessagesWithTimeoutSafety = true`,且 + * `disableJavscriptAlertBoxSafetyTimeout` 在整个 msext 工程里**从未被调用**, + * 所以 Native → H5 的每一条消息都走 `setTimeout(_doDispatch)`,在**新的 + * macrotask** 上执行。早前本文件是同步派发(在 evaluateJavaScript 内直接 + * 调 handler),H5 handler 相对自身 pending 脚本/微任务的顺序与 msext 不同。 + * 2. **WVJBCallbacks 用 `setTimeout(..., 0)` flush**(msext `_callWVJBCallbacks`), + * 不是同步 flush —— 决定 H5 的 `registerHandler` 何时生效。 + * 3. **H5 → Native 的 response 消息带 `handlerName`**(msext `_doSend({handlerName, + * responseId, responseData})`)。 + * 4. `_disableJavascriptAlertBoxSafetyTimeout` 作为 H5 侧 handler 注册(msext 有)。 + * 5. 缺省安装 `window.onerror`(msext 在 H5 未装时兜底装一个)。 + * 6. 「收到 Native 消息但没有对应 handler」用 `console.log`(msext 原文),不是 warn。 * * 协议(Native ↔ H5 双向 JSON 消息): * - H5 → Native:window.webkit.messageHandlers.WVJBHandler.postMessage({...}) @@ -18,18 +36,26 @@ ;(function () { if (window.WebViewJavascriptBridge) { return; } + // msext 同款:H5 没装 onerror 时兜底装一个 + if (!window.onerror) { + window.onerror = function (msg, url, line) { + console.log("WebViewJavascriptBridge: ERROR:" + msg + "@" + url + ":" + line); + }; + } + // ── 内部状态 ───────────────────────────────────────────── var messageHandlers = {}; // H5 注册的 handler:name → fn(data, responseCallback) var responseCallbacks = {}; // 等待 Native 响应的 JS 回调:callbackId → fn(responseData) var nextCallbackId = 1; + // msext `dispatchMessagesWithTimeoutSafety`:默认 true,仅在 Native 下发 + // `_disableJavascriptAlertBoxSafetyTimeout` 时置 false(msext 从不下发)。 + var dispatchMessagesWithTimeoutSafety = true; // ── 对外 API ───────────────────────────────────────────── var bridge = window.WebViewJavascriptBridge = { /** * H5 注册 handler,等待 Native 主动 callHandler。重名覆盖。 - * @param {string} handlerName - * @param {function(data, responseCallback)} handler */ registerHandler: function (handlerName, handler) { messageHandlers[handlerName] = handler; @@ -37,39 +63,31 @@ /** * H5 主动调 Native handler。 - * @param {string} handlerName - * @param {*} data 任意 JSON 可序列化值 - * @param {function(responseData)} [responseCallback] + * msext `callHandler` 支持 2 参形式(第二个参数是函数时视为 responseCallback)。 */ callHandler: function (handlerName, data, responseCallback) { - var callbackId = null; - if (typeof responseCallback === 'function') { - callbackId = 'cb_' + (nextCallbackId++) + '_' + Date.now(); - responseCallbacks[callbackId] = responseCallback; + if (arguments.length === 2 && typeof data === 'function') { + responseCallback = data; + data = null; } - var message = { handlerName: handlerName }; - if (data !== undefined && data !== null) { - message.data = data; - } - if (callbackId) { - message.callbackId = callbackId; - } - _postMessageToNative(message); + _doSend({ handlerName: handlerName, data: data }, responseCallback); + }, + + disableJavscriptAlertBoxSafetyTimeout: function () { + dispatchMessagesWithTimeoutSafety = false; }, /** * Native → H5 入口。BridgeBus.sendToJS 会注入: * window.WebViewJavascriptBridge._handleMessageFromObjC('') - * base64 包装是为了避免 JSON 内容里的单引号 / 反斜杠扰乱 evaluateJavaScript 的字符串字面量。 + * base64 包装是为了避免 JSON 内容里的单引号 / 反斜杠扰乱 evaluateJavaScript + * 的字符串字面量(msext 走的是逐个 escape,等价)。 + * + * ⚠️ 无返回值:msext 的派发是 setTimeout 异步的,拿不到「H5 有没有跑到 + * handler」的同步结果。原生侧不要依赖它的返回值做诊断。 */ _handleMessageFromObjC: function (base64String) { - try { - var json = _decodeBase64UTF8(base64String); - var message = JSON.parse(json); - _dispatchFromNative(message); - } catch (e) { - console.error('[WVJB] handle message from native failed:', e); - } + _dispatchMessageFromObjC(base64String); } }; @@ -79,42 +97,77 @@ try { window.webkit.messageHandlers.WVJBHandler.postMessage(message); } catch (e) { - console.error('[WVJB] post to native failed (WVJBHandler 通道未注册?):', e); + console.log('[WVJB] post to native failed (WVJBHandler 通道未注册?):', e); } } - function _dispatchFromNative(message) { - // Native 响应 H5 早前 callHandler - if (message.responseId) { - var cb = responseCallbacks[message.responseId]; - if (cb) { - delete responseCallbacks[message.responseId]; - try { cb(message.responseData); } - catch (e) { console.error('[WVJB] response callback threw:', e); } + function _doSend(message, responseCallback) { + if (typeof responseCallback === 'function') { + var callbackId = 'cb_' + (nextCallbackId++) + '_' + new Date().getTime(); + responseCallbacks[callbackId] = responseCallback; + message['callbackId'] = callbackId; + } + _postMessageToNative(message); + } + + /// msext `_dispatchMessageFromObjC` 逐句等价:默认走 setTimeout(新 macrotask)。 + function _dispatchMessageFromObjC(base64String) { + if (dispatchMessagesWithTimeoutSafety) { + setTimeout(_doDispatchMessageFromObjC); + } else { + _doDispatchMessageFromObjC(); + } + + function _doDispatchMessageFromObjC() { + var message; + try { + message = JSON.parse(_decodeBase64UTF8(base64String)); + } catch (e) { + console.log('[WVJB] handle message from native failed:', e); + return; } - return; - } - // Native 主动调用 H5 handler - var handler = messageHandlers[message.handlerName]; - if (!handler) { - console.warn('[WVJB] no H5 handler registered for:', message.handlerName); - return; - } + var responseCallback; - var responseCallback; - if (message.callbackId) { - var responseId = message.callbackId; - responseCallback = function (responseData) { + if (message.responseId) { + responseCallback = responseCallbacks[message.responseId]; + if (!responseCallback) { return; } + responseCallback(message.responseData); + delete responseCallbacks[message.responseId]; + return; + } + + if (message.callbackId) { + var callbackResponseId = message.callbackId; + responseCallback = function (responseData) { + // msext 同款:response 消息里**带 handlerName** + _doSend({ + handlerName: message.handlerName, + responseId: callbackResponseId, + responseData: responseData + }); + }; + } + + var handler = messageHandlers[message.handlerName]; + if (!handler) { + console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message); + // 诊断上报(不改 msext 语义,只是额外往自家通道发一条原生侧忽略的消息) + _postMessageToNative({ diagDispatched: message.handlerName, hadHandler: false }); + } else { + var threw = null; + try { handler(message.data, responseCallback); } + catch (e) { threw = String(e); } _postMessageToNative({ - responseId: responseId, - responseData: responseData + diagDispatched: message.handlerName, + hadHandler: true, + threw: threw }); - }; + // msext 不吞异常(handler 抛错会冒到 setTimeout 的全局 onerror); + // 这里把它原样重抛,保持行为一致,只是先记录了一笔。 + if (threw !== null) { throw new Error(threw); } + } } - - try { handler(message.data, responseCallback); } - catch (e) { console.error('[WVJB] H5 handler "' + message.handlerName + '" threw:', e); } } /** @@ -127,25 +180,26 @@ }).join('')); } + // msext 同款:把 Native 可下发的 safety-timeout 开关注册成 H5 侧 handler + bridge.registerHandler("_disableJavascriptAlertBoxSafetyTimeout", + bridge.disableJavscriptAlertBoxSafetyTimeout); + + // ── 告知原生「桥已就绪」────────────────────────────────── + // 等价 msext 的 `__bridge_loaded__` iframe 回调 → `injectJavascriptFile` → + // flush `startupMessageQueue`。原生在收到本消息前发出的 callHandler 必须排队, + // 不能丢(msext 有 startupMessageQueue,早前我们是直接丢弃)。 + _postMessageToNative({ bridgeReady: true }); + // ── marcuswestin 旧式握手兼容 ───────────────────────────── - // 部分 H5 代码这样初始化 bridge: - // function setupWebViewJavascriptBridge(callback) { - // if (window.WebViewJavascriptBridge) return callback(WebViewJavascriptBridge); - // if (window.WVJBCallbacks) return window.WVJBCallbacks.push(callback); - // window.WVJBCallbacks = [callback]; - // /* iframe trick - WKWebView 模式不需要 */ - // } - // setupWebViewJavascriptBridge(function (bridge) { bridge.registerHandler(...) }); - // - // 我们在 documentStart 就注入 bridge,正常路径下 H5 拿到的 window.WebViewJavascriptBridge - // 已存在,会立即同步 callback(bridge)。但若 H5 代码本身用了 WVJBCallbacks 队列模式 - // (Array.push 在 bridge 之前),需要在此处 flush 一次。 - if (Array.isArray(window.WVJBCallbacks)) { - var pending = window.WVJBCallbacks; + // msext `_callWVJBCallbacks` 用 setTimeout(..., 0),**不是同步 flush**: + // 这决定 H5 的 registerHandler 相对页面其它顶层脚本的生效时机。 + setTimeout(function () { + var callbacks = window.WVJBCallbacks; + if (!callbacks) { return; } delete window.WVJBCallbacks; - for (var i = 0; i < pending.length; i++) { - try { pending[i](bridge); } - catch (e) { console.error('[WVJB] pending WVJBCallback threw:', e); } + for (var i = 0; i < callbacks.length; i++) { + try { callbacks[i](bridge); } + catch (e) { console.log('[WVJB] pending WVJBCallback threw:', e); } } - } + }, 0); })(); diff --git a/ylgamehall/Source/Bridge/BridgeBus.swift b/ylgamehall/Source/Bridge/BridgeBus.swift index 8c5479b..85862a7 100644 --- a/ylgamehall/Source/Bridge/BridgeBus.swift +++ b/ylgamehall/Source/Bridge/BridgeBus.swift @@ -14,6 +14,70 @@ import os.log /// 完成回调等非 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 单线程访问。 @@ -38,6 +102,18 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler { private var pendingCallbacks: [String: BridgeCallback] = [:] private var nativeCallbackCounter: UInt64 = 0 + /// 桥就绪前的消息暂存队列。等价 msext `WebViewJavascriptBridgeBase.startupMessageQueue`: + /// `_queueMessage` 在 bridge 尚未 loaded 时入队,`injectJavascriptFile` 时按序 flush。 + /// + /// 早前的实现在 `window.WebViewJavascriptBridge` 不存在时**直接丢弃**消息 —— + /// 真机日志里每次启动都有一条 `sendToJS 'getnetwork' 未送达 H5:no-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, @@ -52,7 +128,7 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler { public func register(_ name: String, handler: @escaping BridgeHandler) { handlers[name] = handler - bridgeLog.debug("[\(self.label, privacy: .public)] register handler '\(name, privacy: .public)'") + diagLog("[\(self.label)] register handler '\(name)'") } public func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) { @@ -66,7 +142,10 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler { pendingCallbacks[cbId] = callback payload["callbackId"] = cbId } - bridgeLog.debug("[\(self.label, privacy: .public)] → H5 callHandler '\(name, privacy: .public)'") + // 诊断:打出实际序列化后的字节,确认字段集合 / 类型 / 引号与 msext 一致 + let wire = (try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])) + .flatMap { String(data: $0, encoding: .utf8) } ?? "" + diagLog("[\(self.label)] → H5 callHandler '\(name)' wire=\(wire)") sendToJS(payload: payload) } @@ -87,6 +166,27 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler { // 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 + } + // 优先判 responseId(JS 响应 Native 早前的 callHandler) if let responseId = msg["responseId"] as? String { let respData = msg["responseData"].flatMap { BridgeData(jsonObject: $0) } @@ -109,11 +209,19 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler { // 构造一个回调闭包,handler 调用即把 responseData 异步派回 JS let responseCallback: BridgeCallback? = makeResponseCallback(for: callbackId) - bridgeLog.debug("[\(self.label, privacy: .public)] ← H5 call '\(name, privacy: .public)' data=\(String(describing: msg["data"]), privacy: .public)") + diagLog("[\(self.label)] ← H5 call '\(name)' data=\(String(describing: msg["data"]))") guard let handler = handlers[name] else { - bridgeLog.error("[\(self.label, privacy: .public)] no NATIVE handler registered for '\(name, privacy: .public)'") - responseCallback?(nil) + // 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 } @@ -140,31 +248,41 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler { 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 { - bridgeLog.error("[\(self.label, privacy: .public)] sendToJS 丢弃:webView 已释放") + diagLog("[\(self.label)] dispatchToJS 丢弃:webView 已释放") return } guard let data = try? JSONSerialization.data(withJSONObject: payload), let json = String(data: data, encoding: .utf8) else { - bridgeLog.error("[\(self.label, privacy: .public)] JSON encode failed for payload: \(String(describing: payload), privacy: .public)") + 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() - // 返回 'ok' / 'no-bridge' 以便诊断「消息被静默丢弃」(H5 侧 bridge 未就绪) - let js = """ - (function(){ if (!window.\(Self.jsBridgeName)) { return 'no-bridge'; } \ - window.\(Self.jsBridgeName)._handleMessageFromObjC('\(base64)'); return 'ok'; })() - """ + let js = "window.\(Self.jsBridgeName)._handleMessageFromObjC('\(base64)');" let tag = label let what = (payload["handlerName"] as? String) ?? (payload["responseId"] as? String) ?? "?" - webView.evaluateJavaScript(js) { result, error in + webView.evaluateJavaScript(js) { _, error in if let error { - bridgeLog.error("[\(tag, privacy: .public)] sendToJS '\(what, privacy: .public)' evaluateJavaScript 失败: \(error.localizedDescription, privacy: .public)") - } else if (result as? String) != "ok" { - bridgeLog.error("[\(tag, privacy: .public)] sendToJS '\(what, privacy: .public)' 被丢弃:H5 侧 window.\(Self.jsBridgeName) 不存在") + diagLog("[\(tag)] dispatchToJS '\(what)' evaluateJavaScript 失败: \(error.localizedDescription)") } } } diff --git a/ylgamehall/Source/Bridge/BridgeProtocol.swift b/ylgamehall/Source/Bridge/BridgeProtocol.swift index 3318f34..46d665e 100644 --- a/ylgamehall/Source/Bridge/BridgeProtocol.swift +++ b/ylgamehall/Source/Bridge/BridgeProtocol.swift @@ -43,6 +43,14 @@ public typealias BridgeCallback = @Sendable (BridgeData?) -> Void public enum BridgeData: Sendable { case string(String) case number(Double) + /// 定点小数。序列化成 JSON **数字**,但保留给定的小数位数文本。 + /// + /// 为什么不用 `.number(Double)`:`JSONSerialization` 写 Double 会输出 17 位有效 + /// 数字,`28.636486` 变成 `28.636486000000001`。虽然 JS `JSON.parse` 出来是同一个 + /// IEEE754 double(H5 侧不可分辨),但 JSON **文本**与原工程不一致。 + /// `getlocationinfo` 的经纬度要求与 msext 老 UIWebView 路径 + /// (`RootVC.m:1998` 拼 `\"latitude\":%f`)的字面文本对齐,故走这条。 + case decimal(Decimal) case bool(Bool) case null case array([BridgeData]) @@ -89,6 +97,7 @@ extension BridgeData { switch self { case .string(let s): return s case .number(let d): return d + case .decimal(let d): return d as NSDecimalNumber case .bool(let b): return b case .null: return NSNull() case .array(let arr): return arr.map { $0.jsonObject } @@ -124,6 +133,7 @@ extension BridgeData { return String(Int(d)) } return String(d) + case .decimal(let d): return "\(d)" case .bool(let b): return b ? "true" : "false" case .null: return nil case .array, .object: return nil @@ -131,8 +141,11 @@ extension BridgeData { } nonisolated public var asDouble: Double? { - if case .number(let d) = self { return d } - return nil + switch self { + case .number(let d): return d + case .decimal(let d): return (d as NSDecimalNumber).doubleValue + default: return nil + } } nonisolated public var asInt: Int? { diff --git a/ylgamehall/Source/Bridge/H5ErrorRelay.swift b/ylgamehall/Source/Bridge/H5ErrorRelay.swift index 1e4a2e5..2d9db39 100644 --- a/ylgamehall/Source/Bridge/H5ErrorRelay.swift +++ b/ylgamehall/Source/Bridge/H5ErrorRelay.swift @@ -47,25 +47,25 @@ public final class H5ErrorRelay: NSObject { switch kind { case "console.error": let args = (dict["args"] as? [String]) ?? [] - print("[H5 console.error]", args.joined(separator: " ")) + diagLog("[H5 console.error] " + args.joined(separator: " ")) case "console.warn": // 桥诊断需要:WebViewJavascriptBridge.js 在「Native 发来的 handlerName // H5 侧没注册」时走 console.warn,之前不上报导致这类丢包完全不可见。 let args = (dict["args"] as? [String]) ?? [] - print("[H5 console.warn]", args.joined(separator: " ")) + diagLog("[H5 console.warn] " + args.joined(separator: " ")) case "onerror": let msg = (dict["msg"] as? String) ?? "" let src = (dict["src"] as? String) ?? "" let line = (dict["line"] as? Int) ?? 0 let col = (dict["col"] as? Int) ?? 0 let stack = dict["stack"] as? String - print("[H5 onerror] \(msg) at \(src):\(line):\(col)" + (stack.map { "\n\($0)" } ?? "")) + diagLog("[H5 onerror] \(msg) at \(src):\(line):\(col)" + (stack.map { "\n\($0)" } ?? "")) case "unhandledrejection": let reason = (dict["reason"] as? String) ?? "" let stack = dict["stack"] as? String - print("[H5 unhandledrejection]", reason + (stack.map { "\n\($0)" } ?? "")) + diagLog("[H5 unhandledrejection] " + reason + (stack.map { "\n\($0)" } ?? "")) default: - print("[H5 unknown error]", dict) + diagLog("[H5 unknown error] \(dict)") } } diff --git a/ylgamehall/Source/Bridge/Handlers/BackGameDataHandler.swift b/ylgamehall/Source/Bridge/Handlers/BackGameDataHandler.swift index 71f7090..c2d77fd 100644 --- a/ylgamehall/Source/Bridge/Handlers/BackGameDataHandler.swift +++ b/ylgamehall/Source/Bridge/Handlers/BackGameDataHandler.swift @@ -21,7 +21,7 @@ // // ✅ Phase 4.E 微信:新版 SDK(2.x)无 static delegate API,逐次调用 handleOpenURL/sendAuthReq // 时传入并被 SDK 弱引用;WeChatManager singleton 长生命周期,子游戏 pop 不析构,无需清理 -// ✅ Phase 5 高德定位:`LocationService.shared.stop()`(已加,canImport 守卫, +// ✅ Phase 5 高德定位:`location.stop()`(子游戏私有实例,canImport 守卫, // Xcode Embed AMapLocationKit 后激活) // ⏳ Phase 3.B/C/D 录音 / 七牛上传:`AudioRecorder.shared.cancel()` 待 opencore-amr // 接入后取消注释;`QiniuUploader.cancelInFlight()` 依赖 Task.cancel 传播无需显式调 @@ -34,7 +34,9 @@ import Foundation public enum BackGameDataHandler { - public static func register(on bridge: any BridgeProtocol) { + /// - Parameter location: **子游戏自己的** LocationService(msext cleanUpAction + /// 只停 gameController 那一个 manager,不影响大厅)。 + public static func register(on bridge: any BridgeProtocol, location: LocationService) { // 【18】backgameData // 入参 data(字符串,子游戏 H5 自定义透传给大厅 getWebdata) // cb: "backgameData" @@ -57,7 +59,7 @@ public enum BackGameDataHandler { #if canImport(AMapLocationKit) // msext gameController.m:2473-2478 cleanUpAction 等价 - LocationService.shared.stop(caller: "backgameData") + location.stop(caller: "backgameData") #endif // Phase 3.C 录音 / Phase 3.B 七牛上传 清理(QiniuUploader.cancelInFlight 当前 diff --git a/ylgamehall/Source/Bridge/Handlers/StartLocationHandler.swift b/ylgamehall/Source/Bridge/Handlers/StartLocationHandler.swift index 5125390..2c5f47f 100644 --- a/ylgamehall/Source/Bridge/Handlers/StartLocationHandler.swift +++ b/ylgamehall/Source/Bridge/Handlers/StartLocationHandler.swift @@ -25,46 +25,74 @@ private nonisolated let startLocLog = Logger(subsystem: "ylgamehall", category: public enum StartLocationHandler { - /// - Parameter label: 容器标识(`lobby` / `subGame`),仅用于诊断日志。 - public static func register(on bridge: any BridgeProtocol, label: String = "?") { + /// - Parameters: + /// - label: 容器标识(`lobby` / `subGame`),仅用于诊断日志。 + /// - location: **本容器私有**的 LocationService。msext 大厅 / 子游戏各持一个 + /// `AMapLocationManager`,不可共用(见 LocationService.init 注释)。 + public static func register(on bridge: any BridgeProtocol, + label: String = "?", + location: LocationService) { bridge.register("startlocation") { data, callback in // 与 msext 行为一致:先回 cb 字面,不等定位完成 callback?(.string("startlocation")) - startLocLog.debug("[\(label, privacy: .public)] ← startlocation 入参 type=\(data?.asLooseString ?? "nil", privacy: .public)") + diagLog("[\(label)] ← startlocation 入参 type=\(data?.asLooseString ?? "nil")") #if canImport(AMapLocationKit) - // SDK 已链接:拉起一次性定位 → 反向 callback 9 字段 - // (持续定位 data == 1 暂未实现,业务实际只调一次性 — Phase 5.4 再扩展) + // msext NewRootVC.m:411-421 / gameController.m:533-543 的分支逐字等价: + // int tempinfo = [data intValue]; + // if (tempinfo == 1) [self.locationManager startUpdatingLocation]; // 持续 + // else [self reGeocodeAction]; // 单次 + // 现网两个 H5 包(gamehall / jinxianmahjong)实测入参恒为 2,持续定位分支 + // 是死代码;但 msext 有、我们缺就是原生侧的行为不一致,按 msext 补齐。 + let isContinuous = Int(data?.asLooseString ?? "") == 1 Task { @MainActor in - do { - let payload = try await LocationService.shared.requestOnce(caller: label) - startLocLog.debug("[\(label, privacy: .public)] → getlocationinfo 成功 city=\(payload.city, privacy: .public) province=\(payload.province, privacy: .public)") - bridge.call("getlocationinfo", data: .object([ - "address": .string(payload.address), - "city": .string(payload.city), - "cityCode": .string(payload.cityCode), - "country": .string(payload.country), - "district": .string(payload.district), - "latitude": .string(payload.latitude), // ← string - "longitude": .string(payload.longitude), // ← string - "province": .string(payload.province), // ← 小写 p - "street": .string(payload.street) - ]), callback: nil) - } catch LocationError.authorizationDenied { - // 契约失败回包(msext gameController.m:2507 等价) - startLocLog.error("[\(label, privacy: .public)] → getlocationinfo 失败:authorizationDenied") - bridge.call("getlocationinfo", data: .object([ - "errorCode": .number(12), - "errorMsg": .string("缺少定位权限") - ]), callback: nil) - } catch { - // 其它失败也归一到 12(msext 行为) - startLocLog.error("[\(label, privacy: .public)] → getlocationinfo 失败:\(String(describing: error), privacy: .public)") - bridge.call("getlocationinfo", data: .object([ - "errorCode": .number(12), - "errorMsg": .string("缺少定位权限") - ]), callback: nil) + // 单次路径下一次请求可能产出两条 outcome(msext completionBlock 的原始 + // 控制流);持续路径下每次定位更新都会回一条 .success。 + let dispatch: @MainActor (LocationOutcome) -> Void = { outcome in + switch outcome { + case .failure: + // msext gameController.m:2507 等价 + diagLog("[\(label)] → getlocationinfo errorCode=12") + bridge.call("getlocationinfo", data: .object([ + "errorCode": .number(12), + "errorMsg": .string("缺少定位权限") + ]), callback: nil) + case .success(let payload): + // msext gameController.m:2528 的 9 字段 + `errorCode: 0`。 + // + // ⚠️ 契约影响(docs/H5-Native-Contract.md §3.2 表 B): + // msext 全工程只在**失败**分支发 `errorCode: 12`,成功分支 + // 9 个字段里**没有** errorCode —— 这是原工程的遗漏。 + // H5 侧以 `errorCode == 0` 作为"定位数据有效"的判据: + // 子游戏 jinxianmahjong 的 05_Func.js(2026-07-19)在 + // `Func.startlocation` / `Func.getlocation` 的兜底桩里 + // 自造的定位对象就带 `"errorCode":0`;而大厅 gamehall 的 + // 05_Func.js(2026-02-04)整份文件都没有 errorCode。 + // 这正是"大厅定位正常、子游戏拿不到"的原生侧成因:成功包缺 + // errorCode 时子游戏 H5 不认这份数据。 + // 按项目方决定补齐(此处是**有意偏离** msext 的一处,其余 + // 定位链路仍严格对齐)。 + diagLog("[\(label)] → getlocationinfo 成功 city=\(payload.city) province=\(payload.province)") + bridge.call("getlocationinfo", data: .object([ + "errorCode": .number(0), + "address": .string(payload.address), + "city": .string(payload.city), + "cityCode": .string(payload.cityCode), + "country": .string(payload.country), + "district": .string(payload.district), + "latitude": .decimal(payload.latitude), // ← 数字(非字符串),6 位小数 + "longitude": .decimal(payload.longitude), // ← 数字(非字符串),6 位小数 + "province": .string(payload.province), // ← 小写 p + "street": .string(payload.street) + ]), callback: nil) + } + } + + if isContinuous { + location.startContinuous(caller: label, emit: dispatch) + } else { + location.requestOnce(caller: label, emit: dispatch) } } #endif diff --git a/ylgamehall/Source/Coordinator/AppCoordinator.swift b/ylgamehall/Source/Coordinator/AppCoordinator.swift index 88dd442..316a0be 100644 --- a/ylgamehall/Source/Coordinator/AppCoordinator.swift +++ b/ylgamehall/Source/Coordinator/AppCoordinator.swift @@ -90,6 +90,7 @@ public final class AppCoordinator { AudioPlayer.shared.stopAllBackground() let subGame = SubGameViewController(request: request) + diagLog("[coordinator] push 子游戏(栈深 \(nav.viewControllers.count) → \(nav.viewControllers.count + 1))") nav.pushViewController(subGame, animated: true) return true } @@ -97,6 +98,7 @@ public final class AppCoordinator { /// 子游戏 pop 回大厅。data 是 H5 调 backgameData 时传入的字符串, /// 通过通知派发给大厅 WebContainer,由大厅触发 `getWebdata` 反向 callback。 public func popSubGame(returningData data: String) { + diagLog("[coordinator] pop 子游戏回大厅") navigationController?.popViewController(animated: true) NotificationCenter.default.post( name: .subGameDidReturn, diff --git a/ylgamehall/Source/Location/LocationService.swift b/ylgamehall/Source/Location/LocationService.swift index 6c65efc..2ea79e1 100644 --- a/ylgamehall/Source/Location/LocationService.swift +++ b/ylgamehall/Source/Location/LocationService.swift @@ -32,93 +32,276 @@ public struct LocationPayload: Sendable { public let cityCode: String public let country: String public let district: String - public let latitude: String - public let longitude: String + /// ⚠️ **JSON 数字**(不是字符串),且保留 `%f` 的 6 位小数文本。 + /// 用 `Decimal` 而不是 `Double`,见下方 init 注释与契约 §3.2 表 B。 + public let latitude: Decimal + public let longitude: Decimal public let province: String public let street: String } -public enum LocationError: Error, Sendable { - case sdkNotLinked - case authorizationDenied - case timeout - case underlying(any Error) +#if canImport(AMapLocationKit) +extension LocationPayload { + /// 严格照搬 msext 的 `@{...}` 字面量语义。 + /// + /// msext: + /// ```objc + /// @try{ [_bridge callHandler:@"getlocationinfo" data:@{ + /// @"address":regeocode.formattedAddress, @"city":regeocode.city, ... }]; } + /// @catch (NSException * e) { NSLog(...); } + /// ``` + /// `@{}` 字面量里**任何一个 value 为 nil 都会抛 NSException**(这正是它包 + /// `@try/@catch` 的原因),净效果是:只要 9 个字段里有一个是 nil, + /// **整条 getlocationinfo 都不会发给 H5**。 + /// + /// 早前我们对每个字段做 `?? ""` 兜底,于是会发出一个带空串的包 —— 数据格式 + /// 与原工程不一致(H5 会收到 msext 里根本收不到的消息)。这里改为任一字段 + /// 为 nil 就返回 nil,由调用方跳过发送。 + nonisolated init?(location: CLLocation, reGeocode: AMapLocationReGeocode) { + guard let address = reGeocode.formattedAddress, + let city = reGeocode.city, + let cityCode = reGeocode.citycode, + let country = reGeocode.country, + let district = reGeocode.district, + let province = reGeocode.province, + let street = reGeocode.street + else { return nil } + + self.address = address + self.city = city + self.cityCode = cityCode + self.country = country + self.district = district + // 经纬度是 **数字**,保留 `%f` 的 6 位小数精度。 + // + // ⚠️ 契约影响(docs/H5-Native-Contract.md §3.2 表 B):msext 的 WVJB 路径 + // (`gameController.m:2528` / `NewRootVC.m:2132`)用 + // `[NSString stringWithFormat:@"%f", ...]` 把经纬度发成**字符串**;但老 + // UIWebView / JSContext 路径(`RootVC.m:1998` / `fourviewVC.m:1541`)拼的是 + // `\"latitude\":%f` —— **不带引号,是 JSON 数字**。现网子游戏 H5 + // (jinxianmahjong 05_Func.js:2029-2031)自造定位对象时也写 + // `"latitude":28.623546` 数字形式。与 `errorCode` 是同一类问题:WVJB 路径 + // 当年把它字符串化了。按项目方决定改回数字。 + // + // 用 `%f` 文本构造 `Decimal`:既保住 6 位小数的舍入行为,又让 + // JSONSerialization 输出 `28.636486` 而不是 Double 的 17 位有效数字 + // `28.636486000000001`(两者 JS parse 后是同一个 IEEE754 double,H5 不可分辨, + // 但 JSON 文本要与原工程一致)。 + let latText = String(format: "%f", location.coordinate.latitude) + let lonText = String(format: "%f", location.coordinate.longitude) + self.latitude = Decimal(string: latText) ?? Decimal(location.coordinate.latitude) + self.longitude = Decimal(string: lonText) ?? Decimal(location.coordinate.longitude) + self.province = province + self.street = street + } +} +#endif + +/// 一次定位请求可能产出的事件。**一次请求可以产出两条**(先 failure 再 success), +/// 这是 msext `completionBlock` 的原始控制流,不是笔误 —— 详见 requestOnce 注释。 +public enum LocationOutcome: Sendable { + /// → H5 `getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})` + case failure + /// → H5 `getlocationinfo(<9 字段>)` + case success(LocationPayload) } @MainActor public final class LocationService { - public static let shared = LocationService() + /// 容器标识(`lobby` / `subGame`),仅用于诊断日志。 + private let owner: String - public init() {} + /// **每个容器必须持有自己的实例**(不要做成单例)。 + /// msext `NewRootVC.m:1568` / `gameController.m:1230` 各自 + /// `[[AMapLocationManager alloc] init]`,两套 manager + completionBlock + + /// delegate 完全独立。共用一个 manager 会引入三个原生侧故障: + /// 1. `requestLocationWithReGeocode:completionBlock:` 同一时刻只保留一个 + /// 单次定位请求,两个容器并发请求会互相顶掉,被顶掉的一侧 + /// completionBlock 永不触发 → continuation 永挂 → H5 什么都收不到; + /// 2. `stopUpdatingLocation`「会 cancel 掉所有的单次定位请求」,子游戏 + /// `backgameData` 会连带取消大厅在飞的定位; + /// 3. `delegate = nil` 作用在共享 manager 上是**进程级永久**副作用。 + public init(owner: String = "?") { + self.owner = owner + #if canImport(AMapLocationKit) + // msext `configLocationManager` 里 `setDelegate:self` 是**无条件**装上的, + // 不只服务持续定位:`amapLocationManager:doRequireLocationAuth:` 也走 delegate。 + // 所以这里在 init 就把 shim 挂上,而不是等持续定位分支才 lazy 触发。 + _ = delegateShim + #endif + } #if canImport(AMapLocationKit) + /// msext `configLocationManager` 逐项等价。 private let manager: AMapLocationManager = { let m = AMapLocationManager() m.desiredAccuracy = kCLLocationAccuracyHundredMeters + m.pausesLocationUpdatesAutomatically = false m.locationTimeout = 6 m.reGeocodeTimeout = 3 m.locatingWithReGeocode = true return m }() + + /// 持续定位(`startlocation` type == 1)的 delegate 承接器。 + /// msext `configLocationManager` 里 `setDelegate:self` 由 VC 自己承担, + /// 这里用独立对象持有,避免 LocationService 被迫继承 NSObject。 + private lazy var delegateShim: LocationDelegateShim = { + let shim = LocationDelegateShim(owner: owner) + manager.delegate = shim + return shim + }() #endif /// 诊断用:requestOnce 调用序号,日志里可对上「谁发起、谁回来」。 private var requestSeq: UInt64 = 0 - /// 一次性定位 + 逆地理(msext locAction + completionBlock 等价) - public func requestOnce(caller: String = "?") async throws -> LocationPayload { + /// 一次性定位 + 逆地理。**逐行等价 msext `initCompleteBlock` 的 completionBlock** + /// (`gameController.m:2493-2545` / `NewRootVC.m:2099-2150`): + /// + /// ```objc + /// if (error) { + /// callHandler getlocationinfo {errorCode:12,...} // ① + /// if (error.code == AMapLocationErrorLocateFailed) return; + /// } + /// if (location) { if (regeocode) { + /// if (regeocode.formattedAddress != nil) + /// callHandler getlocationinfo <9 字段> // ② + /// }} + /// ``` + /// + /// 注意 ① 之后**没有 return**(除 locateFailed):高德在「拿到了 CLLocation 但 + /// 逆地理超时/网络出错」时会带着 error **和** location 一起回调,msext 会先发一条 + /// errorCode 12、再把真实定位补发出去,H5 最终拿到的是真实定位。 + /// + /// 早前的实现用 `async throws -> LocationPayload` 表达,单个返回值天然只能二选一, + /// 于是在 error 分支直接 return,②永远不会发 —— H5 只收到 errorCode 12, + /// `C_Player.SetLocationInfo({errorCode:12})` 还会把已有的 addr 覆盖掉。子游戏 + /// 进房瞬间网络/CPU 最忙、逆地理最容易超时,所以这个分叉在子游戏侧暴露得多。 + /// 因此改成回调式:一次请求可以按 msext 的顺序产出两条 outcome。 + /// + /// - Parameter emit: 在 MainActor 上调用,可能被调用 0 / 1 / 2 次。 + public func requestOnce(caller: String = "?", + emit: @escaping @MainActor (LocationOutcome) -> Void) { #if canImport(AMapLocationKit) requestSeq += 1 let seq = requestSeq - locLog.debug("[\(caller, privacy: .public)] requestOnce #\(seq, privacy: .public) 发起(shared manager id=\(String(describing: ObjectIdentifier(self.manager)), privacy: .public))") - return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - let accepted = manager.requestLocation(withReGeocode: true) { loc, regeo, err in - locLog.debug("[\(caller, privacy: .public)] requestOnce #\(seq, privacy: .public) completionBlock 回来:loc=\(loc != nil, privacy: .public) regeo=\(regeo != nil, privacy: .public) err=\(err?.localizedDescription ?? "nil", privacy: .public)") + diagLog("[\(caller)] requestOnce #\(seq) 发起(owner=\(owner) manager=\(UInt(bitPattern: ObjectIdentifier(self.manager).hashValue)))") + let t0 = Date() + // 高德的 completionBlock 在主线程回调;这里保持与 msext 同样的同步语义。 + let accepted = manager.requestLocation(withReGeocode: true) { loc, regeo, err in + let ms = Int(Date().timeIntervalSince(t0) * 1000) + diagLog("[\(caller)] requestOnce #\(seq) completionBlock 回来(耗时 \(ms)ms):loc=\(loc != nil) regeo=\(regeo != nil) err=\(err?.localizedDescription ?? "nil")") + + MainActor.assumeIsolated { if let err = err as NSError? { - // msext 行为:权限相关错误归一到 12 errorCode(handler 转字段) + emit(.failure) // ① if err.code == AMapLocationErrorCode.locateFailed.rawValue { - cont.resume(throwing: LocationError.authorizationDenied) - } else { - cont.resume(throwing: LocationError.underlying(err)) + diagLog("[\(caller)] requestOnce #\(seq) locateFailed,按 msext 就此结束") + return } + diagLog("[\(caller)] requestOnce #\(seq) 有 error 但非 locateFailed,按 msext 继续尝试补发真实定位") + } + // msext 要求 location / regeocode / formattedAddress 三者都在才发 ② + guard let loc, let regeo, regeo.formattedAddress != nil else { + diagLog("[\(caller)] requestOnce #\(seq) 无可用逆地理(loc=\(loc != nil) regeo=\(regeo != nil)),按 msext 不发 9 字段") return } - guard let loc, let regeo else { - cont.resume(throwing: LocationError.timeout) + guard let payload = LocationPayload(location: loc, reGeocode: regeo) else { + diagLog("[\(caller)] requestOnce #\(seq) 逆地理有 nil 字段,按 msext(@{} 抛 NSException)整条不发") return } - cont.resume(returning: LocationPayload( - address: regeo.formattedAddress ?? "", - city: regeo.city ?? "", - cityCode: regeo.citycode ?? "", - country: regeo.country ?? "", - district: regeo.district ?? "", - latitude: String(format: "%f", loc.coordinate.latitude), - longitude: String(format: "%f", loc.coordinate.longitude), - province: regeo.province ?? "", - street: regeo.street ?? "" - )) + emit(.success(payload)) // ② } - // ⚠️ AMapLocationManager.h:「是否成功添加单次定位Request」。返回 NO 时 - // completionBlock 永远不会被调用 → continuation 永挂 → H5 收不到任何 - // getlocationinfo(连 errorCode 12 都没有)。诊断阶段先只记录。 - locLog.debug("[\(caller, privacy: .public)] requestOnce #\(seq, privacy: .public) requestLocation 返回 accepted=\(accepted, privacy: .public)") + } + // AMapLocationManager.h:返回值是「是否成功添加单次定位Request」。msext 忽略它, + // 效果就是什么都不发给 H5;回调式实现天然等价(不再有 continuation 可挂死), + // 这里只留一条日志便于排查。 + if !accepted { + diagLog("[\(caller)] requestOnce #\(seq) requestLocation 返回 NO(未挂上请求),按 msext 静默") } #else - locLog.error("[\(caller, privacy: .public)] requestOnce: AMapLocationKit 未链接,抛 sdkNotLinked") - throw LocationError.sdkNotLinked + diagLog("[\(caller)] requestOnce: AMapLocationKit 未链接,按 msext 无 SDK 场景不回 H5") + #endif + } + + /// 持续定位(`startlocation` type == 1)。 + /// msext `NewRootVC.m:414` / `gameController.m:537`: + /// `if (tempinfo == 1) [self.locationManager startUpdatingLocation];` + /// 结果通过 delegate `amapLocationManager:didUpdateLocation:reGeocode:` 逐次推 + /// `getlocationinfo`(msext `gameController.m:2551-2560`),只在 `reGeocode` 与 + /// `formattedAddress` 都非空时发;`didFailWithError` msext 只 NSLog、不通知 H5。 + /// + /// - Parameter emit: 每次定位更新都会调用(不是一次性)。 + public func startContinuous(caller: String = "?", + emit: @escaping @MainActor (LocationOutcome) -> Void) { + #if canImport(AMapLocationKit) + diagLog("[\(caller)] startContinuous 发起(owner=\(owner))") + delegateShim.onUpdate = emit + delegateShim.caller = caller + manager.startUpdatingLocation() + #else + diagLog("[\(caller)] startContinuous: AMapLocationKit 未链接,按 msext 无 SDK 场景不回 H5") #endif } /// 停止:msext gameController.m:2473-2478 cleanUpAction 等价 public func stop(caller: String = "?") { #if canImport(AMapLocationKit) - // ⚠️ AMapLocationManager.h:stopUpdatingLocation「会 cancel 掉所有的单次定位请求」。 - // 本类是 shared 单例、manager 唯一,因此这里会连带取消**其它容器**在飞的单次定位。 - locLog.debug("[\(caller, privacy: .public)] stop():stopUpdatingLocation + delegate=nil(会取消所有在飞的单次定位)") + // `stopUpdatingLocation` 会 cancel 掉本 manager 上所有单次定位请求。因为 + // manager 是本容器私有的(每容器一个实例),影响范围仅限本容器 —— 与 msext + // cleanUpAction 只动自己那个 manager 一致。 + diagLog("[\(caller)] stop():stopUpdatingLocation + delegate=nil(owner=\(owner))") manager.stopUpdatingLocation() manager.delegate = nil #endif } } + +#if canImport(AMapLocationKit) + +/// `AMapLocationManagerDelegate` 承接器(仅持续定位路径用到)。 +/// msext 里这些方法直接实现在 NewRootVC / gameController 上;新外壳把它独立出来, +/// 每个 LocationService 一个,生命周期随宿主容器。 +@MainActor +private final class LocationDelegateShim: NSObject, AMapLocationManagerDelegate { + + var onUpdate: (@MainActor (LocationOutcome) -> Void)? + var caller: String = "?" + private let owner: String + + init(owner: String) { + self.owner = owner + super.init() + } + + /// msext `gameController.m:2551-2560` 等价:仅 reGeocode 与 formattedAddress + /// 都非空时推 9 字段,其余情况什么都不发。 + nonisolated func amapLocationManager(_ manager: AMapLocationManager!, + didUpdate location: CLLocation!, + reGeocode: AMapLocationReGeocode!) { + // 先在当前上下文把 AMap 的非 Sendable 对象拆成纯值,只把 Sendable 的 + // LocationPayload 送进 MainActor(否则 Swift 6 判定 reGeocode 跨隔离域有数据竞争)。 + guard let location, let reGeocode, reGeocode.formattedAddress != nil else { + diagLog("[持续定位] 更新但无可用逆地理,按 msext 不发") + return + } + guard let payload = LocationPayload(location: location, reGeocode: reGeocode) else { + diagLog("[持续定位] 逆地理有 nil 字段,按 msext(@{} 抛 NSException)整条不发") + return + } + MainActor.assumeIsolated { + diagLog("[\(caller)] 持续定位 → getlocationinfo city=\(payload.city)") + onUpdate?(.success(payload)) + } + } + + /// msext `gameController.m:2547` 等价:**只打日志,不通知 H5**。 + nonisolated func amapLocationManager(_ manager: AMapLocationManager!, + didFailWithError error: (any Error)!) { + diagLog("[持续定位 owner=\(owner)] didFailWithError: \(error?.localizedDescription ?? "nil")(msext 同款:不通知 H5)") + } +} + +#endif diff --git a/ylgamehall/Source/WebView/SubGameViewController.swift b/ylgamehall/Source/WebView/SubGameViewController.swift index e987427..1156a2e 100644 --- a/ylgamehall/Source/WebView/SubGameViewController.swift +++ b/ylgamehall/Source/WebView/SubGameViewController.swift @@ -34,6 +34,10 @@ public final class SubGameViewController: UIViewController { // MARK: - UI private let bridgedWebView = BridgedWebView(label: "subGame") + + /// 子游戏私有的定位服务(msext gameController.m:1230 `configLocationManager` + /// 每次进子游戏都 alloc 一个自己的 AMapLocationManager)。随本 VC 一起析构。 + private let locationService = LocationService(owner: "subGame") private let splash = SplashOverlay() // MARK: - Handlers @@ -88,7 +92,7 @@ public final class SubGameViewController: UIViewController { DeviceInfoHandler.register(on: bridge) BrowserHandler.register(on: bridge) OpenSaomaHandler.register(on: bridge) - StartLocationHandler.register(on: bridge, label: "subGame") + StartLocationHandler.register(on: bridge, label: "subGame", location: locationService) // assetsRoot 闭包 = 子游戏 H5 根目录(msext gameController.m:364 等价)。 // 用 closure 是因为 effectiveGameDir 在 boot pipeline 升级路径下会变化, @@ -118,7 +122,7 @@ public final class SubGameViewController: UIViewController { OpenurlTitleDataHandler.register(on: bridge) // 子游戏专属:backgameData(退出回大厅 + 反向 callback getWebdata) - BackGameDataHandler.register(on: bridge) + BackGameDataHandler.register(on: bridge, location: locationService) // 视频房间 3 件套 stub(业务暂未启用,仅维持桥契约不让 H5 报 "no handler") // 未来接 Agora 时把 VideoRoomHandlers 内 3 个 stub 展开实现,注册点不变。 diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index a849fe9..d288b71 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -16,6 +16,10 @@ public final class WebContainerViewController: UIViewController { // MARK: - UI private let bridgedWebView = BridgedWebView(label: "lobby") + + /// 大厅私有的定位服务(msext NewRootVC.m:1568 `configLocationManager` 等价)。 + /// 与子游戏那一个互不影响 —— 这是 msext 的原始结构,不可再合并成单例。 + private let locationService = LocationService(owner: "lobby") private let splash = SplashOverlay() // MARK: - Handlers (有状态的 handler 注册器持有,无状态的走 enum 静态 register) @@ -77,7 +81,7 @@ public final class WebContainerViewController: UIViewController { DeviceInfoHandler.register(on: bridge) // §3.1 [21] + §3.2 [1] BrowserHandler.register(on: bridge) // §3.1 [16] OpenSaomaHandler.register(on: bridge) // §3.1 [22]空 stub - StartLocationHandler.register(on: bridge, label: "lobby") // §3.1 [20]Phase 5 完整实现,Phase 2 stub + StartLocationHandler.register(on: bridge, label: "lobby", location: locationService) // §3.1 [20]Phase 5 完整实现,Phase 2 stub // Phase 3.A 本地音频 + 3.C/3.D stub // assetsRoot 闭包 = 大厅 H5 根目录(lobbyIndex 父目录),lobby 不做升级