NSURLProtocol探究及实践

初识NSURLProtocol 及 URL Loading System

Hybrid应用逐渐普遍,对于iOS开发,NSURLProtocol为其提供了许多重要的Hybrid能力。
说到NSURLProtocol,首先要提到URL Loading System,后者支持着整个App访问URL指定内容。根据文档配图,其结构大致如下:

URL Loading System

都有哪些网络请求经由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为抽象类,需要继承并实现以下方法:

1
2
3
4
5
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

想要通过子类拦截请求,我们需要注册该类

1
2
3
4
// URLConnection、UIWebView、WKWebView使用URLProtocol的registerClass:方法
class func registerClass(_ protocolClass: AnyClass) -> Bool
// URLSession 使用 URLSessionConfiguration的protocolClasses属性
var protocolClasses: [AnyClass]? { get set }

拦截请求

URLProtocol选择是否拦截请求的时候,会调用如下方法:

func canInit(with request: URLRequest) -> Bool```
1
2
3
4
5
6
7
我们可以根据该request上下文判断是否要处理,如判断当前URL scheme,从而处理我们自定义的url请求,实现前端对本地沙盒的直接读取。后文将会演示该实现方式。

### 处理请求
拦截请求后,我们可以根据需要对该请求进行进一步处理。

我们可以根据请求内容,对其重新包装,然后进行下一步处理。
```class func canonicalRequest(for request: URLRequest) -> URLRequest

在此方法中,我们根据原request的上下文,生成一个新request并备用。

上面是URLProtocol的入口方法,下面则是具体处理逻辑:
当我们拦截了请求时,系统将会要求我们创建一个URLProtocol实例,并负责所有加载逻辑。
如下方法则是根据当前request生成一个URLProtocol子类实例,进行后续处理工作。

URLRequest, cachedResponse: CachedURLResponse?, client: URLProtocolClient?)```
1
2
3

接下来进入最重要的方法,**我们需要在startLoading方法中实现所有自定义加载逻辑**
```func startLoading()

常见的处理逻辑:

  • 根据当前Request及任何上下文信息,生成新的逻辑及请求并发送出去。
  • 解析自定义url scheme,读取本地沙盒文件并返回,实现前端url直接读取沙盒文件

URLProtocolClient

在我们拦截并处理请求时,我们有时需要把当前的处理情况反馈给URL Loading System,URLProtocol的client对象则代表了这个反馈信息的接受者。我们应在处理过程的适当位置使用这些回调。
URLProtocolClient协议包含如下方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// 缓存是否可用
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目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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拦截实现

1
2
3
4
5
6
7
8
9
10
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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。