Swift iOS 缓存 WKWebView 内容以供离线查看

Swift iOS Cache WKWebView content for offline view

我们正在尝试将 WKWebView 的内容 (HTML) 保存在持久存储(NSUserDefaults、CoreData 或磁盘文件)中。用户在没有互联网连接的情况下重新进入应用程序时可以看到相同的内容。 WKWebView 不像 UIWebView 那样使用 NSURLProtocol(参见 post here)。

尽管我已经看到 post"The offline application cache is not enabled in WKWebView."(Apple 开发者论坛),但我知道存在解决方案。

我知道了两种可能性,但我无法使它们起作用:

1) 如果我在Safari 中打开一个网站Mac 和select 文件>> 另存为,它会在下图中出现以下选项。 Mac 应用程序存在 [[[webView mainFrame] dataSource] webArchive],但在 UIWebView 或 WKWebView 上没有这样的 API。但是如果我在 WKWebView 的 Xcode 中加载一个 .webarchive 文件(就像我从 Mac Safari 获得的那个),那么内容就会正确显示(html,外部图像,视频预览)如果没有互联网连接。 .webarchive 文件实际上是一个 plist(属性 列表)。我尝试使用创建 .webarchive 文件的 mac 框架,但它不完整。

2) 我在 webView:didFinishNavigation 中获取了 HTML 但它不保存外部图像,css, javascript

 func webView(webView: WKWebView, didFinishNavigation navigation: WKNavigation!) {

    webView.evaluateJavaScript("document.documentElement.outerHTML.toString()",
        completionHandler: { (html: AnyObject?, error: NSError?) in
            print(html)
    })
}

我们努力了一个多星期,这是我们的主要功能。 非常感谢任何想法。

谢谢!

我建议调查使用 App Cache 的可行性,从 iOS 10 开始,WKWebView 现在支持它:

我不确定您是只想缓存已经访问过的页面,还是有特定的请求要缓存。我目前正在研究后者。所以我会谈到这一点。我的 url 是根据 api 请求动态生成的。从这个响应中,我将 requestPaths 设置为 non-image url,然后为每个 url 发出请求并缓存响应。对于图像 urls,我使用了 Kingfisher 库来缓存图像。我已经在我的 AppDelegate 中设置了我的共享缓存 urlCache = URLCache.shared。并分配我需要的内存: urlCache = URLCache(memoryCapacity: <setForYourNeeds>, diskCapacity: <setForYourNeeds>, diskPath: "urlCache") 然后只需为 requestPaths 中的每个 url 调用 startRequest(:_)。 (如果不需要立即在后台完成)

class URLCacheManager {

static let timeout: TimeInterval = 120
static var requestPaths = [String]()

class func startRequest(for url: URL, completionWithErrorCallback: @escaping (_ error: Error?) -> Void) {

    let urlRequest = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: timeout)

    WebService.sendCachingRequest(for: urlRequest) { (response) in

        if let error = response.error {
            DDLogError("Error: \(error.localizedDescription) from cache response url: \(String(describing: response.request?.url))")
        }
        else if let _ = response.data,
            let _ = response.response,
            let request = response.request,
            response.error == nil {

            guard let cacheResponse = urlCache.cachedResponse(for: request) else { return }

            urlCache.storeCachedResponse(cacheResponse, for: request)
        }
    }
}
class func startCachingImageURLs(_ urls: [URL]) {

    let imageURLs = urls.filter { [=10=].pathExtension.contains("png") }

    let prefetcher = ImagePrefetcher.init(urls: imageURLs, options: nil, progressBlock: nil, completionHandler: { (skipped, failed, completed) in
        DDLogError("Skipped resources: \(skipped.count)\nFailed: \(failed.count)\nCompleted: \(completed.count)")
    })

    prefetcher.start()
}

class func startCachingPageURLs(_ urls: [URL]) {
    let pageURLs = urls.filter { ![=10=].pathExtension.contains("png") }

    for url in pageURLs {

        DispatchQueue.main.async {
            startRequest(for: url, completionWithErrorCallback: { (error) in

                if let error = error {
                    DDLogError("There was an error while caching request: \(url) - \(error.localizedDescription)")
                }

            })
        }
    }
}
}

我将 Alamofire 用于网络请求,并使用适当的 headers 配置了 cachingSessionManager。所以在我的网络服务中 class 我有:

typealias URLResponseHandler = ((DataResponse<Data>) -> Void)

static let cachingSessionManager: SessionManager = {

        let configuration = URLSessionConfiguration.default
        configuration.httpAdditionalHeaders = cachingHeader
        configuration.urlCache = urlCache

        let cachingSessionManager = SessionManager(configuration: configuration)
        return cachingSessionManager
    }()

    private static let cachingHeader: HTTPHeaders = {

        var headers = SessionManager.defaultHTTPHeaders
        headers["Accept"] = "text/html" 
        headers["Authorization"] = <token>
        return headers
    }()

@discardableResult
static func sendCachingRequest(for request: URLRequest, completion: @escaping URLResponseHandler) -> DataRequest {

    let completionHandler: (DataResponse<Data>) -> Void = { response in
        completion(response)
    }

    let dataRequest = cachingSessionManager.request(request).responseData(completionHandler: completionHandler)

    return dataRequest
}

然后在 webview 委托方法中我加载了 cachedResponse。我使用变量 handlingCacheRequest 来避免无限循环。

func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {

    if let reach = reach {

        if !reach.isReachable(), !handlingCacheRequest {

            var request = navigationAction.request
            guard let url = request.url else {

                decisionHandler(.cancel)
                return
            }

            request.cachePolicy = .returnCacheDataDontLoad

           guard let cachedResponse = urlCache.cachedResponse(for: request),
                let htmlString = String(data: cachedResponse.data, encoding: .utf8),
                cacheComplete else {
                    showNetworkUnavailableAlert()
                    decisionHandler(.allow)
                    handlingCacheRequest = false
                    return
            }

            modify(htmlString, completedModification: { modifiedHTML in

                self.handlingCacheRequest = true
                webView.loadHTMLString(modifiedHTML, baseURL: url)
            })

            decisionHandler(.cancel)
            return
    }

    handlingCacheRequest = false
    DDLogInfo("Currently requesting url: \(String(describing: navigationAction.request.url))")
    decisionHandler(.allow)
}

当然,如果出现加载错误,您也需要处理它。

func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {

    DDLogError("Request failed with error \(error.localizedDescription)")

    if let reach = reach, !reach.isReachable() {
        showNetworkUnavailableAlert()
        handlingCacheRequest = true
    }
    webView.stopLoading()
    loadingIndicator.stopAnimating()
}

希望对您有所帮助。我仍然想弄清楚的唯一一件事是图像资产没有离线加载。我在想我需要对这些图像提出单独的请求并在本地保留对它们的引用。只是一个想法,但我会在解决后更新。

已更新,使用以下代码离线加载图像 我使用 Kanna 库从我的缓存响应中解析我的 html 字符串,找到 [=47] 的 style= background-image: 属性中嵌入的 url =],使用正则表达式获取url(这也是Kingfisher缓存图像的关键),获取缓存图像,然后修改css以使用图像数据(基于这篇文章:https://css-tricks.com/data-uris/),然后加载修改后的webview html。 (Phew!)这是一个完整的过程,也许有更简单的方法……但我还没有找到它。我的代码已更新以反映所有这些更改。祝你好运!

func modify(_ html: String, completedModification: @escaping (String) -> Void) {

    guard let doc = HTML(html: html, encoding: .utf8) else {
        DDLogInfo("Couldn't parse HTML with Kannan")
        completedModification(html)
        return
    }

    var imageDiv = doc.at_css("div[class='<your_div_class_name>']")

    guard let currentStyle = imageDiv?["style"],
        let currentURL = urlMatch(in: currentStyle)?.first else {

            DDLogDebug("Failed to find URL in div")
            completedModification(html)
            return
    }

    DispatchQueue.main.async {

        self.replaceURLWithCachedImageData(inHTML: html, withURL: currentURL, completedCallback: { modifiedHTML in

            completedModification(modifiedHTML)
        })
    }
}

func urlMatch(in text: String) -> [String]? {

    do {
        let urlPattern = "\((.*?)\)"
        let regex = try NSRegularExpression(pattern: urlPattern, options: .caseInsensitive)
        let nsString = NSString(string: text)
        let results = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsString.length))

        return results.map { nsString.substring(with: [=14=].range) }
    }
    catch {
        DDLogError("Couldn't match urls: \(error.localizedDescription)")
        return nil
    }
}

func replaceURLWithCachedImageData(inHTML html: String, withURL key: String, completedCallback: @escaping (String) -> Void) {

    // Remove parenthesis
    let start = key.index(key.startIndex, offsetBy: 1)
    let end = key.index(key.endIndex, offsetBy: -1)

    let url = key.substring(with: start..<end)

    ImageCache.default.retrieveImage(forKey: url, options: nil) { (cachedImage, _) in

        guard let cachedImage = cachedImage,
            let data = UIImagePNGRepresentation(cachedImage) else {
                DDLogInfo("No cached image found")
                completedCallback(html)
                return
        }

        let base64String = "data:image/png;base64,\(data.base64EncodedString(options: .endLineWithCarriageReturn))"
        let modifiedHTML = html.replacingOccurrences(of: url, with: base64String)

        completedCallback(modifiedHTML)
    }
}

Swift 4.0中使用缓存网页最简单的方法如下:-

/* 其中isCacheLoad = true(离线加载数据)& isCacheLoad = false (正常加载数据) */

internal func loadWebPage(fromCache isCacheLoad: Bool = false) {

    guard let url =  url else { return }
    let request = URLRequest(url: url, cachePolicy: (isCacheLoad ? .returnCacheDataElseLoad: .reloadRevalidatingCacheData), timeoutInterval: 50)
        //URLRequest(url: url)
    DispatchQueue.main.async { [weak self] in
        self?.webView.load(request)
    }
}

我知道我来晚了,但我最近一直在寻找一种存储网页以供离线阅读的方法,但仍然找不到任何不依赖于网页本身且不会的可靠解决方案'不要使用已弃用的 UIWebView。很多人写道应该使用现有的 HTTP 缓存,但 WebKit 似乎在进程外做了很多事情,这使得它几乎不可能强制执行完整的缓存(参见 here or here)。但是,这个问题引导我走向了正确的方向。修补 Web 存档方法,我发现 编写自己的 Web 存档导出程序 实际上非常容易。

正如问题中所写,网络存档只是 plist 文件,所以它所需要的只是一个从 HTML 页面中提取所需资源的爬虫,将它们全部下载并存储在一个大的 plist 文件中.然后可以通过 loadFileURL(URL:allowingReadAccessTo:).

将此存档文件加载到 WKWebView

我创建了一个演示应用程序,允许使用以下方法从 WKWebView 归档和恢复到 WKWebViewhttps://github.com/ernesto-elsaesser/OfflineWebView

编辑:存档生成代码现在可以作为独立的 Swift 包使用:https://github.com/ernesto-elsaesser/WebArchiver

实现仅依赖于Fuzi进行HTML解析。