iOS Webview 离线加载调研与实践

11,511 阅读4分钟

 背景

Webview 在客户端的使用场景越来越多,离线加载能节约网络加载耗时,提升用户体验。随着内部安卓端离线加载落地(《Android 离线加载落地》),iOS 端也进行了相关前期可行性和方案的调研

 方案选择

目前主流的离线包的请求拦截方案有两种:

  1. 通过NSURLProtocol实现, 注册scheme拦截

  2. 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: WKWebViewstart urlSchemeTask: WKURLSchemeTask) { 
        // 1. 获取 Request 实例       
        let request = urlSchemeTask.request
        // 2. 判断是否存在本地资源
        // ...
        // 2.1 加载本地资源
        // 2.2 转发请求,加载线上资源
    }
    
    func webView(_ webView: WKWebViewstop 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: WKWebViewstart 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: WKWebViewstop 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 拦截的一些调研与实践,如有错误欢迎大家指正与交流~