iOS离线包计划调研
为啥运用离线包
离线包相关于混杂来说是一种更安稳的过审计划, 其首要优势如下
传统的 H5 技能容易受到网络环境影响,因而降低 H5 页面的功用。经过运用离线包,能够处理该问题,一起保存 H5 的优点。
离线包 是将包含 HTML、JavaScript、CSS 等页面内静态资源打包到一个压缩包内。预先下载该离线包到本地,然后经过客户端翻开,直接从本地加载离线包,然后最大程度地脱节网络环境对 H5 页面的影响。
完结动态更新:在推出新版本或是紧急发布的时分,能够把修正的资源放入离线包,经过更新装备让应用主动下载更新。因而, 无需经过应用商铺审阅,就能让用户及早接纳更新
离线包的计划选择
现在干流的离线包的恳求阻拦计划有两种:
- 经过NSURLProtocol完结, 注册scheme阻拦
- WKURLSchemeHandler完结, 自界说sheme阻拦
WKURLSchemeHandler
WKURLSchemeHandler
是 WebKit
结构中的一个类,用于处理自界说的 URL 协议。WebKit
是一个供给网页烘托和阅读功用的结构,它首要用于创立阅读器和网页视图等, 其间WKURLSchemeHandler
是iOS11之后的API, 其大致完结如下.
WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
//其间CustomSchemeHandler需求完结WKURLSchemeHandler协议
CustomSchemeHandler *handler = [[CustomSchemeHandler alloc] init];
NSString *scheme = "yourscheme";
NSString *schemes = "yourschemes"
//http + https
[configuration setURLSchemeHandler:handler forURLScheme:scheme];
[configuration setURLSchemeHandler:handler forURLScheme:schemes];
_webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:configuration];
...
//恳求时只需scheme共同, 就能被CustomSchemeHandler阻拦
NSString *requestURL = @"yourscheme://{resourcePath}"
NSURLRequest *request = [NSURLRequest requestWithURL:requestURL];
[self.webView loadRequest:request];
用户需求自界说scheme,拜访时域名大概customScheme://{packageId}/page
,需求自界说scheme, 能够针对单个的网页进行阻拦,粒度较细.
NSURLProtocol
NSURLProtocol
是 Foundation
结构中的一个抽象类,它供给了一个根本的结构来完结自界说的 URL 协议。经过承继 NSURLProtocol
类,你能够界说自己的 URL 协议,并在应用程序中运用该协议来进行网络恳求
而mpaas中运用的就是这种计划,相关于WKURLSchemeHandler
能够供给虚拟域名的支持,mpass的文档阐明如下:
总体上来说2种计划完结思路是共同的,API的相似度很高,可是在前端处理的细节上会有些区别 ,下面我模仿mpaas的办法,完结简略的离线包
NSURLPotocol目标注册
WKWebView并没有供给公开注册NSURLProtocol的办法,可是依据Apple的WebKit开源项目中的测验代码,能够得知运用私有api完结这已功用
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
[(id)cls performSelector:sel withObject:@"http"];
[(id)cls performSelector:sel withObject:@"https"];
其间WKBrowsingContextController
和registerSchemeForCustomProtocol
都是私有的,上线时需求进行混杂.
这样WKWebView中所有的http/https的恳求都会被注册的NSURLPotocol子类目标所阻拦,比方
//OfflinePackageURLProtocol担任离线资源的加载
[NSURLProtocol registerClass:[OfflinePackageURLProtocol class]];
//KKJSBridgeAjaxURLProtocol担任从头拼装来自ajax的恳求
[NSURLProtocol registerClass:[KKJSBridgeAjaxURLProtocol class]];
其间OfflinePackageURLProtocol
,和KKJSBridgeAjaxURLProtocol
都需求承继NSURLProtocol
. 其间心办法如下
// 该办法用于判别指定的网络恳求是否能够由自界说的 URL 协议处理。假如该办法回来 YES,则表示该恳求能够由该协议处理;假如回来 NO,则表示该恳求不能由该协议处理
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
// 该办法用于发动网络恳求。在该办法中,能够完结自界说的网络传输逻辑,来处理网络恳求并回来响应。
- (void)startLoading;
// 该办法用于停止网络恳求。在该办法中,能够完结整理逻辑,来释放与恳求相关的资源。
- (void)stopLoading;
NSURLProtocol
能够注册多个子类,而且阻拦的顺序是后注册的先阻拦, 被阻拦的恳求,会从头由新创立的Session来办理,因而不会持续被后续的Protocol处理. 所以KKJSBridgeAjaxURLProtocol
需求阻拦ajaxhook之后的恳求从头拼装body, 它的注册有必要在OfflinePackageURLProtocol
之后
此外, 运用NSURLProtocol
的registerClass
办法会污染[NSURLSession sharedSession]
目标. 通常向NSURLSession
中注册NSURLProtocol
的办法如下
// 创立一个默认的 session configuration 目标
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
// 为 session configuration 目标增加自界说的 NSURLProtocol 子类
config.protocolClasses = @[[MyURLProtocol class]];
// 创立 NSURLSession 目标
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
运用NSURLProtocol
的registerClass
会主动完结这一进程,然后导致所有的[NSURLSession sharedSession]
办理的恳求都会被阻拦, 除了功率问题以外,还会可能会导致回调失效(因为通常在阻拦中会构建新的Session和Task目标)等问题, 因而假如是Native的恳求, 咱们需求防止运用[NSURLSession sharedSession]
防止造成不必要的麻烦.
离线资源的加载的完结
离线包解压之后的沙盒目录如下
其间zipFiles
中为打包的离线包资源, 能够经过内嵌或许下载的办法,保存到沙盒中,unzipFiles
为解压之后的目录.其文件夹名即为packageId/version
, 这样关于同一个离线包来说能够经过版原本进行升级或许版本回退. 其间static
中有一些不变化的资源后续能够独自拿出来,作为一个资源包内嵌或许下发到app中削减网路流量.
//模仿Mpaas的api, 离线包控制器经过packageId初始化
OfflinePackageController *vc = [OfflinePackageController controllerWithPackageId:@"6"];
[self.navigationController pushViewController:vc animated:YES];
// packageId映射为url再经过URLProtocol阻拦
- (instancetype)initWithPackageId:(NSString *)packageId{
if (self = [super init]) {
_url = [NSString stringWithFormat:@"https://%@.package",packageId];
[self commonInit];
}
return self;
}
其间url
就是所谓的虚拟域名, 在本比方中,packageId为app, 虚拟域名为https://app.package
,这样在拜访内部资源时,比方主页途径为https://app.package/index.html
OfflinePackageURLProtocol
的中心代码
- (void)startLoading{
NSURLRequest *originRequest = self.request;
NSMutableURLRequest *mutableReqeust = [originRequest mutableCopy];
// 标示改request已经处理过了,防止无限循环
[NSURLProtocol setProperty:@YES forKey:kOfflinePackageDidHandleRequest inRequest:mutableReqeust];
if ([self.request.URL.host containsString:@".package"]) {
//本地
NSString *packageId = [self.request.URL.host componentsSeparatedByString:@"."][0];
NSString *relativePath;
if (self.request.URL.pathExtension.length > 0) {
relativePath = self.request.URL.relativePath;
}else{
if([self.request.URL.lastPathComponent isEqualToString:@"/"]){
relativePath = @"index.html";
}else{
relativePath = [NSString stringWithFormat:@"%@.html",self.request.URL.lastPathComponent];
}
}
//依据离线包id 版本号 来定位离线包资源
NSString *version = [PackageManager currentVersionOfPackage:packageId];
NSString *filePath = [@[
NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0],
@"unzipFiles",
packageId,
version,
relativePath
] componentsJoinedByString:@"/"];
NSData *data = [NSData dataWithContentsOfFile:filePath];;
NSURLResponse *res = [[NSURLResponse alloc] initWithURL:self.request.URL MIMEType:[self getMimeTypeWithFilePath:filePath] expectedContentLength:data.length textEncodingName:nil];
[self.client URLProtocol:self didReceiveResponse:res cacheStoragePolicy:NSURLCacheStorageNotAllowed];
[self.client URLProtocol:self didLoadData:data];
[self.client URLProtocolDidFinishLoading:self];
}else{
//非离线包资源,结构task,持续建议恳求.
NSURLSessionConfiguration *configure = [NSURLSessionConfiguration defaultSessionConfiguration];
self.session = [NSURLSession sessionWithConfiguration:configure delegate:self delegateQueue:self.queue];
self.task = [self.session dataTaskWithRequest:mutableReqeust];
[self.task resume];
}
经过host
判别是否离线包资源, 假如是离线包资源,结构NSURLResponse
,经过client
目标回来.假如不是离线包资源, 从头结构Task并进行恳求.
经过上述代码可知, 关于非离线包的资源, 咱们应该尽量在canInitWithRequest
中提早判别协议不可用,不然假如进入startLoading
接收后, 因为NSURLSession和Task
目标是从头结构的,会导致一些问题, 比方前端无法获取网络恳求的进度.
NSURLProtocol采坑
NSURLProtocol阻拦Post恳求Body丢掉的问题
上述计划存在一个问题, 服务端承受的ajax的post恳求body为空,在URLProtocol中断点能够得知, NSURLProtocol阻拦Post恳求后会将参数清空.
这个问题的发生首要是因为
WKWebView
的网络恳求的进程与APP不是同一个进程,所以网络恳求的进程是这样的: 由APP地点的进程建议request,然后经过IPC通讯(进程间通讯)将恳求的相关信息(恳求头、恳求行、恳求体等)传递给webkit
网络线进程接纳包装,进行数据的HTTP恳求,终究再进行IPC的通讯回传给APP地点的进程的。这里假如建议的request
恳求是post恳求
的话,因为要进行IPC数据传递,传递的恳求体body中依据系统调度,将其舍弃,终究在WKWebView
网络进程承受的时分恳求体body中的内容变成了空,导致此种状况下的服务器获取不到恳求体,导致问题的发生。
所以这里的处理思路就是想办法将WebView的Post恳求的body传递到native端保存, 然后恳求时从头结构参数. 依据这个思路现在有2种处理计划
-
将body放到恳求Header中, 然后从头结构.
因为Header的长度限制, 这种计划不适合文件传输.
-
经过JSAPI将参数发送到native端保存.
这种计划通用性更高, 需求完结body的缓存.
现在选用第二种计划,具体步骤分为2步
-
js中注入ajax hook, 对open和send进行hook.
-
open中生新的成带符号的URL,一起 ,用于native端区别哪些恳求是ajax宣布的比方
http://192.168.33.39:8000/testpost?KKJSBridge-RequestId=166986530337824940
,其间KKJSBridge
用来符号这个恳求是由ajax宣布的,RequestId
用来区别恳求,进行body的匹配. -
send中担任将依据body类型进行编码, 并将body发送到native端.
中心代码如下
var originOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url, async, username, password) { var args = [].slice.call(arguments); var xhr = this; // 生成仅有恳求id xhr.requestId = _KKJSBridgeXHR.generateXHRRequestId(); xhr.requestUrl = url; xhr.requestHref = document.location.href; xhr.requestMethod = method; xhr.requestAsync = async; if (_KKJSBridgeXHR.isNonNormalHttpRequest(url, method)) { // 假如是非正常恳求,则调用原始 open return originOpen.apply(xhr, args); } if (!window.KKJSBridgeConfig.ajaxHook) { // 假如没有敞开 ajax hook,则调用原始 open return originOpen.apply(xhr, args); } // 生成新的 url args[1] = _KKJSBridgeXHR.generateNewUrlWithRequestId(url, xhr.requestId); originOpen.apply(xhr, args); }; var originSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (body) { var args = [].slice.call(arguments); var xhr = this; var request = { requestId: xhr.requestId, requestHref: xhr.requestHref, requestUrl: xhr.requestUrl, bodyType: "String", value: null }; if (_KKJSBridgeXHR.isNonNormalHttpRequest(xhr.requestUrl, xhr.requestMethod)) { // 假如是非正常恳求,则调用原始 send return originSend.apply(xhr, args); } if (!window.KKJSBridgeConfig.ajaxHook) { // 假如没有敞开 ajax hook,则调用原始 send return originSend.apply(xhr, args); } if (!body) { // 没有 body,调用原始 send return originSend.apply(xhr, args); } else if (body instanceof ArrayBuffer) { // 阐明是 ArrayBuffer,转成 base64 request.bodyType = "ArrayBuffer"; request.value = KKJSBridgeUtil.convertArrayBufferToBase64(body); } else if (body instanceof Blob) { // 阐明是 Blob,转成 base64 request.bodyType = "Blob"; var fileReader = new FileReader(); fileReader.onload = function (ev) { var base64 = ev.target.result; request.value = base64; _KKJSBridgeXHR.sendBodyToNativeForCache("AJAX", xhr, originSend, args, request); }; fileReader.readAsDataURL(body); return; } else if (body instanceof FormData) { // 阐明是表单 request.bodyType = "FormData"; request.formEnctype = "multipart/form-data"; KKJSBridgeUtil.convertFormDataToJson(body, function (json) { request.value = json; _KKJSBridgeXHR.sendBodyToNativeForCache("AJAX", xhr, originSend, args, request); }); return; } else { // 阐明是字符串或许json request.bodyType = "String"; request.value = body; } // 发送到 native 缓存起来 _KKJSBridgeXHR.sendBodyToNativeForCache("AJAX", xhr, originSend, args, request, xhr.requestAsync); };
-
-
native端结构URLProtocol,对ajax宣布的恳求进行阻拦,解析得到RequestId, 依据RequestId获取并拼装body.大致逻辑如下
- (void)startLoading { NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy]; NSString *requestId; if ([mutableReqeust.URL.absoluteString containsString:kRequestId]) { requestId = [self getRequestId:mutableReqeust.URL.absoluteString]; } self.requestId = requestId; self.requestHTTPMethod = mutableReqeust.HTTPMethod; NSArray *bodySupportMethods = @[@"POST",@"PUT"]; if (mutableReqeust.HTTPMethod.length > 0 && [bodySupportMethods containsObject:mutableReqeust.HTTPMethod]) { NSDictionary *body = [self getBodyFromRequestId:requestId]; if (body) { // 从把缓存的 body 设置给 request [self setBody:bodyReqeust forRequest:mutableReqeust]; } } NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil]; self.customTask = [session dataTaskWithRequest:mutableReqeust]; [self.customTask resume]; }
Cookie同步问题
Cookie的分类
WKWebView的Cookie办理一直是比较令人头疼的问题.这是因为客户端,服务端和前端都能进行Cookie的操作,其间Session Cookie是不需求耐久化的, 由WKWebView对应的WKProcessPool
进行办理,关于需求耐久化的Cookie来说,不同端操作的Cookie目标是分隔保存的,如下图
iOS 沙盒目录/Library/Cookies
中存储的2种Cookie, 其间 {appid}.binarycookies
是NSHTTPCookieStorage
的文件目标, Cookies.binarycookies
是WKWebView的Cookie目标,经过WKHTTPCookieStore
进行办理.
WKHTTPCookieStore
和NSHTTPCookieStorage
是iOS中两个用于办理Cookie的类。它们都供给了相似的功用,如存储、删去、更新和查找Cookie,但它们在完结办法和运用场景上有所不同。
-
WKHTTPCookieStore
是WebKit结构中供给的类,首要用于办理WebView的Cookie。它供给了一系列办法,能够在WebView加载恳求时,主动增加、删去或修正Cookie,以便在WebView中坚持用户的登录状态和个性化设置。WKHTTPCookieStore的运用办法和WebView相关,只能在WebView的署理办法或JavaScript脚本中调用,不能在其他当地运用。具体来说, 由前端建议的网络恳求,经过服务端Set-Cookie
发生的Cookie 和 JavaScript中经过document.cookie
设置的Cookie 由WKHTTPCookieStore
进行办理, 由前端建议的网络恳求, 会带着WKHTTPCookieStore
中办理的耐久化Cookie 和WKProcessPool
办理的Session Cookie. -
NSHTTPCookieStorage
是Foundation结构中供给的类,首要用于办理网络恳求的Cookie。它供给了一系列静态办法,能够在发送网络恳求时,主动增加、删去或修正Cookie,以便在网络传输中坚持用户的登录状态和个性化设置。NSHTTPCookieStorage
的运用办法和网络恳求相关,只能在NSURLSession
、AFNetworking
等网络库的办法中调用,不能在其他当地运用。具体来说: 除了客户端经过NSHTTPCookieStorage
进行的Cookie操作以外, 由客户端建议的网络恳求,经过服务端Set-Cookie
发生的Cookie也交由NSHTTPCookieStorage
办理, 由客户端建议的网络恳求,会带着NSHTTPCookieStorage
中办理的Cookie
在运用离线包时, 因为咱们运用了URLProtocol进行阻拦, 相当于将前端的恳求转发到客户端进行处理,为了防止Cookie运用的混乱,咱们要统一运用NSHTTPCookieStorage
来进行Cookie的办理
, 所有的前端Cookie需求同步到客户端中, 一起前端建议的恳求需求从NSHTTPCookieStorage
同步Cookie
需求处理的场景
-
WKWebView的cookie同步到
NSHTTPCookieStorage
, 这里有3种状况-
场景1: 跳转,包含webview LoadRequest, 前端a标签, 服务端的重定向 都看做是跳转, 选择
WKNavigationDelegate
中 承受响应后,跳转之前的署理办法decidePolicyForNavigationResponse
中进行同步- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler { WKHTTPCookieStore *cookieStroe = webView.configuration.websiteDataStore.httpCookieStore; [cookieStroe getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) { for (NSHTTPCookie *cookie in cookies) { [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie]; } }]; decisionHandler(WKNavigationResponsePolicyAllow); };
-
场景2: Js中由
document.cookie
设置, 同过hook cookie的set办法, 将音讯转发到native端处理.//这里需求注意虽然在阅读器环境中能够只是经过name,value来创立Cookie 可是在Native端创立Cookie时并无webView作为上下文, 因而有必要声明domain和path特点,不然无法创立成功,以下5个字段是有必要设置的. NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:@{ NSHTTPCookieName:@"cookie_form_user", NSHTTPCookieValue:@"1", NSHTTPCookieDomain:[NSString stringWithFormat:@"%@.package",appId], NSHTTPCookieExpires:[NSDate dateWithTimeIntervalSinceNow:86400], NSHTTPCookiePath: @"/", }]; [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
-
场景3: 这个状况比较特别, 假如前端进行的AjaxHook, 将恳求转发到客户端署理,这时经过服务端
Set-Cookie
发生的Cookie将不会进行任何存储.在一些场景下会发生问题,比方 假如用户的登录是经过前端登录的,一起又进行了AjaxHook, 是无法经过Cookie保存信息的, 这里咱们需求手动保存Cookie来防止该场景发生. 因为Ajax恳求中服务端Set-Cookie
, 该恳求会被客户端接收, 能够在客户端恳求完结的回调中独自处理, 比方//客户端的恳求回调中处理Server Set-Cookie [self.sessionManager dataTaskWithRequest:request uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse * _Nonnull response, id _Nullable responseObject, NSError * _Nullable error) { if ([response isKindOfClass:[NSHTTPURLResponse class]]) { NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:[httpResponse allHeaderFields] forURL:httpResponse.URL]; for (NSHTTPCookie *cookie in cookies) { [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie]; } ... }];
-
-
WKWebView恳求时,NSHTTPCookieStorage中Cookie同步,分为2种状况
-
场景1: WebView 跳转恳求, 在这里选择
WKNavigationDelegate
中发送恳求前的署理办法decidePolicyForNavigationAction
中进行同步- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { if ([navigationAction.request isKindOfClass:NSMutableURLRequest.class]) { NSMutableURLRequest *request = navigationAction.request NSArray<NSHTTPCookie *> *availableCookie = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:request.URL]; if (availableCookie.count > 0) { NSDictionary *reqHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:availableCookie]; NSString *cookieStr = [reqHeader objectForKey:@"Cookie"]; [request setValue:cookieStr forHTTPHeaderField:@"Cookie"]; } } decisionHandler(WKNavigationActionPolicyAllow); }
-
场景2: WebView中Ajax恳求, 在
URLProtocol
的startLoading
中同步Cookie- (void)startLoading { NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy]; NSArray<NSHTTPCookie *> *availableCookie = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:mutableReqeust.URL]; if (availableCookie.count > 0) { NSDictionary *reqHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:availableCookie]; NSString *cookieStr = [reqHeader objectForKey:@"Cookie"]; [mutableReqeust setValue:cookieStr forHTTPHeaderField:@"Cookie"]; } ... NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:(id<NSURLSessionDelegate>)[KKJSBridgeWeakProxy proxyWithTarget:self] delegateQueue:nil]; self.customTask = [session dataTaskWithRequest:mutableReqeust]; }
-