「拒绝踩坑」唯一一种拦截 WKWebView 资源请求的方式

8,655 阅读9分钟

由于当前公司的旧 Web 容器已无法继续维护(懂得都懂),所以需要重构一套新的来支撑越来越多的在线页面业务体系。但在拦截资源,本地缓存加速这个过程中,踩了特别多的坑,这里特地记录一下,让大家们能少走些弯路。

背景

简单聊下背景,以前业务上更多是使用 Web 离线包下载到本地,然后加载本地资源渲染,类似小程序一样的设计。但随之问题也很多,毕竟没有一套成熟的开发体系,最重要的是开发的离线包都和盲盒一样,联调排查问题的时间都赶的上开发时间了。

在降本增效的前提下,已经不可能有人力单独为 App 开发一套离线包来支撑业务的情况下,势必就需要融合前端体系,直接加载在线页面。

那随之而来的一个结果就是对用户来说出现了体验降级的情况,以前秒开的页面变慢了,甚至在网络差的情况下白屏情况变得十分明显。

当然,这肯定是不能接受的,所以要重新建设整个 Web 容器。

总体规划

Web 容器 建设总览.png

其他建设暂且不提,这里只聊聊,如何让在线 URL 页面达到秒开加载这件事。

方案选型

image2023-2-23_14-18-25.png

如何提高秒开率?就是减少整个建立连接到渲染完成的这段时间。

其实说白了,无非就是资源缓存加上提前预加载,而这也有几种方式能选择。

WebView 自带 Cache

最常见的就是 WebView 自带缓存 Cache,缓存规则也是依托于前端开发,但这个缓存策略上经常会有问题,比如版本不对、意外白屏、缓存丢失加载过慢等问题。

简单代码示意(来源 GPT-3.5)

// 配置缓存策略以及是否可使用cookie
WKWebsiteDataStore *store = [WKWebsiteDataStore defaultDataStore];
WKWebsiteDataStore *nonPersistentDataStore = [store nonPersistentDataStore];
WKWebsiteDataStore *ephermeralDataStore = [store ephemeralDataStore];

NSURLCache *cache = [[NSURLCache alloc] initWithMemoryCapacity:8 * 1024 * 1024
                                                  diskCapacity:500 * 1024 * 1024
                                                      diskPath:nil];
configuration.websiteDataStore = nonPersistentDataStore;

// 配置 URL 缓存策略
configuration.urlCache = cache;
configuration.requestCachePolicy = NSURLRequestUseProtocolCachePolicy;

// 例如,设置一个自定义的Cookie策略
WKUserContentController *userContentController = [[WKUserContentController alloc] init];
NSString *cookieScript = @"document.cookie = 'cookie_name=cookie_value; domain=your_domain.com;';";
WKUserScript *cookieScriptObj = [[WKUserScript alloc] initWithSource:cookieScript
                                                       injectionTime:WKUserScriptInjectionTimeAtDocumentStart
                                                    forMainFrameOnly:YES];

使用前端方案service worker

service worker是一种 Web API,它允许开发者将脚本文件运行在用户浏览器的后台进程中,独立于当前页面/标签,提供了强大的离线数据存储、网络请求拦截和消息推送等功能。

但其最大的硬伤是,在 iOS App 中是不被支持的,这在苹果开发文档中是有明确的。只在 iOS Safari 浏览器上才被支持。

拦截资源请求

以上两种还有一个硬伤,就是必须依托 WebView 环境,那在预加载上就必须启动一个 WebView 容器,这一点其实有性能损耗的,如果有多个页面需要预缓存,那就必须开多个容器或者提供一个预加载队列,控制起来也是尤为费事,特别在预加载完之前用户就进入页面的情景下,缓存意外也是有可能的。

那我们还能怎么做呢?

其实思路很简单,在总体规划上也说的很明白。

我们拦截资源加载的请求,让它去访问我们本地资源,如果本地资源不存在就先下载到本地再返回给 Web 页面。

那提前预加载,就让前端提供资源清单,我们根据资源清单提前做一个资源更新即可。这样的好处还在于就算预加载未完成用户就进入了 Web 页面,那也没关系,已下载好的资源仍然可以提供加速能力。

iOS 的坑

方案大致讲了下, Android 实现很顺利的就完成了。但 iOS 确实就被 WKWebView 这货卡住了。

想象真的很美好,但网上的资源甚至 GPT-3.5 给出的方案有真有假的,尝试了很多种,走了很多歪门邪道。

邪道一: NSURLProtocol

亲们,这个确实是不能用的,虽然我们可以用它拦截 http / https 请求,也可以通过 + (BOOL)canInitWithRequest:(NSURLRequest *)request 方法来放过网络请求,从 safari 调试下看 post 请求内容也还在,但确实是在发送的时候就丢掉了 ...

总结:还是会拦截掉网络请求,并且丢掉了 post 请求中的 body 信息。并且会影响整个全局。

邪道二: Hook XMLHttpRequest / fetch

这个是基于上一个“邪道”的延伸,既然请求一定会被拦截,那要不然就重写 XMLHttpRequest / fetch,让网络请求走我们的桥接方法,这样前端业务侧也无需修改任何代码。

代码示意(来源:反复询问 GPT-3.5 并修正)

function replaceXHR() {
  // 保存原始的 XMLHttpRequest 构造函数
  const origXHR = window.XMLHttpRequest

  // 定义新的 XMLHttpRequest 构造函数
  function NewXHR() {
    const xhr = new origXHR()

    let status = 200
    let statusText = 'OK'
    let response = ''

    // 重写 open 方法
    xhr.open = function (method, url, async, user, pass) {
      this.url = url
      this.method = method
      origXHR.prototype.open.call(this, method, url, async, user, pass)
    }

    // 重写 send 方法
    xhr.send = function (data) {
      var urlObj
      if (isUrlComplete(this.url)) {
        urlObj = new URL(this.url)
      } else {
        urlObj = new URL('https://gaoding.com' + this.url)
      }

      const path = urlObj.pathname
      const params = new URLSearchParams(urlObj.search)

      // 非稿定请求,不拦截
      if (!urlObj.host.includes('gaoding.com')) {
        origXHR.prototype.send.call(this, data)
        return
      }

      const paramObj = {}
      for (const [key, value] of params) {
        paramObj[key] = value
      }

      const config = {
        method: this.method,
        path: path,
        query: paramObj,
      }

      if (this.method.toUpperCase() === 'POST' || this.method.toUpperCase() === 'PUT') {
        config['body'] = data
      }

      // 执行桥接方法
      request(config)
        .then((response) => {
          // 处理响应结果
          return response.result.response_data
        })
        .then((text) => {
          // 调用原始的 onreadystatechange 函数,并传入响应结果
          this.status = 200
          this.statusText = JSON.stringify(text)
          this.response = text
          this.onreadystatechange && this.onreadystatechange()
          this.onload && this.onload()
        })
        .catch((error) => {
          // 调用原始的 onerror 函数,并传入错误信息
          this.onerror && this.onerror(error)
        })
    }

    return xhr
  }

  // 用新的 XMLHttpRequest 构造函数替换原始的 XMLHttpRequest 构造函数
  window.XMLHttpRequest = NewXHR
}

replaceXHR()

然后我们在 WKWebView 中注入这段 JS 即可。

[userContentController addUserScript:[[WKUserScript alloc] initWithSource:[GDWebUtils injectJSForBundle:@"network-hook"] injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];

生效是生效了,但对我们的 Web 页面却出现了加载异常的情况,最后得知,我们项目中已经对 XMLHttpRequest hook 过了,所以我这真的是魔改 ...

总结:不能胡乱注入 JS 代码,容易翻车 ...

邪道三:自定义 Scheme

为什么一开始会执着会执着去使用NSURLProtocol,而不使用WKURLSchemeHandler。原因是WKURLSchemeHandler最低支持 iOS 11, 且在 iOS 11.3 以下 post body 一样会丢失[手动狗头]。

那笔者奇思妙想下,我们先拦截加载的 HTML,然后再替换其中的资源加载路径呢?

做法大概描述下:

使用WKURLSchemeHandler拦截page://assets://两种自定义的 Scheme。

比如加载https://www.google.com时,其实是加载的 page://www.google.com来让WKURLSchemeHandler可以拦截到页面加载了。

这样在 Response 中修改返回的资源加载路径,比如assets://xxxxx.css,这样也就可以让WKURLSchemeHandler拦截到资源加载了。

理想很美好,对于页面来说确实拦截加载成功了。

网络请求跨域了 ... 因为我们当前域名是page://而网络请求发出去的是https://

真要这么做,只能是服务端开放跨域限制,但这一点对于服务端是极不安全的。

结论:绕了半天然并卵。

正道:拦截 Http / Https Scheme

虽然苹果不建议甚至不允许拦截 Http / Https 协议的 Scheme,但这就是唯一一种方式了。

使用前提

  • 确认项目/功能只需支持 iOS 11.3 以上版本。
  • 确认前端其中没有File 上传请求,因为就算 iOS 11.3 以上版本,也会丢失 blob 格式的 body 数据。真要做文件上传,请提供给前端相应的 Bridge 方法。
  • 切记处理 iOS 13 版本中的 post 请求的崩溃问题(如下图)。

image.png

代码示意

让 Http / Https 请求可被拦截

黑魔法替换类方法实现

@implementation WKWebView (GDWebURLSchemeHandler)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Method originalMethod = class_getClassMethod(self, @selector(handlesURLScheme:));
        Method swizzledMethod = class_getClassMethod(self, @selector(gd_handlesURLScheme:));
        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

+ (BOOL)gd_handlesURLScheme:(NSString *)urlScheme {
    if ([urlScheme isEqualToString:kGDWebHookURLScheme]) {
        return NO;
    } else {
        return [self handlesURLScheme:urlScheme];
    }
}

@end

拦截步骤

构造拦截类,实现WKURLSchemeHandler协议

image.png

在 WKWebView 中注册

[config setURLSchemeHandler:[GDWebURLSchemeHandler new] forURLScheme:@"https"];

拦截资源实现

上图中的GDWebAssetStorageService服务就是做资源请求拦截及实现的。

内部实现也很简单,先判断本地是否存在资源,不存在就去下载后返回即可。

image.png

拦截请求实现

最后我们还是要处理拦截到的网络请求,WKURLSchemeHandler协议真的是很坑爹,拦截掉的就无法调用默认实现了,需要自己构造。

但从原理来讲,苹果这样设计是合理的,毕竟 WKWebView 不是跟 App 同一个进程的,这牵扯到跨进程通信的问题,也是为什么苹果会在请求中过滤掉 blob 数据格式。

简单的构造一个NSURLSession即可使用。

image.png

但这里还是有一个坑的,你会发现请求重定向失效了,这里我们采用的是调用私有类来做统一的处理,私有类直接调用有审核风险,简单的做一些代码混淆绕过去。

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler {
    // 调用 _didPerformRedirection:newRequest: 执行重定向
    NSArray *privateSelStrArr = @[@"st:", @"que", @"ewRe", @"n:n", @"ctio", @"dire", @"ormRe", @"_didPerf"];
    NSString *selName = [[[privateSelStrArr reverseObjectEnumerator] allObjects] componentsJoinedByString:@""];
    SEL sel = NSSelectorFromString(selName);
    #pragma clang diagnostic push
    #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    [self.schemeTask performSelector:sel withObject:response withObject:request];
    #pragma clang diagnostic pop
    completionHandler(request);
}

结论

不想让前端代码做一些 App 个性化适配的前提下,想要提高秒开率,又不想开隐藏容器增加内存开销,那在 iOS 上只有这一种拦截方式了。

虽然有着诸多使用限制,但至少能满足现在的业务需求。

关于 blob

再简单的讲一下,关于前端 blob 数据传输给 App 的问题。

这个其实也走了很多歪门邪道,前期想着用 base64 的方式不如直接 blob 传输省性能,但确实是做不到的。

这里也听了 GPT-3.5 的很多鬼话 ... 要是用 GPT-4 的话,它会明确告诉你只有两种选择:转 base64 或者 App 搭建本地服务器。AI 的差距真的十分明显[手动狗头]。


感谢阅读,如果对你有用请点个赞 ❤️

中秋节GIF动图引导在看提示.gif

本文正在参加「金石计划」