排查「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 设计要点。
218 lines
9.8 KiB
Swift
218 lines
9.8 KiB
Swift
//
|
||
// H5ErrorRelay.swift
|
||
// ylgamehall
|
||
//
|
||
// H5 日志/错误中继:把 WebView 内 `console.error` / `console.warn` /
|
||
// `window.onerror` / `unhandledrejection` 通过 `webkit.messageHandlers.h5error`
|
||
// 上报到原生 print;Debug 构建下额外转发 `console.log` / `console.info`。
|
||
//
|
||
// Phase 9.2(Design §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 而不是 diagLog:H5 把 `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 proxy(weak 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)
|
||
}
|
||
}
|