修复定位的回调问题

This commit is contained in:
joywayer
2026-08-07 12:18:44 +08:00
parent 3f8b382ad3
commit 1203957924
12 changed files with 637 additions and 180 deletions
+134 -16
View File
@@ -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) } ?? "<encode-failed>"
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)")
}
}
}
+15 -2
View File
@@ -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? {
+5 -5
View File
@@ -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)")
}
}
@@ -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 当前
@@ -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