背景
Webview 在客户端的使用场景越来越多,离线加载能节约网络加载耗时,提升用户体验。随着内部安卓端离线加载落地(《Android 离线加载落地》),iOS 端也进行了相关前期可行性和方案的调研
方案选择
目前主流的离线包的请求拦截方案有两种:
-
通过NSURLProtocol实现, 注册scheme拦截
-
WKURLSchemeHandler实现, 自定义sheme拦截
由于 NSURLProtocol 拦截会存在 Post 请求丢失 body 的问题,而且拦截范围是全局的,风险性更大,因此 WKURLSchemeHandler 相较之下会是更好的选择,下文的讨论也是针对 WKURLSchemeHandler 的实现和坑点
代码实现
WKURLSchemeHandler 是 iOS 11 后提供给开发者支持加载自定义协议资源的接口,如果需要支持 scheme 为 http 或 https请求的数据管理则需要 hook WKWebView 的 handlesURLScheme: 方法
1. hook handlesURLScheme方法
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Method originalMethod1 = class_getClassMethod(self, @selector(handlesURLScheme:));
Method swizzledMethod1 = class_getClassMethod(self, @selector(ts_handlesURLScheme:));
method_exchangeImplementations(originalMethod1, swizzledMethod1);
});
}
+ (BOOL) ts_handlesURLScheme:(NSString *)urlScheme {
if ([urlScheme isEqualToString:@"http"] || [urlScheme isEqualToString:@"https"]) {
return NO;
} else {
return [self handlesURLScheme:urlScheme];
}
}
@end
2. 创建 WKURLSchemeHandler
@available(iOS 11.0, *)
class TSURLSchemeHandler: NSObject, WKURLSchemeHandler {
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
// 1. 获取 Request 实例
let request = urlSchemeTask.request
// 2. 判断是否存在本地资源
// ...
// 2.1 加载本地资源
// 2.2 转发请求,加载线上资源
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
}
}
3. 设置webviewConfiguration
let config = WKWebViewConfiguration()
// iOS 11 以上设置拦截
if #available(iOS 11.0, *) {
let handler = TSURLSchemeHandler()
config.setURLSchemeHandler(handler, forURLScheme: "https")
config.setURLSchemeHandler(handler, forURLScheme: "http")
}
webview = WKWebView(frame: frame, configuration: config)
注意点
1. The task has already been stopped崩溃问题
崩溃的原因是因为 WKURLSchemeTask
// 定义一个字典存放任务
private var taskDict: [String: WKURLSchemeTask] = [:]
// 任务开始
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
// 保存任务
taskDict[urlSchemeTask.description] = urlSchemeTask
let request = urlSchemeTask.request
// 发起请求
URLSession.shared.dataTask(with: request) { data, resp, err in
// 判断task是否已结束
guard let task = self.taskDict[urlSchemeTask.description] else {
print("任务已结束,丢弃task")
return
}
// 判断请求是否成功
guard let resp = resp as? HTTPURLResponse else {
let err = err ?? NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Response Nil"])
task.didFailWithError(err)
return
}
// 接收响应头
task.didReceive(resp)
// 接收响应数据
if let data = data {
task.didReceive(data)
}
// 任务结束
task.didFinish()
// 删除任务
self.taskDict.removeValue(forKey: task.description)
}.resume()
}
// 任务停止
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
taskDict.removeValue(forKey: urlSchemeTask.description)
}
2. Blob上传崩溃问题
当 WKWebView 创建一个httpBody中的时,会调用WebCore::blobRegistry方法,但返回的是一个空指针,从而导致了崩溃。查阅资源,可通过私有api +[WebView _setLoadResourcesSerially:] 来解决。webkit - iOS WKWebView - WKURLSchemeHandler crash on posting body (EXC_BAD_ACCESS) - Stack Overflow
JS 测试代码
let blob = new Blob(["Hello, world!"], {type: "text/plain"});
let formData = new FormData();
formData.append("file", blob, "file.txt");
// 使用fetch API发送请求
fetch("xxxx", {
method: "POST",
body: formData
})
解决方案,使用私有API,需注意混淆
let selector = sel_registerName("_setLoadResourcesSerially:")
if let wv = NSClassFromString("WebView") as? NSObject.Type,
wv.responds(to: selector) {
wv.perform(selector, with: false as Any)
}
3. Blob上传内容丢失问题
解决了上述崩溃问题后,还存在上传 Blob 时数据丢失情况。经测试,网页中使用 <input type="file">
上传文件数据能正常传输,而手动构造的 Blob 则会丢失数据。
要解决这个问题,需要注入 JS 脚本来 hook 前端的 ajax 请求处理 Blob 对象
1. 将 Blob 二进制数据 base64 后放到请求头,客户端拦截后从请求头取出数据,重新组装
2. 将 Blob 二进制数据 base64 后先通过 messageHandler 传给客户端,客户端发起请求时再取出数据,重新组装
4. cookie 同步问题
WKWebView 的 cookies 是由 WKHTTPCookieStore 进行管理,而客户端请求的 cookies 是由 NSHTTPCookieStoreage 进行管理,因此可能存在 cookie 同步的问题
// 请求成功之后,同步cookie到webview
if let header = resp.allHeaderFields as? [String: String],
let url = resp.url {
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, for: url)
DispatchQueue.main.async {
cookies.forEach { cookie in
WKWebsiteDataStore.default().httpCookieStore.setCookie(cookie)
}
}
}
总结
iOS 上要实现拦截 WKWebview 请求来实现离线化加载相对于 Android 来说,还是存在不少的坑点,过程中需要使用 hook 系统方法、私有 api 等,存在一定的风险性。同时还需依赖注入 JS 脚本并 hook 前端代码配合,对前端有一定的侵入性,并且还需注意各种的边界情况。因为实现上存在很多的风险点,上线前务必预留好开关、灰度验证和线上监控。
最后,本文是针对 iOS 实现 Webview 拦截的一些调研与实践,如有错误欢迎大家指正与交流~