ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

WKWebView白屏与POST请求body丢失:进程模型、检测恢复与JS注入备份方案

WKWebView白屏与POST请求body丢失:进程模型、检测恢复与JS注入备份方案 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重新发POSTbody本身是还在的但如果你在Native侧做了拦截重发拿到的request是空body。还有一类场景WKWebView在iOS 11之前对POST请求的支持本身就有问题loadRequest传POST body到某些版本上根本不起作用需要前端配合做兼容。虽然现在iOS版本都高了但老代码里的历史债还在。我在项目里遇到最多的情况是H5有一个比较复杂的表单页用户填了一半切到后台再回来页面白屏了用户回到页面触发恢复逻辑Native调用reload结果页面重新加载成一个没有参数的空白表单用户填的所有内容都没了。这个体验基本等于劝退。2. 白屏检测与恢复从被动监听到主动巡检2.1 第一道防线webViewWebContentProcessDidTerminateiOS 9之后WKNavigationDelegate提供了一个回调方法专门通知AppWebContent进程挂了。方法名很长但记下来很值钱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有可能不生效页面还是白的。我实测下来更稳妥的方式是先想办法重新触发一次加载而不仅仅是reloadprivate 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 方案ANSURLProtocol 私有注册了解原理谨慎使用既然代理回调拿不到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 方案BJS注入备份请求体推荐生产可用既然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再走普通reloadfunc 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并注入JSfunc 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需要在合适时机invalidateprivate 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)手动拼接httpBodydecidePolicyFor里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秒批量收集多个状态数据一次性传回Native6. 一些个人经验和最后提醒我在实际项目里最初只做了webViewWebContentProcessDidTerminate监听上线后白屏率确实降了一些但还有一部分用户反馈偶尔白屏排查下来发现是巡检逻辑没有跟上进程被杀的时候没有触发回调或者触发了但reload无效。后来把主动巡检和状态备份补上整个方案才算完整。另外说一个细节body备份虽然用JS注入能拿到数据但这里有一个坑如果页面里同时用XHR和fetch发POST请求两套Hook都要写而且要小心脚本在页面重载后重复注入执行。我遇到过WKUserScript重复注入导致hook逻辑执行两次的问题虽然不是致命错误但备份数据会被覆盖成空值调试了半天才发现。在注入脚本开头加一个window.__bodyBackupInjected标记就能避免重复执行。最后不管方案做得再完善WKWebView毕竟是黑盒系统更新后行为可能变化。我建议你在每次新iOS版本Beta出来的时候拿这套容器去跑一遍自测流程重点看两个点白屏巡检脚本会不会因为iOS更新导致执行失败以及进程终止后恢复逻辑还管不管用。这两件事都不难但能帮你避免线上突发大面积问题。
返回列表