iOS离线包方案调研

iOS离线包方案调研

为啥使用离线包

离线包相对于混淆来说是一种更稳定的过审方案, 其主要优势如下

传统的 H5 技术容易受到网络环境影响,因而降低 H5 页面的性能。通过使用离线包,可以解决该问题,同时保留 H5 的优点。
离线包 是将包括 HTML、JavaScript、CSS 等页面内静态资源打包到一个压缩包内。预先下载该离线包到本地,然后通过客户端打开,直接从本地加载离线包,从而最大程度地摆脱网络环境对 H5 页面的影响。

实现动态更新:在推出新版本或是紧急发布的时候,可以把修改的资源放入离线包,通过更新配置让应用自动下载更新。因此, 无需通过应用商店审核,就能让用户及早接收更新

离线包的方案选择

目前主流的离线包的请求拦截方案有两种:

  • 通过NSURLProtocol实现, 注册scheme拦截
  • WKURLSchemeHandler实现, 自定义sheme拦截

WKURLSchemeHandler

WKURLSchemeHandlerWebKit 框架中的一个类,用于处理自定义的 URL 协议。WebKit 是一个提供网页渲染和浏览功能的框架,它主要用于创建浏览器和网页视图等, 其中WKURLSchemeHandler是iOS11之后的API, 其大致实现如下.

ini 复制代码
​
  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完成这已功能

ini 复制代码
  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子类对象所拦截,比如

objectivec 复制代码
//OfflinePackageURLProtocol负责离线资源的加载
[NSURLProtocol registerClass:[OfflinePackageURLProtocol class]];
//KKJSBridgeAjaxURLProtocol负责重新组装来自ajax的请求
[NSURLProtocol registerClass:[KKJSBridgeAjaxURLProtocol class]];

其中OfflinePackageURLProtocol,和KKJSBridgeAjaxURLProtocol都需要继承NSURLProtocol. 其核心方法如下

objectivec 复制代码
// 该方法用于判断指定的网络请求是否可以由自定义的 URL 协议处理。如果该方法返回 YES,则表示该请求可以由该协议处理;如果返回 NO,则表示该请求不能由该协议处理
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
// 该方法用于启动网络请求。在该方法中,可以实现自定义的网络传输逻辑,来处理网络请求并返回响应。
- (void)startLoading;
// 该方法用于停止网络请求。在该方法中,可以实现清理逻辑,来释放与请求相关的资源。
- (void)stopLoading;

NSURLProtocol可以注册多个子类,并且拦截的顺序是后注册的先拦截 , 被拦截的请求,会重新由新创建的Session来管理,因此不会继续被后续的Protocol处理. 所以KKJSBridgeAjaxURLProtocol需要拦截ajaxhook之后的请求重新组装body, 它的注册必须在OfflinePackageURLProtocol之后

此外, 使用NSURLProtocolregisterClass方法会污染[NSURLSession sharedSession]对象. 通常向NSURLSession中注册NSURLProtocol的方法如下

ini 复制代码
// 创建一个默认的 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中减少网路流量.

ini 复制代码
 //仿照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的核心代码

ini 复制代码
- (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端.

      核心代码如下

      ini 复制代码
      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.大致逻辑如下

    ini 复制代码
    - (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中进行同步

      objectivec 复制代码
      - (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端处理.

      objectivec 复制代码
      //这里需要注意虽然在浏览器环境中可以仅仅通过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, 该请求会被客户端接管, 可以在客户端请求完成的回调中单独处理, 比如

      objectivec 复制代码
      //客户端的请求回调中处理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中进行同步

      ini 复制代码
      - (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

      ini 复制代码
      - (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];
      }
相关推荐
Patrick_Wilson2 分钟前
青苔漫染待客迟
前端·设计模式·架构
写不出来就跑路23 分钟前
基于 Vue 3 的智能聊天界面实现:从 UI 到流式响应全解析
前端·vue.js·ui
OpenTiny社区26 分钟前
盘点字体性能优化方案
前端·javascript
FogLetter30 分钟前
深入浅出React Hooks:useEffect那些事儿
前端·javascript
Savior`L31 分钟前
CSS知识复习4
前端·css
0wioiw01 小时前
Flutter基础(前端教程④-组件拼接)
前端·flutter
花生侠1 小时前
记录:前端项目使用pnpm+husky(v9)+commitlint,提交代码格式化校验
前端
一涯1 小时前
Cursor操作面板改为垂直
前端
我要让全世界知道我很低调1 小时前
记一次 Vite 下的白屏优化
前端·css