1. 问题现象与影响范围
WKWebView 白屏问题、request body 丢失问题,这两个词放在一起,基本上就是 iOS 端 Hybrid 应用开发者绕不过去的两道坎。我在做 App 内嵌 H5 业务改造的两年里,这两类问题交替出现过,每回线上反馈进来,都是带着用户截图和一串"页面打不开""表单提交没反应"的吐槽。今天把它们放在一起聊,是因为它们表面上是两个独立问题,底层却都指向同一个根源——WKWebView 的进程模型与请求转发机制。
简单说一下我实际遇到的典型场景。
第一个场景是白屏。用户从 App 首页点进某个活动页,页面在 Wi-Fi 弱网环境下加载到一半,突然退出到后台,再切回来,整个 WebView 区域变成一片纯白,底部的内容全部消失。杀掉 App 重进又好了。这个问题在低内存设备(iPhone 7 及以下)上尤其频繁,系统日志里能看到WebContent process crashed之类的痕迹。
第二个场景是 request body 丢失。业务方做了一个 H5 版的客户回访表单,用户花十分钟填完一大段文字,点提交,前端把数据用 POST 请求发到后端,结果服务端收到的 body 是空的。前端查 Network 面板,明明 payload 里有完整 JSON。这个问题的诡异之处在于它不是必现的,只在特定条件下触发,比如用户在页面停留时间较长、内存紧张、或者 WebView 被系统回收又恢复之后。
这两个问题单独看都是老掉牙的话题,网上方案一搜一大把。但真正把它们放在一起系统性排查的时候,你会发现很多网上的方案是互相矛盾的,有的甚至会在解决一个问题的同时把另一个问题引入。我打算从底层原理到实际修复,把这套完整的排查思路和最终落地方案整理出来。
先说结论:白屏问题的核心是 WebContent 进程被杀,而 request body 丢失的核心是 WKWebView 在跨进程通信时对 HTTP body 的序列化限制。理解了这两点,后面所有修复方案都是在跟系统机制做博弈。
2. 白屏问题的根因分析
2.1 WebContent 进程崩溃与系统回收机制
WKWebView 从 iOS 8 开始就是多进程架构,App 进程和 WebContent 渲染进程分开跑。这个设计的好处是网页卡死、崩溃不会直接带走 App 主进程,坏处是 WebContent 进程随时可能被系统杀掉,而且杀的时候不会提前打招呼。
WebContent 进程被杀通常分成两类。
第一类是真正的崩溃。页面 JS 执行了非法操作、加载了异常资源、或者 WebContent 内部 bug,进程直接崩溃退出,iOS 系统的崩溃日志里能看到com.apple.WebKit.WebContent的异常退出记录。这种崩溃在低版本系统上更容易触发,尤其是 iOS 11 到 iOS 13 那一批 WebKit 的 bug,比如音频播放、视频播放、Canvas 大规模渲染都可能导致 WebContent 崩溃。
第二类是 Jetsam 内存回收。iOS 的内存管理机制是"谁内存占用大就优先杀谁",WebContent 进程是系统内存压力的主要清理对象。一个复杂的 H5 页面,尤其是有大量图片、长列表、高频 DOM 操作的页面,WebContent 进程的内存很容易冲到 300MB 以上。这时系统如果判定内存紧张,会优先杀掉 WebContent 进程——注意,它宁可杀 WebContent 也不杀你的 App 主进程,因为主进程在用户感知里"活着",WebContent 进程挂了用户看到的只是白屏,系统觉得这个代价最小。
但这里有一个关键细节:WebContent 进程被杀后,WKWebView 并没有重新加载页面。App 进程里 WKWebView 对象还活着,backForwardList 也还在,但 webView 的渲染内容已经没了。如果没有做任何处理,你看到的就直接是一块白屏。
2.2 WKWebView 与 App 进程的通信桥接
为了讲清楚白屏的恢复机制,这里需要引入 WKWebView 的进程通信模型。
WKWebView 的 UI 层在主进程(App 进程),渲染层在 WebContent 进程,两者通过 XPC 通信。你在 JS 里执行window.webkit.messageHandlers.xxx.postMessage(),这个调用会从 WebContent 进程通过 XPC 传到 App 进程;反过来,App 进程调用webView.evaluateJavaScript(),内容是传到 WebContent 进程去执行的。
问题就出在这条链路上。这条通信链路要求两边进程状态都正常,任何一边挂了,通信就断了。而系统并没有提供一个"WebContent 进程挂了自动拉起并重载页面"的机制——它只在主进程监听相关状态变化,把异常抛给实现了WKNavigationDelegate的对象,然后就不管了。
另外,WKWebView 在 iOS 13 之前没有公开的 API 告诉你 WebContent 进程的状态。常见的检测手段要么依赖webViewWebContentProcessDidTerminate:,要么在 iOS 14 及以后用WKUIDelegate的webViewWebContentProcessDidTerminate:方法(苹果在 iOS 14 给这个方法加了新的调用时机),要么通过加载一个空的 data URL 来观察是否触发导航回调。
我早期踩过的一个坑是:只处理了webViewWebContentProcessDidTerminate:回调就以为万事大吉,结果线上还是有人反馈白屏。后来打日志才发现,某些情况下 WebContent 进程被杀后这个方法根本没有被调用——系统逻辑是如果 WKWebView 本身已经不在窗口层级上(比如被 App 切到后台),它不会立刻回调,等切回前台时页面已经白了,但回调却没有触发。
3. 白屏问题的检测与自动恢复方案
3.1 白屏检测的三个信号源
解决白屏问题的第一步是准确地检测到"真的白屏了"。这里我整理出三个信号源,实际项目中需要组合使用,单独依赖任何一个都不够可靠。
第一个信号源是webViewWebContentProcessDidTerminate:回调。这是最直接的信号,WebContent 进程挂了系统会调用它。但正如上面说的,回调有延迟和遗漏的场景,不能作为唯一依据。
第二个信号源是WKNavigationDelegate的导航回调。如果 WebContent 进程挂了,导航相关的回调会停在一个中间状态——比如didStartProvisionalNavigation:之后一直没有didFinish:。可以通过一个超时计时器,比如超过 10 秒还没有 finish,就触发一次白屏检测逻辑。
第三个信号源是主动探测。用evaluateJavaScript执行一个空代码(比如window.location.href),正常情况下这段代码会在几百毫秒内回调;如果 WebContent 进程挂了,回调永远不会回来。可以封装一个带超时检测的探活函数,每隔一段时间主动探测一次。
我用主动探测比较多,原因是在 iOS 14 之后,webViewWebContentProcessDidTerminate:的调用时机变得非常不稳定,而主动探测不受系统回调时机的限制。探测逻辑的代码大致是这样:
- (void)checkWebViewAlive { __weak typeof(self) weakSelf = self; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __strong typeof(self) strongSelf = weakSelf; if (!strongSelf) return; dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); __block BOOL isAlive = NO; [strongSelf.webView evaluateJavaScript:@"1 + 1" completionHandler:^(id result, NSError *error) { if (error == nil) { isAlive = YES; } dispatch_semaphore_signal(semaphore); }]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ if (!isAlive) { // WebContent 进程可能已挂,触发白屏恢复逻辑 [strongSelf handleWebViewWhiteScreen]; } }); }); }这个方案的关键在于超时判定——evaluateJavaScript的回调在进程挂了的情况下永远不会触发,所以用一个延迟 2 秒的检查来判断回调是否已经回来。我在 iOS 11 到 iOS 16 的机型上都验证过,误判率很低,因为正常情况下的 JS 执行回调不会超过 1 秒。
3.2 页面恢复与状态保持策略
检测到白屏之后,恢复逻辑的处理直接决定用户体验。最简单的方案是直接reload,但这里有一个问题:如果页面有未提交的表单数据,或者用户已经滚动到某个位置,reload 之后这些状态全丢了,用户会非常愤怒。
我采用的是一套分级的恢复策略。
第一步,先尝试在 WebContent 进程重新启动后自动恢复页面。iOS 系统在 WebContent 进程崩溃后会自动重建进程,但不会自动恢复页面内容。我的做法是在webViewWebContentProcessDidTerminate:回调里先拿到当前页面的 URL,然后调用webView.reload(),这样至少保证页面能回来。
第二步,如果页面有状态保存需求,通过WKWebView的interactionState属性(iOS 11 之后支持)来保存页面滚动位置等状态。设置方法是webView.interactionState = @(scrollOffset),恢复时再取出来。
第三步,对于 H5 页面内容本身的状态,通过 URL 参数传递。在 reload 的 URL 上追加一个fromTerminated=1的参数,H5 端检测到之后会主动从缓存或存储中恢复表单数据等状态。这一步需要 H5 侧配合,但实际操作下来效果最好,比 App 端强行注入 JS 去恢复状态要稳妥得多。
还有一个细节值得注意:reload 之前要判断页面当前是否处于前台。如果用户已经切到其他页面甚至切到后台,白屏恢复的优先级应该降低,等用户回到页面时再执行恢复逻辑,否则在后台执行 reload 会白白浪费流量,还可能触发一些业务埋点的重复上报。
3.3 白屏预防机制的工程化实践
除了被动检测和恢复,主动预防也很重要。实战中发现,优化 H5 页面的内存占用能显著降低 WebContent 进程被系统回收的概率。这里有几个可落地的措施。
第一个是控制 WebView 的复用。不要在一个页面里无限创建新的 WKWebView,用完即销毁。多个 WKWebView 同时存在时,内存是叠加的,系统杀 WebContent 的概率会成倍增长。
第二个是配置WKWebViewConfiguration时,把websiteDataStore设置为非持久化的。如果业务不需要 localStorage 跨 App 启动持久保存,用WKWebsiteDataStore.nonPersistentDataStore()可以少一部分磁盘和内存开销。但要注意,这个设置在 iOS 9 之后有坑,非持久化 store 的 JS 执行上下文在某些版本上有 bug,需要在测试机上验证你要支持的 iOS 版本。
第三个是及时清理 WKWebView 的 backForwardList。长列表浏览场景下,backForwardList 会累积大量的历史快照,每个快照都包含页面内容,占内存严重。我的做法是在导航完成时,判断如果 backForwardList 超过 5 项,就执行一次removeAllItems,但这里需要权衡——如果业务需要支持返回上一页,就不能清理得太频繁。
第四个是给 WKWebView 设置合理的 frame 和透明背景。不要用复杂的 layer 动画频繁操作 webView 所在的父视图,某些系统版本上对 WKWebView 做 transform 动画会导致渲染异常甚至崩溃。
4. request body 丢失问题的来龙去脉
4.1 WKWebView 对 POST 请求的"特殊处理"
说完白屏,来看第二个问题——request body 丢失。
要理解这个问题,先要搞明白 WKWebView 里一个不太为人知的行为:WKWebView 在跨进程处理网络请求时,对 HTTP body 的处理和 UIWebView 完全不同。UIWebView 时代,网络请求是直接基于 App 进程的 NSURLProtocol 体系,请求的 body 可以完整保留;WKWebView 时代,WebContent 进程和 App 进程隔离,请求数据要跨进程传输,为了效率,WebKit 对请求做了一个"瘦身"处理。
实际表现是:当你通过WKNavigationDelegate的decidePolicyForNavigationAction拿到NSURLRequest时,如果是 POST 请求,httpBody很可能是 nil。这不是 bug,而是 WebKit 的设计决定——它默认通过httpBodyStream来传输 body 数据,而httpBodyStream在跨进程传递时会被消费掉,你拿到手的时候 stream 已经被读过一遍,没法再读了。
这是一个非常隐蔽的问题。前端页面用XMLHttpRequest或fetch发 POST 请求,数据在 WebContent 进程内部是正常的,网络层也能正常发出。但如果有人想在 App 侧拦截、观察、修改这个 POST 请求,就会遇到httpBody == nil的尴尬局面。
对于大多数普通业务来说,这个行为不影响页面本身的请求——因为请求是 WebContent 进程自己发的,body 数据在进程内完整存在。问题出在那些"App 侧试图拦截请求做二次处理"的场景。
哪些场景会踩到这个坑?
App 侧注入统一的请求头(比如给所有请求加上业务鉴权字段)。如果你的实现方式是修改NSURLRequest然后重新发起,那 POST body 就会在重发时丢失。
用自定义NSURLProtocol拦截 WKWebView 的请求做全局处理。这个在 iOS 11 之后已经基本废掉了——WKWebView 默认不走 App 进程的 NSURLProtocol,注册了也没用。
用WKNavigationDelegate做统一的请求参数补充。在decidePolicyForNavigationAction里拿到 request,因为是 GET 就把参数拼在 URL 上,是 POST 就想读 body 再补充字段,结果发现 body 是 nil。
还有一个我遇到的具体场景:为了统计 H5 的接口耗时,我在 App 侧通过decidePolicyForNavigationAction观察所有导航请求,想从 POST body 里解析出业务参数来辅助定位问题。结果 body 拿不到,导致定位问题只能靠猜,非常被动。
4.2 body 丢失与页面白屏的关联场景
这两个问题还会叠加出现,而且叠加之后影响特别严重。
我的一个真实案例是,用户在 App 内嵌的 H5 页面填写了一个很长的问卷,填到一半切到微信回了个消息,再切回 App,页面白屏了。用户被迫重新加载,填写的内容全部丢失。但更麻烦的是,这期间如果用户恰好点了提交,前端请求已经发出去了,请求体大概率在某个环节丢了或者后端收到了不完整的 payload。
这类场景在低内存设备上特别常见。iOS 系统杀 WebContent 进程的时间点往往在用户切后台的几秒钟内。如果此时页面正在断点续传一个 POST 请求(比如上传日志、提交表单),请求的状态管理就全乱了。
解决这类叠加问题的核心思路是:白屏检测与恢复逻辑要尽早介入,同时 App 侧和 H5 侧要建立一套请求状态同步机制。H5 在提交数据前,先把 payload 保存在 localStorage 里,提交成功后再删除。App 侧在检测到 WebContent 进程终止时,通过 URL 参数通知 H5"刚刚发生过白屏崩溃",H5 读取 localStorage 中未完成的请求,在页面恢复后自动重试。
这套方案实现了之后,至少让我负责的线上事故单从每周十几条降到了每周两三条。
5. request body 丢失的完整解决方案
5.1 方案 A:使用 WKWebView 的 navigationAction.request 解决方案
先看最常见的decidePolicyForNavigationAction拦截场景。
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { NSURLRequest *request = navigationAction.request; if ([request.HTTPMethod isEqualToString:@"POST"]) { // 这里 request.HTTPBody 很可能是 nil NSData *body = request.HTTPBody; if (body == nil && request.HTTPBodyStream != nil) { // 需要手动读取 stream NSInputStream *stream = request.HTTPBodyStream; [stream open]; NSMutableData *data = [NSMutableData data]; uint8_t buffer[1024]; NSInteger len = 0; while ((len = [stream read:buffer maxLength:sizeof(buffer)]) > 0) { [data appendBytes:buffer length:len]; } [stream close]; body = data; } // 业务处理 NSLog(@"POST body: %@", [[NSString alloc] initWithData:body encoding:NSUTF8StringEncoding]); } decisionHandler(WKNavigationActionPolicyAllow); }这个方案在同一个 runloop 周期内直接读取HTTPBodyStream,通常能拿到完整的 body。但缺点是 stream 只能读一次,如果你把这个 request 重新赋值给一个新的导航请求,stream 已经消费过了,请求体依然会丢。
所以这个方案的适用场景是"只读不改"。如果你想在拦截后改 body 再发出去,这个方案解决不了。
5.2 方案 B:通过自定义 NSURLProtocol 重新注册拦截(仅适用于 iOS 11 以下或者特定配置)
很多人不知道,WKWebView 其实支持通过私有 API 开启 NSURLProtocol 的注册,让 WKWebView 的请求走App 进程的 NSURLProtocol 体系。但这个方案有两个前提:iOS 11 之前的系统版本可以正常工作;iOS 11 之后的系统版本,WebKit 的请求走的是他自己的网络栈,NSURLProtocol 根本不会被调用。
为了兼容低版本系统,可以在使用时做一个判断:
Class cls = NSClassFromString(@"WKBrowsingContextController"); // 通过私有 API 注册 scheme handler SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");需要说明的是,这个方案非常"脆弱"。一方面它依赖系统私有 API,Apple 审核存在风险;另一方面,iOS 11 之后注册了也只能拦截部分请求类型,POST body 依然可能被 WebKit 吃掉一部分。我一般不推荐用了,除非你的用户群体里有大量 iOS 10 及以下的设备,那可以做一个防御性的兼容处理。
5.3 方案 C:在 WKWebView 初始化时注入脚本捕获请求体(推荐方案)
既然系统层面对 body 的跨进程传输约束很多,那换个思路:直接在 WebContent 进程内部就拿原始请求数据。
通过WKUserScript在页面加载前注入一段 JavaScript,拦截 XHR 和 fetch 的请求。XHR 可以用override open/send的方式,fetch 可以包一层。拦截到的 body 数据通过window.webkit.messageHandlers传到 App 进程。
下面是 XHR 拦截的实际代码:
(function() { var originalOpen = XMLHttpRequest.prototype.open; var originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url) { this.__method = method; this.__url = url; originalOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function(body) { this.__body = typeof body === 'string' ? body : ''; if (this.__body) { try { window.webkit.messageHandlers.intercept.postMessage({ type: 'xhr', method: this.__method, url: this.__url, body: this.__body }); } catch(e) {} } originalSend.apply(this, arguments); }; })();fetch 拦截类似:
(function() { var originalFetch = window.fetch; window.fetch = function(input, init) { var url = typeof input === 'string' ? input : input.url; var method = (init && init.method) || 'GET'; var body = (init && init.body) || ''; if (method.toUpperCase() === 'POST' && body && typeof body === 'string') { try { window.webkit.messageHandlers.intercept.postMessage({ type: 'fetch', method: method, url: url, body: body }); } catch(e) {} } return originalFetch.apply(this, arguments); }; })();注入脚本之后,App 侧注册一个 message handler:
WKUserContentController *userContentController = [[WKUserContentController alloc] init]; [userContentController addScriptMessageHandler:self name:@"intercept"]; WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; config.userContentController = userContentController; WKUserScript *script = [[WKUserScript alloc] initWithSource:integrationScript injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]; [userContentController addUserScript:script];这个方案的优点是直接拿到了原始请求体,无论页面用的是 XHR 还是 fetch,只要有 POST 请求就能监控到。缺点是需要 H5 侧配合接入注入脚本,而且对页面的 JS 性能有一点点影响——好在拦截逻辑足够轻量,实测对页面初始化性能的影响可以忽略不计。
方案 C 是我目前在生产环境中主力使用的方案,稳定性最好,且不依赖私有 API,适配所有 iOS 版本。
5.4 方案 D:通过 WKWebView 的 URL 和请求头来恢复 request body
最后一个思路,针对"请求已经发出但 body 在服务器端没收到"的情况。这种场景下能做的是补救和诊断。
在 App 侧检测到导航失败或是网络请求异常时,把当前页面的 URL、请求方法、WebContent 进程的崩溃日志、以及从注入脚本里缓存的最近几次 POST body 一起打包上报到服务端。服务端通过这些数据可以判断:是前端请求本身就没发出去?还是 App 侧拦截逻辑干扰了?还是 WebContent 进程在请求过程中被杀导致 body 传输中断?
这套诊断链路建立之后,排查效率提升非常明显。以前靠用户截图和口头描述定位问题,现在看一眼后台的请求明细就知道原因。
6. 常见问题排查与复盘
6.1 必现白屏的排查流程
如果你的白屏问题是必现的,那大概率不是 Jetsam 回收,而是某个特定操作触发了 WebContent 崩溃。排查路径如下。
第一步,拿崩溃日志。用 Xcode 打开 Window -> Devices and Simulators,查看设备日志里的com.apple.WebKit.WebContent相关条目,定位崩溃的 stack frame。如果崩溃发生在 WebKit 内部,基本确定是系统 bug;如果崩溃发生在 JS 调用栈里,可以通过堆栈定位到具体是哪个 JS 函数。
第二步,在 WKWebView 的 controller 里实现webViewWebContentProcessDidTerminate:,打日志确认崩溃发生的时机。
第三步,逐项检查 H5 页面里是否有以下高风险操作:WebGL 大规模渲染、Canvas 的离屏绘制、音视频播放(这个在 iOS 12 之前崩溃率奇高)、window.open打开新页面、alert弹窗阻塞主线程等。
第四步,用 Safari 的 Web Inspector 远程调试 H5 页面,在操作步骤里逐步触发,配合崩溃日志一步步缩小范围。
6.2 POST body 获取为 nil 的排查思路
如果注入脚本已经生效,但某些请求仍然拿不到 body,按下面顺序排查。
首先确认请求是否真的走了注入脚本。因为注入脚本只对XMLHttpRequest和fetch两种方式生效,如果页面用的是navigator.sendBeacon()或者<form>表单提交,拦截不到。
其次,检查脚本注入时机。如果页面在DOMContentLoaded之后动态创建的 XHR 对象,而你的脚本在atDocumentStart注入,混乱的上下文不会影响拦截——因为你是 override 了原型方法,任何时刻创建的对象都会走被修改过的方法。但如果页面用了某种方式隔离 JS 上下文(比如 iframe 里执行),mainFrameOnly 的设置就会让 iframe 的请求漏掉。
最后,检查 body 大小。如果 POST body 超过 1MB,postMessage传大字符串可能内存暴涨甚至卡死,需要做数据压缩或者分段传输。
6.3 白屏恢复后 JS 注入失效的坑
这是我最想分享的一个坑。
白屏恢复后直接reload,我在测试时常发现恢复后的页面里注入的WKUserScript没有生效。排查发现,reload之后WKUserScript确实会重新注入,但如果注入的脚本里有异步逻辑,比如setTimeout加载一个外部 JS,再在外部 JS 里调用webkit.messageHandlers,很可能消息处理器已经注册了,但外部的注入脚本被页面 CSP(Content Security Policy)拦截。
所以我的建议是:白屏恢复后不要只是 reload,要主动检查一下 WKWebView 的世界状态。如果页面用到了WKUserScript做桥接,reload 后要用evaluateJavaScript主动探测一下桥对象是否存在:typeof window.webkit.messageHandlers.xxx !== 'undefined'。如果探测不到,需要重新走一遍桥接注册流程。
在 iOS 15 之后的系统里,WKUserScript的注入机制没有大的变化,但WKWebViewConfiguration的创建时机影响很大——必须在 WKWebView 实例初始化之前配置好。如果你的WKUserScript是懒加载创建的,第一次进页面没问题,但白屏恢复时如果代码走到了"复用 WebView 但重新配置"的逻辑,就很容易出问题。
6.4 内存优化的一线实测数据
这里贴一组我实测的数据,给大家一个量级概念。同一台 iPhone 8,iOS 15.7,加载同一个包含长列表和高清图片的 H5 页面:
| 配置方式 | WebContent 峰值内存 | 白屏复现概率 |
|---|---|---|
| 默认配置,创建后不管理 backForwardList | 约 420MB | 高 |
| 开启 nonPersistentDataStore,定期清理 backForwardList | 约 280MB | 中等 |
| 上述基础上 + 降级图片分辨率 + 懒加载长列表 | 约 190MB | 低 |
内存从 420MB 降到 190MB,白屏概率从"后台切换必现"降到了"偶尔出现"。优化手段里,H5 降内存的效果远大于 App 侧的 WebView 配置优化。所以如果你有白屏问题,先让前端看一下页面的内存画像,可能比你在 App 侧调配置效率高得多。
7. 一套可落地的综合处理模板
最后分享一个我在项目里沉淀下来的综合处理模板,把白屏检测、恢复、body 拦截、请求重试这几件事做成一个统一的控制器。它需要你替换成你自己的业务逻辑,核心思路可以直接参考。
@interface WebViewManager : NSObject <WKNavigationDelegate, WKScriptMessageHandler> @property (nonatomic, strong) WKWebView *webView; @property (nonatomic, assign) BOOL isRecovering; @property (nonatomic, strong) NSMutableDictionary *pendingBodies; @end @implementation WebViewManager #pragma mark - 初始化 - (WKWebView *)createWebViewWithFrame:(CGRect)frame { WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; // 非持久化数据存储,降低系统回收 WebContent 进程的概率 config.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore]; // 缓存注入脚本 WKUserContentController *userController = [[WKUserContentController alloc] init]; // 注入 HTTP body 捕获脚本 NSString *bodyCaptureScript = [self bodyCaptureScript]; WKUserScript *script = [[WKUserScript alloc] initWithSource:bodyCaptureScript injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO]; [userController addUserScript:script]; // 注册消息处理器 [userController addScriptMessageHandler:self name:@"httpBody"]; config.userContentController = userController; _webView = [[WKWebView alloc] initWithFrame:frame configuration:config]; _webView.navigationDelegate = self; return _webView; } #pragma mark - Navigation Delegate - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { // 页面加载完成后,主动探测 WebContent 进程是否存活 __weak typeof(self) weakSelf = self; [webView evaluateJavaScript:@"window.location.href" completionHandler:^(id result, NSError *error) { if (error) { [weakSelf handleWhiteScreenForWebView:webView]; } }]; } - (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView { // WebContent 进程终止,白屏处理入口 [self handleWhiteScreenForWebView:webView]; } #pragma mark - 白屏处理 - (void)handleWhiteScreenForWebView:(WKWebView *)webView { if (self.isRecovering) return; self.isRecovering = YES; NSString *currentURL = webView.URL.absoluteString; if (currentURL.length == 0) { self.isRecovering = NO; return; } // 保存当前交互状态 UIScrollView *scrollView = webView.scrollView; CGPoint offset = scrollView.contentOffset; webView.interactionState = [NSValue valueWithCGPoint:offset]; // 恢复到前台时执行 reload,避免后台白屏恢复造成额外消耗 __weak typeof(self) weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ if ([UIApplication sharedApplication].applicationState == UIApplicationStateActive) { NSURLRequest *request = [NSURLRequest requestWithURL:webView.URL]; [webView loadRequest:request]; } }); // 重置恢复标记,带延迟避免频繁触发 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ weakSelf.isRecovering = NO; }); } #pragma mark - Script Message Handler - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if ([message.name isEqualToString:@"httpBody"]) { NSDictionary *body = message.body; // 存储 body,用于请求重试或数据上报 [self.pendingBodies setObject:body forKey:[NSDate date]]; // 业务处理... } } #pragma mark - 注入脚本 - (NSString *)bodyCaptureScript { return @"(function() { ... })();"; // 填入上述 XHR + fetch 拦截脚本 } @end这段代码的核心思路是:把白屏检测、WebContent 进程终止探测、状态保存与恢复、HTTP body 拦截统一到同一个 manager 里,避免多个 delegate 互相争抢。实际使用中你可以根据业务做裁剪,但整体结构建议保留。
8. 工程实践中的几点心得
第一个心得,不要迷信单一方案,多信号源组合判断才是正道。白屏检测如果只依赖webViewWebContentProcessDidTerminate:,会漏掉一些系统不回调的场景;只依赖主动探测,又会有 1 到 2 秒的检测延迟。把导航超时、JS 探活、系统回调三个信号源结合起来,白屏恢复的准确率能做到 99% 以上,漏报率和误报率都降到可接受范围。
第二个心得,HTTP body 丢失问题的根本解法在 H5 侧,不在 App 侧。App 侧的注入脚本只是"观察者",真正要保证请求的可靠性,H5 需要在发送请求前做好数据缓存和重试机制。我们最后的落地方案里,重要表单提交前先把 payload 写到 localStorage,提交成功后清理;App 侧收到 WebContent 进程终止通知时带上参数white_screen_reload=1,H5 检测到这个参数后读取 localStorage 里的未完成请求并自动重发。这套联动机制上线后,问卷类业务的提交失败率从 3% 降到了 0.2% 以下。
第三个心得,低内存设备上的 WKWebView 优化要系统化,不能只看某一个指标。内存优化是一个全局工程:H5 资源压缩、图片降级、列表懒加载、WebView 复用策略、backForwardList 清理、后台挂起时的 JS 暂停,每个环节省一点,综合下来 WebContent 进程的内存峰值就有明显改善。单一手段的效果都很有限,组合起来才能解决问题。
第四个心得,调试这类问题一定要有线上日志和统一的诊断入口。我们早期吃了很多"用户说不清、开发猜不出"的亏。后来统一在 App 侧记录 WebView 的完整生命周期事件:创建时间、导航开始/完成时长、进程终止时间、恢复时间、JS 探活结果、POST body 拦截结果,所有日志打点到统一的日志平台,支持按用户 ID 或设备 ID 检索。有了这套日志,再遇到线上反馈白屏或者提交失败,基本能在五分钟内定位到具体原因。
最后再说一个小技巧:如果你们的线上环境有专门用于灰度的小流量用户群,建议在灰度阶段打开 WKWebView 的setValue:forKey:@"_diagnosticLoggingEnabled"私有属性,拿到 WebKit 内部的诊断日志,很多莫名奇妙的白屏都能从里面找到线索。注意这只是调试用途,不建议线上长期开着。
WKWebView 的白屏和 request body 丢失问题,技术上不算特别深,但涉及的坑很多,网上资料也比较分散。我写这篇的初衷就是把自己踩过的坑和最终验证过的方案整理成一份可以直接抄作业的参考,希望能帮你少走一些弯路。如果你们的业务场景里有更特殊的表现,欢迎一起交流讨论。