iOS离线包计划调研

为啥运用离线包

离线包相关于混杂来说是一种更安稳的过审计划, 其首要优势如下

传统的 H5 技能容易受到网络环境影响,因而降低 H5 页面的功用。经过运用离线包,能够处理该问题,一起保存 H5 的优点。

离线包 是将包含 HTML、JavaScript、CSS 等页面内静态资源打包到一个压缩包内。预先下载该离线包到本地,然后经过客户端翻开,直接从本地加载离线包,然后最大程度地脱节网络环境对 H5 页面的影响。

完结动态更新:在推出新版本或是紧急发布的时分,能够把修正的资源放入离线包,经过更新装备让应用主动下载更新。因而, 无需经过应用商铺审阅,就能让用户及早接纳更新

离线包的计划选择

现在干流的离线包的恳求阻拦计划有两种:

  • 经过NSURLProtocol完结, 注册scheme阻拦
  • WKURLSchemeHandler完结, 自界说sheme阻拦

WKURLSchemeHandler

WKURLSchemeHandlerWebKit 结构中的一个类,用于处理自界说的 URL 协议。WebKit 是一个供给网页烘托和阅读功用的结构,它首要用于创立阅读器和网页视图等, 其间WKURLSchemeHandler是iOS11之后的API, 其大致完结如下.

​
  WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
  //其间CustomSchemeHandler需求完结WKURLSchemeHandler协议
  CustomSchemeHandler *handler = [[CustomSchemeHandler alloc] init];
  NSString *scheme = "yourscheme";
  NSString *schemes = "yourschemes"
  //http + https
  [configuration setURLSchemeHandler:handler forURLScheme:scheme];
  [configuration setURLSchemeHandler:handler forURLScheme:schemes];
  _webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:configuration];
​
...
 
  //恳求时只需scheme共同, 就能被CustomSchemeHandler阻拦
  NSString *requestURL = @"yourscheme://{resourcePath}"
  NSURLRequest *request = [NSURLRequest requestWithURL:requestURL];
  [self.webView loadRequest:request];

用户需求自界说scheme,拜访时域名大概customScheme://{packageId}/page,需求自界说scheme, 能够针对单个的网页进行阻拦,粒度较细.

NSURLProtocol

NSURLProtocolFoundation 结构中的一个抽象类,它供给了一个根本的结构来完结自界说的 URL 协议。经过承继 NSURLProtocol 类,你能够界说自己的 URL 协议,并在应用程序中运用该协议来进行网络恳求

而mpaas中运用的就是这种计划,相关于WKURLSchemeHandler能够供给虚拟域名的支持,mpass的文档阐明如下:

总体上来说2种计划完结思路是共同的,API的相似度很高,可是在前端处理的细节上会有些区别 ,下面我模仿mpaas的办法,完结简略的离线包

NSURLPotocol目标注册

WKWebView并没有供给公开注册NSURLProtocol的办法,可是依据Apple的WebKit开源项目中的测验代码,能够得知运用私有api完结这已功用

 Class cls = NSClassFromString(@"WKBrowsingContextController");
 SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
  [(id)cls performSelector:sel withObject:@"http"];
  [(id)cls performSelector:sel withObject:@"https"];

其间WKBrowsingContextControllerregisterSchemeForCustomProtocol都是私有的,上线时需求进行混杂.

这样WKWebView中所有的http/https的恳求都会被注册的NSURLPotocol子类目标所阻拦,比方

//OfflinePackageURLProtocol担任离线资源的加载
[NSURLProtocol registerClass:[OfflinePackageURLProtocol class]];
//KKJSBridgeAjaxURLProtocol担任从头拼装来自ajax的恳求
[NSURLProtocol registerClass:[KKJSBridgeAjaxURLProtocol class]];

其间OfflinePackageURLProtocol,和KKJSBridgeAjaxURLProtocol都需求承继NSURLProtocol. 其间心办法如下

// 该办法用于判别指定的网络恳求是否能够由自界说的 URL 协议处理。假如该办法回来 YES,则表示该恳求能够由该协议处理;假如回来 NO,则表示该恳求不能由该协议处理
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
// 该办法用于发动网络恳求。在该办法中,能够完结自界说的网络传输逻辑,来处理网络恳求并回来响应。
- (void)startLoading;
// 该办法用于停止网络恳求。在该办法中,能够完结整理逻辑,来释放与恳求相关的资源。
- (void)stopLoading;

NSURLProtocol能够注册多个子类,而且阻拦的顺序是后注册的先阻拦, 被阻拦的恳求,会从头由新创立的Session来办理,因而不会持续被后续的Protocol处理. 所以KKJSBridgeAjaxURLProtocol需求阻拦ajaxhook之后的恳求从头拼装body, 它的注册有必要在OfflinePackageURLProtocol之后

此外, 运用NSURLProtocolregisterClass办法会污染[NSURLSession sharedSession]目标. 通常向NSURLSession中注册NSURLProtocol的办法如下

// 创立一个默认的 session configuration 目标
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
​
// 为 session configuration 目标增加自界说的 NSURLProtocol 子类
config.protocolClasses = @[[MyURLProtocol class]];
​
// 创立 NSURLSession 目标
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];

运用NSURLProtocolregisterClass会主动完结这一进程,然后导致所有的[NSURLSession sharedSession]办理的恳求都会被阻拦, 除了功率问题以外,还会可能会导致回调失效(因为通常在阻拦中会构建新的Session和Task目标)等问题, 因而假如是Native的恳求, 咱们需求防止运用[NSURLSession sharedSession]防止造成不必要的麻烦.

离线资源的加载的完结

离线包解压之后的沙盒目录如下

其间zipFiles中为打包的离线包资源, 能够经过内嵌或许下载的办法,保存到沙盒中,unzipFiles为解压之后的目录.其文件夹名即为packageId/version, 这样关于同一个离线包来说能够经过版原本进行升级或许版本回退. 其间static中有一些不变化的资源后续能够独自拿出来,作为一个资源包内嵌或许下发到app中削减网路流量.

 //模仿Mpaas的api, 离线包控制器经过packageId初始化
 OfflinePackageController *vc = [OfflinePackageController controllerWithPackageId:@"6"];
 [self.navigationController pushViewController:vc animated:YES];
​
// packageId映射为url再经过URLProtocol阻拦
- (instancetype)initWithPackageId:(NSString *)packageId{
   if (self = [super init]) {
     _url = [NSString stringWithFormat:@"https://%@.package",packageId];
     [self commonInit];
   }
   return self;
}

其间url就是所谓的虚拟域名, 在本比方中,packageId为app, 虚拟域名为https://app.package,这样在拜访内部资源时,比方主页途径为https://app.package/index.html

OfflinePackageURLProtocol的中心代码

- (void)startLoading{
   NSURLRequest *originRequest = self.request;
   NSMutableURLRequest *mutableReqeust = [originRequest mutableCopy];
​
   // 标示改request已经处理过了,防止无限循环
   [NSURLProtocol setProperty:@YES forKey:kOfflinePackageDidHandleRequest inRequest:mutableReqeust];
  
   if ([self.request.URL.host containsString:@".package"]) {
     //本地
     NSString *packageId = [self.request.URL.host componentsSeparatedByString:@"."][0];
     NSString *relativePath;
     if (self.request.URL.pathExtension.length > 0) {
       relativePath = self.request.URL.relativePath;
     }else{
       if([self.request.URL.lastPathComponent isEqualToString:@"/"]){
         relativePath = @"index.html";
       }else{
         relativePath = [NSString stringWithFormat:@"%@.html",self.request.URL.lastPathComponent];
       }
     }
     //依据离线包id 版本号 来定位离线包资源
     NSString *version = [PackageManager currentVersionOfPackage:packageId];
     NSString *filePath = [@[
       NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0],
       @"unzipFiles",
       packageId,
       version,
       relativePath
     ] componentsJoinedByString:@"/"];
     NSData *data = [NSData dataWithContentsOfFile:filePath];;
     NSURLResponse *res = [[NSURLResponse alloc] initWithURL:self.request.URL MIMEType:[self getMimeTypeWithFilePath:filePath] expectedContentLength:data.length textEncodingName:nil];
     [self.client URLProtocol:self didReceiveResponse:res cacheStoragePolicy:NSURLCacheStorageNotAllowed];
     [self.client URLProtocol:self didLoadData:data];
     [self.client URLProtocolDidFinishLoading:self];
    
   }else{
       //非离线包资源,结构task,持续建议恳求.
     NSURLSessionConfiguration *configure = [NSURLSessionConfiguration defaultSessionConfiguration];
     self.session  = [NSURLSession sessionWithConfiguration:configure delegate:self delegateQueue:self.queue];
     self.task = [self.session dataTaskWithRequest:mutableReqeust];
     [self.task resume];
   }

经过host判别是否离线包资源, 假如是离线包资源,结构NSURLResponse,经过client 目标回来.假如不是离线包资源, 从头结构Task并进行恳求.

经过上述代码可知, 关于非离线包的资源, 咱们应该尽量在canInitWithRequest中提早判别协议不可用,不然假如进入startLoading接收后, 因为NSURLSession和Task目标是从头结构的,会导致一些问题, 比方前端无法获取网络恳求的进度.

NSURLProtocol采坑

NSURLProtocol阻拦Post恳求Body丢掉的问题

上述计划存在一个问题, 服务端承受的ajax的post恳求body为空,在URLProtocol中断点能够得知, NSURLProtocol阻拦Post恳求后会将参数清空.

这个问题的发生首要是因为WKWebView的网络恳求的进程与APP不是同一个进程,所以网络恳求的进程是这样的: 由APP地点的进程建议request,然后经过IPC通讯(进程间通讯)将恳求的相关信息(恳求头、恳求行、恳求体等)传递给webkit网络线进程接纳包装,进行数据的HTTP恳求,终究再进行IPC的通讯回传给APP地点的进程的。这里假如建议的request恳求是post恳求的话,因为要进行IPC数据传递,传递的恳求体body中依据系统调度,将其舍弃,终究在WKWebView网络进程承受的时分恳求体body中的内容变成了空,导致此种状况下的服务器获取不到恳求体,导致问题的发生。

所以这里的处理思路就是想办法将WebView的Post恳求的body传递到native端保存, 然后恳求时从头结构参数. 依据这个思路现在有2种处理计划

  • 将body放到恳求Header中, 然后从头结构.

    因为Header的长度限制, 这种计划不适合文件传输.

  • 经过JSAPI将参数发送到native端保存.

    这种计划通用性更高, 需求完结body的缓存.

现在选用第二种计划,具体步骤分为2步

  • js中注入ajax hook, 对open和send进行hook.

    • open中生新的成带符号的URL,一起 ,用于native端区别哪些恳求是ajax宣布的比方http://192.168.33.39:8000/testpost?KKJSBridge-RequestId=166986530337824940,其间KKJSBridge用来符号这个恳求是由ajax宣布的,RequestId用来区别恳求,进行body的匹配.

    • send中担任将依据body类型进行编码, 并将body发送到native端.

      中心代码如下

      var originOpen = XMLHttpRequest.prototype.open;
      XMLHttpRequest.prototype.open = function (method, url, async, username, password) {
        var args = [].slice.call(arguments);
        var xhr = this;
        // 生成仅有恳求id
        xhr.requestId = _KKJSBridgeXHR.generateXHRRequestId();
        xhr.requestUrl = url;
        xhr.requestHref = document.location.href;
        xhr.requestMethod = method;
        xhr.requestAsync = async;
        if (_KKJSBridgeXHR.isNonNormalHttpRequest(url, method)) { // 假如是非正常恳求,则调用原始 open
          return originOpen.apply(xhr, args);
         }
        if (!window.KKJSBridgeConfig.ajaxHook) { // 假如没有敞开 ajax hook,则调用原始 open
          return originOpen.apply(xhr, args);
         }
        // 生成新的 url
        args[1] = _KKJSBridgeXHR.generateNewUrlWithRequestId(url, xhr.requestId);
        originOpen.apply(xhr, args);
      };
      var originSend = XMLHttpRequest.prototype.send;
      XMLHttpRequest.prototype.send = function (body) {
        var args = [].slice.call(arguments);
        var xhr = this;
        var request = {
          requestId: xhr.requestId,
          requestHref: xhr.requestHref,
          requestUrl: xhr.requestUrl,
          bodyType: "String",
          value: null
         };
        if (_KKJSBridgeXHR.isNonNormalHttpRequest(xhr.requestUrl, xhr.requestMethod)) { // 假如是非正常恳求,则调用原始 send
          return originSend.apply(xhr, args);
         }
        if (!window.KKJSBridgeConfig.ajaxHook) { // 假如没有敞开 ajax hook,则调用原始 send
          return originSend.apply(xhr, args);
         }
        if (!body) { // 没有 body,调用原始 send
          return originSend.apply(xhr, args);
         }
        else if (body instanceof ArrayBuffer) { // 阐明是 ArrayBuffer,转成 base64
          request.bodyType = "ArrayBuffer";
          request.value = KKJSBridgeUtil.convertArrayBufferToBase64(body);
         }
        else if (body instanceof Blob) { // 阐明是 Blob,转成 base64
          request.bodyType = "Blob";
          var fileReader = new FileReader();
          fileReader.onload = function (ev) {
            var base64 = ev.target.result;
            request.value = base64;
            _KKJSBridgeXHR.sendBodyToNativeForCache("AJAX", xhr, originSend, args, request);
           };
          fileReader.readAsDataURL(body);
          return;
         }
        else if (body instanceof FormData) { // 阐明是表单
          request.bodyType = "FormData";
          request.formEnctype = "multipart/form-data";
          KKJSBridgeUtil.convertFormDataToJson(body, function (json) {
            request.value = json;
            _KKJSBridgeXHR.sendBodyToNativeForCache("AJAX", xhr, originSend, args, request);
           });
          return;
         }
        else { // 阐明是字符串或许json
          request.bodyType = "String";
          request.value = body;
         }
        // 发送到 native 缓存起来
        _KKJSBridgeXHR.sendBodyToNativeForCache("AJAX", xhr, originSend, args, request, xhr.requestAsync);
      };
      
  • native端结构URLProtocol,对ajax宣布的恳求进行阻拦,解析得到RequestId, 依据RequestId获取并拼装body.大致逻辑如下

    - (void)startLoading {
       NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
       NSString *requestId;
       if ([mutableReqeust.URL.absoluteString containsString:kRequestId]) {
         requestId = [self getRequestId:mutableReqeust.URL.absoluteString];
       }
      
       self.requestId = requestId;
       self.requestHTTPMethod = mutableReqeust.HTTPMethod;
      
       NSArray *bodySupportMethods = @[@"POST",@"PUT"];
      
       if (mutableReqeust.HTTPMethod.length > 0 && [bodySupportMethods containsObject:mutableReqeust.HTTPMethod]) {
         NSDictionary *body = [self getBodyFromRequestId:requestId];
         if (body) {
           // 从把缓存的 body 设置给 request
           [self setBody:bodyReqeust forRequest:mutableReqeust];
         }
       }
       NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil];
       self.customTask = [session dataTaskWithRequest:mutableReqeust];
       [self.customTask resume];
    }
    ​
    

Cookie同步问题

Cookie的分类

WKWebView的Cookie办理一直是比较令人头疼的问题.这是因为客户端,服务端和前端都能进行Cookie的操作,其间Session Cookie是不需求耐久化的, 由WKWebView对应的WKProcessPool进行办理,关于需求耐久化的Cookie来说,不同端操作的Cookie目标是分隔保存的,如下图

iOS 沙盒目录/Library/Cookies中存储的2种Cookie, 其间 {appid}.binarycookiesNSHTTPCookieStorage的文件目标, Cookies.binarycookies是WKWebView的Cookie目标,经过WKHTTPCookieStore进行办理.

WKHTTPCookieStoreNSHTTPCookieStorage是iOS中两个用于办理Cookie的类。它们都供给了相似的功用,如存储、删去、更新和查找Cookie,但它们在完结办法和运用场景上有所不同。

  • WKHTTPCookieStore是WebKit结构中供给的类,首要用于办理WebView的Cookie。它供给了一系列办法,能够在WebView加载恳求时,主动增加、删去或修正Cookie,以便在WebView中坚持用户的登录状态和个性化设置。WKHTTPCookieStore的运用办法和WebView相关,只能在WebView的署理办法或JavaScript脚本中调用,不能在其他当地运用。具体来说, 由前端建议的网络恳求,经过服务端Set-Cookie发生的Cookie 和 JavaScript中经过document.cookie设置的Cookie 由 WKHTTPCookieStore 进行办理, 由前端建议的网络恳求, 会带着WKHTTPCookieStore中办理的耐久化Cookie 和 WKProcessPool办理的Session Cookie.
  • NSHTTPCookieStorage是Foundation结构中供给的类,首要用于办理网络恳求的Cookie。它供给了一系列静态办法,能够在发送网络恳求时,主动增加、删去或修正Cookie,以便在网络传输中坚持用户的登录状态和个性化设置。NSHTTPCookieStorage的运用办法和网络恳求相关,只能在NSURLSessionAFNetworking等网络库的办法中调用,不能在其他当地运用。具体来说: 除了客户端经过NSHTTPCookieStorage进行的Cookie操作以外, 由客户端建议的网络恳求,经过服务端Set-Cookie发生的Cookie也交由NSHTTPCookieStorage办理, 由客户端建议的网络恳求,会带着NSHTTPCookieStorage中办理的Cookie

在运用离线包时, 因为咱们运用了URLProtocol进行阻拦, 相当于将前端的恳求转发到客户端进行处理,为了防止Cookie运用的混乱,咱们要统一运用NSHTTPCookieStorage来进行Cookie的办理, 所有的前端Cookie需求同步到客户端中, 一起前端建议的恳求需求从NSHTTPCookieStorage同步Cookie

需求处理的场景
  • WKWebView的cookie同步到NSHTTPCookieStorage, 这里有3种状况

    • 场景1: 跳转,包含webview LoadRequest, 前端a标签, 服务端的重定向 都看做是跳转, 选择WKNavigationDelegate中 承受响应后,跳转之前的署理办法decidePolicyForNavigationResponse中进行同步

      - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler {
          WKHTTPCookieStore *cookieStroe = webView.configuration.websiteDataStore.httpCookieStore;
         [cookieStroe getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) {
           for (NSHTTPCookie *cookie in cookies) {
             [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
           }
         }];
         decisionHandler(WKNavigationResponsePolicyAllow);
      };
      
    • 场景2: Js中由 document.cookie设置, 同过hook cookie的set办法, 将音讯转发到native端处理.

      //这里需求注意虽然在阅读器环境中能够只是经过name,value来创立Cookie 可是在Native端创立Cookie时并无webView作为上下文, 因而有必要声明domain和path特点,不然无法创立成功,以下5个字段是有必要设置的.
       NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:@{
         NSHTTPCookieName:@"cookie_form_user",
         NSHTTPCookieValue:@"1",
         NSHTTPCookieDomain:[NSString stringWithFormat:@"%@.package",appId],
         NSHTTPCookieExpires:[NSDate dateWithTimeIntervalSinceNow:86400],
         NSHTTPCookiePath: @"/",
        }];
        [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
      
    • 场景3: 这个状况比较特别, 假如前端进行的AjaxHook, 将恳求转发到客户端署理,这时经过服务端Set-Cookie发生的Cookie将不会进行任何存储.在一些场景下会发生问题,比方 假如用户的登录是经过前端登录的,一起又进行了AjaxHook, 是无法经过Cookie保存信息的, 这里咱们需求手动保存Cookie来防止该场景发生. 因为Ajax恳求中服务端Set-Cookie, 该恳求会被客户端接收, 能够在客户端恳求完结的回调中独自处理, 比方

      //客户端的恳求回调中处理Server Set-Cookie
      [self.sessionManager dataTaskWithRequest:request uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse * _Nonnull response, id _Nullable responseObject, NSError * _Nullable error) {
          if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
            NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:[httpResponse allHeaderFields]           forURL:httpResponse.URL];
            for (NSHTTPCookie *cookie in cookies) {
               [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
             }
          ...
       }];
      
  • WKWebView恳求时,NSHTTPCookieStorage中Cookie同步,分为2种状况

    • 场景1: WebView 跳转恳求, 在这里选择WKNavigationDelegate中发送恳求前的署理办法decidePolicyForNavigationAction中进行同步

      - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
       if ([navigationAction.request isKindOfClass:NSMutableURLRequest.class]) {
        NSMutableURLRequest *request = navigationAction.request
          NSArray<NSHTTPCookie *> *availableCookie = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:request.URL];
         if (availableCookie.count > 0) {
           NSDictionary *reqHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:availableCookie];
           NSString *cookieStr = [reqHeader objectForKey:@"Cookie"];
            [request setValue:cookieStr forHTTPHeaderField:@"Cookie"];
          }
        }
       decisionHandler(WKNavigationActionPolicyAllow);
      }
      
    • 场景2: WebView中Ajax恳求, 在URLProtocolstartLoading中同步Cookie

      - (void)startLoading {
        NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
        NSArray<NSHTTPCookie *> *availableCookie = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:mutableReqeust.URL];
        if (availableCookie.count > 0) {
          NSDictionary *reqHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:availableCookie];
          NSString *cookieStr = [reqHeader objectForKey:@"Cookie"];
           [mutableReqeust setValue:cookieStr forHTTPHeaderField:@"Cookie"];
         }
         ...
        NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:(id<NSURLSessionDelegate>)[KKJSBridgeWeakProxy proxyWithTarget:self] delegateQueue:nil];
        self.customTask = [session dataTaskWithRequest:mutableReqeust];
      }