NSURLProtocol探究及实践
原文见我的个人博客
初识NSURLProtocol 及 URL Loading System
Hybrid应用逐渐普遍,对于iOS开发,NSURLProtocol为其提供了许多重要的Hybrid能力。
说到NSURLProtocol,首先要提到URL Loading System,后者支持着整个App访问URL指定内容。根据文档配图,其结构大致如下:
都有哪些网络请求经由URL Loading System呢? 从上图可以看出,包括NSURLConnection、NSURLSession等均是经由该加载系统。而直接使用CFNetwork的请求并不经过此系统(ASIHTTPRequest使用CFNetwork),同时,WKWebView使用了WebKit,也不经过该加载系统。
在整个URL Loading System中,NSURLProtocol并不负责主要处理逻辑,其作为一个工具独立于URL Loading的业务逻辑。拦截所有经由URL Loading System的网络请求并处理,是一个存在于切面的抽象类。也就是说,我们通过URLProtocol,可以拦截/处理URLConnection、URLSession、UIWebView的请求,对于WebKit(WKWebView)可以通过使用私有API实现拦截WKWebView的请求。同时,iOS11之后提供了WKURLSchemeHandler实现拦截逻辑。
使用URLProtocol
URL为抽象类,需要继承并实现以下方法:
class func canInit(with request: URLRequest) -> Bool
class func canonicalRequest(for request: URLRequest) -> URLRequest
init(request: URLRequest, cachedResponse: CachedURLResponse?, client: URLProtocolClient?)
func startLoading()
func stopLoading()
注册URLProtocol
想要通过子类拦截请求,我们需要注册该类
// URLConnection、UIWebView、WKWebView使用URLProtocol的registerClass:方法
class func registerClass(_ protocolClass: AnyClass) -> Bool
// URLSession 使用 URLSessionConfiguration的protocolClasses属性
var protocolClasses: [AnyClass]? { get set }
拦截请求
URLProtocol选择是否拦截请求的时候,会调用如下方法:
class func canInit(with request: URLRequest) -> Bool
我们可以根据该request上下文判断是否要处理,如判断当前URL scheme,从而处理我们自定义的url请求,实现前端对本地沙盒的直接读取。后文将会演示该实现方式。
处理请求
拦截请求后,我们可以根据需要对该请求进行进一步处理。
我们可以根据请求内容,对其重新包装,然后进行下一步处理。
class func canonicalRequest(for request: URLRequest) -> URLRequest
在此方法中,我们根据原request的上下文,生成一个新request并备用。
上面是URLProtocol的入口方法,下面则是具体处理逻辑:
当我们拦截了请求时,系统将会要求我们创建一个URLProtocol实例,并负责所有加载逻辑。
如下方法则是根据当前request生成一个URLProtocol子类实例,进行后续处理工作。
init(request: URLRequest, cachedResponse: CachedURLResponse?, client: URLProtocolClient?)
接下来进入最重要的方法,我们需要在startLoading方法中实现所有自定义加载逻辑
func startLoading()
常见的处理逻辑:
- 根据当前Request及任何上下文信息,生成新的逻辑及请求并发送出去。
- 解析自定义url scheme,读取本地沙盒文件并返回,实现前端url直接读取沙盒文件
URLProtocolClient
在我们拦截并处理请求时,我们有时需要把当前的处理情况反馈给URL Loading System,URLProtocol的client对象则代表了这个反馈信息的接受者。我们应在处理过程的适当位置使用这些回调。
URLProtocolClient协议包含如下方法
/// 缓存是否可用
func urlProtocol(_ protocol: URLProtocol, cachedResponseIsValid cachedResponse: CachedURLResponse)
/// 请求取消
func urlProtocol(_ protocol: URLProtocol, didCancel challenge: URLAuthenticationChallenge)
/// 请求失败
func urlProtocol(_ protocol: URLProtocol, didFailWithError error: Error)
/// 成功加载数据
func urlProtocol(_ protocol: URLProtocol, didLoad data: Data)
/// 收到身份验证请求
func urlProtocol(_ protocol: URLProtocol, didReceive challenge: URLAuthenticationChallenge)
/// 接收到Response
func urlProtocol(_ protocol: URLProtocol, didReceive response: URLResponse, cacheStoragePolicy policy: URLCache.StoragePolicy)
/// 请求被重定向
func urlProtocol(_ protocol: URLProtocol, wasRedirectedTo request: URLRequest, redirectResponse: URLResponse)
/// 加载过程结束,请求完成
func urlProtocolDidFinishLoading(_ protocol: URLProtocol)
实战应用
URLProtocol拦截常用于 hybrid应用的前端-客户端交互如实现网页对沙盒文件访问、浏览器数据拦截等,以下介绍两种常见case:
工程代码可见:此链接
Hybrid应用
Hybrid应用较为常见,经常存在网页需要访问本地目录的需求,包括存储clientvar、获取客户端cache、访问沙盒文件等。
若不适用URLProtocol,上述过程可以通过前端通知客户端提供某资源->客户端通过接口传输资源这一过程实现。但存在适配复杂,两过程分离等问题。而通过URLProtocol拦截请求,可使这一过程对前端透明,其无须关心数据请求逻辑。
示例代码见LocalFile目录
override func startLoading() {
if let urlStr = request.url?.absoluteString,
let scheme = request.url?.scheme {
let startIndex = urlStr.index(urlStr.startIndex, offsetBy: scheme.count + 3)
let endIndex = urlStr.endIndex
let imagePath: String = String(urlStr[startIndex..<endIndex])
if let image = UIImage(contentsOfFile: imagePath),
let data = UIImagePNGRepresentation(image) {
// Logic of Success
let response = HTTPURLResponse(url: self.request.url!, statusCode: 200, httpVersion: "1.1", headerFields: nil)
self.client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: URLCache.StoragePolicy.notAllowed)
self.client?.urlProtocol(self, didLoad: data)
self.client?.urlProtocolDidFinishLoading(self)
return
}
}
// Logic of Failed
let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile, userInfo: nil) as Error
self.client?.urlProtocol(self, didFailWithError: error)
return
}
上述代码拦截了前端对于mcimg://
的网络请求,同时从Bundle中查找该文件并返回请求。该逻辑同样适用于从本地Cache、持久化存储中获取,实现了native资源获取与前端资源获取过程的解耦。
拦截请求数据
对于应用内置浏览器等场景,经常需要记录用户访问了那些网页等信息,并进行危险提示、免责提示、数据统计、竞品拦截等工作。此过程同样可通过URLProtocol拦截实现
override func startLoading() {
RequestInfoProtocol.requestInfoProtocolDict.insert(request.hashValue)
NotificationCenter.default.post(name: NSNotification.Name.RequestInfoURL, object: request.url?.absoluteString)
if let newRequest = (request as NSURLRequest).copy() as? URLRequest {
let newTask = session.dataTask(with: newRequest)
newTask.resume()
self.copiedTask = newTask
}
}
上述代码实现了收到请求时做出处理逻辑(如通知)。但由于该请求被拦截将无法继续发至目的地,故复制该请求并发起,同时实现下述URLSession方法正确返回response。
extension RequestInfoProtocol: URLSessionDataDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
self.client?.urlProtocol(self, didFailWithError: error)
return
}
self.client?.urlProtocolDidFinishLoading(self)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
self.client?.urlProtocol(self, didLoad: data)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Void) {
completionHandler(proposedResponse)
}
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
self.client?.urlProtocol(self, wasRedirectedTo: request, redirectResponse: response)
RequestInfoProtocol.requestInfoProtocolDict.remove(request.hashValue)
let redirectError = NSError(domain: NSURLErrorDomain, code: NSUserCancelledError, userInfo: nil)
task.cancel()
self.client?.urlProtocol(self, didFailWithError: redirectError)
}
}
上述代码实现了URLSessionDataDelegate,主要作用是将已发送请求所收到的响应,正确返回给请求者。
通过拦截请求,并按序返回二次确认页面、危险提示页面等,实现了内置浏览器拦截需求,并保证了浏览器的正常运行。
Tips: 上述过程需要使用WebKit私有API,WKWebView在iOS 11开放了WKURLSchemeHandler,流程类似URLProtocol。