目录
?
前言
一、离线包是什么?
二、方案调研
NSURLProtocol
WKURLSchemeHandler
三、具体实施
1、离线包的分发
2、服务器对请求接口处理
3、客户端下载离线包
4、webview设置拦截
5、WKURLSchemeHandler处理
四、开发中遇到的问题
1、拦截http、https崩溃问题
2、线程切换导致的The task has already been stopped的崩溃
3、xhr upload的crash
总结
前言
? ? 现在成熟的APP里基本都会用到WebView,在开发中高效的引入 Web 内容实现 Hybrid 应用成为可能。但是,它的弊端也是相当明显,加载一个网页常常白屏一两秒钟。在现如今的网络时代,这是不能忍受的。
? ? ? ? 这个时候离线包加载就应用而生了,它不仅支持了WebView的高灵活性,并且具备了相对来说更快速的页面响应。
一、离线包是什么?
? ? ? ? 离线包技术是利用了从服务器动态下载zip资源包,解压到本地,并拦截WebView网络请求直接读取本地资源进行及时响应的一种技术方案。
二、方案调研
NSURLProtocol
? ? ? ? ?该技术方案是通过拦截所有经过 URL Loading System 的请求,在WebView发出URL Loading System请求的时候就可以被拦截。具体拦截方式如下:
Class cls = [[[WKWebView new] valueForKey:@"browsingContextController"] class];
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
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的子类,来重写
+ (BOOL)canInitWithRequest:(NSURLRequest *)request
方法,因为webView所有网络请求都会走这个方法,所以可以在这里分析所有URL并且将信息传递到其他处理类中但是这里对WKWebView 请求并进行替换,但是,拦截其请求需要使用私有接口,存在兼容性风险以及上架被拒的风险。
? ? ? ?WKWebView是基于webkit来实现的,它是独立于主进程外单独的进程进行的。所有的请求以及一些逻辑走的都是webkit里面的东西,所以他对于网页的加载之之类的操作也不会走系统本省的URL Loading System,这么说来他的请求不能被NSURLProtocol拦截也是理所当然的了。
WKURLSchemeHandler
? ? ? ? ?该方案是iOS11苹果推出来的,负责自定义scheme的网络处理。??:这里说的自定义scheme,所以如果你贸然直接拦截http、https就会导致崩溃。所以,目前市面上或者你看到的的方案中大多都是需要在webview编写的时候,与前端同学协商好特定的scheme,拦截特定的scheme。但是,很多时候我们遇到的场景是软件的前端同学早就做完了,这时候你再去找人家将需要加载资源的scheme设置成你们自定义的scheme,这既增加了人家的工作量,又对前端代码产生入侵。显然极不合理。
? ? ? ? 那么,就没有办法拦截http、https的网络请求了吗?答案是可以的,之所以我们不能拦截http、https的网络请求,正是由于系统的handlesURLScheme:方法进行了处理。我们可以利用runtime技术hook住系统的handlesURLScheme:方法,将该方法返回NO,这样就可以将网络请求交给我们自己的handler处理了。
三、具体实施
1、离线包的分发
? ? ? ? 我们可以将webview对应的资源文件,如:js、css、mp3等打包成一个zip包。并且zip包以对应的功能为命名,如:h5_web_xxx。并生成一个与之对应的信息表。
2、服务器对请求接口处理
- 服务器提供一个网络请求的接口,接口返回对应xxx zip包的信息,如:对应的版本、名字、md5(用来下载后进行校验)等。
- 客户端在启动APP时候,将目前本地所持有的离线包信息作为请求参数,请求第一步的接口。
- 服务器根据客户端参数,判断客户端需要全量更新、增量更新、不用更新。并返回对应更新的地址。
3、客户端下载离线包
? ? ? ? 客户端通过第二步的接口返回值,可以使用 AFNetworking 进行下载,将 zip 格式的离线包保存至沙盒中的/Library/Caches,将zip包的md5与接口返回的md5值进行比对。如果md5比对通过,就使用 SSZipArchive 将离线包解压至 /Documents/hybrid-xxx 目录中。并将本地的离线包信息更新,作为下次请求的参数。如果比对不通过,不进行解压,移除下载的资源包。
? ? ? ?注意:下载完成解压的时候判断如果是全量更新,需要在解压之前将之前下载解压的资源从本地移除。如果是增量更新就直接下载解压到相应的位置。不用更新则不去下载。
4、webview设置拦截
? ? ? ? 在webview初始化时,通过URL中的字段判断是否支持off_line来判断是否进行拦截,同时只能支持iOS11以上系统。
//设置自定义handler来拦截请求
if([self.m_WebUrl containsString:@"off_line"] &&
@available(iOS 11, *)) {
BBURLSchemeHandler *handler = [[BBURLSchemeHandler alloc] init];
[wkConfigure setURLSchemeHandler:handler forURLScheme:@"http"];
[wkConfigure setURLSchemeHandler:handler forURLScheme:@"https"];
}
因为之前也提到了不能直接拦截http、https的scheme。所以需要hook住handlesURLScheme:方法。我们写一个WKWebView的分类来处理
@implementation WKWebView (BBSchemeHandler)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Method originalMethod1 = class_getClassMethod(self, @selector(handlesURLScheme:));
Method swizzledMethod1 = class_getClassMethod(self, @selector(bbhandlesURLScheme:));
method_exchangeImplementations(originalMethod1, swizzledMethod1);
});
}
+ (BOOL)bbhandlesURLScheme:(NSString *)urlScheme {
if ([urlScheme isEqualToString:@"http"] ||
[urlScheme isEqualToString:@"https"]) {
return NO; //这里让返回NO,不走系统断言,走自己的handler处理
} else {
return [self bbhandlesURLScheme:urlScheme];
}
}
@end
5、WKURLSchemeHandler处理
- 将请求的task保存到本地的数组中,管理task
- 通过task拿到对应的网络请求URL
- 通过去掉http:// 、https:// 用之后的路径查找对应的资源文件
- 找到资源文件,封装对应的response,回调给task (移除本地数组对task的管理)
- 找不到对应的本地资源文件,进行网络请求
- 请求完毕后,将请求的reponse和data返回给task(移除本地数组对task的管理)
另外我们需要监听webview的stop回调方法,将task从管理数组中移除。具体代码:
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask{
// 获取系统版本 ios13才特殊处理防止post崩溃
NSString *versionNum = [[UIDevice currentDevice] systemVersion];
if ([versionNum containsString:@"13."]) {
SEL selector = sel_registerName("_setLoadResourcesSerially:");
id webViewClass = NSClassFromString(@"WebView");
if ([webViewClass respondsToSelector:selector]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[webViewClass performSelector:selector withObject:@NO];
#pragma clang diagnostic pop
}
}
[self.hashTable addObject:urlSchemeTask];
NSString *filePath = [self filePath:urlSchemeTask.request];
BOOL resourceExist = [[NSFileManager defaultManager] fileExistsAtPath:filePath];
if (resourceExist && filePath.length > 0) {
NSString *mineType = [self fileMIMETypeWithCAPIAtFilePath:filePath];
NSData *data = [NSData dataWithContentsOfFile:filePath];
[self sendRequestWithUrlSchemeTask:urlSchemeTask mimeType:mineType requestData:data];
} else {
[[[NSURLSession sharedSession] dataTaskWithRequest:urlSchemeTask.request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (!urlSchemeTask) {
return;
}
if ([self.hashTable containsObject:urlSchemeTask]) {
if (error){
[urlSchemeTask didFailWithError:error];
} else {
@try {
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
} @catch (NSException *exception) {
} @finally {
}
}
[self.hashTable removeObject:urlSchemeTask];
}
});
}] resume];
}
}
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask {
if ([self.hashTable containsObject:urlSchemeTask]) {
[self.hashTable removeObject:urlSchemeTask];
}
}
- (void)sendRequestWithUrlSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask
mimeType:(NSString *)mimeType
requestData:(NSData *)requestData API_AVAILABLE(ios(11.0)) {
if ([self.hashTable containsObject:urlSchemeTask]) {
NSData *data = requestData ? requestData : [NSData data];
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:urlSchemeTask.request.URL statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:[self s_getResponseHeaders]];
@try {
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
} @catch (NSException *exception) {
} @finally {
}
}
}
- (NSString *)fileMIMETypeWithCAPIAtFilePath:(NSString *)path {
CFStringRef UTI = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)[path pathExtension], NULL);
CFStringRef MIMEType = UTTypeCopyPreferredTagWithClass (UTI, kUTTagClassMIMEType);
CFRelease(UTI);
if (!MIMEType) {
return @"application/octet-stream";
}
return (__bridge NSString *)(MIMEType);
}
/// response的header
- (NSDictionary *)s_getResponseHeaders {
return @{@"Access-Control-Allow-Origin":@"*",
@"Access-Control-Allow-Headers":@"Content-Type"
};
}
四、开发中遇到的问题
1、拦截http、https崩溃问题
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: ''http' is a URL scheme that WKWebView handles natively'
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: ''https' is a URL scheme that WKWebView handles natively'
如果没有hook系统的handlesURLScheme:系统会进入一个断言导致崩溃。以上已经给出答案,分类hook,返回no即可。
2、线程切换导致的The task has already been stopped的崩溃
出现这个问题是因为线程切换,导致WKURLSchemeTask状态无法及时同步。对一个已经stoped的task发送didReceiveResponse:、didReceiveData:方法。
解决方案:如上我代码中hashTable一个可变数组,当然你也可以采用字典。当task触发
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask
该方法时,将task从数组中移除。或者将字典中task对应的value置为NO。在给task发消息时候,判断task的状态。
3、xhr upload的crash
?这是当时的一个崩溃堆栈,为了解决这个问题也是相当崩溃的。因为查找这个WKURLSchemeHandler的时候找到的都是怎么用,没有说这个问题的。后来不断的看崩溃信息。发现友盟统计的崩溃信息中,只有iOS13系统崩溃,然后找到对应的设备测试,并没有崩溃。后来拿用户的崩溃日志,发现都是调用了一个接口后崩溃的。根据对应的接口找到对应的网页。进入后果然崩溃了。
? ? ? ?在经历了很久的苦恼后,最终在WebKit的issue中找到关键点:WebKit的issue?这里描述的崩溃原因是:当WKWebView创建一个httpBody中的data时,会调用WebCore::blobRegistry 方法,但返回的是一个空指针,从而导致了崩溃。
? ? ? 并且在stackoverflow中也找到了对应的问题stackoverflow。
? ? ?解决方法就是我代码中的
// 获取系统版本 ios13才特殊处理防止post崩溃
NSString *versionNum = [[UIDevice currentDevice] systemVersion];
if ([versionNum containsString:@"13."]) {
SEL selector = sel_registerName("_setLoadResourcesSerially:");
id webViewClass = NSClassFromString(@"WebView");
if ([webViewClass respondsToSelector:selector]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[webViewClass performSelector:selector withObject:@NO];
#pragma clang diagnostic pop
}
}
总结
WKWebView是一个独立进程,对于开发者来说,WKWebView相当于一个黑盒。所以,在做这个功能的时候一定要留好判断条件,如果发生问题,及时关闭离线拦截,及时止损。
|