美文网首页
NSURLProtocol子类使用的坑

NSURLProtocol子类使用的坑

作者: 吸血鬼de晚餐 | 来源:发表于2018-05-17 19:40 被阅读315次

    (去年写的文档)

    ​ 之前使用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发出去,对性能不会有太大影响。

    相关文章

      网友评论

          本文标题:NSURLProtocol子类使用的坑

          本文链接:https://www.haomeiwen.com/subject/zcecrftx.html