web-dev-qa-db-fra.com

Swift iOS Cache WKWebView contenu pour l'affichage hors connexion

Nous essayons de sauvegarder le contenu (HTML) de WKWebView dans un stockage persistant (NSUserDefaults, CoreData ou fichier sur disque). L'utilisateur peut voir le même contenu lorsqu'il entre de nouveau dans l'application sans connexion Internet. WKWebView n'utilise pas NSURLProtocol comme UIWebView (voir article ici ).

Bien que j'aie vu des publications selon lesquelles "Le cache d'application hors connexion n'est pas activé dans WKWebView". (Forums Apple dev), je sais qu’une solution existe.

J'ai appris deux possibilités, mais je ne pouvais pas les faire fonctionner:

1) Si j'ouvre un site Web dans Safari pour Mac et que je sélectionne Fichier >> Enregistrer sous, l’option suivante apparaît dans l’image ci-dessous. Pour les applications Mac, il existe [[[[webView mainFrame]] dataSource] webArchive], mais sur UIWebView ou WKWebView, aucune API de ce type n'existe. Mais si je charge un fichier .webarchive dans Xcode sur WKWebView (comme celui que j'ai obtenu de Mac Safari), le contenu s'affiche correctement (html, images externes, prévisualisations vidéo) s'il n'y a pas de connexion Internet. Le fichier .webarchive est en fait un plist (liste de propriétés). J'ai essayé d'utiliser un framework mac qui crée un fichier .webarchive, mais celui-ci était incomplet.

 enter image description here

2) J'ai obtenu le code HTML dans webView: didFinishNavigation mais il n'enregistre pas les images externes, css, javascript.

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

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

Nous luttons depuis une semaine et c'est une caractéristique principale pour nous. Toute idée est vraiment appréciée.

Je vous remercie!

9
Cristi Ghinea

Je sais que je suis en retard, mais je recherchais récemment un moyen de stocker des pages Web pour une lecture hors ligne. Je ne trouvais toujours pas de solution fiable qui ne dépendrait pas de la page elle-même et n'utiliserait pas la valeur obsolète UIWebView. . Beaucoup de gens écrivent qu'il faut utiliser la mise en cache HTTP existante, mais WebKit semble faire beaucoup de choses en dehors du processus, ce qui rend pratiquement impossible l'application de la mise en cache complète (voir ici ou ici ). Cependant, cette question m'a guidé dans la bonne direction. En bricolant l’approche des archives Web, j’ai trouvé qu’il était très facile de écrire votre propre exportateur d’archives Web .

Comme indiqué dans la question, les archives Web ne sont que des fichiers Plist. Il suffit donc d'un robot d'exploration qui extrait les ressources requises de la page HTML, les télécharge toutes et les stocke dans un gros fichier Plist. Ce fichier d'archive peut ensuite être chargé dans la WKWebView via loadFileURL(URL:allowingReadAccessTo:).

J'ai créé une application de démonstration qui permet de stocker et de restaurer une WKWebView en utilisant cette approche: https://github.com/ernesto-elsaesser/OfflineWebView

L'implémentation ne dépend que de Fuzi pour les requêtes XPath. L'archiveur a été inspiré par BiblioArchiver (malheureusement, il ne compile plus).

5
Ernesto Elsäßer

Je recommanderais d'étudier la faisabilité d'utiliser App Cache, qui est maintenant pris en charge dans WKWebView à partir de iOS 10: https://stackoverflow.com/a/44333359/233602

0
Andrew Ebling

Je ne sais pas si vous souhaitez simplement mettre en cache les pages déjà visitées ou si vous avez des demandes spécifiques que vous souhaitez mettre en cache. Je travaille actuellement sur ce dernier. Je vais donc en parler. Mes URL sont générées dynamiquement à partir d'une demande d'API. À partir de cette réponse, je mets requestPaths avec les URL non-image, puis je lance une requête pour chacune des URL et mets en cache la réponse. Pour les URL des images, j'ai utilisé la bibliothèque Kingfisher pour mettre les images en cache. J'ai déjà configuré mon cache partagé urlCache = URLCache.shared dans mon AppDelegate. Et alloué la mémoire dont j'ai besoin: urlCache = URLCache(memoryCapacity: <setForYourNeeds>, diskCapacity: <setForYourNeeds>, diskPath: "urlCache") Ensuite, appelez simplement startRequest(:_) pour chacune des URL de requestPaths. (Peut être fait en arrière-plan si ce n'est pas nécessaire tout de suite)

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)")
                }

            })
        }
    }
}
}

J'utilise Alamofire pour la requête réseau avec un cachingSessionManager configuré avec les en-têtes appropriés. Donc, dans mon cours WebService, j'ai:

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
}

Ensuite, dans la méthode déléguée webview, je charge cachRessonse. J'utilise une variable handlingCacheRequest pour éviter une boucle infinie.

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)
}

Bien sûr, vous voudrez vous en occuper également s'il y a une erreur de chargement.

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()
}

J'espère que ça aide. La seule chose que j'essaie encore de comprendre, c'est que les images ne sont pas chargées hors connexion. Je pense que je devrai faire une demande séparée pour ces images et garder une référence à eux localement. Juste une pensée, mais je mettrai à jour ceci quand cela aura fonctionné.

MISE À JOUR avec le chargement des images hors connexion avec le code ci-dessous J'ai utilisé la bibliothèque Kanna pour analyser la chaîne html de ma réponse en cache, trouver l'URL incorporée dans l'attribut style= background-image: de la div, regex utilisé pour récupère l'URL (qui est également la clé de l'image en cache Kingfisher), récupère l'image en cache, puis modifie le fichier css pour utiliser les données d'image (basé sur cet article: https://css-tricks.com/data-uris/ ), puis chargé la vue Web avec le code HTML modifié. (Ouf!) C'était tout à fait le processus et il y a peut-être un moyen plus facile .. mais je ne l'avais pas trouvé. Mon code est mis à jour pour refléter tous ces changements. Bonne chance!

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)
    }
}
0
FromTheStix

Le moyen le plus simple d’utiliser la page Web du cache est le suivant: Swift 4.0 : -

/ * Où isCacheLoad = true (données de chargement hors connexion) & isCacheLoad = false (données de chargement normal) * /

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)
    }
}
0
Sidd