在前端(PC 网站)和 WebView(移动端 App 内嵌网页)中实现白屏检测与处理。白屏问题通常指页面未能成功渲染出有效内容,用户看到的是一片空白,这可能是由 JavaScript 错误、资源加载失败、网络问题、服务器错误等多种原因引起的。
我会分两大部分来讲解:
- PC 网站的白屏检测与处理
- WebView 的白屏检测与处理
每一部分都会包含检测原理、实现方法、代码示例和处理策略,并尽量提供详尽的代码和解释。
一、PC 网站的白屏检测与处理
PC 网站的白屏检测主要依赖于 JavaScript 在浏览器环境中运行,通过分析 DOM 结构、元素可见性、关键资源加载情况以及捕获 JS 错误等方式来判断。
1.1 检测原理与方法
1.1.1 DOM 元素检测 (关键节点检查)
- 原理 :一个正常渲染的页面,其
<body>
标签下应该包含有实际内容的 DOM 元素,而不仅仅是空的根容器(如<div id="app"></div>
)。如果页面加载完成后,关键的容器元素(如#app
,#root
)内部为空,或者只包含注释节点、空的文本节点,则很可能发生了白屏。 - 方法 :在页面加载完成(如
DOMContentLoaded
或window.onload
事件后,或者对于 SPA 应用在路由切换、组件挂载后)的一小段时间后,检查预先定义的关键 DOM 节点是否存在,并且其内部是否含有有效子元素(children.length > 0
或innerHTML.trim() !== ''
)。
1.1.2 视口采样点检测 (Sampling Points)
- 原理 :模拟人眼观察,在页面的可视区域内选取多个(例如 9 个或 17 个)分散的点,检查这些坐标点最上层的 DOM 元素。如果所有或大部分采样点对应的元素都是
<html>
或<body>
标签,或者都是同一个根容器元素,说明这些点下方没有实际渲染内容,可判定为白屏。 - 方法 :使用
document.elementsFromPoint(x, y)
(或elementFromPoint
,但前者能获取层叠元素) API 获取指定坐标下的元素列表。选取视口内多个坐标点(如九宫格、中心点加四周等),检查返回的元素。
1.1.3 JavaScript 错误监控
-
原理:导致白屏的一个常见原因是 JavaScript 执行出错,特别是那些阻断了渲染流程的错误(如 React/Vue 组件渲染错误未被捕获)。
-
方法:
- 使用
window.onerror
捕获全局未处理的同步错误。 - 使用
window.addEventListener('unhandledrejection', event => ...)
捕获未处理的 Promise 拒绝。 - 对于 React,使用错误边界(Error Boundaries)组件捕获其子组件树中的 JS 错误。
- 对于 Vue,使用
app.config.errorHandler
(Vue 3) 或Vue.config.errorHandler
(Vue 2) 全局处理组件渲染和侦听器错误。 - 当捕获到可能导致渲染中断的严重错误时,可以结合 DOM 检测来确认是否真的发生了白屏。
- 使用
1.1.4 性能指标与资源加载监控
-
原理:
- FP (First Paint) / FCP (First Contentful Paint) :浏览器绘制第一个像素或第一个 DOM 内容的时间。如果这些指标迟迟未出现或时间过长,可能预示着白屏。
- 关键资源加载失败:核心 CSS 或 JavaScript 文件加载失败,必然导致页面无法正常渲染。
-
方法:
- 使用
PerformanceObserver
监听paint
类型的性能条目,获取 FP 和 FCP 时间。设置一个超时阈值,若超时仍未记录到 FCP,则认为可能白屏。 - 监听
<link>
(CSS) 和<script>
(JS) 标签的onerror
事件,或者使用PerformanceObserver
监听resource
类型,检查关键资源的responseStatus
或加载时长。
- 使用
1.1.5 心跳检测 (Heartbeat)
- 原理:页面加载完成后,启动一个定时器,周期性地执行上述的 DOM 检测或采样点检测。这可以捕获到初始加载正常,但后续因某种原因(如 JS 错误、内存溢出导致渲染挂起)变成白屏的情况。
- 方法 :使用
setInterval
定期运行检测逻辑。需要注意性能开销,频率不宜过高,且在页面卸载时清除定时器。
1.2 代码示例 (PC 端 )
JavaScript
/**
* 前端白屏检测与上报 SDK (简化示例)
*
* 功能:
* 1. DOM 关键节点检测
* 2. 视口采样点检测
* 3. JS 错误捕获辅助判断
* 4. 结合 Performance API 检测 FCP
* 5. 白屏事件上报
*/
class WhiteScreenDetector {
constructor(options = {}) {
// 默认配置
this.config = {
checkInterval: 5000, // 心跳检测间隔 (ms)
maxChecks: 3, // 最大心跳检测次数
rootSelectors: ['#app', '#root', 'body > div:first-child'], // 关键根节点选择器
emptyThreshold: 1, // 允许的非空子元素最小数量,小于此值视为空
samplingPoints: 9, // 采样点数量 (九宫格)
samplingRootTags: ['html', 'body'], // 采样点检测时,如果元素是这些标签,则认为无内容
fcpTimeout: 10000, // FCP 超时时间 (ms)
reportUrl: '/api/report/white-screen', // 上报接口地址
enableHeartbeat: true, // 是否启用心跳检测
debug: false, // 调试模式
...options,
};
this.jsErrorOccurred = false; // 标记是否有 JS 错误发生
this.fcpRecorded = false; // 标记 FCP 是否已记录
this.checkCount = 0; // 心跳检测计数
this.timer = null; // 心跳定时器
this.isWhiteScreen = false; // 当前是否已判定为白屏
this._log('Initializing WhiteScreenDetector...');
this._init();
}
_log(...args) {
if (this.config.debug) {
console.log('[WhiteScreenDetector]', ...args);
}
}
_init() {
// 监听 JS 错误
this._setupErrorTracking();
// 监听 FCP
this._checkFCP();
// 页面加载完成后进行首次检测
if (document.readyState === 'complete') {
this._runChecks();
} else {
window.addEventListener('load', () => this._runChecks(), { once: true });
}
// 启动心跳检测 (如果启用)
if (this.config.enableHeartbeat) {
this._startHeartbeat();
}
}
_setupErrorTracking() {
const originalOnError = window.onerror;
window.onerror = (message, source, lineno, colno, error) => {
this.jsErrorOccurred = true;
this._log('JavaScript Error caught:', { message, source, lineno, colno, error });
if (originalOnError) {
originalOnError.call(window, message, source, lineno, colno, error);
}
// 可根据错误类型决定是否立即触发白屏检查
// this._runChecksIfNecessary();
return false; // 返回 false 让默认处理继续
};
window.addEventListener('unhandledrejection', (event) => {
this.jsErrorOccurred = true;
this._log('Unhandled Promise Rejection caught:', event.reason);
// 可根据错误类型决定是否立即触发白屏检查
// this._runChecksIfNecessary();
});
// 针对 React/Vue 的错误处理可以在框架层面集成,调用 detector 实例的方法标记错误
// 例如:Vue.config.errorHandler = (err, vm, info) => { detector.markJsError(err); ... }
}
markJsError(error) {
this.jsErrorOccurred = true;
this._log('Framework Error marked:', error);
}
_checkFCP() {
if (typeof PerformanceObserver !== 'function') {
this._log('PerformanceObserver not supported, skipping FCP check.');
this.fcpRecorded = true; // 无法检测,假设已完成
return;
}
const observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntriesByName('first-contentful-paint')) {
this._log(`FCP recorded: ${entry.startTime}ms`);
this.fcpRecorded = true;
observer.disconnect(); // 获取到后停止观察
clearTimeout(fcpTimeoutTimer); // 清除超时定时器
return;
}
});
observer.observe({ type: 'paint', buffered: true });
// 设置 FCP 超时
const fcpTimeoutTimer = setTimeout(() => {
if (!this.fcpRecorded) {
this._log(`FCP timeout (${this.config.fcpTimeout}ms) reached.`);
// FCP 超时本身不直接判定白屏,但可以作为辅助信息
// 可以在上报时加入此状态
}
}, this.config.fcpTimeout);
}
_runChecks() {
if (this.isWhiteScreen) {
this._log('Already detected as white screen, skipping checks.');
return;
}
this._log('Running white screen checks...');
this.checkCount++;
const isWhiteByDom = this._checkDOMEmpty();
const isWhiteBySampling = this._checkSamplingPoints();
this._log(`Check results: DOM Empty=${isWhiteByDom}, Sampling Points=${isWhiteBySampling}`);
// 综合判断逻辑 (可以根据业务调整)
// 1. 如果两种方法都认为是白屏,则判定为白屏
// 2. 如果采样法认为是白屏,且发生过 JS 错误 或 FCP 超时,也可能判定为白屏
// 3. 如果 DOM 检测认为是白屏,也可能直接判定 (取决于关键节点的选择是否可靠)
let detectedWhite = false;
if (isWhiteByDom && isWhiteBySampling) {
detectedWhite = true;
this._log('Detected white screen based on both DOM and Sampling checks.');
} else if (isWhiteBySampling && (this.jsErrorOccurred || !this.fcpRecorded)) {
detectedWhite = true;
this._log(`Detected white screen based on Sampling check and auxiliary info (JS Error: ${this.jsErrorOccurred}, FCP Recorded: ${this.fcpRecorded}).`);
} else if (isWhiteByDom) {
// 根据业务场景决定 DOM 空判断的权重
// detectedWhite = true;
// this._log('Detected potential white screen based on DOM check.');
}
if (detectedWhite) {
this.isWhiteScreen = true;
this._reportWhiteScreen({
reason: 'DOM and/or Sampling check failed',
domCheck: isWhiteByDom,
samplingCheck: isWhiteBySampling,
jsError: this.jsErrorOccurred,
fcpTimeout: !this.fcpRecorded,
checkCount: this.checkCount,
});
this._stopHeartbeat(); // 判定白屏后停止心跳
} else if (this.config.enableHeartbeat && this.checkCount >= this.config.maxChecks) {
this._log(`Max heartbeat checks (${this.config.maxChecks}) reached without detecting white screen.`);
this._stopHeartbeat(); // 达到最大次数后停止
}
}
_startHeartbeat() {
if (!this.config.enableHeartbeat || this.timer) return;
this._log(`Starting heartbeat check every ${this.config.checkInterval}ms, max ${this.config.maxChecks} checks.`);
this.timer = setInterval(() => {
this._runChecks();
}, this.config.checkInterval);
}
_stopHeartbeat() {
if (this.timer) {
this._log('Stopping heartbeat check.');
clearInterval(this.timer);
this.timer = null;
}
}
// 检测关键 DOM 节点是否为空
_checkDOMEmpty() {
for (const selector of this.config.rootSelectors) {
const element = document.querySelector(selector);
if (element) {
// 检查是否有非文本、非注释的子元素
let meaningfulChildrenCount = 0;
for (let i = 0; i < element.childNodes.length; i++) {
const node = element.childNodes[i];
// 忽略文本节点(只包含空白符)和注释节点
if (node.nodeType === Node.ELEMENT_NODE ||
(node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== '')) {
meaningfulChildrenCount++;
}
}
if (meaningfulChildrenCount >= this.config.emptyThreshold) {
this._log(`DOM check passed: Found content in "${selector}". Meaningful children: ${meaningfulChildrenCount}`);
return false; // 找到一个非空的关键节点,就认为不是白屏
} else {
this._log(`DOM check: Element "${selector}" found but seems empty. Meaningful children: ${meaningfulChildrenCount}`);
}
} else {
this._log(`DOM check: Element "${selector}" not found.`);
}
}
// 如果所有关键节点都未找到或为空
this._log('DOM check failed: All critical elements are empty or not found.');
return true;
}
// 检测视口采样点
_checkSamplingPoints() {
if (typeof document.elementsFromPoint !== 'function') {
this._log('elementsFromPoint not supported, skipping sampling check.');
return false; // 无法检测,保守返回 false
}
const points = this._getSamplingPoints();
if (!points.length) return false;
let emptyPoints = 0;
for (const point of points) {
const elements = document.elementsFromPoint(point.x, point.y);
if (elements.length > 0) {
// 取最顶层的元素进行判断
const topElement = elements[0];
const tagName = topElement.tagName.toLowerCase();
// 如果最顶层元素是 html 或 body,或者是在配置中指定的根容器标签,则认为该点是空的
if (this.config.samplingRootTags.includes(tagName) ||
this.config.rootSelectors.some(selector => topElement.matches(selector))) {
emptyPoints++;
this._log(`Sampling point (${point.x}, ${point.y}) is empty (Element: <${tagName}>).`);
} else {
this._log(`Sampling point (${point.x}, ${point.y}) has content (Element: <${tagName}>).`);
// 只要有一个点有内容,就可以提前结束判断(优化)
// return false;
}
} else {
// 如果该点没有任何元素,也算作空点
emptyPoints++;
this._log(`Sampling point (${point.x}, ${point.y}) has no elements.`);
}
}
// 判断是否所有采样点都为空
const isAllEmpty = emptyPoints === points.length;
this._log(`Sampling check result: ${emptyPoints} out of ${points.length} points are empty. All empty: ${isAllEmpty}`);
return isAllEmpty;
}
// 获取视口内的采样点坐标
_getSamplingPoints() {
const points = [];
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (viewportWidth <= 0 || viewportHeight <= 0) {
this._log('Viewport dimensions are invalid, cannot get sampling points.');
return points;
}
const numPoints = this.config.samplingPoints;
if (numPoints <= 0) return points;
// 简化实现:九宫格采样 (3x3)
if (numPoints === 9) {
const rows = 3, cols = 3;
for (let i = 1; i <= rows; i++) {
for (let j = 1; j <= cols; j++) {
points.push({
x: Math.round(viewportWidth * j / (cols + 1)),
y: Math.round(viewportHeight * i / (rows + 1)),
});
}
}
}
// 可根据需要实现其他采样策略,如 17 点 (中心 + 四周 + 角落等)
// ...
// 确保点在视口内 (虽然计算时已考虑,但边缘情况可能需要检查)
return points.filter(p => p.x > 0 && p.x < viewportWidth && p.y > 0 && p.y < viewportHeight);
}
// 上报白屏事件
_reportWhiteScreen(details) {
this._log('Reporting white screen event...', details);
const reportData = {
type: 'white_screen',
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent,
viewport: { width: window.innerWidth, height: window.innerHeight },
details: details,
// 可以添加更多上下文信息,如用户信息、网络状态、性能指标等
// performance: performance.timing,
// connection: navigator.connection ? { type: navigator.connection.effectiveType, rtt: navigator.connection.rtt } : null,
};
// 使用 navigator.sendBeacon 或 fetch/XMLHttpRequest 发送数据
if (navigator.sendBeacon) {
try {
const blob = new Blob([JSON.stringify(reportData)], { type: 'application/json' });
navigator.sendBeacon(this.config.reportUrl, blob);
this._log('White screen report sent via sendBeacon.');
} catch (e) {
this._log('Error sending report via sendBeacon:', e);
this._fallbackReport(reportData); // sendBeacon 失败时尝试备用方法
}
} else {
this._fallbackReport(reportData);
}
}
_fallbackReport(reportData) {
fetch(this.config.reportUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reportData),
keepalive: true, // 尝试在页面卸载时也能发送
}).then(() => {
this._log('White screen report sent via fetch.');
}).catch(error => {
this._log('Error sending report via fetch:', error);
});
}
// 页面卸载时清理
destroy() {
this._log('Destroying WhiteScreenDetector...');
this._stopHeartbeat();
// 移除事件监听器 (如果需要的话,但 onerror 和 unhandledrejection 通常是全局的)
// window.onerror = originalOnError; // 恢复原始处理
// window.removeEventListener('unhandledrejection', ...);
}
}
// 使用示例:
// const detector = new WhiteScreenDetector({
// reportUrl: 'https://your-monitoring-service.com/report',
// rootSelectors: ['#app', '#main-content'], // 根据你的项目调整
// debug: true,
// });
// 可以在需要的时候手动销毁
// window.addEventListener('beforeunload', () => detector.destroy());
// --- 代码行数说明 ---
// 这个示例代码(包括注释和空行)大约在 250 行左右。
// 要达到 1000 行,需要更复杂的逻辑、更多的辅助函数、更细致的错误处理、
// 更多的配置选项、以及可能的框架集成代码(如 React Error Boundary 包装器)。
// 下面会继续补充 WebView 部分的代码。
1.3 处理策略
-
日志上报:检测到白屏后,最重要的是将详细信息上报到监控系统。信息应包括:
- 发生时间、页面 URL、用户标识 (如有)、User Agent。
- 检测方法和结果(DOM 空、采样点空、JS 错误、FCP 超时等)。
- 捕获到的 JS 错误堆栈信息。
- 设备信息(屏幕分辨率、网络类型等)。
- 性能数据 (
performance.timing
或performance.navigation
)。 - DOM 快照(可选,可能数据量大):获取
document.documentElement.outerHTML
。
-
用户提示与引导:
- 显示一个友好的错误提示浮层,告知用户页面加载出现问题。
- 提供"刷新重试"按钮。
- 提供"返回上一页"或"回到首页"的选项。
- 提供联系客服或反馈问题的渠道。
-
自动恢复尝试:
- 自动刷新 :在提示用户后,可以尝试延时自动刷新一次页面 (
location.reload()
)。注意避免无限刷新循环。 - 清除缓存/Storage (谨慎) :如果怀疑是缓存或本地存储导致的问题,可以在用户同意或特定策略下尝试清除相关站点的
sessionStorage
,localStorage
或 Service Worker 缓存,然后刷新。这属于比较激进的操作。
- 自动刷新 :在提示用户后,可以尝试延时自动刷新一次页面 (
-
降级处理:如果主内容无法渲染,是否可以展示一个极简的、静态的或者来自缓存的旧版本页面作为降级方案?(通常较难实现)
二、WebView 的白屏检测与处理
WebView 中的白屏检测通常需要 Native (Android/iOS) 代码与 WebView 内的 JavaScript 代码相互配合。
2.1 检测原理与方法
2.1.1 JavaScript 端检测 (同 PC 端)
- 原理:WebView 加载的网页本质上还是一个 Web 环境,因此 PC 端基于 JavaScript 的检测方法(DOM 节点检查、采样点检查、JS 错误监控、性能监控)理论上都适用。
- 方法:在 WebView 加载的 H5 页面中嵌入与 PC 端类似的 JS 检测逻辑。
2.1.2 Native-JS Bridge 通信
-
原理:Native 代码可以通过 Bridge 调用 WebView 中的 JS 函数,反之亦然。Native 端可以在合适的时机(如页面加载完成回调后)调用 JS 函数执行白屏检测,并将结果返回给 Native。JS 端检测到白屏后,也可以主动调用 Native 提供的方法通知 App。
-
方法:
- Android : 使用
WebView.evaluateJavascript()
(推荐) 或WebView.loadUrl("javascript:...")
从 Native 调用 JS。使用@JavascriptInterface
注解或WebMessageListener
(Android X) 让 JS 调用 Native。 - iOS : 使用
WKWebView.evaluateJavaScript()
从 Native 调用 JS。使用WKScriptMessageHandler
协议让 JS 通过window.webkit.messageHandlers.<handlerName>.postMessage()
调用 Native。
- Android : 使用
2.1.3 Native WebView 加载状态与错误回调
-
原理:Native 层可以监听 WebView 的加载生命周期事件和错误事件。
-
方法:
-
Android (
WebViewClient
) :onPageFinished(WebView view, String url)
: 页面加载完成。在此之后触发 JS 检测。注意此回调可能在 iframe 加载完成时也触发,需要判断url
是否为主页面 URL。X5 内核等可能回调多次,需处理。onReceivedError(WebView view, WebResourceRequest request, WebResourceError error)
: 加载资源时发生错误(网络或服务器错误,针对主资源)。onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse)
: 收到 HTTP 错误码(如 404, 500)。- 结合
onPageFinished
和错误回调:如果在onPageFinished
后短时间内没有收到 JS 检测通过的信号,或者之前收到了关键资源的加载错误,则可判定为白屏。
-
iOS (
WKNavigationDelegate
) :webView(_:didFinish:)
: 导航加载完成。在此之后触发 JS 检测。webView(_:didFailProvisionalNavigation:withError:)
: 页面内容开始加载前发生错误。webView(_:didFail:withError:)
: 加载过程中发生错误。webViewWebContentProcessDidTerminate(_:)
: WebView 的内容进程终止(通常因为内存不足),这必然导致白屏。这是 iOS 上一个非常重要的白屏信号。
-
2.1.4 Native 截图与像素分析 (较少用,性能开销大)
-
原理:Native 代码对 WebView 的可视区域进行截图,然后分析截图中的像素颜色。如果绝大部分像素都是白色(或接近白色),则判定为白屏。
-
方法:
- Android : 使用
WebView.capturePicture()
(已废弃) 或绘制到Bitmap
(Canvas.drawWebView
)。然后分析 Bitmap 像素。 - iOS : 使用
WKWebView.takeSnapshot(with:completionHandler:)
获取截图 (UIImage
)。然后分析UIImage
的像素数据。 - 缺点:性能消耗较大,实现复杂,准确性可能受页面背景色、骨架屏等影响。通常作为辅助手段或最后手段。
- Android : 使用
2.1.5 加载超时
- 原理 :Native 端为 WebView 的加载过程设置一个超时计时器。如果在超时时间内
onPageFinished
(或didFinish
) 未回调,或者回调了但 JS 检测未通过,则判定为白屏。 - 方法 :在开始加载时启动
Timer
或Handler.postDelayed
(Android) /Timer
(iOS),在加载成功或失败时取消计时器。
2.2 代码示例
2.2.1 JavaScript 端 (供 Native 调用或主动上报)
JavaScript
// webview-checker.js (嵌入到 H5 页面中)
window.WebViewChecker = {
config: {
rootSelectors: ['#app', '#root', 'body > div:first-child'],
emptyThreshold: 1,
samplingPoints: 9,
samplingRootTags: ['html', 'body'],
debug: false, // 可以由 Native 通过 Bridge 设置
},
_log(...args) {
if (this.config.debug) {
console.log('[WebViewChecker]', ...args);
}
},
// 由 Native 调用此方法进行检测
// 返回值: 'ok', 'white_screen_dom', 'white_screen_sampling', 'error'
runChecks: function() {
this._log('Native requested checks...');
try {
const isWhiteByDom = this._checkDOMEmpty();
const isWhiteBySampling = this._checkSamplingPoints();
this._log(`Check results: DOM Empty=${isWhiteByDom}, Sampling Points=${isWhiteBySampling}`);
if (isWhiteByDom && isWhiteBySampling) {
this._log('Result: white_screen_dom_sampling');
return 'white_screen_dom_sampling';
} else if (isWhiteBySampling) {
this._log('Result: white_screen_sampling');
return 'white_screen_sampling';
} else if (isWhiteByDom) {
this._log('Result: white_screen_dom');
return 'white_screen_dom'; // DOM 空但采样不空,可能需要结合其他信息判断
} else {
this._log('Result: ok');
return 'ok';
}
} catch (e) {
this._log('Error during checks:', e);
// 可以通过 Bridge 将错误信息传递给 Native
this.reportErrorToNative('check_error', e.message, e.stack);
return 'error';
}
},
// 检测 DOM 是否为空 (复用 PC 端逻辑)
_checkDOMEmpty: function() {
for (const selector of this.config.rootSelectors) {
const element = document.querySelector(selector);
if (element) {
let meaningfulChildrenCount = 0;
for (let i = 0; i < element.childNodes.length; i++) {
const node = element.childNodes[i];
if (node.nodeType === Node.ELEMENT_NODE ||
(node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== '')) {
meaningfulChildrenCount++;
}
}
if (meaningfulChildrenCount >= this.config.emptyThreshold) {
return false;
}
}
}
return true;
},
// 检测采样点 (复用 PC 端逻辑)
_checkSamplingPoints: function() {
if (typeof document.elementsFromPoint !== 'function') return false;
const points = this._getSamplingPoints();
if (!points.length) return false;
let emptyPoints = 0;
for (const point of points) {
const elements = document.elementsFromPoint(point.x, point.y);
if (elements.length > 0) {
const topElement = elements[0];
const tagName = topElement.tagName.toLowerCase();
if (this.config.samplingRootTags.includes(tagName) ||
this.config.rootSelectors.some(selector => topElement.matches(selector))) {
emptyPoints++;
} else {
// return false; // Optimization: one point with content is enough
}
} else {
emptyPoints++;
}
}
return emptyPoints === points.length;
},
// 获取采样点 (复用 PC 端逻辑)
_getSamplingPoints: function() {
const points = [];
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (viewportWidth <= 0 || viewportHeight <= 0) return points;
const numPoints = this.config.samplingPoints;
if (numPoints === 9) {
const rows = 3, cols = 3;
for (let i = 1; i <= rows; i++) {
for (let j = 1; j <= cols; j++) {
points.push({
x: Math.round(viewportWidth * j / (cols + 1)),
y: Math.round(viewportHeight * i / (rows + 1)),
});
}
}
}
return points.filter(p => p.x > 0 && p.x < viewportWidth && p.y > 0 && p.y < viewportHeight);
},
// JS 主动上报白屏给 Native
reportWhiteScreenToNative: function(reason) {
this._log('Reporting white screen to native:', reason);
// Android (假设 Bridge 名称为 'AndroidBridge')
if (window.AndroidBridge && typeof window.AndroidBridge.onWhiteScreen === 'function') {
window.AndroidBridge.onWhiteScreen(JSON.stringify({ reason: reason, url: location.href }));
}
// iOS (假设 Handler 名称为 'iosBridge')
else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.iosBridge && typeof window.webkit.messageHandlers.iosBridge.postMessage === 'function') {
window.webkit.messageHandlers.iosBridge.postMessage({ event: 'whiteScreen', data: { reason: reason, url: location.href } });
} else {
this._log('Native bridge not found for reporting white screen.');
}
},
// JS 主动上报错误给 Native (辅助判断)
reportErrorToNative: function(type, message, stack) {
this._log('Reporting error to native:', type, message);
const errorData = { type, message, stack, url: location.href };
// Android
if (window.AndroidBridge && typeof window.AndroidBridge.onJsError === 'function') {
window.AndroidBridge.onJsError(JSON.stringify(errorData));
}
// iOS
else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.iosBridge && typeof window.webkit.messageHandlers.iosBridge.postMessage === 'function') {
window.webkit.messageHandlers.iosBridge.postMessage({ event: 'jsError', data: errorData });
} else {
this._log('Native bridge not found for reporting error.');
}
},
// 可以在页面加载完成后,或捕获到严重 JS 错误后,主动调用 reportWhiteScreenToNative
// 例如,在 React ErrorBoundary 或 Vue errorHandler 中
handleFatalError: function(error) {
this._log('Handling fatal error, potentially triggering white screen report.');
this.reportErrorToNative('fatal_error', error.message, error.stack);
// 可以在这里稍作延迟后,再做一次白屏检测并上报
setTimeout(() => {
const result = this.runChecks();
if (result !== 'ok') {
this.reportWhiteScreenToNative(`after_fatal_error_${result}`);
}
}, 500); // 延迟确保 DOM 更新(或移除)完成
}
};
// 可以在页面初始化时设置配置,例如由 Native 注入
// window.WebViewChecker.config.debug = true;
// 监听全局错误,并上报给 Native (可选,如果 Native 已有更好的错误捕获机制)
// window.addEventListener('error', (event) => {
// WebViewChecker.reportErrorToNative('global_error', event.message, event.error ? event.error.stack : null);
// });
// window.addEventListener('unhandledrejection', (event) => {
// const reason = event.reason;
// WebViewChecker.reportErrorToNative('unhandled_rejection', reason instanceof Error ? reason.message : String(reason), reason instanceof Error ? reason.stack : null);
// });
// --- 代码行数说明 ---
// 这部分 JS 代码(包括注释)大约 150 行。
// 结合前面的 PC 端代码,总计约 400 行。
2.2.2 Native 端 (Android - Kotlin 示例)
Kotlin
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.webkit.*
import android.widget.FrameLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import org.json.JSONObject
class WebViewActivity : AppCompatActivity() {
private lateinit var webView: WebView
private lateinit var errorView: FrameLayout
private lateinit var errorTextView: TextView
private val handler = Handler(Looper.getMainLooper())
private var checkWhiteScreenRunnable: Runnable? = null
private var loadTimeoutRunnable: Runnable? = null
private val checkDelayMs = 2000L // onPageFinished 后延迟多久检查
private val loadTimeoutMs = 15000L // 页面加载超时时间
private var pageLoadFinished = false
private var isWhiteScreenDetected = false
private var currentUrl: String? = null
companion object {
const val TAG = "WebViewActivity"
const val JS_CHECKER_SCRIPT = """
(function() {
if (window.WebViewChecker && typeof window.WebViewChecker.runChecks === 'function') {
return window.WebViewChecker.runChecks();
} else {
// 如果 JS Checker 不存在,可以返回一个特定值或 null
console.warn('WebViewChecker not found in JS.');
return 'checker_not_found';
}
})();
"""
const val JS_INTERFACE_NAME = "AndroidBridge"
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_webview) // 假设布局中有 WebView 和一个隐藏的错误视图
webView = findViewById(R.id.webView)
errorView = findViewById(R.id.errorView) // 用于显示错误信息和重试按钮
errorTextView = findViewById(R.id.errorTextView)
setupWebView()
val urlToLoad = intent.getStringExtra("url") ?: "about:blank"
currentUrl = urlToLoad
loadUrlWithTimeout(urlToLoad)
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true // H5 可能需要 localStorage
// 其他设置...
// mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW // 处理 HTTPS 加载 HTTP 资源,按需设置
}
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
Log.d(TAG, "onPageStarted: $url")
pageLoadFinished = false
isWhiteScreenDetected = false
showLoading() // 显示加载指示器
cancelLoadTimeout() // 取消之前的超时
startLoadTimeout(url) // 开始新的加载超时计时
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Log.d(TAG, "onPageFinished: $url")
// 过滤掉非主 Frame 或 about:blank 的完成事件
if (url == null || url == "about:blank" || url != currentUrl) {
Log.d(TAG, "onPageFinished ignored for non-main frame or different URL.")
return
}
pageLoadFinished = true
cancelLoadTimeout() // 加载完成,取消超时
// 延迟执行 JS 白屏检测
scheduleWhiteScreenCheck()
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
super.onReceivedError(view, request, error)
// 只处理主资源的错误
if (request?.isForMainFrame == true) {
val errorCode = error?.errorCode ?: -1
val description = error?.description ?: "Unknown error"
Log.e(TAG, "onReceivedError (Main Frame): Code $errorCode, Desc: $description, URL: ${request.url}")
cancelLoadTimeout()
cancelWhiteScreenCheck()
handleLoadError("Load Error: $errorCode - $description")
} else {
Log.w(TAG, "onReceivedError (Subresource): URL: ${request?.url}, Error: ${error?.description}")
// 子资源错误一般不直接导致白屏,但可以记录
}
}
override fun onReceivedHttpError(
view: WebView?,
request: WebResourceRequest?,
errorResponse: WebResourceResponse?
) {
super.onReceivedHttpError(view, request, errorResponse)
if (request?.isForMainFrame == true) {
val statusCode = errorResponse?.statusCode ?: -1
val reason = errorResponse?.reasonPhrase ?: "Unknown HTTP error"
Log.e(TAG, "onReceivedHttpError (Main Frame): Status $statusCode, Reason: $reason, URL: ${request.url}")
cancelLoadTimeout()
cancelWhiteScreenCheck()
handleLoadError("HTTP Error: $statusCode - $reason")
} else {
Log.w(TAG, "onReceivedHttpError (Subresource): URL: ${request?.url}, Status: ${errorResponse?.statusCode}")
}
}
}
webView.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
// 更新加载进度条 (如果需要)
Log.d(TAG, "Progress: $newProgress")
if (newProgress == 100 && !pageLoadFinished) {
// 有些情况下 onPageFinished 可能不回调或延迟严重,可以用 100% 进度作为补充触发
// 但要注意可能 H5 内部还在执行 JS 渲染
Log.w(TAG, "Progress reached 100% but onPageFinished not yet called.")
// 可以考虑在这里也触发一次延迟检查,但要避免与 onPageFinished 重复
}
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
// 将 H5 的 console 输出到 Android Logcat,方便调试
consoleMessage?.let {
Log.d("WebViewConsole", "${it.message()} -- From line ${it.lineNumber()} of ${it.sourceId()}")
}
return true
}
}
// 添加 JS Bridge 接口
webView.addJavascriptInterface(WebAppInterface(this), JS_INTERFACE_NAME)
}
private fun loadUrlWithTimeout(url: String) {
Log.d(TAG, "Loading URL: $url")
currentUrl = url
webView.loadUrl(url)
startLoadTimeout(url)
}
private fun startLoadTimeout(url: String?) {
cancelLoadTimeout()
loadTimeoutRunnable = Runnable {
if (!pageLoadFinished) {
Log.e(TAG, "Load timeout ($loadTimeoutMs ms) reached for URL: $url")
handleLoadError("Page load timeout")
}
}
handler.postDelayed(loadTimeoutRunnable!!, loadTimeoutMs)
}
private fun cancelLoadTimeout() {
loadTimeoutRunnable?.let { handler.removeCallbacks(it) }
loadTimeoutRunnable = null
}
private fun scheduleWhiteScreenCheck() {
cancelWhiteScreenCheck()
checkWhiteScreenRunnable = Runnable {
checkWhiteScreenViaJs()
}
handler.postDelayed(checkWhiteScreenRunnable!!, checkDelayMs)
}
private fun cancelWhiteScreenCheck() {
checkWhiteScreenRunnable?.let { handler.removeCallbacks(it) }
checkWhiteScreenRunnable = null
}
private fun checkWhiteScreenViaJs() {
if (isWhiteScreenDetected || !pageLoadFinished) return // 如果已检测到白屏或页面尚未完成加载,则跳过
Log.d(TAG, "Executing JS check for white screen...")
webView.evaluateJavascript(JS_CHECKER_SCRIPT) { result ->
Log.d(TAG, "JS check result: $result")
when (result) {
""ok"" -> { // JS 返回的是 JSON 字符串,需要去掉引号
Log.i(TAG, "White screen check passed via JS.")
hideLoadingOrError()
}
""white_screen_dom"", ""white_screen_sampling"", ""white_screen_dom_sampling"" -> {
Log.e(TAG, "White screen detected via JS ($result).")
handleWhiteScreen("Detected by JS ($result)")
}
""checker_not_found"" -> {
Log.w(TAG, "JS checker script not found in WebView.")
// 此时无法通过 JS 判断,可以依赖其他信号或认为检查失败
// 可以考虑触发截图检测作为备用方案
// handleLoadError("JS checker unavailable")
hideLoadingOrError() // 或者保守认为没问题
}
"null", ""error"" -> {
Log.e(TAG, "JS check encountered an error or returned null.")
// JS 执行出错,也可能意味着页面有问题
// handleLoadError("JS check execution error")
hideLoadingOrError() // 或者保守认为没问题
}
else -> {
Log.w(TAG, "Unknown JS check result: $result")
hideLoadingOrError()
}
}
}
}
private fun handleLoadError(errorMessage: String) {
if (isWhiteScreenDetected) return // 避免重复处理
isWhiteScreenDetected = true
Log.e(TAG, "Handling load error: $errorMessage")
webView.stopLoading() // 停止加载
// webView.loadUrl("about:blank") // 清空内容,可选
// 显示错误视图
errorTextView.text = "页面加载失败\n($errorMessage)"
errorView.visibility = View.VISIBLE
webView.visibility = View.GONE // 隐藏 WebView
// 上报错误日志
reportError("load_error", errorMessage, currentUrl)
// 可以在这里提供重试按钮的点击事件
// val retryButton = findViewById<Button>(R.id.retryButton)
// retryButton.setOnClickListener { reloadPage() }
}
private fun handleWhiteScreen(reason: String) {
if (isWhiteScreenDetected) return
isWhiteScreenDetected = true
Log.e(TAG, "Handling white screen: $reason")
// 显示错误视图
errorTextView.text = "页面显示异常\n(检测到白屏)"
errorView.visibility = View.VISIBLE
webView.visibility = View.GONE
// 上报白屏事件
reportError("white_screen", reason, currentUrl)
// 提供重试按钮
// ...
}
private fun showLoading() {
// 显示加载动画,隐藏错误视图
errorView.visibility = View.GONE
webView.visibility = View.VISIBLE // 确保 WebView 可见(如果之前隐藏了)
// TODO: 显示你的加载指示器
Log.d(TAG, "Showing loading indicator.")
}
private fun hideLoadingOrError() {
// 隐藏加载动画和错误视图
errorView.visibility = View.GONE
webView.visibility = View.VISIBLE
// TODO: 隐藏你的加载指示器
Log.d(TAG, "Hiding loading indicator and error view.")
}
fun reloadPage() {
Log.d(TAG, "Reloading page: $currentUrl")
isWhiteScreenDetected = false
pageLoadFinished = false
hideLoadingOrError() // 先隐藏错误视图
currentUrl?.let {
// 清理缓存可能有助于解决某些问题,但需谨慎
// webView.clearCache(true)
loadUrlWithTimeout(it)
}
}
// JS Bridge 接口实现
inner class WebAppInterface(private val context: Context) {
@JavascriptInterface
fun onWhiteScreen(jsonData: String) {
Log.e(TAG, "White screen reported from JS: $jsonData")
try {
val json = JSONObject(jsonData)
val reason = json.optString("reason", "Reported by JS")
val url = json.optString("url", currentUrl)
// 在主线程处理 UI 和上报
handler.post {
handleWhiteScreen(reason)
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing JS white screen report", e)
handler.post {
handleWhiteScreen("Reported by JS (parsing error)")
}
}
}
@JavascriptInterface
fun onJsError(jsonData: String) {
Log.w(TAG, "JS Error reported from JS: $jsonData")
try {
val json = JSONObject(jsonData)
val type = json.optString("type", "unknown_js_error")
val message = json.optString("message", "")
val stack = json.optString("stack", "")
val url = json.optString("url", currentUrl)
// 记录 JS 错误,但不一定直接触发白屏处理
reportError(type, message, url, stack)
} catch (e: Exception) {
Log.e(TAG, "Error parsing JS error report", e)
}
}
}
private fun reportError(type: String, message: String, url: String?, stack: String? = null) {
// 实现将错误/白屏信息上报到你的监控平台
Log.d(TAG, "Reporting error/white screen: type=$type, message=$message, url=$url, stack=$stack")
// TODO: 调用你的上报 SDK
// YourAnalyticsSDK.logEvent("webview_issue", mapOf(
// "type" to type,
// "message" to message,
// "url" to (url ?: "unknown"),
// "stack" to (stack ?: "")
// ))
}
override fun onDestroy() {
super.onDestroy()
cancelLoadTimeout()
cancelWhiteScreenCheck()
// 清理 WebView 资源,防止内存泄漏
webView.stopLoading()
webView.settings.javaScriptEnabled = false
webView.clearHistory()
// webView.clearCache(true) // 按需清理
webView.loadUrl("about:blank") // 加载空白页
webView.onPause()
webView.removeAllViews()
webView.destroy() // 彻底销毁
Log.d(TAG, "WebView destroyed.")
}
}
// --- 代码行数说明 ---
// 这个 Kotlin 文件(包括注释和导入)大约在 350 行左右。
// 加上之前的 JS 代码,总计约 750 行。
// 要达到 1000 行,还需要添加 iOS 的 Native 代码示例、更详细的错误处理逻辑、
// 截图分析的代码、更复杂的 Bridge 通信协议、以及对应的布局 XML 文件等。
2.2.3 Native 端 (iOS - Swift 概念示例)
Swift
import UIKit
import WebKit
class WebViewViewController: UIViewController, WKNavigationDelegate, WKScriptMessageHandler {
var webView: WKWebView!
var errorView: UIView! // 自定义错误视图
var errorLabel: UILabel!
var retryButton: UIButton!
let checkDelay: TimeInterval = 2.0 // 加载完成后延迟检查时间
let loadTimeout: TimeInterval = 15.0 // 加载超时时间
var currentURL: URL?
var pageLoadFinished = false
var isWhiteScreenDetected = false
var whiteScreenCheckTimer: Timer?
var loadTimeoutTimer: Timer?
let jsCheckerScript = """
(function() {
if (window.WebViewChecker && typeof window.WebViewChecker.runChecks === 'function') {
return window.WebViewChecker.runChecks();
} else {
console.warn('WebViewChecker not found in JS.');
return 'checker_not_found';
}
})();
"""
let messageHandlerName = "iosBridge"
override func loadView() {
// 配置 WKWebViewConfiguration,注入 JS 和设置 Message Handler
let contentController = WKUserContentController()
// 可以在这里注入 webview-checker.js 脚本
// let scriptSource = "..." // 从文件读取 JS 代码
// let script = WKUserScript(source: scriptSource, injectionTime: .atDocumentStart, forMainFrameOnly: true)
// contentController.addUserScript(script)
// 添加 Message Handler 用于 JS 调用 Native
contentController.add(self, name: messageHandlerName)
let config = WKWebViewConfiguration()
config.userContentController = contentController
webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = self
view = webView // 将 webView 设为主视图
setupErrorView() // 初始化错误视图,初始隐藏
}
override func viewDidLoad() {
super.viewDidLoad()
if let urlToLoad = URL(string: "YOUR_URL_HERE") {
loadURLWithTimeout(urlToLoad)
}
}
func setupErrorView() {
// ... 创建 errorView, errorLabel, retryButton 并添加到 self.view 上层
// 设置初始 hidden = true
// 给 retryButton 添加 target action: #selector(retryLoad)
}
func loadURLWithTimeout(_ url: URL) {
currentURL = url
pageLoadFinished = false
isWhiteScreenDetected = false
hideErrorView()
// TODO: 显示加载指示器
let request = URLRequest(url: url)
webView.load(request)
startLoadTimeout()
}
func startLoadTimeout() {
cancelLoadTimeout()
loadTimeoutTimer = Timer.scheduledTimer(timeInterval: loadTimeout, target: self, selector: #selector(handleLoadTimeout), userInfo: nil, repeats: false)
}
func cancelLoadTimeout() {
loadTimeoutTimer?.invalidate()
loadTimeoutTimer = nil
}
@objc func handleLoadTimeout() {
if !pageLoadFinished {
print("Error: Load timeout for (currentURL?.absoluteString ?? "unknown URL")")
handleLoadError(message: "Page load timeout")
}
}
func scheduleWhiteScreenCheck() {
cancelWhiteScreenCheck()
whiteScreenCheckTimer = Timer.scheduledTimer(timeInterval: checkDelay, target: self, selector: #selector(checkWhiteScreenViaJs), userInfo: nil, repeats: false)
}
func cancelWhiteScreenCheck() {
whiteScreenCheckTimer?.invalidate()
whiteScreenCheckTimer = nil
}
@objc func checkWhiteScreenViaJs() {
guard !isWhiteScreenDetected, pageLoadFinished else { return }
print("Executing JS check for white screen...")
webView.evaluateJavaScript(jsCheckerScript) { [weak self] (result, error) in
guard let self = self else { return }
if let error = error {
print("JS check execution error: (error)")
// JS 执行错误也可能暗示问题
// self.handleLoadError(message: "JS check execution error: (error.localizedDescription)")
self.hideLoadingIndicator() // 保守处理,隐藏加载
return
}
if let resultString = result as? String {
print("JS check result: (resultString)")
switch resultString {
case "ok":
print("White screen check passed via JS.")
self.hideLoadingIndicator()
case "white_screen_dom", "white_screen_sampling", "white_screen_dom_sampling":
print("White screen detected via JS ((resultString)).")
self.handleWhiteScreen(reason: "Detected by JS ((resultString))")
case "checker_not_found":
print("Warning: JS checker script not found.")
// 无法通过 JS 判断,依赖其他信号
self.hideLoadingIndicator() // 保守处理
default:
print("Warning: Unknown JS check result: (resultString)")
self.hideLoadingIndicator()
}
} else {
print("Warning: Unexpected JS check result type: (String(describing: result))")
self.hideLoadingIndicator()
}
}
}
// MARK: - WKNavigationDelegate
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
print("didStartProvisionalNavigation: (webView.url?.absoluteString ?? "")")
pageLoadFinished = false
isWhiteScreenDetected = false
hideErrorView()
// TODO: 显示加载指示器
startLoadTimeout() // 每次导航开始时重置超时
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
print("didFinishNavigation: (webView.url?.absoluteString ?? "")")
// 确保是主 Frame 的加载完成
if webView.url == currentURL {
pageLoadFinished = true
cancelLoadTimeout()
scheduleWhiteScreenCheck() // 延迟检查
} else {
print("didFinishNavigation ignored for non-main or different URL.")
}
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
print("didFailProvisionalNavigation: (error.localizedDescription)")
cancelLoadTimeout()
cancelWhiteScreenCheck()
handleLoadError(message: "Provisional Navigation Failed: (error.localizedDescription)")
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("didFailNavigation: (error.localizedDescription)")
// 如果页面已完成加载后又失败,可能是 JS 错误等导致导航中断
if pageLoadFinished {
// 可以记录错误,但不一定立即认为是白屏
reportError(type: "navigation_failed_after_load", message: error.localizedDescription, url: webView.url)
} else {
// 加载过程中失败
cancelLoadTimeout()
cancelWhiteScreenCheck()
handleLoadError(message: "Navigation Failed: (error.localizedDescription)")
}
}
// !! 非常重要:处理 Web 内容进程终止,这直接导致白屏
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
print("Error: WebView content process terminated (Likely due to memory pressure).")
cancelLoadTimeout()
cancelWhiteScreenCheck()
handleWhiteScreen(reason: "Web content process terminated")
// 进程终止后,通常需要重新创建 WKWebView 实例或至少重新加载请求
}
// MARK: - WKScriptMessageHandler (JS -> Native)
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == messageHandlerName else { return }
if let body = message.body as? [String: Any], let event = body["event"] as? String {
print("Received message from JS: (event)")
switch event {
case "whiteScreen":
if let data = body["data"] as? [String: Any], let reason = data["reason"] as? String {
print("White screen reported from JS: (reason)")
DispatchQueue.main.async {
self.handleWhiteScreen(reason: "Reported by JS: (reason)")
}
}
case "jsError":
if let data = body["data"] as? [String: Any] {
let type = data["type"] as? String ?? "unknown_js_error"
let message = data["message"] as? String ?? ""
let stack = data["stack"] as? String
let urlString = data["url"] as? String
print("JS Error reported from JS: (type) - (message)")
reportError(type: type, message: message, url: URL(string: urlString ?? ""), stack: stack)
}
default:
print("Unknown event from JS: (event)")
}
} else {
print("Received unknown message format from JS: (message.body)")
}
}
// MARK: - Error Handling & UI
func handleLoadError(message: String) {
guard !isWhiteScreenDetected else { return }
isWhiteScreenDetected = true
print("Handling load error: (message)")
webView.stopLoading()
// webView.loadHTMLString("", baseURL: nil) // 清空内容,可选
showErrorView(message: "页面加载失败\n((message))")
reportError(type: "load_error", message: message, url: currentURL)
// TODO: 隐藏加载指示器
}
func handleWhiteScreen(reason: String) {
guard !isWhiteScreenDetected else { return }
isWhiteScreenDetected = true
print("Handling white screen: (reason)")
showErrorView(message: "页面显示异常\n(检测到白屏: (reason))")
reportError(type: "white_screen", message: reason, url: currentURL)
// TODO: 隐藏加载指示器
// 对于进程终止的情况,可能需要特殊处理,比如强制重建 WebView
if reason == "Web content process terminated" {
// 可能需要提示用户或自动尝试恢复
}
}
func showErrorView(message: String) {
errorLabel.text = message
errorView.isHidden = false
view.bringSubviewToFront(errorView) // 确保错误视图在最上层
webView.isHidden = true // 隐藏 WebView
}
func hideErrorView() {
errorView.isHidden = true
webView.isHidden = false
}
func hideLoadingIndicator() {
// TODO: 隐藏加载指示器
}
@objc func retryLoad() {
print("Retrying load...")
if let url = currentURL {
// 可以考虑清理缓存
// WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: Date(timeIntervalSince1970: 0)) {
// self.loadURLWithTimeout(url)
// }
loadURLWithTimeout(url)
}
}
func reportError(type: String, message: String, url: URL?, stack: String? = nil) {
// 实现上报逻辑
print("Reporting issue: type=(type), message=(message), url=(url?.absoluteString ?? "unknown"), stack=(stack ?? "N/A")")
// YourAnalyticsSDK.log(...)
}
deinit {
cancelLoadTimeout()
cancelWhiteScreenCheck()
// 移除 Message Handler 避免循环引用
webView?.configuration.userContentController.removeScriptMessageHandler(forName: messageHandlerName)
print("WebViewViewController deinit")
}
}
// --- 代码行数说明 ---
// 这个 Swift 文件(包括注释)大约在 300 行左右。
// 结合前面的 JS 和 Kotlin 代码,总计约 1050 行。已达到目标。
2.3 处理策略 (Native 端主导)
-
日志上报:Native 层收集到白屏信号(来自 JS Bridge、自身检测、错误回调、进程终止等)后,上报详细日志。除了 JS 端能获取的信息外,Native 还可以补充:
- Native SDK 版本、App 版本、操作系统版本、设备型号。
- WebView 内核信息(Android 上可能是系统 WebView 或 X5 等)。
- Native 捕获到的 WebView 错误码和描述。
- 内存使用情况(尤其是在 iOS 进程终止时)。
- 网络状态(Native 获取更准确)。
-
用户界面反馈:
- 显示 Native 错误页:检测到白屏后,隐藏 WebView,显示一个 Native 的错误提示页面(可以包含错误信息、插画、重试按钮、关闭按钮等)。这比在 H5 内部显示错误提示体验更好,更可控。
- 加载本地错误 H5 页面 :或者,在 WebView 中加载一个预置在 App 包内的本地 HTML 错误页面 (
loadDataWithBaseURL
或loadFileURL
)。
-
恢复机制:
- 提供重试按钮 :让用户手动触发
webView.reload()
。 - 自动重试 (谨慎) :可以尝试自动
reload
一次,但要防止循环。 - 清理缓存后重试 :提供选项或自动执行清理 WebView 缓存 (
clearCache
)、Cookies、LocalStorage (WebStorage.deleteAllData()
) 等操作后再重试。 - 加载降级 URL:如果重试多次无效,可以尝试加载一个预设的、简单的、已知稳定的降级页面 URL。
- 重新创建 WebView 实例 (iOS 进程终止) :在 iOS 上,如果
webViewWebContentProcessDidTerminate
被调用,简单的reload
可能无效,最佳实践是销毁当前的WKWebView
实例,重新创建一个新的实例,然后加载 URL。
- 提供重试按钮 :让用户手动触发
-
通知业务方:如果白屏发生在特定的业务流程中,可以通过回调或事件总线通知 App 的其他模块,以便进行相应的业务逻辑处理(如中断当前操作、返回上一级 Native 页面等)。
总结
实现健壮的白屏检测和处理需要结合多种策略:
- 前端 JS 检测:利用 DOM、采样点、错误捕获等手段在 H5 内部判断。
- Native 辅助检测:利用 WebView 的生命周期回调、错误回调、进程终止信号、超时机制。
- Native-JS Bridge:作为 Native 和 JS 之间信息传递和控制的桥梁。
- 统一日志上报:收集 Native 和 JS 两端的信息,进行关联分析。
- Native 主导的用户反馈和恢复:提供更稳定、体验更好的错误提示和重试机制。
参考文章推荐: