Swift iOS Cache WKWebView для офлайн-просмотра

Мы пытаемся сохранить содержимое (HTML) WKWebView в постоянном хранилище (NSUserDefaults, CoreData или файл на диске). Пользователь может видеть один и тот же контент при повторном входе в приложение без подключения к Интернету. WKWebView не использует NSURLProtocol как UIWebView (см. Post здесь).

Хотя я видел сообщения, что "Кэш офлайн-приложений не включен в WKWebView". (Форумы Apple dev), я знаю, что решение существует.

Я узнал о двух возможностях, но я не мог заставить их работать:

1) Если я открою сайт в Safari для Mac и выберите "Файл → Сохранить как", на изображении ниже появится следующая опция. Для приложений Mac существует [[[webView mainFrame] dataSource] webArchive], но в UIWebView или WKWebView такого API нет. Но если я загружаю файл .webarchive в Xcode на WKWebView (например, тот, который я получил из Mac Safari), тогда контент отображается правильно (html, внешние изображения, видео-превью), если нет подключения к Интернету. Файл .webarchive на самом деле является plist (список свойств). Я попытался использовать фреймворк mac, который создает файл .webarchive, но он был неполным.

введите описание изображения здесь

2) Я обманул HTML в webView: didFinishNavigation, но он не сохраняет внешние изображения, css, javascript

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

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

Мы боремся за неделю, и это главная особенность для нас. Любая идея действительно оценена.

Спасибо!

Ответ 1

Я знаю, что опоздал, но недавно я искал способ хранения веб-страниц для чтения в автономном режиме и все еще не мог найти надежного решения, которое не зависело бы от самой страницы и не использовало бы устаревший UIWebView, Многие люди пишут, что нужно использовать существующее кэширование HTTP, но WebKit, похоже, делает много вещей вне процесса, делая практически невозможным принудительное полное кэширование (см. Здесь или здесь). Однако этот вопрос направил меня в правильном направлении. Работая с подходом веб-архива, я обнаружил, что написать собственный экспортер веб-архива довольно просто.

Как написано в вопросе, веб-архивы - это просто plist файлы, поэтому все, что требуется, это сканер, который извлекает необходимые ресурсы из HTML-страницы, загружает их все и сохраняет их в большом plist файле. Этот архивный файл затем может быть загружен в WKWebView через loadFileURL(URL:allowingReadAccessTo:).

Я создал демонстрационное приложение, которое позволяет архивировать и восстанавливать в WKWebView используя этот подход: https://github.com/ernesto-elsaesser/OfflineWebView

Реализация зависит только от Fuzi для разбора HTML.

Ответ 2

Я бы рекомендовал изучить возможность использования App Cache, который теперь поддерживается в WKWebView с iOS 10: fooobar.com/questions/717637/...

Ответ 3

Я не уверен, что вы просто хотите кэшировать страницы, которые уже были посещены, или если у вас есть конкретные запросы, которые вы хотите кэшировать. В настоящее время я работаю над последним. Поэтому я поговорю с этим. Мои URL-адреса динамически генерируются из запроса api. Из этого ответа я установил requestPaths с не-образными URL-адресами, а затем сделаю запрос для каждого из URL-адресов и кешировал ответ. Для URL-адресов изображений я использовал библиотеку Kingfisher для кэширования изображений. Я уже настроил свой общий кэш urlCache = URLCache.shared в своем AppDelegate. И выделил нужную мне память: urlCache = URLCache(memoryCapacity: <setForYourNeeds>, diskCapacity: <setForYourNeeds>, diskPath: "urlCache") Затем просто вызовите startRequest(:_) для каждого из URL-адресов в requestPaths. (Может быть сделано в фоновом режиме, если это не нужно сразу)

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 { $0.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 { !$0.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 для сетевого запроса с помощью cachingSessionManager, настроенного с соответствующими заголовками. Поэтому в моем классе WebService у меня есть:

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 из моего кэшированного ответа, найти url, встроенный в атрибут style= background-image: div, использовать регулярное выражение для получения URL-адреса (который также является ключом к кешированному изображению Kingfisher), извлечение кэша образ, а затем изменил css, чтобы использовать данные изображения (на основе этой статьи: https://css-tricks.com/data-uris/), а затем загрузил веб-просмотр с измененным HTML. (Фу!) Это был довольно процесс, и, возможно, есть более простой способ... но я его не нашел. Мой код обновлен, чтобы отразить все эти изменения. Удачи!

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: $0.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)
    }
}

Ответ 4

Самый простой способ использовать веб-страницу кеша в 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)
    }
}