NSURLProtocol子类使用的坑

(去年写的文档)

​ 之前使用NSURLProtocol子类做了一个H5资源的缓存,本来以为是个简单的事情,但是后来在实际做的过程中却遇到了不少的坑和槽点,特此记录下来以备后来者查阅。

  1. NSURLProtocol 正常情况下只有UIWebView支持,更先进的WKWebView正常情况下是不支持的,需要调用私有API,还是很危险的。
    详细见 https://www.jianshu.com/p/55f5ac1ab817
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
    [(id)cls performSelector:sel withObject:@"http"];
    [(id)cls performSelector:sel withObject:@"https"];
}
[NSURLProtocol registerClass:NSClassFromString(@“Some”)];
  1. NSURLProtocol在拦截POST的请求时不能获取到request的HTTPBody。苹果解释是因为HTTPBody可能会很大,如果拦截将会产生很大的性能开销,然后苹果就干脆自己砍了需求(嗯,好手段,佩服,👏)。

  2. NSURLProtocol子类中我们截获了request一般都是做缓存取数据或者处理数据,这可能需要自己去下载网络数据,也就自然要依赖网络下载框架,这里我使用了NSURLSession来做数据请求(我们当然可以使用别的封装好的更易用网络框架来做,比如AFNetwork,但从设计的角度来讲如果这个缓存作为一个独立的组件,方便移植则不应该有其他依赖,最好只依赖OS基础框架)。NSURLSession有个问题就是它回调的delegate是这个强引用(当然可以不用delegate这种方式)。如果要释放这个delegate就必须主动关闭NSURLSession,但这就违背了苹果设计NSURLSession的初衷了。

    解决方法:将delegate设计为一个Manager,其作为一个单例或者多例去实现NSURLSession的代理方法,下载过程中相关临时的数据等的存储在一个字典里面,Manager通过Task去字典获取对应的数据存储对象,这么做稍微麻烦一点,现在的大部分的下载框架都是这么做的。

  3. NSURLSessionTask delegate的didReceiveResponse回调中初始化容器,可能导致头部数据丢失

    ​ 解决方法:不要在didReceiveResponse初始化数据接收容器,要将初始化提前到下载任务开始的时候。因为didReceiveResponse不一定在didReceiveData之前回调。这是我当时遇到的最棘手的问题,其复现的概率很低,难以定位问题,而且一旦出现就会造成持续性影响,页面回退,App重启都无效,用户很难改出,体验影响较大。(另外网上有人说:MIME为多部分的document,会多次回调didReceiveResponse,可能导致容器多次初始化。)

    这个问题主要是认知的漏洞,历史遗留知识的影响。我们先回顾一下:

    A. 苹果官方文档是这么描述的NSURLSession的线程安全的:

    Thread Safety

    The URL session API itself is fully thread-safe. You can freely create sessions and tasks in any thread context, and when your delegate methods call the provided completion handlers, the work is automatically scheduled on the correct delegate queue.

    他的这个完全线程安全倒也没错,不过让人放松了警惕。

    B. 如果看过AFNetwork早期的源码就知道,在使用NSURLConnection的时候,其是在didReceiveResponse里面打开接收容器的outputStream的,然后在didReceiveData中往容器中添加数据,一切看上去是那么自然和谐。

    - (void)connection:(NSURLConnection __unused *)connection
    didReceiveResponse:(NSURLResponse *)response 
    {
        self.response = response;
        [self.outputStream open];
    }
    

    ​ 但在NSURLSession时代,AFNetwork其实已经改变容器初始化时机了。其接收数据的是AFURLSessionManagerTaskDelegate中的mutableData,是在其init方法中初始化的。而AFURLSessionManagerTaskDelegate的创建是在dataTaskWithRequest(其他也类似)生成后立即创建的,并且会同时完成添加回调和其他一些初始化工作后才调用dataTaskresume开始下载任务。所以AFNetwork不知在有意还是无意中避免了我所遇到的这个问题。

    实验条件:

    ​ 新建了一个串行队列,将NSLog提交的队列中输出;这里我还放了一个int静态变量做为序列标号,并且提交队列的时候做一个数据copy,防止静态变量在block捕获的时候变成指针;同时在didReceiveResponse这个回调中写了一个巨量的for的空循环(比如1亿次啥的),当然使用sleep更好些,让didReceiveResponse这个代理返回慢些。

    实际测试中回调发生的顺序:

    2017-04-27 14:54:11.819596 shop[1535:301392] data:   0x13124eb90   0x0   0
    2017-04-27 14:54:11.819633 shop[1535:301392] resp:   0x13124eb90   0x171459680   1
    2017-04-27 14:54:11.819663 shop[1535:301392] data:   0x13124eb90   0x171459680   2
    2017-04-27 14:54:11.819715 shop[1535:301392] data:   0x13124eb90   0x171459680   3
    2017-04-27 14:54:11.819871 shop[1535:301392] data:   0x13124eb90   0x171459680   4
    2017-04-27 14:54:11.819925 shop[1535:301392] data:   0x13124eb90   0x171459680   5
    2017-04-27 14:54:11.820015 shop[1535:301392] data:   0x13124eb90   0x171459680   6
    2017-04-27 14:54:11.820123 shop[1535:301392] data:   0x13124eb90   0x171459680   7
    2017-04-27 14:54:11.820272 shop[1535:301392] data:   0x13124eb90   0x171459680   8
    2017-04-27 14:54:11.820358 shop[1535:301392] data:   0x13124eb90   0x171459680   9
    2017-04-27 14:54:11.820485 shop[1535:301392] data:   0x13124eb90   0x171459680   10
    2017-04-27 14:54:11.820518 shop[1535:301392] data:   0x13124eb90   0x171459680   11
    2017-04-27 14:54:11.820545 shop[1535:301392] comp:   0x13124eb90   0x171459680   12
    

    ​ 图中第一列,data表示在didReceiveData中回调,resp表示在didReceiveResponse中回调,comp是didCompleteWithError,表示第二列是task,第三列是容器,第四列是编号。

    ​ 从上面的实验数据可以看出,didReceiveData多次回调和didCompleteWithError是依次先后执行的,但didReceiveResponse不一定是,也就是说下载框架根本不会等待didReceiveResponse回调完成以后就开始调didReceiveData,在多线程并发的情况下,就有可能导致后者比前者先回调,这就坑了!我们想根据response的具体信息灵活创建接收容器或者解析数据就不行了。

    ​ 还是想吐槽一下,苹果文档信誓旦旦的说“The URL session API itself is fully thread-safe.”,而且在NSURLConnection还可以这么做,结果到了NSURLSession不行了,文档却又没有任何说明,真坑,当然或许他们自己都没有发现这个问题甚至他们可能认为这不是个问题,😂,好吧,谁让咱遇上了。

  4. 回调队列并发问题(注意这里是队列并发)

    [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:netQueue];
    

    netQueue只要不设置为主队列(主队列是串行的,而且只会在主线程执行),那么didReceiveData回调的线程就是任意的线程,无论队列是串行还是并发。之前被苹果坑了,所以这里我们还是去验证一下。

    netQueue的maxConcurrentOperationCount=5(AFNetwork这里设置的是1,应该不是必须的),同时在didReceiveData的回调delegate中sleep(1),发现确实是回调在线程池的任意线程。NSURLSessionDataTasktaskIdentifier编号,每一个下载任务都有这个编号,它是唯一的,用来标记不同的下载任务,AFNetwork也是使用这个标记作为key来区分任务的。我们发现这里1s之内只会回调5次,也就是说框架会在调用完成以后同时根据并发量来决定是否继续回调。

    2017-04-27 15:16:52.016605+0800 shop[6791:3907908] receive data: <NSThread: 0x1c04656c0>{number = 8, name = (null)}, taskIdentifier: 14
    2017-04-27 15:16:52.017814+0800 shop[6791:3907894] receive data: <NSThread: 0x1c06764c0>{number = 18, name = (null)}, taskIdentifier: 15
    2017-04-27 15:16:52.035222+0800 shop[6791:3907877] receive data: <NSThread: 0x1c0274fc0>{number = 5, name = (null)}, taskIdentifier: 16
    2017-04-27 15:16:52.057579+0800 shop[6791:3907931] receive data: <NSThread: 0x1c0678cc0>{number = 17, name = (null)}, taskIdentifier: 18
    2017-04-27 15:16:52.057896+0800 shop[6791:3907920] receive data: <NSThread: 0x1c047a600>{number = 16, name = (null)}, taskIdentifier: 17
    
    2017-04-27 15:16:53.023380+0800 shop[6791:3907894] receive data: <NSThread: 0x1c06764c0>{number = 18, name = (null)}, taskIdentifier: 19
    2017-04-27 15:16:53.024568+0800 shop[6791:3907897] receive data: <NSThread: 0x1c4a71580>{number = 15, name = (null)}, taskIdentifier: 20
    2017-04-27 15:16:53.040872+0800 shop[6791:3907877] receive data: <NSThread: 0x1c0274fc0>{number = 5, name = (null)}, taskIdentifier: 21
    2017-04-27 15:16:53.063312+0800 shop[6791:3907931] receive data: <NSThread: 0x1c0678cc0>{number = 17, name = (null)}, taskIdentifier: 22
    2017-04-27 15:16:53.070365+0800 shop[6791:3907920] receive data: <NSThread: 0x1c047a600>{number = 16, name = (null)}, taskIdentifier: 6
    
    2017-04-27 15:16:54.026639+0800 shop[6791:3907894] receive data: <NSThread: 0x1c06764c0>{number = 18, name = (null)}, taskIdentifier: 7
    2017-04-27 15:16:54.035697+0800 shop[6791:3907897] receive data: <NSThread: 0x1c4a71580>{number = 15, name = (null)}, taskIdentifier: 9
    2017-04-27 15:16:54.045185+0800 shop[6791:3907877] receive data: <NSThread: 0x1c0274fc0>{number = 5, name = (null)}, taskIdentifier: 11
    2017-04-27 15:16:54.065700+0800 shop[6791:3907931] receive data: <NSThread: 0x1c0678cc0>{number = 17, name = (null)}, taskIdentifier: 13
    2017-04-27 15:16:54.075119+0800 shop[6791:3907920] receive data: <NSThread: 0x1c047a600>{number = 16, name = (null)}, taskIdentifier: 15
    
    2017-04-27 15:16:55.028882+0800 shop[6791:3907894] receive data: <NSThread: 0x1c06764c0>{number = 18, name = (null)}, taskIdentifier: 16
    2017-04-27 15:16:55.037104+0800 shop[6791:3907897] receive data: <NSThread: 0x1c4a71580>{number = 15, name = (null)}, taskIdentifier: 18
    2017-04-27 15:16:55.047318+0800 shop[6791:3907877] receive data: <NSThread: 0x1c0274fc0>{number = 5, name = (null)}, taskIdentifier: 17
    2017-04-27 15:16:55.071031+0800 shop[6791:3907931] receive data: <NSThread: 0x1c0678cc0>{number = 17, name = (null)}, taskIdentifier: 23
    2017-04-27 15:16:55.079910+0800 shop[6791:3907920] receive data: <NSThread: 0x1c047a600>{number = 16, name = (null)}, taskIdentifier: 24
    
    2017-04-27 15:16:56.034739+0800 shop[6791:3907894] receive data: <NSThread: 0x1c06764c0>{number = 18, name = (null)}, taskIdentifier: 25
    2017-04-27 15:16:56.042935+0800 shop[6791:3907897] receive data: <NSThread: 0x1c4a71580>{number = 15, name = (null)}, taskIdentifier: 26
    2017-04-27 15:16:56.051829+0800 shop[6791:3907877] receive data: <NSThread: 0x1c0274fc0>{number = 5, name = (null)}, taskIdentifier: 27
    2017-04-27 15:16:56.076896+0800 shop[6791:3907931] receive data: <NSThread: 0x1c0678cc0>{number = 17, name = (null)}, taskIdentifier: 28
    2017-04-27 15:16:56.082527+0800 shop[6791:3907920] receive data: <NSThread: 0x1c047a600>{number = 16, name = (null)}, taskIdentifier: 29
    

    ​ 多次试验以后,还发现其绝不会同时回调同一个task,也就是说我们不需要加锁去解决并发的问题,但多个task很可能存在同一字典里面,操作字典还是需要加锁的,但从字典取出task后再操作就不需要加锁了,这大大减少了临界区的范围,可以提高并发速度(这一点大家可以去看AFNetwork的实现,但其 maxConcurrentOperationCount=1,😂)。

    还是贴个源码吧

    - (void)URLSession:(NSURLSession *)session
              dataTask:(NSURLSessionDataTask *)dataTask
        didReceiveData:(NSData *)data
    {
        AFURLSessionManagerTaskDelegate *delegate = [self delegateForTask:dataTask];//这里面是加锁的
        [delegate URLSession:session dataTask:dataTask didReceiveData:data];//这个里面就一句[self.mutableData appendData:data],其是不需要加锁的,
        if (self.dataTaskDidReceiveData) {
            self.dataTaskDidReceiveData(session, dataTask, data);
        }
    }
    
    //加锁从字典self.mutableTaskDelegatesKeyedByTaskIdentifier获取task
    - (AFURLSessionManagerTaskDelegate *)delegateForTask:(NSURLSessionTask *)task {
        NSParameterAssert(task);
        AFURLSessionManagerTaskDelegate *delegate = nil;
        [self.lock lock];
        delegate = self.mutableTaskDelegatesKeyedByTaskIdentifier[@(task.taskIdentifier)];
        [self.lock unlock];
        return delegate;
    }
    

    经典的库去深究后会发现很多细节的知识。

    这里做了个示意图,有的三个任务队列最大两个线程并发:

    任务并发调用

    ​ 图中横向是时间线,纵向是队列,方块是回调,数字是线程编号(假设的),任意一个纵向切面上都只有两个方块,也就是队列并发2;同一个task肯定是串行回调的;回调线程是随机的,哪个空闲用哪个。

  5. 系统默认的缓存NSURLCache

    URL Loading System有很多的组件,[NSURLCache sharedCache]有系统默认的Cache,这个东西让人有点捉摸不透。什么时候使用缓存,什么时候加载刷新,通过我的测试发现真没一个准儿。另外系统缓存是没有白名单黑名单机制的,所以很可能会缓存了不该缓存的数据,同时产生较多的垃圾文件。所以我们使用自定义的缓存,对于自定义的缓存在原始数据加载时也应该禁用系统缓存。这里我是通过将reqeust header的If-None-Match设置为空来实现的,对于不同的缓存策略需要解决办法也不一样可能还需要处理ExpiresCatch-ControlLast-Modified / If-Modified-Since等情况。

  6. 搜索下载苹果官方给的demo——CustomHTTPProtocol

    对照苹果demo,NSURLProtocol的一些问题和注意事项,这里我列举了一下:

    7.1. 需要注意的问题是:session的回调代理队列并发个数为1(demo中是1,但其实是可以大于1的)

    7.2. 使用方单例(多例),每个使用者可以各自将QNSURLSessionDemux创建一个单例来使用,局部来看就是一个单例。如果有多个NSURLProtocol,还可以复用这个下载类。

    7.3. 如果使用自定义的sessionNSURLSessionConfiguration.protocolClasses要传入NSURLProtocol子类;

    7.4. 回调必须在对应的runloop mode下

    *7.5. 最重要的一点,以下回调:

     -URLProtocol:wasRedirectedToRequest:redirectResponse:
     -URLProtocol:didReceiveResponse:cacheStoragePolicy:
        
     -URLProtocol:didLoadData:
     -URLProtocolDidFinishLoading:
     -URLProtocol:didFailWithError:
     -URLProtocol:didReceiveAuthenticationChallenge:
     -URLProtocol:didCancelAuthenticationChallenge:
    

    必须要和NSURLProtocol的startLoading在同一线程中回调。

    NSURLProtocolstartLoading记录当前线程和mode,与下载的task关联存储(一一对应),在task回调时,在对应的线程对应的mode调用URLProtocol相关方法。我打印了一下这个线程,可能是专用的

    <NSThread: 0x1c0664bc0>{number = 11, name = com.apple.CFNetwork.CustomProtocols

    如果不在同一线程回调,可能会崩溃,复现不易, 以下是其崩溃日志特征

    CFURLProtocol_NS::forgetProtocolClient()

    readme的注释写的很详细,这一点官方demo确实还是很棒的,这里有人将readme翻译了一下,方便查阅。

    https://www.jianshu.com/p/0de4f52ffac7

  7. 同一个URL多次调用canInitWithRequest

    原因应该有两个一个是未标记request

    就是当我们处理一个request之后需要调用

    [[self class] setProperty:@YES forKey:kQCRecursiveRequestFlagProperty inRequest:request];

    标记该request,然后在canInitWithRequest通过该标记

    if ([self propertyForKey:kQCRecursiveRequestFlagProperty inRequest:request]) {
        return NO;
    }
    

    来过滤数据

    这样处理之后发现有些情况下还是不行,这个可能更NSURLProtocol多次注册有关系,具体没有验证。不过问题不大,通过代理观察会发现:系统网络框架对同一URL同时只会有一个request发出去,对性能不会有太大影响。

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

推荐阅读更多精彩内容