iOS离线包方案调研
为啥使用离线包
离线包相对于混淆来说是一种更稳定的过审方案, 其主要优势如下
传统的 H5 技术容易受到网络环境影响,因而降低 H5 页面的性能。通过使用离线包,可以解决该问题,同时保留 H5 的优点。
离线包 是将包括 HTML、JavaScript、CSS 等页面内静态资源打包到一个压缩包内。预先下载该离线包到本地,然后通过客户端打开,直接从本地加载离线包,从而最大程度地摆脱网络环境对 H5 页面的影响。实现动态更新:在推出新版本或是紧急发布的时候,可以把修改的资源放入离线包,通过更新配置让应用自动下载更新。因此, 无需通过应用商店审核,就能让用户及早接收更新
离线包的方案选择
目前主流的离线包的请求拦截方案有两种:
- 通过NSURLProtocol实现, 注册scheme拦截
- WKURLSchemeHandler实现, 自定义sheme拦截
WKURLSchemeHandler
WKURLSchemeHandler
是 WebKit
框架中的一个类,用于处理自定义的 URL 协议。WebKit
是一个提供网页渲染和浏览功能的框架,它主要用于创建浏览器和网页视图等, 其中WKURLSchemeHandler
是iOS11之后的API, 其大致实现如下.
ini
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完成这已功能
ini
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子类对象所拦截,比如
objectivec
//OfflinePackageURLProtocol负责离线资源的加载
[NSURLProtocol registerClass:[OfflinePackageURLProtocol class]];
//KKJSBridgeAjaxURLProtocol负责重新组装来自ajax的请求
[NSURLProtocol registerClass:[KKJSBridgeAjaxURLProtocol class]];
其中OfflinePackageURLProtocol
,和KKJSBridgeAjaxURLProtocol
都需要继承NSURLProtocol
. 其核心方法如下
objectivec
// 该方法用于判断指定的网络请求是否可以由自定义的 URL 协议处理。如果该方法返回 YES,则表示该请求可以由该协议处理;如果返回 NO,则表示该请求不能由该协议处理
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
// 该方法用于启动网络请求。在该方法中,可以实现自定义的网络传输逻辑,来处理网络请求并返回响应。
- (void)startLoading;
// 该方法用于停止网络请求。在该方法中,可以实现清理逻辑,来释放与请求相关的资源。
- (void)stopLoading;
NSURLProtocol
可以注册多个子类,并且拦截的顺序是后注册的先拦截 , 被拦截的请求,会重新由新创建的Session来管理,因此不会继续被后续的Protocol处理. 所以KKJSBridgeAjaxURLProtocol
需要拦截ajaxhook之后的请求重新组装body, 它的注册必须在OfflinePackageURLProtocol
之后
此外, 使用NSURLProtocol
的registerClass
方法会污染[NSURLSession sharedSession]
对象. 通常向NSURLSession
中注册NSURLProtocol
的方法如下
ini
// 创建一个默认的 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中减少网路流量.
ini
//仿照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
的核心代码
ini
- (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端.
核心代码如下
inivar 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.大致逻辑如下
ini- (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
中进行同步objectivec- (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端处理.objectivec//这里需要注意虽然在浏览器环境中可以仅仅通过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
, 该请求会被客户端接管, 可以在客户端请求完成的回调中单独处理, 比如objectivec//客户端的请求回调中处理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
中进行同步ini- (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
中同步Cookieini- (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]; }
-