由于当前公司的旧 Web 容器已无法继续维护(懂得都懂),所以需要重构一套新的来支撑越来越多的在线页面业务体系。但在拦截资源,本地缓存加速这个过程中,踩了特别多的坑,这里特地记录一下,让大家们能少走些弯路。
背景
简单聊下背景,以前业务上更多是使用 Web 离线包下载到本地,然后加载本地资源渲染,类似小程序一样的设计。但随之问题也很多,毕竟没有一套成熟的开发体系,最重要的是开发的离线包都和盲盒一样,联调排查问题的时间都赶的上开发时间了。
在降本增效的前提下,已经不可能有人力单独为 App 开发一套离线包来支撑业务的情况下,势必就需要融合前端体系,直接加载在线页面。
那随之而来的一个结果就是对用户来说出现了体验降级的情况,以前秒开的页面变慢了,甚至在网络差的情况下白屏情况变得十分明显。
当然,这肯定是不能接受的,所以要重新建设整个 Web 容器。
总体规划
其他建设暂且不提,这里只聊聊,如何让在线 URL 页面达到秒开加载这件事。
方案选型
如何提高秒开率?就是减少整个建立连接到渲染完成的这段时间。
其实说白了,无非就是资源缓存加上提前预加载,而这也有几种方式能选择。
WebView 自带 Cache
最常见的就是 WebView 自带缓存 Cache,缓存规则也是依托于前端开发,但这个缓存策略上经常会有问题,比如版本不对、意外白屏、缓存丢失加载过慢等问题。
简单代码示意(来源 GPT-3.5)
// 配置缓存策略以及是否可使用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 = 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]];
复制代码
生效是生效了,但对我们的 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
复制代码
拦截步骤
构造拦截类,实现WKURLSchemeHandler
协议
在 WKWebView 中注册
[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 pop
completionHandler(request);
}
复制代码
结论
不想让前端代码做一些 App 个性化适配的前提下,想要提高秒开率,又不想开隐藏容器增加内存开销,那在 iOS 上只有这一种拦截方式了。
虽然有着诸多使用限制,但至少能满足现在的业务需求。
关于 blob
再简单的讲一下,关于前端 blob 数据传输给 App 的问题。
这个其实也走了很多歪门邪道,前期想着用 base64 的方式不如直接 blob 传输省性能,但确实是做不到的。
这里也听了 GPT-3.5 的很多鬼话 ... 要是用 GPT-4 的话,它会明确告诉你只有两种选择:转 base64 或者 App 搭建本地服务器。AI 的差距真的十分明显[手动狗头]。
感谢阅读,如果对你有用请点个赞 ❤️
本文正在参加「金石计划」