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为抽象类,需要继承并实现以下方法:

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。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,706评论 4 366
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,002评论 1 301
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,462评论 0 250
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,375评论 0 216
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,763评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,849评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,033评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,768评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,490评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,734评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,204评论 1 264
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,566评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,227评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,137评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,934评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,926评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,774评论 2 274

推荐阅读更多精彩内容

  • 前言:NSURLProtocol是NSURLConnection的handle类, 它更像一套协议,如果遵守这套协...
    天下林子阅读 2,134评论 0 3
  • 概览 缓存组件应该说是每个客户端程序必备的核心组件,试想对于每个界面的访问都必须重新请求势必降低用户体验。但是如何...
    默默_David阅读 1,858评论 1 9
  • title: NSURLProtocol 全攻略author: 全凯description: NSURLProto...
    84a6eed103c0阅读 10,453评论 6 46
  • 前言   因为DNS发生域名劫持,所以需要手动将URL请求的域名重定向到指定的IP地址,但是由于请求可能是通过NS...
    小盟城主阅读 5,000评论 5 20
  • 如果每个人都那么单纯相处该有多好,就不会去在乎别人的看法,不会在意别人喜不喜欢。还是一如既往按照自己的方式生活。让...
    wy5阅读 115评论 1 4