Files
joywayer 7fe089577b 补齐非 console.error 类 H5 报错的观察通路
排查「H5 有输出但 Safari 看不到」时发现,即便绕开 Safari 只看 Xcode,也有三类
故障是完全静默的:

1. 资源 404(<script>/<img>/<audio> 加载失败)
   error 事件只在元素自身触发且不冒泡,原先 addEventListener('error', fn) 收不到。
   改为捕获阶段 (capture=true),JS 异常的 ev.target 是 window,据此在同一 listener
   内分流 onerror / resourceerror 两类。缺图缺 js 是 H5 最常见的线上故障,此前零信号。

2. WebView 加载失败(didFailProvisionalNavigation / didFail)
   两个 VC 的 WKNavigationDelegate 此前只实现了 didFinish。AppSchemeHandler 找不到
   index.html、子游戏 zip 解压残缺时,splash 永不淡出、卡在启动图,Xcode 无任何输出。

3. WebContent 进程终止(webViewWebContentProcessDidTerminate)
   canvas 游戏 JSC OOM 被系统回收时页面白屏,同样静默。
   ⚠️ 只 log 不自动 reload —— 自动恢复是行为变化,msext 没有,不引入。

另:H5ErrorRelay.install 增加 label 参数,输出前缀从 [H5 xxx] 变为
[H5:lobby xxx] / [H5:subGame xxx]。同时存在多个 BridgedWebView 时原先分不清
日志来自谁,也就无法反推该在 Safari「开发」菜单里选哪个条目。

无契约影响:H5 可观察行为不变,新增回调仅记录不改流程。
Debug / Release 两个配置均已编译验证。
docs/H5-Debug-Guide.md §8.1 更新能力表,新增 §8.2 已知盲区 / §8.3 设计要点。
2026-08-08 14:19:58 +08:00

218 lines
9.8 KiB
Swift
Raw Permalink 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.
//
// H5ErrorRelay.swift
// ylgamehall
//
// H5 日志/错误中继:把 WebView 内 `console.error` / `console.warn` /
// `window.onerror` / `unhandledrejection` 通过 `webkit.messageHandlers.h5error`
// 上报到原生 printDebug 构建下额外转发 `console.log` / `console.info`。
//
// Phase 9.2Design §11.2)。msext 时代仅靠用户/运营手工反馈;本项目作为
// 原则 B「内部自由现代化」的一部分,开发期把 H5 异常打到 Xcode console
// 减少调试盲点。生产期无开关控制(输出 print 默认走 OSLog,对体积影响极小)。
//
// **不修改 H5 一行**(契约原则 A):用 WKUserScript `.atDocumentStart` 在
// 业务 JS 跑前就 hook,不要求 H5 团队配合。
//
// 仅在 BridgedWebView(大厅 + 子游戏)注入;OverlayViewController 是
// 第三方外链,错误不属于 ylgamehall 内部信号,故不挂。
//
import Foundation
import WebKit
@MainActor
public final class H5ErrorRelay: NSObject {
public static let shared = H5ErrorRelay()
/// WKScriptMessageHandler 名字。与下面 JS source 内 `webkit.messageHandlers.h5error` 必须一致。
public static let messageName = "h5error"
public override init() { super.init() }
/// 把 hook JS + message handler 注册到给定的 UserContentController。
/// BridgedWebView.init 在挂 WVJB 之后调一次。
///
/// - Parameter label: 该 WebView 的标识(大厅 / 子游戏),会作为输出前缀。
/// 同时存在多个 BridgedWebView 时,没有前缀就分不清哪条日志来自谁,
/// 也就无法反推该在 Safari「开发」菜单里选哪个条目。
public func install(into controller: WKUserContentController, label: String) {
let userScript = WKUserScript(
source: Self.javaScriptSource,
injectionTime: .atDocumentStart, // 业务 JS 之前 hook
forMainFrameOnly: true // iframe 不抓
)
controller.addUserScript(userScript)
controller.add(MessageProxy(target: self, label: label), name: Self.messageName)
}
fileprivate func handle(_ body: Any, label: String) {
guard let dict = body as? [String: Any],
let kind = dict["kind"] as? String else { return }
let tag = "[H5:\(label)"
switch kind {
case "console.log", "console.info":
// 仅 Debug 构建会有这两类(见 javaScriptSource 的 #if DEBUG)。
// ⚠️ 走裸 print 而不是 diagLogH5 把 `Game_Config.Debugger.isDebugger`
// 打开后每个收发包都会 console.log 一次,而 diagLog 会同步落盘到
// `Documents/diag.log`(无轮转、无大小上限,见 BridgeBus.swift:47),
// firehose 灌进去会把设备磁盘吃光。Xcode console 看得到就够了。
let args = (dict["args"] as? [String]) ?? []
print("\(tag) \(kind)] " + args.joined(separator: " "))
case "console.error":
let args = (dict["args"] as? [String]) ?? []
diagLog("\(tag) console.error] " + args.joined(separator: " "))
case "console.warn":
// 桥诊断需要:WebViewJavascriptBridge.js 在「Native 发来的 handlerName
// H5 侧没注册」时走 console.warn,之前不上报导致这类丢包完全不可见。
let args = (dict["args"] as? [String]) ?? []
diagLog("\(tag) 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
diagLog("\(tag) onerror] \(msg) at \(src):\(line):\(col)" + (stack.map { "\n\($0)" } ?? ""))
case "resourceerror":
// 资源 404 / 加载失败。H5 层完全静默(不会走 console.error),
// 缺图 / 缺 js 在线上就是白屏或功能失灵,这里是唯一可观察点。
let tagName = (dict["tag"] as? String) ?? "?"
let src = (dict["src"] as? String) ?? ""
diagLog("\(tag) resourceerror] <\(tagName)> 加载失败:\(src)")
case "unhandledrejection":
let reason = (dict["reason"] as? String) ?? ""
let stack = dict["stack"] as? String
diagLog("\(tag) unhandledrejection] " + reason + (stack.map { "\n\($0)" } ?? ""))
default:
diagLog("\(tag) unknown] \(dict)")
}
}
/// JS 端 hook 各类日志/异常源,统一通过 `webkit.messageHandlers.h5error.postMessage(payload)`
/// 上报。所有 send 调用都包 try/catch,hook 自身永远不应抛错(避免污染业务流程)。
///
/// `console.log` / `console.info` 只在 Debug 构建注入:Release 包不该为每条业务
/// 日志付一次 JS→Native IPC,也不该把 H5 内部输出暴露给外部审阅。
private static var javaScriptSource: String {
#if DEBUG
let verboseHooks = verboseConsoleHookJS
#else
let verboseHooks = ""
#endif
return """
(function(){
function send(payload){
try { window.webkit.messageHandlers.h5error.postMessage(payload); } catch(e){}
}
// 参数格式化:直接 String(obj) 会得到无用的 "[object Object]",而大厅业务
// 大量使用 console.log(msg) / console.log(res) 打整包,故对象走 JSON.stringify。
// 循环引用时 stringify 抛错,回退 String();再抛就放弃,绝不让 hook 影响业务。
function fmt(v){
try {
if (v === null) { return 'null'; }
if (v === undefined) { return 'undefined'; }
var t = typeof v;
if (t === 'string') { return v; }
if (t === 'number' || t === 'boolean' || t === 'function') { return String(v); }
if (v instanceof Error) { return v.stack || (v.name + ': ' + v.message); }
var s = JSON.stringify(v);
return (s === undefined) ? String(v) : s;
} catch(e) {
try { return String(v); } catch(e2) { return '[unstringifiable]'; }
}
}
function collect(a){
var out = [];
for (var i=0; i<a.length; i++) { out.push(fmt(a[i])); }
return out;
}
var origErr = console.error;
console.error = function(){
try { send({ kind:'console.error', args: collect(arguments) }); } catch(e){}
if (origErr) { origErr.apply(console, arguments); }
};
var origWarn = console.warn;
console.warn = function(){
try { send({ kind:'console.warn', args: collect(arguments) }); } catch(e){}
if (origWarn) { origWarn.apply(console, arguments); }
};
\(verboseHooks)
// capture = true:资源加载失败(<script>/<img>/<audio> 的 404)只在元素自身
// 触发 error 且**不冒泡**,不开捕获阶段就完全收不到 —— 而这恰恰是 H5 最常见的
// 线上故障(缺图 / 缺 js)。JS 异常的 ev.target 是 window,据此分流两类。
// 注:listener 挂在 window 上,window 自己是 target 时走 AT_TARGET 阶段,
// 不受 capture 标记影响,所以一个 listener 同时收得到两类。
window.addEventListener('error', function(ev){
var t = ev.target;
if (t && t !== window && t.tagName) {
send({
kind: 'resourceerror',
tag: String(t.tagName),
src: String(t.src || t.href || '')
});
return;
}
send({
kind:'onerror',
msg: String(ev.message || ''),
src: String(ev.filename || ''),
line: ev.lineno || 0,
col: ev.colno || 0,
stack: (ev.error && ev.error.stack) ? String(ev.error.stack) : null
});
}, true);
window.addEventListener('unhandledrejection', function(ev){
var r = ev.reason;
send({
kind:'unhandledrejection',
reason: String(r),
stack: (r && r.stack) ? String(r.stack) : null
});
});
})();
"""
}
/// Debug 专用:`console.log` / `console.info` 全量转发。
///
/// 注意 H5 自带调试总开关 `Game_Config.Debugger.isDebugger`
/// `gamehall.zip` → `js/01_SubGame/00_SubGame_Config.js`)出厂为 `false`
/// 收发包日志(`发送数据:` / `接收数据:`)被它挡住。要看这些需在
/// Safari Web Inspector 控制台运行时打开:`Game_Config.Debugger.isDebugger = true`
/// —— 运行时改内存属性,不动 H5 任何文件(契约原则 A)。
private static let verboseConsoleHookJS = """
var origLog = console.log;
console.log = function(){
try { send({ kind:'console.log', args: collect(arguments) }); } catch(e){}
if (origLog) { origLog.apply(console, arguments); }
};
var origInfo = console.info;
console.info = function(){
try { send({ kind:'console.info', args: collect(arguments) }); } catch(e){}
if (origInfo) { origInfo.apply(console, arguments); }
};
"""
}
// MARK: - WKScriptMessageHandler proxyweak target 切断 retain cycle
@MainActor
private final class MessageProxy: NSObject, WKScriptMessageHandler {
private weak var target: H5ErrorRelay?
private let label: String
init(target: H5ErrorRelay, label: String) {
self.target = target
self.label = label
super.init()
}
func userContentController(_ ucc: WKUserContentController,
didReceive message: WKScriptMessage) {
// WKScriptMessageHandler 在主队列派发;MessageProxy / H5ErrorRelay 都
// MainActor,直接调即可,无需 Task 切换。
target?.handle(message.body, label: label)
}
}