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

你可能感兴趣的:(WKWebView离线方案)