当前位置: 代码迷 >> 综合 >> WKWebView 离线资源加载方案
  详细解决方案

WKWebView 离线资源加载方案

热度:66   发布时间:2024-02-20 23:57:33.0

文章目录

    • 零. 背景
    • 一. WKWebView加载离线的方案
    • 二. 使用NSURLProtocol存在的问题及解决方案
      • 1. 利用反射调用私有API
      • 2. Ajax POST请求丢失参数处理
          • 2.1 Ajax-hook脚本及hook代码注入的方式及时机
          • 2.2 与客户端同步参数的方式
      • 3.NSURLRequestCachePolicy对NSURLProtocol拦截机制的影响
    • 三. Demo
    • 参考

零. 背景

        目前移动端APP大多采用Native+H5的方式,优点是减少开发成本,加速更新(服务器更新远快于应用更新,后者涉及商店审核),但存在的缺点也很明显:加载H5时过长的白屏时间,极大影响了用户的体验,为了优化H5加载速度,目前有很多策略,其中最重要的一个优化点就是使用离线资源,即拦截webView请求的资源(html、css等),替换成本地的资源。在UIWebView时期,这些都是常规操作,但随着苹果的声明,WKWebView使用离线策略将成为必然。

        iOS14来了,向上兼容喜+1,按规矩向下兼容可以-1了,故而本文方案针对iOS9+。

一. WKWebView加载离线的方案

根据调研,目前的方案大致有两条:

  • 使用NSURLCache
  • 使用NSURLProtocol

        本文主要介绍第二种,因为其与UIWebView的处理方式相近,易于理解,NSURLProtocol的具体使用可以参见[1],大体思路如下:

  • 1.实现并注册自定义的NSURLProtocol,用于拦截webView发起的Request的URL。
  • 2.根据拦截的URL,进行处理,匹配沙盒中存在的离线资源
  • 3.根据拿到的离线资源构造Response,回调给webView

二. 使用NSURLProtocol存在的问题及解决方案

        该方案存在一些比较严重的问题:

  • 1.苹果官方不建议用NSURLProtocol拦截请求,涉及的API都是私有的,无法通过App Store审核。
  • 2.使用NSURLProtocol拦截后,页面发起ajax POST请求会丢失参数。
  • 3.NSURLRequestCachePolicy对NSURLProtocol拦截机制的影响

下面依次分析:

1. 利用反射调用私有API

        关于这点,网上有很多资料讲述了探究之路,下面主要说下需要注意的是,WKWebView 在独立于app进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在WKWebView上直接使用NSURLProtocol无法拦截请求。故而需要在程序启动后,应调用私有API向系统注册全局拦截的Scheme(http、https、file甚至自定义的scheme)。

Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
    // 全局拦截 http 和 https [(id)cls performSelector:sel withObject:@"http"];[(id)cls performSelector:sel withObject:@"https"];
}

        这样做仍然不够,存在审核机扫被苹果发现的风险,可以对敏感词WKBrowsingContextControllerregisterSchemeForCustomProtocol:做个简单加密[2]:

static NSString* const GMCWebOfflineLoaderKey = @"blackfish";
static NSString* const GMCWebOfflineLoaderCls = @"T0M7aWh1bmRqXztoZW1jc28/Z2ZtaWhqZ2Bu";
static NSString* const GMCWebOfflineLoaderSel = @"al1gYGxyYG1PW2BeZF5Eam0/bWttZmZObWpwZ1toYzM=";{
    Class cls = NSClassFromString([GMCWebOfflineLoadManager decodeString:GMCWebOfflineLoaderCls key:GMCWebOfflineLoaderKey]);SEL sel = NSSelectorFromString([GMCWebOfflineLoadManager decodeString:GMCWebOfflineLoaderSel key:GMCWebOfflineLoaderKey]);if ([(id)cls respondsToSelector:sel]) {
    
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"// 把 http 和 https 请求交给 NSURLProtocol 处理[(id)cls performSelector:sel withObject:@"http"];[(id)cls performSelector:sel withObject:@"https"];
#pragma clang diagnostic pop}
}+ (NSString *)decodeString:(NSString *)string key:(NSString *)key
{
    NSData *data = [[NSData alloc]initWithBase64EncodedString:string options:NSDataBase64DecodingIgnoreUnknownCharacters];NSString *deString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];NSMutableString *mutString = [NSMutableString new];for (int i = 0;i < deString.length;i++) {
    int strChar = [deString characterAtIndex:i];int keyChar = [key characterAtIndex:(i % key.length)];int charactor = strChar + (keyChar % 10);[mutString appendString:[NSString stringWithFormat:@"%c",charactor]];}return mutString;
}

2. Ajax POST请求丢失参数处理

        关于这个问题,在网上找到了一篇个人认为比较合适的解决方案[3],但该文章仅介绍了思路:利用开源库Ajax-hook拦截Ajax请求,获取原请求的参数后,下发客户端存储,客户端拦截请求准备发起请求前,取出参数进行装配。

<script src="https://unpkg.com/ajax-hook/dist/ajaxhook.min.js"></script>///这行插入到HTML文件的head标签内
///如下内容插入到body标签内
<script>ah.hook(// hook functions and callbacks of XMLHttpRequest object{
    open: function (arg, xhr) {
    console.log("open called: method:%s,url:%s,async:%s", arg[0], arg[1], arg[2], xhr)//统一添加请求头//arg[1] += "?hook_tag=1";},send: function (arg, xhr) {
    //arg[0] 是获取的参数console.log("send called: %O", arg[0])},// hook attributes of XMLHttpRequest objecttimeout: {
    setter: function (v, xhr) {
    //timeout shouldn't exceed 10sreturn Math.max(v, 1000);}}});
</script>

下面有以下几点需要注意:

2.1 Ajax-hook脚本及hook代码注入的方式及时机

        按照Ajax-hook的推荐,H5页面引入Ajax-hook.js文件,在代码里写hook代码,很明显这是针对前端开发的方式,对于客户端的Native-H5模式肯定是不合适的(仅iOS需要),因此可以考虑向页面注入js脚本的方式。上面文章[3]推荐的植入方式存在严重的缺陷,即在将Response回调给H5前,解析Response的data,向html字符串中植入脚本及hook代码,这样虽然可以实现效果,但解析的过程是有性能损耗的,而且造成H5页面污染(洁癖症),另一种方式就是利用WKWebView提供的API,在页面渲染完毕后(或者WKUserScriptInjectionTimeAtDocumentBegin/End)注入脚本,这便引发了另一个问题:

如果页面在渲染过程中,注入hook脚本前发起Ajax请求怎么办?
实际上,H5页面引入脚本内容的过程是异步的(有些浏览器表现是同步的,但Safari是异步的),所以即使是文章[3]中的策略,也无法保证这个问题。

        所以目前没有发现完美的解决方案,参考腾讯TMF离线的处理,可以提供一套接口用来临时挂起/恢复拦截,经过测试是可行的。

//注销拦截
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"unRegisterSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
    // 全局拦截 http 和 https [(id)cls performSelector:sel withObject:@"http"];[(id)cls performSelector:sel withObject:@"https"];
}
2.2 与客户端同步参数的方式

a.在ah.hooksend函数中下发参数是一个好时机,通过WK的API下发 url和对应的参数。

send: function (arg, xhr) {
    // 这里需要告知客户端存储参数console.log("send called:",xhr,arg);var paramObj = {
    };paramObj.key = xhr._url || xhr.responseURL;paramObj.value = arg[0];console.log('key:'+paramObj.key + 'value:'+paramObj.value);//通过WK的API下发window.webkit.messageHandlers.GMCJSBridgeAjaxHookMessageNative.postMessage(paramObj);},

b.客户端收到后以url为key,参数为value进行存储

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if ([message.body isKindOfClass:[NSDictionary class]]) {
    NSDictionary *dic = [NSDictionary dictionaryWithDictionary:message.body];if ([message.name isEqualToString:GMCJSBridgeAjaxHookMessageNative]) {
    [[GMCWebOfflinePostHandler sharedWebOfflinePostHandler].hookParams setObject:dic[@"value"] forKey:dic[@"key"]];}}}

c.当自定义的NSURLProtocol拦截到URL,调用startLoading时,从存储中取出,装配进Request的body中

- (void)startLoading {
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];//从单例字典中尝试读取参数NSString *bodyShare = [[GMCWebOfflinePostHandler sharedWebOfflinePostHandler].hookParams objectForKey:mutableReqeust.URL.absoluteString];if (bodyShare.length > 0) {
    [mutableReqeust setHTTPBody:[bodyShare dataUsingEncoding: NSUTF8StringEncoding]];}//判断读取离线的逻辑......}

3.NSURLRequestCachePolicy对NSURLProtocol拦截机制的影响

        在实践中发现,自定义NSURLProtocol有时候没有触发拦截,经过研究发现是缓存在作怪:

    NSURLRequest *request = [NSURLRequest requestWithURL:self.webViewURL];

        这样请求,使用的是默认的请求缓存策略,即会缓存Request,从而导致自定义NSURLProtocol无法正常拦截。

/*! @method requestWithURL:@abstract Allocates and initializes an NSURLRequest with the givenURL.@discussion Default values are used for cache policy(NSURLRequestUseProtocolCachePolicy) and timeout interval (60seconds).@param URL The URL for the request.@result A newly-created and autoreleased NSURLRequest instance. */
+ (instancetype)requestWithURL:(NSURL *)URL;

处理这个问题有两种解决方案:

  • 发起请求时,使用NSURLRequestReloadIgnoringLocalAndRemoteCacheData策略
  • 在容器销毁时,清除掉webView缓存
- (void)deleteH5Cache:(nullable void (^)(void))block {
     All kinds of dataif (@available(iOS 9.0, *)) {
    NSSet *websiteDataTypes = [WKWebsiteDataStore allWebsiteDataTypes]; Date fromNSDate *dateFrom = [NSDate dateWithTimeIntervalSince1970:0]; Execute[[WKWebsiteDataStore defaultDataStore] removeDataOfTypes:websiteDataTypes modifiedSince:dateFrom completionHandler:^{
    // Donedispatch_async(dispatch_get_main_queue(), ^{
    if (block) {
    block();}});}];} else {
    // Fallback on earlier versionsNSString *libraryPath = [NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES) lastObject];NSString *cookiesFolderPath = [libraryPath stringByAppendingString:@"/Cookies"];NSError *errors;[[NSFileManager defaultManager] removeItemAtPath:cookiesFolderPath error:&errors];block();}
}

        按照上述问题解决方案,整体方案流程如下图所示。该方案可能存在其他问题,呆后续持续完善。

在这里插入图片描述

三. Demo

待上传

参考

[1] wkwebview离线化加载h5资源解决方案

[2] NSURLProtocol处理WKWebView的http和https的请求

[3] WKWebview在NSURLProtocol中body丢失问题的思考

[4] WKWebView中NSURLProtocol的使用以及对H5的缓存