WKWebView离线方案

一、背景

由于公司业务大部分使用的H5去实现,而H5页面对网络的依赖也是比较大的。近期公司又提出需要界面秒开需求,自然而然的需要对页面进行离线化处理。

本文按步骤从 更新-下载-合并-解压-使用 五步来写加上一点坑。

二、更新

2.1 更新接口前

1.viewWillAppear:方法中检查更新
2.五分钟间隔
3.检查本地是否存在离线包
4.记录的版本号和本地版本对比,不相等对本地版本删除,重置本地版本,并重新下载罪行离线包
5.对当前下载状态的判断,暂停/下载中继续当前的任务

****5分钟时间内判断
+ (BOOL)withinFiveMinutes {
    NSDate* date = [NSDate dateWithTimeIntervalSinceNow:0];
    NSTimeInterval nowTime = [date timeIntervalSince1970];
    NSLog(@"🎋离线:当前时间:%.2f 时差:%.2f分钟",nowTime,(nowTime - beforeTime)/60);
    if ((nowTime - beforeTime)>=5*60) {
        beforeTime = nowTime;
        return NO;
    }else{
        return YES;
    }
}

2.1 更新接口后

首先得确定服务需要返回哪些数据用于APP端用,这里是我们这边约定的数据。

1.当前版本<最新版本 更新
2.判断当前网络是否下载

***服务返回数据
NSString *updatePackageVersion;//新版本
NSString *updatePackageUrl;//下载包地址
NSString *miniVersion;//最低允许使用版本(离线包版本)
CGFloat networkType;//什么网络下更新
NSInteger status;//未使用
BOOL isPatch;//是否差分包
NSArray *whitelist;//拦截白名单

BOOL needCheckUpdata;//是否需要检查更新(非服务返回,用语下载到解压状态的记录)

三、下载

下载我这边直接使用的一个下载组件,大概使用如下代码。

下载路径是:/var/mobile/Containers/Data/Application/B4D917B3-687B-4030-98A5-B33C35FF2594/Library/Caches/OfflineH5

//开始下载当前离线包
[downloadManager download:curOfflineModel.updatePackageUrl progress:^(NSInteger thisTimeWrittenSize, NSInteger totlalReceivedSize, NSInteger TotalExpectedSize) {

  NSLog(@"🎋离线:离线包大小 %ld 已下载数进度:%.2f",TotalExpectedSize,totlalReceivedSize*1.0/TotalExpectedSize*1.0);
  dispatch_async(dispatch_get_main_queue(), ^{
  [WMHUDUntil showMessageToWindow:[NSString stringWithFormat:@"已下载数进度:%.2f%%",totlalReceivedSize*1.0/TotalExpectedSize*1.0 * 100]];
                        });
                         
  if (totlalReceivedSize*1.0/TotalExpectedSize*1.0==1) {
  NSLog(@"🎋离线:下载完成=====!!!");
  dispatch_async(dispatch_get_main_queue(), ^{
            [WMHUDUntil showMessageToWindow:@"下载完成=====!!!"];
  });
                            
    //子线程进行解压
     dispatch_queue_t zipQueue = 
     dispatch_queue_create("zipQueue", DISPATCH_QUEUE_SERIAL);
    dispatch_async(zipQueue, ^{
    [self mergeZipAndUnZipFileIsPatch:curOfflineModel.isPatch];
      });
    }
  } state:^(WMDownloadState state, NSString * _Nullable filePath, NSError * _Nullable error) {
                     
}];

四、合并

按照我们的需求内容我们下载的内容分为差分包或全量包,和服务约定本地有版本并且与最新版本3个以内下载差分包否者下载全量包。

既然下载的差分包的话就需要与之前的离线包合并,我们这边使用的bsdiff工具。这个工具需要三端统一使用,保证规则一致。

bsdiff简单介绍

bsdiff是一种二级制差分工具,由bsdiff与bspatch组成, 将oldfile与newfile做二进制数据差分(bsdiff操作),得到更新的部分(patch文件),再与oldfile进行合成(bspatch操作)。
详细介绍和使用请看文末链接。

我这边使用代码

    //差量包合并操作
    const char *argv[4];
    argv[0] = "bspatch_error";
    NSString *oldName = [NSString stringWithFormat:@"/%@.zip",loctV];
    argv[1] = [[kOfflineH5Path stringByAppendingPathComponent:oldName] UTF8String];//@"老包名称.zip"
    NSString *newName = [NSString stringWithFormat:@"/%@.zip",curOfflineModel.updatePackageVersion];
    argv[2] = [[kOfflineH5Path stringByAppendingPathComponent:newName] UTF8String];//@"合并后包名称.zip"
    argv[3] = [path UTF8String];//@"增量包.zip"
    int result = BsdiffUntils_bspatch2(4, argv);

在app端我们只需下载差分包后合并差分包,所以不需要生成差分包的部分。

坑:由于这个是一个存c语言写的工具,在XCode中无法正常捕捉移除,所以导致差分包一有问题APP就直接crash。这个问题在文章7.4上写到处理方法。

五、解压

解压工具也是直接使用目前github上最为常用的SSZipArchive使用起来也是非常的简单。

    NSLog(@"🎋离线:----- 解压开始 ------");
    [SSZipArchive unzipFileAtPath:fromPath toDestination:destinationPath progressHandler:^(NSString * _Nonnull entry, unz_file_info zipInfo, long entryNumber, long total) {
        
    } completionHandler:^(NSString * _Nonnull path, BOOL succeeded, NSError * _Nullable error) {
        NSLog(@"🎋离线:path = %@,succeeded = %d",path,succeeded);
        NSLog(@"🎋离线:----- 解压完成 ------");
        if (succeeded) {
            NSArray *pathArray = [self getContentsOfDirectoryAtPath:destinationPath];
            NSLog(@"🎋离线:解压成功 %@",pathArray);
            [WMOfflineH5Cache setDiskOfflineModel:curOfflineModel];
            dispatch_async(dispatch_get_main_queue(), ^{
                [WMHUDUntil showMessageToWindow:[NSString stringWithFormat:@"解压成功  \n 本地文件 %@",pathArray]];
            });            
        }else{
            [self renameWithPath:fromPath];
            NSLog(@"🎋离线:解压失败 error = %@",error);
            dispatch_async(dispatch_get_main_queue(), ^{
                [WMHUDUntil showMessageToWindow:[NSString stringWithFormat:@"解压失败"]];
            });            
        }
    }];

解压前删除下老文件,解压后可以打印下最后文件夹中的文件查看是否是自己想要的文件不就可以。

到这里本地离线包算是准备完成。

六、使用

使用一个难点,在开发过程中也是尝试各种方法。
由于我们APP使用的是WKWebview所以第一反应没有考虑使用同UIWebview那样使用NSURLProtocol,但是当尝试各种方法和综合考虑后还是用NSURLProtocol,并一个个的攻克难题。

下面简单的结束网上常用的几种方案和对应的问题。

方案一

获取沙盒html路径直接通过file协议加载index.html
通过方法加载

- (nullable WKNavigation *)loadFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL API_AVAILABLE(macos(10.11), ios(9.0));

优点:简单
缺点:跨域问题、路径安全性问题、file://协议引起的接口问题、其他安全性问题

我这边主要是遇到加载页面资源时所有的http/https协议都变成了file协议包括请求接口,然后看到网上的一些其他问题就没有继续下去

方案二

使用NSURLProtocol拦截(最后使用的方案)

优点:UIWebview已经能实现,并有完善的文档资料,对前端无侵入性
缺点:WKWebview不支持拦截,需要使用私有方法、POST请求丢失body问题,全局拦截

看上面缺点其实问题也是很多的,但关键点在于这个方案确实能行并且网上参考文档较多,后续通过一个个的攻克算是把功能给实现了

方案三

WKURLSchemeHandler
这个算是官方后续提供的一个比较好的方案。

优点:官方提供的比较完善的方案,使用起来也比较简单
缺点:仅支持iOS11后,对H5侵入性比较大,安卓iOS差异化比较大。

放弃这个方案的主要原因还是iOS11后能使用,H5方不接受,并且在重选方案时安卓已经做了不少工作,让迁就使用这个也不好

方案四

起本地服务器加载离线资源
一个比较高大上的方案,并且涉及知识点比较多。

优点:可能能实现功能吧、无侵入性
缺点:文档不全面、对性能管理要求较高、复杂不知道些未知问题

还是感觉太复杂了并且没有完整的可实现的例子,不敢花大量时间去试错

七、坑

7.1 关于NSURLProtocol使用

因为最终决定使用NSURLProtocol去拦截请求,所以就得解决WKWebview不能拦截问题,也就是使用私有方法去实现。

这里还需注意一点,注册使用NSURLProtocol后,不需要拦截时要相应的注销NSURLProtocol,不然全局的POST请求还是不起作用。

- (void)requestInterceptorToOpen:(BOOL)toOpen {
    if (toOpen) {
        if (!_protocolSuccess) {
            Class cls = NSClassFromString([self decodeString:@"V0tCcm93c2luZ0NvbnRleHRDb250cm9sbGVy"]);
            SEL sel = NSSelectorFromString([self decodeString:@"cmVnaXN0ZXJTY2hlbWVGb3JDdXN0b21Qcm90b2NvbDo="]);
                
            if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
                    // 注册http(s) scheme, 把 http和https请求交给 NSURLProtocol处理
                [(id)cls performSelector:sel withObject:@"http"];
                [(id)cls performSelector:sel withObject:@"https"];
#pragma clang diagnostic pop
            }
            _protocolSuccess = [NSURLProtocol registerClass:[WMOfflineURLProtocol class]];
        }
    }else{
        if (_protocolSuccess) {
            Class cls = NSClassFromString([self decodeString:@"V0tCcm93c2luZ0NvbnRleHRDb250cm9sbGVy"]);
            SEL sel = NSSelectorFromString([self decodeString:@"dW5yZWdpc3RlclNjaGVtZUZvckN1c3RvbVByb3RvY29sOg=="]);
                        
            if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
                // 注册http(s) scheme, 把 http和https请求交给 NSURLProtocol处理
                [(id)cls performSelector:sel withObject:@"http"];
                [(id)cls performSelector:sel withObject:@"https"];
#pragma clang diagnostic pop
            }
            [NSURLProtocol unregisterClass:[WMOfflineURLProtocol class]];
            _protocolSuccess = NO;
        }
    }
}

7.2 关于私有方法

从上面的代码看到对私有方法进行了混淆,就是通过base64编码,再解码,不出现私有方法的明文处理。

7.3 关于POST请求

使用NSURLProtocol拦截会导致POST请求body的丢失,也在网上查了不少方案,最后还是通过与H5沟通让他们把本地化的页面(被拦截的)中POST的body放在Header里面,APP中再对POST请求拼上body重新发送请求。
部分代码

//canInitWithRequest 简单的说是请求的入口,所有的请求都会先进入到这里,如果希望拦截下来自己处理,那么就返回YES,否则就返回NO。
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    
    NSURL *url = request.URL;
    NSString *scheme = [url scheme];
    
    if ( ([scheme caseInsensitiveCompare:@"http"] == NSOrderedSame ||
          [scheme caseInsensitiveCompare:@"https"] == NSOrderedSame))
    {
        NSString *requestUrl = url.absoluteString;
        NSString *loctUrl = [WMOfflineH5Manager canUseOfflineH5WithUrl:url];
        NSString *loctV = [WMOfflineH5Manager getLocOfflineVersion];
        OfflineModel *locOfflineModel = [WMOfflineH5Cache getDiskOfflineModel];
        
        NSLog(@"🎋竹叶获取:%@->%@ \n %@",loctV,locOfflineModel.miniVersion,url);
        
        //离线化操作
        if ((!stringIsEmpty(loctUrl)&&
            (([loctV compare:locOfflineModel.miniVersion options:NSNumericSearch] == NSOrderedDescending)||
             ([loctV compare:locOfflineModel.miniVersion options:NSNumericSearch] == NSOrderedSame)))||[request.HTTPMethod isEqualToString:@"POST"]) {
                NSLog(@"🎋被拦截的URL:\n%@\n%@",requestUrl,loctUrl);
                dispatch_async(dispatch_get_main_queue(), ^{
                    [WMHUDUntil showMessageToWindow:[NSString stringWithFormat:@"被拦截的URL:\n%@",requestUrl]];
                });
                return [NSURLProtocol propertyForKey:FilteredKey inRequest:request] == nil;
        }
    }
    return NO;
}
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    if ([request.HTTPMethod isEqualToString:@"POST"]) {
       request = [[self handlePostRequestBodyWithRequest:[request mutableCopy]] copy];
    }
    return request;
}
#pragma mark 处理POST请求相关POST  用HTTPBodyStream来处理BODY体
+ (NSMutableURLRequest *)handlePostRequestBodyWithRequest:(NSMutableURLRequest *)request {
    NSMutableURLRequest * req = [request mutableCopy];
    if ([request.HTTPMethod isEqualToString:@"POST"]) {
        if (!request.HTTPBody) {
            NSString *bodyString = [request.allHTTPHeaderFields objectForKey:@"x-weimai-h5body"];
            if (stringIsEmpty(bodyString)) {
                NSData *jsonData = [bodyString dataUsingEncoding:NSUTF8StringEncoding];
                NSDictionary *jsonDic = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers error:nil];
                NSData *bodyData = [NSJSONSerialization dataWithJSONObject:jsonDic options:NSJSONWritingPrettyPrinted error:nil];
                req.HTTPBody = bodyData;
            }else{
                req.HTTPBody = nil;
            }
        }
    }
    return req;
}

//当需要自己处理该请求的时候,startLoading便会被调起,在这里你可以处理自己的逻辑。
- (void)startLoading {
    
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    
    //标记该请求已经处理
    [NSURLProtocol setProperty:@YES forKey:FilteredKey inRequest:mutableReqeust];
    
    NSString *filePath = [WMOfflineH5Manager canUseOfflineH5WithUrl:mutableReqeust.URL];
    if (!stringIsEmpty(filePath)) {
        NSFileHandle *file = [NSFileHandle fileHandleForReadingAtPath:filePath];
        NSData *data = [file readDataToEndOfFile];
        NSLog(@"Got data = %@", data);
        [file closeFile];
        
        NSString *mimeType = AFContentTypeForPathExtension([filePath pathExtension]);
        
        NSURLResponse *response = [[NSURLResponse alloc] initWithURL:mutableReqeust.URL
                                                            MIMEType:mimeType
                                               expectedContentLength:data.length
                                                    textEncodingName:nil];
        [self.client URLProtocol:self
              didReceiveResponse:response
              cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        
        [self.client URLProtocol:self didLoadData:data];
        [self.client URLProtocolDidFinishLoading:self];
    }else{
        ///其中mutableReqeust是处理过的请求
        NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
        NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];
        NSURLSessionDataTask *task = [session dataTaskWithRequest:mutableReqeust];
        [task resume];
    }
}

7.4 关于bsdiff方法crash处理

上文说到,bsdiff是C语言写的无法捕捉异常,所以修改一些东西。这边处理方法:
通过修改err和errx->warn和warnx方法错误改成警告。
保证不crash,并且错误返回对应的值。

八、全流程图

最后提供一张较为完整的思维图。


离线化iOS图片.png

九、总结

本文主要提供一个可以实现的思路,并没有给出完整的细节的实现代码,希望对一些需要的朋友提供帮助,也是自己的一点总结。<功能已上线>
谢谢阅读!

十、更新

1.在7.3坑中说到“POST的body放在Header里面”这里得注意一点,body放Header中需要编码下,因为http请求Header不支持中文,否者会导致一直请求过程。在iOS中取出值进行下反编码,最好将添加的Header字段删除再转发。
19-12-11:上面处理对于普通的post请求没什么问题,但对于数据上传会导致接口直接不调用,而目前无法保证离线拦截界面不使用数据上传功能,所以需要再次改造。

十一、参考文档

关于文件操作:https://www.jianshu.com/p/086ca6d2c5de
关于差分包合并:https://www.jianshu.com/p/3c58760079d9
关于离线包使用方案:https://www.jianshu.com/p/efb4f93b10de

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,524评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,869评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,813评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,210评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,085评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,117评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,533评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,219评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,487评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,582评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,362评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,218评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,589评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,899评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,176评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,503评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,707评论 2 335

推荐阅读更多精彩内容