ARTICLE DETAIL

资讯详情

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

iOS WKWebView请求拦截实战:原理、方案与避坑指南

iOS WKWebView请求拦截实战:原理、方案与避坑指南 1. 项目概述为什么iOS开发者需要掌握请求拦截在iOS应用开发中WebView是一个不可或缺的组件它让我们能在原生应用里嵌入网页内容实现所谓的“混合开发”。从早期的UIWebView到现在的WKWebView苹果一直在提升其性能和安全性。但很多时候我们需要的不仅仅是展示一个网页那么简单。想象一下这些场景你的应用里有个活动页面需要注入一段JavaScript来统计用户行为或者你需要对网页加载的所有图片进行压缩以节省用户流量又或者网页里引用了某个不安全的HTTP资源你希望自动将其升级为HTTPS。这些需求都指向了一个核心技术点拦截并修改WKWebView发出的网络请求。这就是我们今天要深入探讨的主题。单纯加载一个URL很简单但能“插手”WebView的每一次网络请求意味着你拥有了对网页内容的深度控制权。这不仅仅是“高级技巧”而是很多成熟商业应用如微信内置浏览器、淘宝客户端等实现定制化功能、性能优化和安全加固的基础能力。无论是处理HTTP、HTTPS协议还是加载本地file://协议的文件一个健壮的拦截方案都能让你的应用如虎添翼。接下来我将结合我多年的移动端开发经验为你拆解WKWebView请求拦截的实现原理、核心步骤、避坑指南以及那些官方文档里不会写的实战技巧。2. 核心原理与方案选型为什么是 WKNavigationDelegate 和 URLProtocol在动手写代码之前我们必须搞清楚WKWebView的请求生命周期以及苹果为我们提供了哪些“钩子”。与老旧的UIWebView不同WKWebView的架构更加复杂它将网络请求、渲染进程与主应用进程分离这带来了更好的性能和安全性但也让请求拦截变得不那么直接。2.1 WKWebView 请求流程解析当一个WKWebView开始加载一个页面时其网络请求大致遵循以下流程初始化请求用户输入URL或点击链接WebView准备发起主文档请求。发起请求WebKit进程一个独立的进程准备发起网络请求。拦截点决策请求在离开WebKit进程、到达真正的网络栈之前会经过我们应用进程设置的“检查点”。请求执行根据拦截点的决策请求可能被放行、被修改、被重定向或者直接被本地数据替代。接收响应服务器返回数据数据流同样可能被我们拦截和处理。在这个过程中苹果主要提供了两个层面的拦截机制它们各有优劣适用于不同的场景。2.2 方案对比WKNavigationDelegate vs NSURLProtocol这是两个最核心的API理解它们的区别是成功的第一步。特性维度WKNavigationDelegate 回调NSURLProtocol 子类拦截时机较早。在请求即将发出时decidePolicyFor或开始加载时didStartProvisionalNavigation被调用。非常早。在系统的URL Loading System层面进行拦截任何使用该系统的网络请求包括URLSession,UIWebView等都可能被捕获。对于WKWebView需要额外注册。拦截范围仅限于当前WKNavigationAction。主要针对页面导航、iframe、表单提交等产生的“新页面加载”请求。对于页面内的静态资源XHR/Fetch, 图片, CSS, JS默认无法拦截。全局且底层。可以拦截几乎所有类型的网络请求包括主文档、XHR/Fetch、图片、CSS、JS等。是真正的“一网打尽”。修改能力有限。在decidePolicyFor中你只能决定是允许.allow、取消.cancel还是下载.download。不能直接修改请求的URL、Header或Body。但可以通过.cancel后自己用URLSession发起一个新请求来模拟。强大。可以完全控制请求读取和修改URLRequest的所有属性URL、HTTPMethod、Header、Body。也可以完全控制响应伪造或修改服务器返回的URLResponse和Data。复杂度相对简单逻辑清晰。非常复杂。需要处理请求的启动、停止、缓存、认证等多个生命周期方法代码量较大。适用场景1. 简单的导航控制如禁止打开外部App Store链接。2. 在请求发起前进行安全校验如Token注入然后取消原请求并自行发起。3. 配合evaluateJavaScript在页面加载后注入脚本。1. 需要拦截并修改所有资源请求如替换图片、修改JS/CSS内容、Mock API数据。2. 实现自定义缓存策略。3. 全局的HTTP/HTTPS流量监控或修改。关键心得很多初学者会困惑于“为什么我在decidePolicyFor里断点打不到图片请求” 原因就在于上表。如果你需要对页面内的Ajax请求或静态资源动手脚NSURLProtocol几乎是唯一选择。但它的复杂度也高出一个数量级。2.3 针对 file:// 协议的特殊考量除了HTTP/HTTPS加载本地HTML包file://协议也是常见需求。这里有一个巨大的坑NSURLProtocol默认无法拦截file://协议的请求因为file://请求不经过标准的网络栈。如果你需要拦截本地文件中的请求例如本地HTML中通过相对路径引用了images/logo.png你需要使用WKWebView的另一个机制WKURLSchemeHandler。WKURLSchemeHandler允许你为特定的URL Scheme如自定义的customfile://注册处理器。你可以将本地HTML中所有的资源请求路径从file://改为customfile://然后在处理器中根据路径读取本地文件并返回。这相当于为本地文件请求“创造”了一个可拦截的通道。方案选型总结只想控制页面跳转用WKNavigationDelegate的decidePolicyFor。想修改或监听所有网络请求包括XHR用NSURLProtocol。需要处理本地文件(file://)的请求拦截用WKURLSchemeHandler。大型复杂项目三者可能都需要用WKNavigationDelegate处理导航用NSURLProtocol处理HTTP/HTTPS资源用WKURLSchemeHandler处理自定义Scheme的本地资源。3. 核心细节解析与实操要点确定了方案我们进入实战环节。我会以最复杂但也最强大的NSURLProtocol方案为主详细拆解每一步因为掌握了它其他两种方案的理解就水到渠成了。3.1 创建自定义的 URLProtocol 子类首先你需要创建一个继承自NSURLProtocol的类。这个类是你的请求拦截器核心。import WebKit class CustomURLProtocol: URLProtocol { // 1. 决定是否要处理传入的请求 override class func canInit(with request: URLRequest) - Bool { // 这里编写你的拦截规则 // 例如只拦截特定主机名的请求避免循环拦截 guard let url request.url else { return false } // 关键标记已处理过的请求防止无限循环 if URLProtocol.property(forKey: CustomURLProtocolHandled, in: request) ! nil { return false } // 示例拦截所有 https 请求 if url.scheme?.caseInsensitiveCompare(https) .orderedSame { return true } // 示例拦截特定域名的请求 // if url.host?.contains(myapi.com) true { return true } return false } // 2. 返回一个规范化的请求副本通常直接返回原请求或在此处修改 override class func canonicalRequest(for request: URLRequest) - URLRequest { return request } // 3. 开始加载请求 - 核心逻辑在这里 override func startLoading() { // 标记请求已被处理 var mutableRequest self.request URLProtocol.setProperty(true, forKey: CustomURLProtocolHandled, in: mutableRequest) // 情景一直接修改请求并转发 // mutableRequest.setValue(Bearer my_token, forHTTPHeaderField: Authorization) // let task URLSession.shared.dataTask(with: mutableRequest) { ... } // 情景二完全本地模拟响应Mock数据 // let mockData {\message\: \Mocked!\}.data(using: .utf8)! // let response HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: HTTP/1.1, headerFields: nil)! // client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) // client?.urlProtocol(self, didLoad: mockData) // client?.urlProtocolDidFinishLoading(self) // 我们以情景一为例继续执行网络请求 let task URLSession.shared.dataTask(with: mutableRequest) { [weak self] data, response, error in guard let self self else { return } if let error error { self.client?.urlProtocol(self, didFailWithError: error) return } // 在返回给WebView前你甚至可以修改响应数据 // var modifiedData data // if let originalData data, var htmlString String(data: originalData, encoding: .utf8) { // htmlString !-- Injected by CustomURLProtocol -- // modifiedData htmlString.data(using: .utf8) // } if let response response { self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) } if let data data { self.client?.urlProtocol(self, didLoad: data) } self.client?.urlProtocolDidFinishLoading(self) } task.resume() } // 4. 停止加载请求 override func stopLoading() { // 取消正在进行的网络任务清理资源 } }关键点解析canInit(with:)这是入口。必须在这里做好去重判断通过property(forKey:in:)否则拦截器会拦截自己发出的请求导致死循环。startLoading()这是主战场。你必须在这里通过client对象与URL加载系统通信告诉它请求开始、收到响应、收到数据、加载完成或失败。忘记调用urlProtocolDidFinishLoading是导致WebView加载卡住的常见原因。线程安全startLoading和stopLoading可能在非主线程被调用如果你的修改操作涉及UI务必切换到主线程。3.2 向 WKWebView 注册你的 Protocol创建好拦截器后你需要让WKWebView知道它的存在。这里有一个至关重要的步骤WKWebView运行在独立的网络进程中你必须将你的CustomURLProtocol注册到那个进程的URL加载系统中。class ViewController: UIViewController, WKNavigationDelegate { var webView: WKWebView! override func viewDidLoad() { super.viewDidLoad() // 1. 首先在App启动时如AppDelegate向默认的URLSessionConfiguration注册这对其他网络请求也有效。 // URLProtocol.registerClass(CustomURLProtocol.self) // 2. 为WKWebView创建自定义的配置并注册这是关键 let config WKWebViewConfiguration() // 获取WKWebView内部URLSession的配置副本 let preferences WKPreferences() preferences.javaScriptCanOpenWindowsAutomatically true config.preferences preferences // 关键代码通过KVC设置URLSchemeHandler但更标准的方式是下面这种 // 我们需要创建一个自定义的WKProcessPool并确保所有WKWebView共享它以保证Protocol生效。 let processPool WKProcessPool() config.processPool processPool // 更现代和可靠的方式使用WKURLSchemeHandler来处理自定义Scheme。 // 但对于拦截http/https传统方法是在配置的URLSchemeHandler中做文章但更底层的做法是下面这种“黑魔法” // 通过反射获取私有API来注入Protocol类此方法有上架风险仅用于理解原理。 // 在实际生产环境中更推荐使用WKNavigationDelegate配合URLSession重发或使用WKURLSchemeHandler处理自定义Scheme来间接实现。 // 3. 创建WebView webView WKWebView(frame: .zero, configuration: config) webView.navigationDelegate self view.addSubview(webView) // 4. 加载页面 if let url URL(string: https://example.com) { webView.load(URLRequest(url: url)) } } }严重警告与最佳实践上面代码中提到了通过反射调用私有API的方法这绝对会导致App Store审核被拒。在正式项目中如果你必须使用NSURLProtocol拦截WKWebView的HTTP/HTTPS请求目前社区比较认可的方案是折中方案对于需要修改Header等简单操作可以在WKNavigationDelegate的decidePolicyFor中取消请求然后使用已配置好Header的URLSession重新发起请求最后用webView.load(_:)加载收到的数据。这无法拦截子资源。彻底方案自建一个轻量级的本地代理服务器例如用GCDWebServer让WKWebView将所有请求发送到这个代理服务器然后在代理层进行任意修改。这功能最强大但复杂度最高。面向场景方案如果只是为了注入JS或CSS优先考虑使用WKWebView的WKUserContentController和evaluateJavaScript这更安全、更简单。3.3 处理 HTTPS 证书与安全挑战当你拦截HTTPS请求时URLSession可能会遇到证书验证问题。你需要实现URLSessionTaskDelegate中的相关方法来处理。extension CustomURLProtocol: URLSessionTaskDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: escaping (URLSession.AuthChallengeDisposition, URLCredential?) - Void) { // 处理SSL证书挑战 if challenge.protectionSpace.authenticationMethod NSURLAuthenticationMethodServerTrust { if let serverTrust challenge.protectionSpace.serverTrust { // 在这里可以验证证书生产环境应进行严格校验 // 为了方便演示我们选择信任这个证书仅限调试环境 let credential URLCredential(trust: serverTrust) completionHandler(.useCredential, credential) return } } completionHandler(.performDefaultHandling, nil) } }安全警告上述代码示例中直接信任了服务器证书这会降低安全性仅用于测试环境。在生产环境中你应该使用SecPolicyCreateSSL等API进行严格的证书链校验或者只信任你预期的特定证书。忽略证书验证会使应用面临中间人攻击的风险。4. 实操过程与核心环节实现让我们聚焦一个更实际、更安全的场景在页面加载前为所有请求自动添加认证Token。我们将采用WKNavigationDelegate方案因为它更简单、安全且能通过审核。4.1 使用 WKNavigationDelegate 实现请求重写与Token注入这个方案的思路是拦截初始请求 - 取消它 - 创建一个带Token的新请求 - 加载新请求返回的数据。class TokenInjectionViewController: UIViewController { var webView: WKWebView! let authToken your_dynamic_token_here // 应从安全存储中获取 override func viewDidLoad() { super.viewDidLoad() let config WKWebViewConfiguration() webView WKWebView(frame: view.bounds, configuration: config) webView.navigationDelegate self view.addSubview(webView) let url URL(string: https://yourapi.com/protected-page)! webView.load(URLRequest(url: url)) } } extension TokenInjectionViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: escaping (WKNavigationActionPolicy) - Void) { // 只处理主框架的导航请求避免重复处理子资源 guard navigationAction.targetFrame?.isMainFrame true else { decisionHandler(.allow) return } // 检查请求如果是我们需要处理的 if let originalRequest navigationAction.request.url, originalRequest.host yourapi.com { // 1. 先取消这次导航 decisionHandler(.cancel) // 2. 创建一个新的、携带Token的请求 var modifiedRequest URLRequest(url: originalRequest) modifiedRequest.setValue(Bearer \(authToken), forHTTPHeaderField: Authorization) // 可以复制原请求的其他属性如HTTPMethod、body等 modifiedRequest.httpMethod navigationAction.request.httpMethod // 3. 使用URLSession发起这个新请求 let task URLSession.shared.dataTask(with: modifiedRequest) { [weak self] data, response, error in guard let self self else { return } if let error error { print(请求失败: \(error)) // 可以在主线程展示错误页面 DispatchQueue.main.async { self.loadErrorPage(in: webView) } return } // 4. 将获取到的数据加载到WebView中 DispatchQueue.main.async { if let mimeType response?.mimeType, let encoding response?.textEncodingName, let data data, let url response?.url { // 使用load方法加载MIME类型数据 webView.load(data, mimeType: mimeType, characterEncodingName: encoding, baseURL: url) } else { // 如果无法获取响应信息尝试用HTML字符串加载 if let data data, let htmlString String(data: data, encoding: .utf8) { webView.loadHTMLString(htmlString, baseURL: originalRequest) } } } } task.resume() } else { // 对于其他请求直接放行 decisionHandler(.allow) } } func loadErrorPage(in webView: WKWebView) { let html htmlbody h2加载失败/h2 p无法加载请求的资源请检查网络。/p /body/html webView.loadHTMLString(html, baseURL: nil) } }这个方案的优缺点优点实现清晰无审核风险能修改请求头。缺点只能拦截主文档请求无法拦截页面内的XHR或静态资源请求。加载方式从load(URLRequest)变成了load(data:mimeType:...)可能会影响页面内相对路径资源的加载需要正确设置baseURL。4.2 使用 WKURLSchemeHandler 拦截本地文件请求对于file://协议我们使用WKURLSchemeHandler。class ViewController: UIViewController { var webView: WKWebView! override func viewDidLoad() { super.viewDidLoad() let config WKWebViewConfiguration() // 1. 注册自定义Scheme处理器 let schemeHandler CustomSchemeHandler() config.setURLSchemeHandler(schemeHandler, forURLScheme: customfile) // 使用自定义scheme webView WKWebView(frame: view.bounds, configuration: config) view.addSubview(webView) // 2. 加载本地HTML但HTML内的资源链接需要是 customfile:// 开头 // 假设我们有一个本地HTML文件其内容中的图片链接为 img srccustomfile://images/logo.png if let htmlPath Bundle.main.path(forResource: localPage, ofType: html), let htmlContent try? String(contentsOfFile: htmlPath, encoding: .utf8) { // 注意baseURL必须设置为Bundle的资源路径这样相对路径才能正确解析为customfile:// let baseURL URL(fileURLWithPath: Bundle.main.bundlePath) webView.loadHTMLString(htmlContent, baseURL: baseURL) } } } // 自定义Scheme处理器 class CustomSchemeHandler: NSObject, WKURLSchemeHandler { // 当WebView开始请求自定义Scheme的资源时调用 func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { guard let url urlSchemeTask.request.url else { urlSchemeTask.didFailWithError(URLError(.badURL)) return } // 解析路径例如 customfile://images/logo.png - 路径为 /images/logo.png let path url.path // 注意这里得到的path是/images/logo.png // 我们需要将其映射到本地Bundle的真实路径 let resourcePath (Bundle.main.bundlePath as NSString).appendingPathComponent(path) // 检查文件是否存在 guard FileManager.default.fileExists(atPath: resourcePath) else { let error URLError(.fileDoesNotExist) urlSchemeTask.didFailWithError(error) return } do { let data try Data(contentsOf: URL(fileURLWithPath: resourcePath)) let mimeType mimeTypeForPath(path: url.path) // 需要实现一个根据后缀判断MIME类型的函数 // 构造响应头 let response HTTPURLResponse(url: url, statusCode: 200, httpVersion: HTTP/1.1, headerFields: [Content-Type: mimeType, Content-Length: \(data.count)]) // 关键步骤告诉任务开始、收到响应、收到数据、完成 urlSchemeTask.didReceive(response!) urlSchemeTask.didReceive(data) urlSchemeTask.didFinish() } catch { urlSchemeTask.didFailWithError(error) } } // 当WebView停止请求时调用例如页面跳转 func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { // 这里可以取消任何正在进行的异步文件读取操作 print(任务被停止: \(urlSchemeTask.request.url?.absoluteString ?? )) } // 一个简单的MIME类型判断函数 private func mimeTypeForPath(path: String) - String { let ext (path as NSString).pathExtension.lowercased() switch ext { case js: return application/javascript case css: return text/css case png: return image/png case jpg, jpeg: return image/jpeg case gif: return image/gif case html, htm: return text/html default: return application/octet-stream } } }核心要点你需要修改你的本地HTML将资源引用从srcimages/logo.png改为srccustomfile://images/logo.png。baseURL的设置至关重要它决定了相对路径如何与自定义Scheme结合。WKURLSchemeHandler是异步的你必须按顺序调用didReceive(_:)、didReceive(_:)和didFinish()否则WebView会一直等待。5. 常见问题与排查技巧实录在实际开发中你会遇到各种各样奇怪的问题。下面是我踩过坑后总结出来的排查清单。5.1 请求拦截不生效或无限循环症状拦截器逻辑没执行或者执行后WebView卡住、崩溃。排查步骤检查注册时机URLProtocol.registerClass必须在第一个网络请求发生之前调用最好在AppDelegate的application(_:didFinishLaunchingWithOptions:)中。检查WKWebView配置确保你创建的WKWebView使用的是注册了Protocol的URLSessionConfiguration。对于WKWebView更可靠的是使用WKProcessPool并确保所有WebView共享同一个实例。检查canInit(with:)逻辑这是最常见的问题源。务必在canInit开头检查请求是否已被标记处理过防止循环拦截。打印请求的URL和Header确认你的过滤逻辑正确。检查startLoading确保你正确调用了client的所有回调方法didReceive、didLoad、didFinishLoading/didFailWithError一个都不能少。线程检查所有client的回调都必须在调用startLoading的同一个线程上执行。如果你在URLSession的完成回调里它在后台线程必须切回正确的线程。通常使用DispatchQueue.main.async是安全的。5.2 页面样式错乱或JS不执行症状拦截后页面能打开但布局乱了或者交互功能失效。原因与解决MIME类型错误当你用load(_:mimeType:characterEncodingName:baseURL:)加载数据时如果mimeType设置错误如把CSS文件标成text/html浏览器就无法正确解析。务必根据响应头的Content-Type或文件后缀设置正确的MIME类型。BaseURL设置错误如果页面通过loadHTMLString或load(data:...)加载且页面内有使用相对路径如./style.css,images/icon.png的资源那么baseURL参数必须设置为这些资源的根目录URL。否则WebView无法定位这些资源。跨域问题(CORS)如果你拦截的是XHR请求并修改了其响应浏览器可能会因为CORS策略而拒绝执行JS。在调试时可以在服务端设置响应头Access-Control-Allow-Origin: *或者在你的拦截器中模拟添加这个头。注意生产环境应严格限制来源。5.3 HTTPS 证书错误导致请求失败症状拦截HTTPS请求时控制台出现“证书无效”、“SSL错误”等日志请求失败。解决开发环境可以像前面示例一样在URLSessionTaskDelegate中信任所有证书。切记这只是临时方案。生产环境方案A推荐确保你的服务器使用有效的、受信任的CA签发的证书。这样就不需要特殊处理。方案B自签名证书如果你的服务器使用自签名证书你需要将证书或根证书打包到App中并在didReceive challenge回调中使用SecTrustEvaluateWithError等API进行严格的本地证书校验只信任你预置的证书。5.4 内存泄漏与性能问题症状随着页面浏览App内存持续增长甚至崩溃。预防措施弱引用在URLSession完成回调或WKURLSchemeHandler中捕获self时务必使用[weak self]避免循环引用。及时取消任务在URLProtocol的stopLoading()方法中务必取消关联的URLSessionDataTask。在WKURLSchemeHandler的stop方法中取消任何正在进行的IO操作。避免过度拦截在canInit中做好过滤只拦截必要的请求。拦截所有请求会严重增加CPU和内存负担。缓存响应对于静态不变的资源如图标、框架JS库可以在拦截器中实现简单的内存或磁盘缓存避免重复网络请求和重复处理。5.5 iOS 系统版本兼容性注意点WKURLSchemeHandler是在iOS 11引入的。如果你的App需要支持更早的系统对于本地文件拦截可能需要降级方案比如先将本地文件复制到临时目录然后用file://协议直接加载但这失去了拦截控制能力。NSURLProtocol在WKWebView中的支持其行为在不同iOS版本上可能有细微差别尤其是在处理POST请求的Body时。务必在目标系统版本上进行充分测试。最后我个人的体会是WKWebView的请求拦截是一把双刃剑它提供了强大的灵活性但也带来了复杂性和潜在的风险尤其是安全和审核方面。在动手之前一定要反复问自己这个需求是否真的需要拦截请求来实现是否有更简单、更安全的替代方案比如服务端渲染、预加载资源、使用合法的JavaScript注入API如果答案都是肯定的那么希望这篇详尽的指南能帮你避开我当年踩过的那些坑顺利实现功能。
返回列表