由于当前公司的旧 Web 容器已无法继续维护(懂得都懂),所以需要重构一套新的来支撑越来越多的在线页面业务体系。但在拦截资源,本地缓存加速这个过程中,踩了特别多的坑,这里特地记录一下,让大家们能少走些弯路。
背景
简单聊下背景,以前业务上更多是使用 Web 离线包下载到本地,然后加载本地资源渲染,类似小程序一样的设计。但随之问题也很多,毕竟没有一套成熟的开发体系,最重要的是开发的离线包都和盲盒一样,联调排查问题的时间都赶的上开发时间了。
在降本增效的前提下,已经不可能有人力单独为 App 开发一套离线包来支撑业务的情况下,势必就需要融合前端体系,直接加载在线页面。
那随之而来的一个结果就是对用户来说出现了体验降级的情况,以前秒开的页面变慢了,甚至在网络差的情况下白屏情况变得十分明显。
当然,这肯定是不能接受的,所以要重新建设整个 Web 容器。
总体规划
其他建设暂且不提,这里只聊聊,如何让在线 URL 页面达到秒开加载这件事。
方案选型
如何提高秒开率?就是减少整个建立连接到渲染完成的这段时间。
其实说白了,无非就是资源缓存加上提前预加载,而这也有几种方式能选择。
WebView 自带 Cache
最常见的就是 WebView 自带缓存 Cache,缓存规则也是依托于前端开发,但这个缓存策略上经常会有问题,比如版本不对、意外白屏、缓存丢失加载过慢等问题。
简单代码示意(来源 GPT-3.5)
// 配置缓存策略以及是否可使用cookieWKWebsiteDataStore *store = [WKWebsiteDataStore defaultDataStore];WKWebsiteDataStore *nonPersistentDataStore = [store nonPersistentDataStore];WKWebsiteDataStore *ephermeralDataStore = [store ephemeralDataStore];NSURLCache *cache = [[NSURLCache alloc] initWithMemoryCapacity:8 * 1024 * 1024diskCapacity:500 * 1024 * 1024diskPath:nil];configuration.websiteDataStore = nonPersistentDataStore;// 配置 URL 缓存策略configuration.urlCache = cache;configuration.requestCachePolicy = NSURLRequestUseProtocolCachePolicy;// 例如,设置一个自定义的Cookie策略WKUserContentController *userContentController = [[WKUserContentController alloc] init];NSString *cookieScript = @"document.cookie = 'cookie_name=cookie_value; domain=your_domain.com;';";WKUserScript *cookieScriptObj = [[WKUserScript alloc] initWithSource:cookieScriptinjectionTime:WKUserScriptInjectionTimeAtDocumentStartforMainFrameOnly:YES];// 配置缓存策略以及是否可使用cookie WKWebsiteDataStore *store = [WKWebsiteDataStore defaultDataStore]; WKWebsiteDataStore *nonPersistentDataStore = [store nonPersistentDataStore]; WKWebsiteDataStore *ephermeralDataStore = [store ephemeralDataStore]; NSURLCache *cache = [[NSURLCache alloc] initWithMemoryCapacity:8 * 1024 * 1024 diskCapacity:500 * 1024 * 1024 diskPath:nil]; configuration.websiteDataStore = nonPersistentDataStore; // 配置 URL 缓存策略 configuration.urlCache = cache; configuration.requestCachePolicy = NSURLRequestUseProtocolCachePolicy; // 例如,设置一个自定义的Cookie策略 WKUserContentController *userContentController = [[WKUserContentController alloc] init]; NSString *cookieScript = @"document.cookie = 'cookie_name=cookie_value; domain=your_domain.com;';"; WKUserScript *cookieScriptObj = [[WKUserScript alloc] initWithSource:cookieScript injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES];// 配置缓存策略以及是否可使用cookie WKWebsiteDataStore *store = [WKWebsiteDataStore defaultDataStore]; WKWebsiteDataStore *nonPersistentDataStore = [store nonPersistentDataStore]; WKWebsiteDataStore *ephermeralDataStore = [store ephemeralDataStore]; NSURLCache *cache = [[NSURLCache alloc] initWithMemoryCapacity:8 * 1024 * 1024 diskCapacity:500 * 1024 * 1024 diskPath:nil]; configuration.websiteDataStore = nonPersistentDataStore; // 配置 URL 缓存策略 configuration.urlCache = cache; configuration.requestCachePolicy = NSURLRequestUseProtocolCachePolicy; // 例如,设置一个自定义的Cookie策略 WKUserContentController *userContentController = [[WKUserContentController alloc] init]; NSString *cookieScript = @"document.cookie = 'cookie_name=cookie_value; domain=your_domain.com;';"; WKUserScript *cookieScriptObj = [[WKUserScript alloc] initWithSource:cookieScript injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES];
使用前端方案service worker
service worker
是一种 Web API,它允许开发者将脚本文件运行在用户浏览器的后台进程中,独立于当前页面/标签,提供了强大的离线数据存储、网络请求拦截和消息推送等功能。
但其最大的硬伤是,在 iOS App 中是不被支持的,这在苹果开发文档中是有明确的。只在 iOS Safari 浏览器上才被支持。
拦截资源请求
以上两种还有一个硬伤,就是必须依托 WebView 环境,那在预加载上就必须启动一个 WebView 容器,这一点其实有性能损耗的,如果有多个页面需要预缓存,那就必须开多个容器或者提供一个预加载队列,控制起来也是尤为费事,特别在预加载完之前用户就进入页面的情景下,缓存意外也是有可能的。
那我们还能怎么做呢?
其实思路很简单,在总体规划上也说的很明白。
我们拦截资源加载的请求,让它去访问我们本地资源,如果本地资源不存在就先下载到本地再返回给 Web 页面。
那提前预加载,就让前端提供资源清单,我们根据资源清单提前做一个资源更新即可。这样的好处还在于就算预加载未完成用户就进入了 Web 页面,那也没关系,已下载好的资源仍然可以提供加速能力。
iOS 的坑
方案大致讲了下, Android 实现很顺利的就完成了。但 iOS 确实就被 WKWebView 这货卡住了。
想象真的很美好,但网上的资源甚至 GPT-3.5 给出的方案有真有假的,尝试了很多种,走了很多歪门邪道。
邪道一: NSURLProtocol
亲们,这个确实是不能用的,虽然我们可以用它拦截 http / https 请求,也可以通过 + (BOOL)canInitWithRequest:(NSURLRequest *)request
方法来放过网络请求,从 safari 调试下看 post 请求内容也还在,但确实是在发送的时候就丢掉了 …
总结:还是会拦截掉网络请求,并且丢掉了 post 请求中的 body 信息。并且会影响整个全局。
邪道二: Hook XMLHttpRequest / fetch
这个是基于上一个“邪道”的延伸,既然请求一定会被拦截,那要不然就重写 XMLHttpRequest / fetch,让网络请求走我们的桥接方法,这样前端业务侧也无需修改任何代码。
代码示意(来源:反复询问 GPT-3.5 并修正)
function replaceXHR() {// 保存原始的 XMLHttpRequest 构造函数const origXHR = window.XMLHttpRequest// 定义新的 XMLHttpRequest 构造函数function NewXHR() {const xhr = new origXHR()let status = 200let statusText = 'OK'let response = ''// 重写 open 方法xhr.open = function (method, url, async, user, pass) {this.url = urlthis.method = methodorigXHR.prototype.open.call(this, method, url, async, user, pass)}// 重写 send 方法xhr.send = function (data) {var urlObjif (isUrlComplete(this.url)) {urlObj = new URL(this.url)} else {urlObj = new URL('https://gaoding.com' + this.url)}const path = urlObj.pathnameconst params = new URLSearchParams(urlObj.search)// 非稿定请求,不拦截if (!urlObj.host.includes('gaoding.com')) {origXHR.prototype.send.call(this, data)return}const paramObj = {}for (const [key, value] of params) {paramObj[key] = value}const config = {method: this.method,path: path,query: paramObj,}if (this.method.toUpperCase() === 'POST' || this.method.toUpperCase() === 'PUT') {config['body'] = data}// 执行桥接方法request(config).then((response) => {// 处理响应结果return response.result.response_data}).then((text) => {// 调用原始的 onreadystatechange 函数,并传入响应结果this.status = 200this.statusText = JSON.stringify(text)this.response = textthis.onreadystatechange && this.onreadystatechange()this.onload && this.onload()}).catch((error) => {// 调用原始的 onerror 函数,并传入错误信息this.onerror && this.onerror(error)})}return xhr}// 用新的 XMLHttpRequest 构造函数替换原始的 XMLHttpRequest 构造函数window.XMLHttpRequest = NewXHR}replaceXHR()function replaceXHR() { // 保存原始的 XMLHttpRequest 构造函数 const origXHR = window.XMLHttpRequest // 定义新的 XMLHttpRequest 构造函数 function NewXHR() { const xhr = new origXHR() let status = 200 let statusText = 'OK' let response = '' // 重写 open 方法 xhr.open = function (method, url, async, user, pass) { this.url = url this.method = method origXHR.prototype.open.call(this, method, url, async, user, pass) } // 重写 send 方法 xhr.send = function (data) { var urlObj if (isUrlComplete(this.url)) { urlObj = new URL(this.url) } else { urlObj = new URL('https://gaoding.com' + this.url) } const path = urlObj.pathname const params = new URLSearchParams(urlObj.search) // 非稿定请求,不拦截 if (!urlObj.host.includes('gaoding.com')) { origXHR.prototype.send.call(this, data) return } const paramObj = {} for (const [key, value] of params) { paramObj[key] = value } const config = { method: this.method, path: path, query: paramObj, } if (this.method.toUpperCase() === 'POST' || this.method.toUpperCase() === 'PUT') { config['body'] = data } // 执行桥接方法 request(config) .then((response) => { // 处理响应结果 return response.result.response_data }) .then((text) => { // 调用原始的 onreadystatechange 函数,并传入响应结果 this.status = 200 this.statusText = JSON.stringify(text) this.response = text this.onreadystatechange && this.onreadystatechange() this.onload && this.onload() }) .catch((error) => { // 调用原始的 onerror 函数,并传入错误信息 this.onerror && this.onerror(error) }) } return xhr } // 用新的 XMLHttpRequest 构造函数替换原始的 XMLHttpRequest 构造函数 window.XMLHttpRequest = NewXHR } replaceXHR()function replaceXHR() { // 保存原始的 XMLHttpRequest 构造函数 const origXHR = window.XMLHttpRequest // 定义新的 XMLHttpRequest 构造函数 function NewXHR() { const xhr = new origXHR() let status = 200 let statusText = 'OK' let response = '' // 重写 open 方法 xhr.open = function (method, url, async, user, pass) { this.url = url this.method = method origXHR.prototype.open.call(this, method, url, async, user, pass) } // 重写 send 方法 xhr.send = function (data) { var urlObj if (isUrlComplete(this.url)) { urlObj = new URL(this.url) } else { urlObj = new URL('https://gaoding.com' + this.url) } const path = urlObj.pathname const params = new URLSearchParams(urlObj.search) // 非稿定请求,不拦截 if (!urlObj.host.includes('gaoding.com')) { origXHR.prototype.send.call(this, data) return } const paramObj = {} for (const [key, value] of params) { paramObj[key] = value } const config = { method: this.method, path: path, query: paramObj, } if (this.method.toUpperCase() === 'POST' || this.method.toUpperCase() === 'PUT') { config['body'] = data } // 执行桥接方法 request(config) .then((response) => { // 处理响应结果 return response.result.response_data }) .then((text) => { // 调用原始的 onreadystatechange 函数,并传入响应结果 this.status = 200 this.statusText = JSON.stringify(text) this.response = text this.onreadystatechange && this.onreadystatechange() this.onload && this.onload() }) .catch((error) => { // 调用原始的 onerror 函数,并传入错误信息 this.onerror && this.onerror(error) }) } return xhr } // 用新的 XMLHttpRequest 构造函数替换原始的 XMLHttpRequest 构造函数 window.XMLHttpRequest = NewXHR } replaceXHR()
然后我们在 WKWebView 中注入这段 JS 即可。
[userContentController addUserScript:[[WKUserScript alloc] initWithSource:[GDWebUtils injectJSForBundle:@"network-hook"] injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];[userContentController addUserScript:[[WKUserScript alloc] initWithSource:[GDWebUtils injectJSForBundle:@"network-hook"] injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];[userContentController addUserScript:[[WKUserScript alloc] initWithSource:[GDWebUtils injectJSForBundle:@"network-hook"] injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
生效是生效了,但对我们的 Web 页面却出现了加载异常的情况,最后得知,我们项目中已经对 XMLHttpRequest hook 过了,所以我这真的是魔改 …
总结:不能胡乱注入 JS 代码,容易翻车 …
邪道三:自定义 Scheme
为什么一开始会执着会执着去使用NSURLProtocol
,而不使用WKURLSchemeHandler
。原因是WKURLSchemeHandler
最低支持 iOS 11, 且在 iOS 11.3 以下 post body 一样会丢失[手动狗头]。
那笔者奇思妙想下,我们先拦截加载的 HTML,然后再替换其中的资源加载路径呢?
做法大概描述下:
使用WKURLSchemeHandler
拦截page://
和assets://
两种自定义的 Scheme。
比如加载https://www.google.com
时,其实是加载的 page://www.google.com
来让WKURLSchemeHandler
可以拦截到页面加载了。
这样在 Response 中修改返回的资源加载路径,比如assets://xxxxx.css
,这样也就可以让WKURLSchemeHandler
拦截到资源加载了。
理想很美好,对于页面来说确实拦截加载成功了。
但
网络请求跨域了 … 因为我们当前域名是page://
而网络请求发出去的是https://
。
真要这么做,只能是服务端开放跨域限制,但这一点对于服务端是极不安全的。
结论:绕了半天然并卵。
正道:拦截 Http / Https Scheme
虽然苹果不建议甚至不允许拦截 Http / Https 协议的 Scheme,但这就是唯一一种方式了。
使用前提
- 确认项目/功能只需支持 iOS 11.3 以上版本。
- 确认前端其中没有File 上传请求,因为就算 iOS 11.3 以上版本,也会丢失 blob 格式的 body 数据。真要做文件上传,请提供给前端相应的 Bridge 方法。
- 切记处理 iOS 13 版本中的 post 请求的崩溃问题(如下图)。
代码示意
让 Http / Https 请求可被拦截
黑魔法替换类方法实现
@implementation WKWebView (GDWebURLSchemeHandler)+ (void)load {static dispatch_once_t onceToken;dispatch_once(&onceToken, ^{Method originalMethod = class_getClassMethod(self, @selector(handlesURLScheme:));Method swizzledMethod = class_getClassMethod(self, @selector(gd_handlesURLScheme:));method_exchangeImplementations(originalMethod, swizzledMethod);});}+ (BOOL)gd_handlesURLScheme:(NSString *)urlScheme {if ([urlScheme isEqualToString:kGDWebHookURLScheme]) {return NO;} else {return [self handlesURLScheme:urlScheme];}}@end@implementation WKWebView (GDWebURLSchemeHandler) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ Method originalMethod = class_getClassMethod(self, @selector(handlesURLScheme:)); Method swizzledMethod = class_getClassMethod(self, @selector(gd_handlesURLScheme:)); method_exchangeImplementations(originalMethod, swizzledMethod); }); } + (BOOL)gd_handlesURLScheme:(NSString *)urlScheme { if ([urlScheme isEqualToString:kGDWebHookURLScheme]) { return NO; } else { return [self handlesURLScheme:urlScheme]; } } @end@implementation WKWebView (GDWebURLSchemeHandler) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ Method originalMethod = class_getClassMethod(self, @selector(handlesURLScheme:)); Method swizzledMethod = class_getClassMethod(self, @selector(gd_handlesURLScheme:)); method_exchangeImplementations(originalMethod, swizzledMethod); }); } + (BOOL)gd_handlesURLScheme:(NSString *)urlScheme { if ([urlScheme isEqualToString:kGDWebHookURLScheme]) { return NO; } else { return [self handlesURLScheme:urlScheme]; } } @end
拦截步骤
构造拦截类,实现WKURLSchemeHandler
协议
在 WKWebView 中注册
[config setURLSchemeHandler:[GDWebURLSchemeHandler new] forURLScheme:@"https"];[config setURLSchemeHandler:[GDWebURLSchemeHandler new] forURLScheme:@"https"];[config setURLSchemeHandler:[GDWebURLSchemeHandler new] forURLScheme:@"https"];
拦截资源实现
上图中的GDWebAssetStorageService
服务就是做资源请求拦截及实现的。
内部实现也很简单,先判断本地是否存在资源,不存在就去下载后返回即可。
拦截请求实现
最后我们还是要处理拦截到的网络请求,WKURLSchemeHandler
协议真的是很坑爹,拦截掉的就无法调用默认实现了,需要自己构造。
但从原理来讲,苹果这样设计是合理的,毕竟 WKWebView 不是跟 App 同一个进程的,这牵扯到跨进程通信的问题,也是为什么苹果会在请求中过滤掉 blob 数据格式。
简单的构造一个NSURLSession
即可使用。
但这里还是有一个坑的,你会发现请求重定向失效了,这里我们采用的是调用私有类来做统一的处理,私有类直接调用有审核风险,简单的做一些代码混淆绕过去。
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler {// 调用 _didPerformRedirection:newRequest: 执行重定向NSArray *privateSelStrArr = @[@"st:", @"que", @"ewRe", @"n:n", @"ctio", @"dire", @"ormRe", @"_didPerf"];NSString *selName = [[[privateSelStrArr reverseObjectEnumerator] allObjects] componentsJoinedByString:@""];SEL sel = NSSelectorFromString(selName);#pragma clang diagnostic push#pragma clang diagnostic ignored "-Warc-performSelector-leaks"[self.schemeTask performSelector:sel withObject:response withObject:request];#pragma clang diagnostic popcompletionHandler(request);}- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler { // 调用 _didPerformRedirection:newRequest: 执行重定向 NSArray *privateSelStrArr = @[@"st:", @"que", @"ewRe", @"n:n", @"ctio", @"dire", @"ormRe", @"_didPerf"]; NSString *selName = [[[privateSelStrArr reverseObjectEnumerator] allObjects] componentsJoinedByString:@""]; SEL sel = NSSelectorFromString(selName); #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" [self.schemeTask performSelector:sel withObject:response withObject:request]; #pragma clang diagnostic pop completionHandler(request); }- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler { // 调用 _didPerformRedirection:newRequest: 执行重定向 NSArray *privateSelStrArr = @[@"st:", @"que", @"ewRe", @"n:n", @"ctio", @"dire", @"ormRe", @"_didPerf"]; NSString *selName = [[[privateSelStrArr reverseObjectEnumerator] allObjects] componentsJoinedByString:@""]; SEL sel = NSSelectorFromString(selName); #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" [self.schemeTask performSelector:sel withObject:response withObject:request]; #pragma clang diagnostic pop completionHandler(request); }
结论
不想让前端代码做一些 App 个性化适配的前提下,想要提高秒开率,又不想开隐藏容器增加内存开销,那在 iOS 上只有这一种拦截方式了。
虽然有着诸多使用限制,但至少能满足现在的业务需求。
关于 blob
再简单的讲一下,关于前端 blob 数据传输给 App 的问题。
这个其实也走了很多歪门邪道,前期想着用 base64 的方式不如直接 blob 传输省性能,但确实是做不到的。
这里也听了 GPT-3.5 的很多鬼话 … 要是用 GPT-4 的话,它会明确告诉你只有两种选择:转 base64 或者 App 搭建本地服务器。AI 的差距真的十分明显[手动狗头]。
感谢阅读,如果对你有用请点个赞 ❤️
本文正在参加「金石计划」