WKWebView白屏,POST请求body丢失——这两个问题我估计每个做过iOS内嵌H5开发的人都遇到过,尤其当你的App里塞了一个比较重的H5页面时,这两个坑经常是成对出现的。白屏的原因通常不是WebView本身挂了,而是WKWebView背后的WebContent子进程被系统回收了;而一旦这个进程没了,你第一次load进去的POST请求体自然也没了,这时候你调reload()恢复,运气好能恢复个空页面,运气不好就是一整屏白。
所以这篇博文不打算分开讲,而是把这两个问题放在一起拆:先讲清楚WKWebView的进程模型为什么会导致白屏和body丢失,再讲白屏的检测与恢复方案,最后讲如何在不让前端大改的前提下,把POST body从崩溃中“抢救”回来。中间会给可运行的Swift代码和踩坑实录,做iOS三年以上的人可以直接抄作业,刚转过来的也能看懂原理。
1. 先说结论:进程模型才是幕后黑手
1.1 WKWebView 到底“轻”在哪
WKWebView相比UIWebView最大的变化是跨进程架构。UIWebView是单进程的,页面渲染、JS执行、网络请求全在你的App进程里,好处是好调,坏处是一旦网页里面有内存炸弹,整个App就没了。WKWebView改成了独立进程,网页内容跑在单独的WebContent进程里,App进程和WebContent进程通过IPC通信。
这个设计对稳定性是好事,但也带来两个直接后果:一是WebContent进程和App进程的生命周期不同步,系统在内存吃紧时可以直接把WebContent进程杀掉,而你的App进程还好好的;二是网页里的网络请求、表单数据、cookie这些状态都存在WebContent进程里,一旦进程没了,这些状态也跟着没了。
所以白屏和body丢失,本质上都是同一个问题:你用的是App进程的视角,但网页状态都在另一个进程里。想清楚这一点,后面所有的方案就都围绕一个核心思路展开:把关键状态从WebContent进程里“搬”到App进程里来,或者至少备份一份。
1.2 白屏的常见触发场景
在实际开发里,白屏不是偶发问题,它在特定场景下几乎必现。我把这几年线上反馈和本地复现的情况整理了一下,大致有这几类:
- App切到后台超过一段时间,用户再回来,WebContent进程已经被系统腾退了,页面看起来还在,但内容区域是白的。
- 低端机内存吃紧,系统发出Memory Warning后,WebContent进程被优先杀掉。做过性能优化的iOS开发都知道,系统在内存紧张时,WebContent进程是第一批被回收的对象。
- 网页自身占内存太大,比如长列表无限滚动、大图懒加载、WebGL 3D渲染这些场景,WebContent进程自身先崩溃了。
- 还有一类隐蔽的情况,App没有进入后台,用户就在当前页面滑着滑着突然白屏,这种往往是网页内存泄漏导致的进程崩溃。
这里面有个麻烦的地方:白屏发生时,WKWebView的frame、layer这些都还在,你从App侧的UI上看不出什么异常,webView.url甚至都还是正常页面地址,光靠KVO监听url、title、isLoading这些状态,很难第一时间发现白屏。
1.3 body丢失的常见触发场景
request body丢失的问题,触发场景比白屏更隐蔽,因为大部分时候接口是通的,只有那么几个特殊操作路径会暴露:
- WebContent进程被回收后,你调webView.reload()恢复页面,如果当前页是POST请求到达的,body会直接丢。因为WKWebView的reload默认是按GET请求重新加载当前URL,不会帮你带上之前的POST body。
- 用户手动下拉刷新(如果H5自己实现了下拉刷新),或者前端代码里有location.reload()的逻辑,POST请求同样会退化成GET。
- Cookie失效后页面自动重发请求,这时候前端如果用fetch或XHR重新发POST,body本身是还在的,但如果你在Native侧做了拦截重发,拿到的request是空body。
- 还有一类场景,WKWebView在iOS 11之前对POST请求的支持本身就有问题,loadRequest传POST body到某些版本上根本不起作用,需要前端配合做兼容。虽然现在iOS版本都高了,但老代码里的历史债还在。
我在项目里遇到最多的情况是:H5有一个比较复杂的表单页,用户填了一半,切到后台再回来,页面白屏了,用户回到页面触发恢复逻辑,Native调用reload,结果页面重新加载成一个没有参数的空白表单,用户填的所有内容都没了。这个体验基本等于劝退。
2. 白屏检测与恢复:从被动监听到主动巡检
2.1 第一道防线:webViewWebContentProcessDidTerminate
iOS 9之后,WKNavigationDelegate提供了一个回调方法,专门通知App:WebContent进程挂了。方法名很长,但记下来很值钱:
extension ViewController: WKNavigationDelegate { func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { // 进程挂了,页面白屏,在这里做恢复 resumePageAfterCrash() } }这个方法触发的前提是,WKWebView的navigationDelegate已经被正确设置。很多人踩过一个坑:在ViewController释放后,delegate没有置空,导致野指针崩溃,或者delegate在子线程回调,导致UI操作错乱。所以我在项目里习惯用weak代理,并且统一在viewDidDisappear里断开:
override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) webView.navigationDelegate = nil }回到恢复逻辑。很多人拿到这个回调后,第一反应就是webView.reload(),但直接reload有个问题:如果进程刚刚被杀,WKWebView内部的状态还没有完全清理干净,reload有可能不生效,页面还是白的。我实测下来,更稳妥的方式是先想办法重新触发一次加载,而不仅仅是reload:
private func resumePageAfterCrash() { guard let url = lastValidURL ?? webView.url else { webView.reload() return } let request = URLRequest(url: url) webView.load(request) }这里用lastValidURL而不是webView.url,是因为进程刚死的时候,webView.url可能已经变成nil了。所以我会在didFinish里把最后一次成功加载的URL存下来:
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { lastValidURL = webView.url }2.2 第二道防线:主动巡检白屏
didTerminate回调能覆盖大部分进程被杀的场景,但有一个漏洞:有些白屏不触发这个方法。比如内存压力过大导致渲染进程在极短时间内重启,或者WebContent进程hang住了,系统直接放弃恢复。这类问题通过被动监听发现不了,还需要一套主动巡检机制。
我的做法是,在页面加载完成后启动一个定时器,每隔3秒执行一次JS脚本,检查页面实际内容:
(function() { var body = document.body; if (!body) { return { readyState: document.readyState, childrenCount: 0, textLength: 0 }; } var childrenCount = body.children ? body.children.length : 0; var textLength = (body.innerText || '').trim().length; return { readyState: document.readyState, childrenCount: childrenCount, textLength: textLength }; })()Native侧解析结果,如果readyState已经是complete,但childrenCount为0且textLength为0,就判定为白屏,触发恢复逻辑:
private func checkWhiteScreen() { guard let webView = webView, !webView.isLoading else { return } webView.evaluateJavaScript(WhiteScreenCheckScript) { result, error in guard error == nil, let dict = result as? [String: Any] else { return } let readyState = dict["readyState"] as? String ?? "" let childrenCount = dict["childrenCount"] as? Int ?? 0 let textLength = dict["textLength"] as? Int ?? 0 if readyState == "complete" && childrenCount == 0 && textLength == 0 { DispatchQueue.main.async { self.resumePageAfterCrash() } } } }这里有几个细节要提醒一下。
evaluateJavaScript是有性能损耗的,频繁调用会抢占WebContent进程的资源。3秒一次算是我压过的极限值,再短就会影响页面滚动流畅度。另外,业务里如果有一些页面本身就是空白页(比如一个纯展示性质的占位页),巡检脚本会误判,所以需要加白名单,对固定的几个URL跳过巡检。
还有一个容易忽略的点:evaluateJavaScript的回调是异步的,如果在页面已经开始重定向或者卸载的时候,回调拿回来的数据可能是过期的。所以我在判断白屏之后,会再检查一次webView.isLoading和webView.url是否有效,双保险。
2.3 恢复时保留页面状态:URL、滚动位置、表单内容
白屏恢复不能只把页面重新load一遍就完事,用户之前浏览的位置、填写的表单,这些状态如果能保留,体验会好很多。
滚动位置比较好办,在崩溃前定期把webView.scrollView.contentOffset存下来,恢复后等页面加载完成,用evaluateJavaScript执行scrollTo:
// 恢复滚动位置 let scrollScript = "window.scrollTo(\(offsetX), \(offsetY));" webView.evaluateJavaScript(scrollScript, completionHandler: nil)表单内容就麻烦一点。因为崩溃是不可预知的,beforeunload事件不一定来得及触发,所以我在项目里采用“input事件实时上报”的方式:注入一段JS,监听所有输入框的input和change事件,一旦用户输入了什么,立刻通过WKScriptMessageHandler把数据同步到Native侧来。
(function() { if (window.__formStateInjected) return; window.__formStateInjected = true; var send = function() { var inputs = document.querySelectorAll('input, textarea, select'); var data = {}; for (var i = 0; i < inputs.length; i++) { var field = inputs[i]; var key = field.name || field.id || field.dataset && field.dataset.name; if (key) { data[key] = field.value; } } try { window.webkit.messageHandlers.formState.postMessage(data); } catch(e) {} }; document.addEventListener('input', send); document.addEventListener('change', send); })();Native侧收到后,存到一个字典里。恢复页面后,在didFinish里再注入一段回填脚本:
(function() { var data = FORM_STATE_PLACEHOLDER; for (var key in data) { if (!data.hasOwnProperty(key)) continue; var el = document.querySelector('[name="' + key + '"]') || document.getElementById(key); if (el && el.value !== data[key]) { el.value = data[key]; // 触发input事件,让页面里的监听器感知到值变化 var event = new Event('input', { bubbles: true }); el.dispatchEvent(event); } } })()把FORM_STATE_PLACEHOLDER替换成JSON字符串。注意特殊字符转义,防止注入出错。
这套方案做下来,白屏恢复的整体体验勉强能达到“用户几乎无感”的水平。当然,前提是页面本身加载足够快,如果页面本身就慢,恢复后还是会有短暂的白屏等待期。
3. request body丢失:拦截不到的请求体
3.1 为什么decidePolicyFor里拿到的request没有HTTPBody
先说一个让很多人头疼的现象:在WKNavigationDelegate的decidePolicyForNavigationAction回调里,通过navigationAction.request取请求,POST请求的httpBody永远都是nil。很多人第一次遇到这种情况时,都以为是自己的代码写错了。
其实这是WKWebView有意为之。因为网页的网络请求发生在WebContent进程里,Native进程拿到的request只是一个轻量级的转发对象,body内容不会跨进程传递过来。你在这条链路上做任何拦截,都不可能拿到原始body。
这也是为什么网上很多老教程里说的“用decidePolicyFor拦截请求保存参数”的方案,在实际的POST请求面前完全失效。我踩过这个坑,后来才搞明白,不是代码写错了,而是WKWebView的架构决定了这条路根本走不通。
3.2 方案A:NSURLProtocol + 私有注册(了解原理,谨慎使用)
既然代理回调拿不到body,很多人会想到用NSURLProtocol做全局网络请求拦截。放在UIWebView时代,这招是通的,但WKWebView发起的请求默认不走NSURLProtocol,除非你用私有API注册一下。
具体做法是:
Class cls = NSClassFromString(@"WKBrowsingContextController"); SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:"); if ([cls respondsToSelector:sel]) { // 让 http/https 协议走自定义 NSURLProtocol [cls performSelector:sel withObject:@"http"]; [cls performSelector:sel withObject:@"https"]; }注册之后,WKWebView里的请求会经过你的NSURLProtocol,这时你可以拿到包含body的原始请求。但这里有两个非常现实的问题:
第一,这个API是私有的。App Store审核时,虽然不一定百分百被查出来,但存在很大的下架风险,而且苹果后续版本随时可能移除这个接口,兼容性无法保证。
第二,NSURLProtocol一旦注册,会拦截所有webView请求,包括页面本身、JS、CSS、图片这些静态资源。你必须在里面做精确判断,只对需要的POST请求做处理,否则会拖慢整个页面加载速度,甚至引发请求死循环。
我的建议是:这个方案仅用于技术调研或者内部工具里,生产环境不要碰。很多大厂早期项目里用了这个方案,后来都陆陆续续在改造,你没必要接这个盘。
3.3 方案B:JS注入备份请求体(推荐,生产可用)
既然Native侧拿不到body,那就在Web侧想办法。WKWebView提供了WKUserScript机制,可以在页面加载初期注入一段JS,拦截并备份页面里发出的POST请求体。这是目前我看到的生产环境里最通用的方案,不需要前端改代码,兼容性也相对好。
核心思路是:Hook掉XMLHttpRequest的open和send方法,以及window.fetch方法,在请求发出去的时候,把URL、Method、Body数据通过WKScriptMessageHandler传给Native侧保存。
注入的JS脚本如下:
(function() { if (window.__bodyBackupInjected) return; window.__bodyBackupInjected = true; var backupMap = {}; window.__bodyBackupMap = backupMap; function notifyNative(url, method, body) { var data = { url: url, method: method, body: body ? String(body) : '' }; try { window.webkit.messageHandlers.bodyBackup.postMessage(data); } catch(e) {} } // Hook XMLHttpRequest var originalOpen = XMLHttpRequest.prototype.open; var originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url, async, user, pass) { this.__method = method; this.__url = url; return originalOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function(body) { if (this.__method && this.__method.toUpperCase() === 'POST') { backupMap[this.__url] = body; notifyNative(this.__url, this.__method, body); } return originalSend.call(this, body); }; // Hook fetch var originalFetch = window.fetch; if (originalFetch) { window.fetch = function(url, options) { if (options && options.method && options.method.toUpperCase() === 'POST') { backupMap[url] = options.body; notifyNative(url, options.method, options.body); } return originalFetch.apply(this, arguments); }; } })();在Native侧,注册WKScriptMessageHandler接收备份数据:
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "bodyBackup", let dict = message.body as? [String: Any], let url = dict["url"] as? String else { return } let body = dict["body"] as? String ?? "" postBodyCache[url] = body }这里有一个关键决策:用什么作为缓存的key?我用的是URL字符串。这样做有一个问题,同一个URL可能对应多个不同body的POST请求。在一些交互复杂的页面里,比如同一个接口被反复调用,只是body不同,用URL做key会互相覆盖。
我的改进方案是,如果URL是唯一的就用URL做key,如果URL会重复,就拼接上一个UUID之类的唯一标识,然后把标识通过请求头或者URL参数带给后端,这样重发时能精准对应。但这个改动需要前端配合,如果前端不好动,退而求其次用URL做key也能覆盖大多数场景。
3.4 恢复POST请求的完整调用链
在进程崩溃或者需要重发请求时,先查缓存,有body就用POST重新load,没有body再走普通reload:
func resumePageAfterCrash() { guard let currentURL = lastValidURL ?? webView.url else { webView.reload() return } let urlString = currentURL.absoluteString if let body = postBodyCache[urlString] { var request = URLRequest(url: currentURL) request.httpMethod = "POST" request.httpBody = body.data(using: .utf8) // 恢复Content-Type,否则后端可能解析不了 if let originalType = bodyContentTypeCache[urlString] { request.setValue(originalType, forHTTPHeaderField: "Content-Type") } else { request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") } webView.load(request) } else { let request = URLRequest(url: currentURL) webView.load(request) } }注意:恢复body时,Content-Type不能丢。很多POST接口对Content-Type非常敏感,如果原来是application/json,你恢复成x-www-form-urlencoded,后端直接返回415错误。所以在备份body的同时,最好把Content-Type请求头也一起拿下来。
3.5 方案C:业务层改造(最稳但需要前后端配合)
如果上面的JS注入方案你觉得太重,或者团队里前端资源充足,还有一个更省心的思路:改造业务代码,让POST请求天然具备“可重发”的能力。
比较简单的方式是,把页面里的关键POST请求,改成“先GET到页面,页面加载后主动POST提交参数”的模式。这样Native在恢复时只需要重新GET加载页面,页面内部的JS会自己重新发起POST请求拿数据。这个方案的缺点是要改前端逻辑,而且如果页面已经处于“提交成功”的状态,重新加载后可能重复提交。
另一种业务层改造是,把请求参数放到URL的query里,虽然不优雅,但好处是reload时参数不会丢。前提是业务接口能接受GET带参。
我的看法是:方案B和方案C不是互斥的。方案B适合Native团队自己就能搞定,不依赖前端的情况;方案C适合那些页面结构复杂、POST请求频繁、用JS注入会有覆盖风险的项目。你可以根据自己团队的实际分工来选。
4. 实操:一个可运行的WKWebView容器封装
4.1 核心类设计
前面讲的都是拆解,这一节给一套可以直接用的封装思路。我一般把WKWebView相关的逻辑统一放到一个WKWebViewContainer类里,避免每个页面都去重复实现delegate和messageHandler。
这个容器需要做的事情有:创建WKWebView、注入JS脚本、注册消息处理、白屏巡检、body备份、崩溃恢复。它对外暴露的接口尽量简单:
final class WebViewContainer: NSObject { var webView: WKWebView! var lastValidURL: URL? private var postBodyCache: [String: String] = [:] private var bodyContentTypeCache: [String: String] = [:] private var whiteScreenTimer: Timer? private let bodyBackupScript = """ // 前面写的JS注入脚本 """ private let whiteScreenCheckScript = """ // 前面写的白屏检测脚本 """ }4.2 创建WebView并注入JS
func makeWebView(frame: CGRect) -> WKWebView { let config = WKWebViewConfiguration() let userController = WKUserContentController() userController.add(self, name: "bodyBackup") userController.add(self, name: "formState") // 注入body备份JS,时机建议用.atDocumentStart,确保最早执行 let backupScript = WKUserScript(source: bodyBackupScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) userController.addUserScript(backupScript) // 注入表单状态监听JS let formScript = WKUserScript(source: formStateScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false) userController.addUserScript(formScript) config.userContentController = userController config.allowsInlineMediaPlayback = true webView = WKWebView(frame: frame, configuration: config) webView.navigationDelegate = self webView.uiDelegate = self return webView }这里有两个注入时机需要注意:body备份脚本要在.atDocumentStart注入,因为页面一启动就可能发请求,晚了就漏了;表单状态监听脚本放.atDocumentEnd就够了,等DOMready后再监听input事件,能减少早期误报。
4.3 启动白屏巡检
在didFinish后启动,在didStartProvisionalNavigation里可以重置一下,防止页面正在跳转时误判白屏:
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { lastValidURL = webView.url startWhiteScreenMonitor() } func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { // 页面开始加载时暂停巡检,避免误判 stopWhiteScreenMonitor() } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { // 加载失败时也暂停一下,等错误页展示完再巡检 stopWhiteScreenMonitor() }定时器用弱引用避免循环持有,注意Timer的block方式会强持有self,需要在合适时机invalidate:
private func startWhiteScreenMonitor() { stopWhiteScreenMonitor() whiteScreenTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in self?.checkWhiteScreen() } } private func stopWhiteScreenMonitor() { whiteScreenTimer?.invalidate() whiteScreenTimer = nil }4.4 自测方法
写完这套东西,怎么验证它真的有用?我平时会在模拟器里做这几件事:
- 跑一个包含长列表的H5页面,在Xcode的Debug菜单里选择Simulate Memory Warning,观察页面是否白屏,以及白屏后是否自动恢复。
- 在Web Inspector里手动执行kill命令杀掉WebContent进程。真机上不好操作,模拟器里可以通过活动监视器找到com.apple.WebKit.WebContent进程,直接结束它。
- 构建一个测试页,里面有一个表单和一个POST请求按钮,先提交一次,然后触发崩溃恢复,看WebView重新加载后,页面是否还用POST方式请求到了数据。
这套自测流程走完之后,把手机熄屏放一边,等10分钟再亮屏打开App,基本也能复现白屏和恢复的过程。多测几轮,确认恢复逻辑没有遗漏。
5. 常见问题排查速查表
| 场景 | 表现 | 原因 | 处理建议 |
|---|---|---|---|
| 白屏但didTerminate没触发 | 页面长时间空白,无任何加载动作 | 巡检定时器还没跑起来,或巡检JS执行失败 | 确认didFinish后startMonitor被调用;用OKHTTP类似的方式检查evaluateJavaScript是否报错 |
| reload后页面是GET请求 | 接口返回参数缺失,页面报错 | WKWebView的reload不携带POST body | 改用load(URLRequest),手动拼接httpBody |
| decidePolicyFor里body为nil | 无法在代理回调中获取POST参数 | WKWebView跨进程架构限制 | 不要在这个回调里取body,改用JS注入备份 |
| body备份总是覆盖 | 同一URL多次POST,缓存只保留最后一次 | 缓存key用的是URL | 改为URL+业务标识组合key |
| 巡检误判白屏 | 正常空白页被误判,触发多次恢复 | 巡检脚本没有白名单 | 添加URL白名单,或对已知空白页跳过巡检 |
| 恢复后表单内容丢失 | 用户输入内容在崩溃后清空 | beforeunload事件来不及触发 | 改用input事件实时上报,崩溃前状态已在Native侧 |
| 注入JS被CSP拦截 | 页面控制台报CSP错误 | 部分站点开启严格的内容安全策略 | 开启allowUniversalAccessFromFileURLs或联系前端调整CSP,必要时放弃JS注入方案 |
| 内存消耗过大 | 注入JS后页面卡顿 | 巡检太频繁或evaluateJavaScript调用过多 | 巡检间隔提高到5秒;批量收集多个状态数据,一次性传回Native |
6. 一些个人经验和最后提醒
我在实际项目里,最初只做了webViewWebContentProcessDidTerminate监听,上线后白屏率确实降了一些,但还有一部分用户反馈偶尔白屏,排查下来发现是巡检逻辑没有跟上,进程被杀的时候没有触发回调,或者触发了但reload无效。后来把主动巡检和状态备份补上,整个方案才算完整。
另外说一个细节:body备份虽然用JS注入能拿到数据,但这里有一个坑,如果页面里同时用XHR和fetch发POST请求,两套Hook都要写,而且要小心脚本在页面重载后重复注入执行。我遇到过WKUserScript重复注入导致hook逻辑执行两次的问题,虽然不是致命错误,但备份数据会被覆盖成空值,调试了半天才发现。在注入脚本开头加一个window.__bodyBackupInjected标记,就能避免重复执行。
最后,不管方案做得再完善,WKWebView毕竟是黑盒,系统更新后行为可能变化。我建议你在每次新iOS版本Beta出来的时候,拿这套容器去跑一遍自测流程,重点看两个点:白屏巡检脚本会不会因为iOS更新导致执行失败,以及进程终止后恢复逻辑还管不管用。这两件事都不难,但能帮你避免线上突发大面积问题。