MENU

WKWebView缓存设计

July 28, 2017 • Read: 27918 • iOS,博客

关于WKWebView

WKWebView是苹果在iOS8之后推出的用于取代UIWebView的一个网页加载框架。它的目的在于解决UIWebView载入速度慢、内存占用大、内存泄漏等问题。

WKWebView和UIWebView的比较

UIWebView性能占用
WKWebView性能占用
从以上对比图,我们可以看出WKWebView的性能甩了UIWebView几条街。二者在流程上也有一定的区别,如下图,左边是 UIWebView,右边是WKWebView,在节点上,WKWebView 比 UIWebView 多了一个询问过程,在服务器响应请求之后会询问是否载入内容到当前 Frame,在控制上会比UIWebView 粒度更细一些。
流程对比

WKWebView结构

WKWebView结构

类名/协议名功能
WKBackForwardList之前访问过的 web 页面的列表,可以通过后退和前进动作来访问到
WKBackForwardListItemwebview 中后退列表里的某一个网页
WKFrameInfo包含一个网页的布局信息
WKNavigation包含一个网页加载进度的信息
WKNavigationAction包含可能让网页导航变化的信息,用于判断是否做出导航变化
WKNavigationResponse包含可能让网页导航变化的返回内容信息,用于判断是否做出导航变化
WKPreferencesWebView的偏好设置
WKProcessPoolWeb内容加载池
WKUserContentController提供使用 JavaScript post 信息和注射 script 的方法
WKScriptMessage包含网页发出的信息
WKUserScript表示可以被网页接受的用户脚本
WKWebViewConfiguration初始化 webview 的设置
WKWindowFeatures指定加载新网页时的窗口属性
WKNavigationDelegate提供了追踪主窗口网页加载过程和判断主窗口和子窗口是否进行页面加载新页面的相关方法
WKScriptMessageHandler提供从网页中收消息的回调方法
WKUIDelegate提供用原生控件显示网页的方法回调

WKWebView的弊端

功能性问题

1.WKWebView 进程崩溃引发的问题

WKWebView 进程崩溃,在 app 内的效果就是白屏,我们要做的就是在得知白屏时重新载入 Request,iOS 9 下有 Delegate 方法能收到崩溃的回调,但在打开相册或拍照比较耗内存的情况下,WKWebView 崩溃 Delegate 方法却不会被调用,同时我们支持的最低版本是 iOS 8,而在 iOS 8 下,可以通过校验webView.title 属性是否为空来确定,title 属性是 WKWebView 内置属性,自动读取 document.title 值,而在进程崩溃的情况下,该值为空。

2.WKWebView 视图尺寸变化对页面的影响

WKWebView 也是通过 ScrollView 实现,设置 contentInset 等相关偏移值会映射到 Web 页面,导致页面的长度增加。其次 WKWebView 的页面渲染与 JS 执行同步进行的,可能你 JS 执行时布局渲染并未完成,所以不管是 JS 还是 Native,在页面载入完成之后就获取innerHeight 或者 contentSize 都是不准确的,要么通过延迟获取,要么监听属性值变化,实时修正获取的值

3.默认的跳转行为,打开 iTuns、tel、mail、open 等

在 UIWebView 上,如果超链接设置未 tel://00-0000 之类的值,点击会直接拨打电话,但在 WKWebView 上,该点击没有反应,类似的都被屏蔽了,通过打开浏览器跳转 AppStore 已然无法实现这类情况只能在跳转询问中处理,校验 scheme 值通过 UIApplication 外部打开。

4.下载链接无法处理

下载链接在 UIWebView 上其实也是需要特殊处理,在服务器响应询问中校验流类型即可。

5.跨域问题

HTTPS 对 HTTPS、HTTP 对 HTTP 跨域默认是能载入的,但如果是 HTTP 想载入 HTTPS 跨域链接,因为安全考虑,WKWebView 会被拦截,这问题在引入跨域 HTTPS 的页面也做 HTTPS。我们 HJ 已经切换了 HTTPS,所以不存在该问题

6.NSURLProtocol 问题

UIWebView 是通过 NSURLConneciton 处理的 HTTP 请求,而通过Conneciton 发出的请求都会遵循 NSURLProtocol 协议,通过这个特性,我们可以代理 Web 资源的下载,做统一的缓存管理或资源管理。但在 WKWebView 上这个不可行了,因为 WKWebView 的载入在单独进程中进行,数据载入 app 无法干涉

7.缓存问题

WKWebView 内部默认使用一套缓存机制,开发能控制的权限很有限,特别是在 iOS 8 下,根本没方式去操作,对于静态资源的更新,客户端经常出现读取缓存不更新的情况。
针对这个问题,如果仅仅是单个资源如此,并且其它缓存比较有用,那对该资源地址加时间戳避开缓存。
如果全局都是如此,这需要手动的去清理缓存,iOS 9 之后,系统提供了缓存管理接口 WKWebsiteDataStore。
而 iOS 9 之前,就只能通过删除文件来解决了,WKWebView 的缓存数据会存储在 ~/Library/Caches/BundleID/WebKit/ 目录下,可通过删除该目录来实现清理缓存

8.其它问题

还有一些零碎的小问题,比如通过写入 NSUserDefaults 来统一修改UserAgent;第三方库可能修改 Delegate 引起问题等等就不一一例举了,通过上述的问题,主要想表明出现问题的解决思路,只要不断去尝试,这些都不是阻碍。
功能性的问题比较典型的大多在 iOS 更新中都会完善,相信随着最低支持版本的提高,问题会越来越少

WKWebView缓存设计

在 WKWebView 的第六点问题中提到过,UIWebView是通过NSURLConnection处理的 HTTP 请求,而WKWebView的载入在另一个进程,我们就无法干涉。但是通过阅读WebKit源码可以知道,在WKWebView启动的时候也使用了NSURLPrototcol注册的。
在WKWebView中包含了一个browsingContextController属性对象,该对象提供了 registerSchemeForCustomProtocolunregisterSchemeForCustomProtocol 两个方法,能通过注册 scheme 来代理同类请求,符合注册 scheme 类型的请求会走 NSURLProtocol 协议。
在NSURLProtocol中,我们可以拦截一些请求,根据这些请求来决定是否进行缓存。
在程序开始的时候我们先注入我们自己实现的协议:

URLProtocol.registerClass(MyCacheURLProtocol.self)

在自己的定义的协议中,需要实现协议的拦截,拦截后加载缓存,未命中缓存则需要重启请求。在请求返回的方法中进行Response数据的缓存。

class MyCacheURLProtocol: URLProtocol {
    override class func canInit(with request: URLRequest) ->Bool {
        if URLProtocol.property(forKey: MyCacheURLProtocol.PropertyKey.tagKey, in: request) != nil {
            return false
        }
        return true
    }
    
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
    
    override func startLoading() {
        func sendRequest() {
            checkRedirectOrSendRequest()
        }
        
        // 有缓存则使用缓存,无缓存则发送请求
        self.getResponse(success: { (cacheResponse) in
            self.client?.urlProtocol(self, didReceive: cacheResponse.response, cacheStoragePolicy: .notAllowed)
            self.client?.urlProtocol(self, didLoad: cacheResponse.data)
            self.client?.urlProtocolDidFinishLoading(self)
        }, failure:{
            sendRequest()
        })
    }
    
    override func stopLoading() {
        self.dataTask?.cancel()
        self.dataTask       = nil
        self.receivedData   = nil
        self.urlResponse    = nil
    }
    
    override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool {
        return super.requestIsCacheEquivalent(a, to: b)
    }
    
    // MARK: - 私有方法
    // 1.查看是否有重定向
    // 1.1 有则映射重定向网站的缓存
    // 1.2 无则继续查看原请求是否有缓存
    // 2.都无缓存,则发送请求
    fileprivate func checkRedirectOrSendRequest() {
        // 未获取到缓存---查看是否有重定向,有则加载重定向
        SQLiteManager.shared.fetchOrDeleteRedirectInfo(url: request.url?.absoluteString, success: { (storedRequest, storedResponse) in
            guard let redirectRequest = (storedRequest as NSURLRequest).mutableCopy() as? NSMutableURLRequest else {return}
            URLProtocol.removeProperty(forKey: MyCacheURLProtocol.PropertyKey.tagKey, in: redirectRequest)
            self.client?.urlProtocol(self, wasRedirectedTo: redirectRequest as URLRequest, redirectResponse: storedResponse)
        }) {
            guard let newRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else {return}
            URLProtocol.setProperty("blablabla", forKey: MyCacheURLProtocol.PropertyKey.tagKey, in: newRequest)
            let sessionConfig = URLSessionConfiguration.default
            let urlSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
            self.dataTask = urlSession.dataTask(with: newRequest as URLRequest)
            self.dataTask?.resume()
        }
    }
    
    /// 将网页数据缓存入数据库
    fileprivate func saveResponse(_ response:URLResponse,_ data:Data) {
        if let url = self.request.url?.absoluteString {
            SQLiteManager.shared.searchAndUpdateOrInsertCacheInfo(url: url, response, data)
        }
    }
    
    /// 获取网页缓存
    fileprivate func getResponse(success:(CachedURLResponse)->Void,failure:()->Void) {
        if let url = self.request.url?.absoluteString {
            SQLiteManager.shared.fetchOrDeleteCacheInfo(url: url, success: success, failure: failure)
        }
    }
    
    // MARK: - 私有变量
    fileprivate var dataTask: URLSessionDataTask?
    fileprivate var urlResponse: URLResponse?
    fileprivate var receivedData: NSMutableData?
}

extension MyCacheURLProtocol {
    struct PropertyKey{
        static var tagKey = "MyURLProtocolTagKey"
    }
}

extension MyCacheURLProtocol:URLSessionTaskDelegate,URLSessionDataDelegate {
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        self.urlResponse = response
        self.receivedData = NSMutableData()
        completionHandler(.allow)
    }
    
    func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
        guard let url = self.request.url?.absoluteString else {return}
        // 插入重定向记录
        SQLiteManager.shared.searchAndUpdateOrInsertRedirectInfo(url: url, response: response, request: request)
        // 请求重定向后的地址
        guard let redirectRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else {return}
        URLProtocol.removeProperty(forKey: MyCacheURLProtocol.PropertyKey.tagKey, in: redirectRequest)
        self.client?.urlProtocol(self, wasRedirectedTo: redirectRequest as URLRequest, redirectResponse: response)
        self.dataTask?.cancel()
        self.client?.urlProtocol(self, didFailWithError: NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil))
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        self.client?.urlProtocol(self, didLoad: data)
        self.receivedData?.append(data)
    }
    
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if error != nil {
            self.client?.urlProtocol(self, didFailWithError: error!)
        } else {
            if self.urlResponse != nil && self.receivedData != nil {
                self.saveResponse(self.urlResponse!, self.receivedData?.copy() as! Data)
            }
            self.client?.urlProtocolDidFinishLoading(self)
        }
    }
}

/// 字符串MD5
extension String {
    func md5() -> String{
        let cStr = self.cString(using: .utf8);
        let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: 16)
        CC_MD5(cStr!,(CC_LONG)(strlen(cStr!)), buffer)
        let md5String = NSMutableString();
        for i in 0 ..< 16{
            md5String.appendFormat("%02x", buffer[i])
        }
        free(buffer)
        return md5String as String
    }
}

/// 归档路径
let cachePath = NSSearchPathForDirectoriesInDomains(.documentDirectory,.userDomainMask, true).first! as NSString

缓存策略

关于缓存策略,有很多种。缓存方式也有很多。但目前我们主要从以下几个方面来考虑:

  • 缓存什么内容
  • 何时进行缓存
  • 缓存存储和清理

缓存内容

由于我们使用的是WKWebView进行网页加载。既然是网页加载,所以缓存内容就很明确了。但是网页的组成元素也很多:JS、CSS、图片、HTML等等。怎么样来进行缓存才更加优雅呢?
在自定义的Protocol中,我们拦截了请求。如果我们请求没有对应的缓存,我们会将这个请求继续发出。而发出请求就用到了URLSession。URLSession的结构如图:
URLSession
我们在进行请求的时候使用的GET、POST等方法,本质上来讲其实都是URLSessionDataTask。在使用URLSession的时候,系统给了我们一系列的回调。

    optional public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)

    optional public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)
    

当服务器给我们返回数据的时候,会回调urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)这个方法。在这个方法里面,返回的Data类型的对象,就是数据,我们可以用一个MutableData接收并来拼接它们。当数据传输完成会调用optional public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)这个方法。在此方法里面,我们能进行数据的缓存处理。将完整的Data缓存起来。

何时进行缓存

缓存的时机其实在上部分已经提到过了,就是在optional public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)这个方法被调用的时候进行缓存。但是并不是所有的数据都需要缓存。如页面的样式CSS、JS脚本等,变动很小的需要进行缓存,但是如活动图片等静态资源,可能随时会发生变化,则不需要进行缓存,或设置缓存的有效时期。当过期之后再次进行请求。
正如人有一生,资源缓存在本地之后,也有自己的生命周期。而资源的生命周期主要根据实际情况来。长期存在且不易变动的资源,缓存有效时间则设置长;而短期变化大,随时变化的资源,缓存有效时间则设置短。

缓存存储和清理

缓存的存储其实是对数据的管理。自然而然想到用数据库。在iOS中可以使用Swift提供的CoreData、SQLite等。SQLite小巧轻便,所以我就使用了SQLite。关于接收的数据,由于是Data类型,遵守NSCoding协议,所以我们可以很简便的使用iOS中的NSKeyedArchiverNSKeyedUnarchiver来进行归档、解档。而在SQLite中,我们需要资源路径、资源存入时间和资源大小等字段。资源路径唯一的确定了资源本身,资源存入时间用于管理资源的生命周期,而资源大小则用来缓存占用空间大小的管理。当然字段并不只有这些,也是要根据实际情况来建立。
当我们想要从服务器获取数据的时候,我们首先应该检索数据库中有没有该资源,如果有则进行有效时间的判断,如果没有过期,则直接取用。过期则从服务器请求,再来刷新本地记录和资源文件。但是根据实际情况可以设置直接使用缓存,如用户断网,为了增强用户体验,我们需要展示数据。这个时候即使数据过期了,也可以使用。当再次连上网的时候再进行刷新。
由于网页可能存在重定向的可能,所以我们也需要对重定向记录也进行缓存。重定向有长期和短期的。记录也随之是长期和短期的。重定向记录的缓存策略和资源的策略其实差别不大。
缓存不止需要存文件,也需要删除文件。删除资源有2条路径,一是资源大小:当资源文件大小超过设定值之后,则进行缓存清理;而是时间:缓存过期,进行清理也是无可厚非的。但是删除资源的时机也是看情况而定。因为在使用的过程中,我们会动态的进行资源的更替。所以主要考虑的就是资源大小,目前App很多都是有清理缓存的选项让用户进行清理。除了用户清理外,我们也要设置缓存检查点,一般在程序开始或结束的时候检查。还有一个缓存监控方案就是开一个后台进程,每隔一段时间对缓存进行清理。具体的做法还是要跟需求而定。

//
//  SQLiteManager.swift
//  Client
//
//  Created by 柳钰柯 on 2017/5/22.
//  Copyright © 2017年 柳钰柯. All rights reserved.
//

import UIKit
import FMDB

final class SQLiteManager {
    //#MARK: - 创建类对象的实例-单例
    // let是线程安全的
    static let shared = SQLiteManager()
    
    //#MARK: - 对外接口
    /// 更改缓存存储默认时间
    func setValidateTime(_ time: Int) {
        timeout = time
    }
    
    // MARK: 数据库相关操作
    func checkSize() -> Double {
        var size:Double = 0
        let checkCacheSQL = SQLConstructor.fetchSQL(tableName: cacheName, primaryKey: nil)
        let redirectSQL = SQLConstructor.fetchSQL(tableName: redirectName, primaryKey: nil)

        let dbQueue = FMDatabaseQueue(path: "\(documentPath!)/app.sqlite")
        dbQueue.inDatabase { (db) in
            guard let rsCache = try?db.executeQuery(checkCacheSQL, values: nil) else {return}
            guard let rsRedir = try?db.executeQuery(redirectSQL, values: nil) else {return}
            while rsCache.next() {
                if let sizeStr = rsCache.string(forColumn: SQLConstructor.size) {
                   size += (Double.init(sizeStr) ?? 0)
                }
            }
            while rsRedir.next() {
                if let sizeStr = rsRedir.string(forColumn: SQLConstructor.size) {
                    size += (Double.init(sizeStr) ?? 0)
                }
            }
            rsRedir.close()
            rsCache.close()
        }
        dbQueue.close()
        return size
    }
    
    
    /// 查找并且更新或者插入重定向记录
    ///
    /// - Parameters:
    ///   - url: 请求URL
    ///   - response: 重定向回复
    ///   - request: 重定向后的新请求
    func searchAndUpdateOrInsertRedirectInfo(url: String,response: HTTPURLResponse, request: URLRequest) {
        var dic = [String:String]()
        let key = url.md5()
        let requestPath = cachePath.appendingPathComponent("\(request.hashValue)")
        let reponsePath = cachePath.appendingPathComponent("\(response.hashValue)")
        
        dic[SQLConstructor.key] = key
        dic[SQLConstructor.request] = "\(request.hashValue)"
        dic[SQLConstructor.response] = "\(response.hashValue)"
        dic[SQLConstructor.time] = formatterDateToString(date: Date())
        
        /// 归档重定向记录
        if NSKeyedArchiver.archiveRootObject(request, toFile: requestPath) && NSKeyedArchiver.archiveRootObject(response, toFile: reponsePath) {
            
            var size = try!FileManager.default.attributesOfItem(atPath: requestPath)[FileAttributeKey.size] as! Double
            size += try!FileManager.default.attributesOfItem(atPath: reponsePath)[FileAttributeKey.size] as! Double
            dic[SQLConstructor.size] = "\(size)"
            
            let querySQL = SQLConstructor.fetchSQL(tableName: redirectName, primaryKey: key)
            let dbQueue = FMDatabaseQueue(path: "\(documentPath!)/app.sqlite")
            dbQueue.inDatabase({ (db) in
                if let result = try?db.executeQuery(querySQL, values: nil) {
                    if result.next() {
                        print("\(key)执行更新数据库操作")
                        let updateSQL = SQLConstructor.updateRedirectSQL(tableName: redirectName, dic: dic)
                        try?db.executeUpdate(updateSQL, values: nil)
                    } else {
                        print("\(key)执行插入数据库操作")
                        let insertSQL = SQLConstructor.insertRedirectSQL(tableName: redirectName, dic: dic)
                        try?db.executeUpdate(insertSQL, values: nil)
                    }
                    result.close()
                } else {
                    print("\(key)执行插入数据库操作")
                    let insertSQL = SQLConstructor.insertRedirectSQL(tableName: redirectName, dic: dic)
                    try?db.executeUpdate(insertSQL, values: nil)
                }
            })
            dbQueue.close()
        }

    }
    
    /// 查找并更新数据库记录,如果没有记录则插入记录
    ///
    /// - Parameters:
    ///   - url: 请求URL
    ///   - response: 回复头
    ///   - data: 回复数据
    func searchAndUpdateOrInsertCacheInfo(url: String,_ response:URLResponse,_ data:Data) {
        let key = url.md5()
        var dic = [String:String]()
        dic[SQLConstructor.url] = url
        dic[SQLConstructor.key] = key
        dic[SQLConstructor.time] = formatterDateToString(date: Date())
        
        // 归档成功---缓存
        if NSKeyedArchiver.archiveRootObject(CachedURLResponse(response: response, data: data, userInfo: nil, storagePolicy: .notAllowed), toFile: cachePath.appendingPathComponent(key)) {
            print("\(key.md5())本地归档成功")
            let size = try!FileManager.default.attributesOfItem(atPath: cachePath.appendingPathComponent(key) as String)[FileAttributeKey.size] as! Int
            dic[SQLConstructor.size] = "\(size)"
            print("存入缓存-----MD5:\(key)")
                
            let querySQL = SQLConstructor.fetchSQL(tableName: cacheName, primaryKey: key)
            let dbQueue = FMDatabaseQueue(path: "\(documentPath!)/app.sqlite")
            dbQueue.inDatabase({ (db) in
                if let result = try?db.executeQuery(querySQL, values: nil) {
                    if result.next() {
                        print("\(key)执行更新数据库操作")
                        let updateSQL = SQLConstructor.updateCacheSQL(tableName: cacheName, dic: dic)
                        try?db.executeUpdate(updateSQL, values: nil)
                    } else {
                        print("\(key)执行插入数据库操作")
                        let insertSQL = SQLConstructor.insertCacheSQL(tableName: cacheName, dic: dic)
                        try?db.executeUpdate(insertSQL, values: nil)
                    }
                    result.close()
                } else {
                    print("\(key)执行插入数据库操作")
                    let insertSQL = SQLConstructor.insertCacheSQL(tableName: cacheName, dic: dic)
                    try?db.executeUpdate(insertSQL, values: nil)
                }
            })
            dbQueue.close()
        }
    }
    

    
    /// 从数据库获取重定向URL记录,如果过期则删除
    ///
    /// - Parameters:
    ///   - primaryKeyValue: 重定向URL
    ///   - success: 查找成功CallBack
    ///   - failure: 查找失败CallBack
    func fetchOrDeleteRedirectInfo(url:String? ,success:(URLRequest,URLResponse)->Void,failure:()->Void) {
        guard let confirmURL = url else {return}
        let dbQueue = FMDatabaseQueue(path: "\(documentPath!)/app.sqlite")
        dbQueue.inDatabase { (db) in
            // 查看是否有重定向
            let fetchSQL = SQLConstructor.fetchSQL(tableName: redirectName, primaryKey: confirmURL.md5())
            if let rs = try?db.executeQuery(fetchSQL, values: nil) {
                // 有重定向
                if rs.next() {
                    guard let request = rs.string(forColumn: SQLConstructor.request) else {failure();return}
                    guard let response = rs.string(forColumn: SQLConstructor.response) else {failure();return}
                    guard let time = rs.string(forColumn: SQLConstructor.time) else {failure();return}
                    let requestPath = cachePath.appendingPathComponent(request)
                    let responsePath = cachePath.appendingPathComponent(response)
                    
                    let now = formatterDateToString(date: Date())
                    let cnn = Reachability(hostName: "www.baidu.com")
                    if cacheIsOutDate(before: time, now: now) && cnn?.currentReachabilityStatus() != NotReachable {
                        print("缓存过期,执行删除")
                        let deleteSQL = SQLConstructor.deleteSQL(tableName: redirectName, primaryKey: confirmURL.md5())
                        try?FileManager.default.removeItem(atPath: requestPath)
                        try?FileManager.default.removeItem(atPath: responsePath)
                        try?db.executeUpdate(deleteSQL, values: nil)
                        failure()
                    } else {
                        if FileManager.default.fileExists(atPath: requestPath) && FileManager.default.fileExists(atPath: responsePath) {
                            success(NSKeyedUnarchiver.unarchiveObject(withFile: requestPath) as! URLRequest,
                                    NSKeyedUnarchiver.unarchiveObject(withFile: responsePath) as! URLResponse)
                        } else {
                            try?db.executeUpdate(SQLConstructor.deleteSQL(tableName: redirectName, primaryKey: confirmURL.md5()), values: nil)
                            failure()
                        }
                    }
                } else {
                    failure()
                }
            } else {
                failure()
            }
        }
        dbQueue.close()
    }
    
    
    /// 从数据库获取缓存记录,如果过期则删除
    ///
    /// - Parameters:
    ///   - url: 请求URL
    ///   - success: 命中缓存回调
    ///   - failure: 未命中回调
    func fetchOrDeleteCacheInfo(url: String,success:(CachedURLResponse)->Void,failure:()->Void) {
        let querySQL = SQLConstructor.fetchSQL(tableName: cacheName, primaryKey: url.md5())
        let dbQueue = FMDatabaseQueue(path: "\(documentPath!)/app.sqlite")
        dbQueue.inDatabase({ (db) in
            // 保存查询到的值
            var dic = [String:String]()
            if let result = try?db.executeQuery(querySQL, values: nil) {
                if result.next() {
                    dic[SQLConstructor.key] = result.string(forColumn: SQLConstructor.key)
                    dic[SQLConstructor.time] = result.string(forColumn: SQLConstructor.time)
                    if let key = dic[SQLConstructor.key], let time = dic[SQLConstructor.time] {
                        
                        let now = formatterDateToString(date: Date())
                        let cnn = Reachability(hostName: "www.baidu.com")
                        // 判断网络状态,网络连通则可以抛弃过期缓存。无网络则直接加载缓存
                        if cacheIsOutDate(before: time, now: now) && cnn?.currentReachabilityStatus() != NotReachable {
                            print("缓存过期,执行删除")
                            let deleteSQL = SQLConstructor.deleteSQL(tableName: cacheName, primaryKey: key)
                            try?db.executeUpdate(deleteSQL, values: nil)
                            failure()
                        } else {
                            let path = cachePath.appendingPathComponent(key)
                            if FileManager.default.fileExists(atPath: path) {
                                print("取出缓存:\(key)")
                                success(NSKeyedUnarchiver.unarchiveObject(withFile: path) as! CachedURLResponse)
                            } else {
                                print("\(key)本地文件不存在,删除数据库记录")
                                let deleteSQL = SQLConstructor.deleteSQL(tableName: cacheName, primaryKey: key)
                                try?db.executeUpdate(deleteSQL, values: nil)
                                failure()
                            }
                        }
                    } else {
                        failure()
                    }
                } else {
                    failure()
                }
                result.close()
            } else {
                failure()
            }
        })
        dbQueue.close()
    }
    
    // MARK: - 缓存策略逻辑
    /// 根据设定时间节点来删除缓存
    func programDeleteCacheFile() {
        // 查看数据库,筛选时间节点,时间节点超过缓存有效时间则删除
        let cacheoutSQL = SQLConstructor.fetchCacheWillDeleteSQL(tableName: cacheName, timeInterval: timeout)
        let redirectoutSQL = SQLConstructor.fetchCacheWillDeleteSQL(tableName: redirectName, timeInterval: timeout)
        let dbQueue = FMDatabaseQueue(path: "\(documentPath!)/app.sqlite")
        dbQueue.inDatabase { (db) in
            guard let rsCacheout = try?db.executeQuery(cacheoutSQL, values: nil) else {return}
            guard let rsRedirect = try?db.executeQuery(redirectoutSQL, values: nil) else {return}
            while rsCacheout.next() {
                guard let MD5 = rsCacheout.string(forColumn: SQLConstructor.key) else {continue}
                if FileManager.default.fileExists(atPath: cachePath.appendingPathComponent(MD5)) {
                    try!FileManager.default.removeItem(atPath: cachePath.appendingPathComponent(MD5))
                    try?db.executeUpdate(SQLConstructor.deleteSQL(tableName: cacheName, primaryKey: MD5), values: nil)
                }
            }
            while rsRedirect.next() {
                guard let MD5 = rsRedirect.string(forColumn: SQLConstructor.key) else {continue}
                guard let requestHash = rsRedirect.string(forColumn: SQLConstructor.request) else {continue}
                guard let responseHash = rsRedirect.string(forColumn: SQLConstructor.response) else {continue}
                if FileManager.default.fileExists(atPath: cachePath.appendingPathComponent(requestHash)) {
                    try!FileManager.default.removeItem(atPath: cachePath.appendingPathComponent(requestHash))
                    
                }
                if FileManager.default.fileExists(atPath: cachePath.appendingPathComponent(responseHash)) {
                    try!FileManager.default.removeItem(atPath: cachePath.appendingPathComponent(responseHash))
                }
                try?db.executeUpdate(SQLConstructor.deleteSQL(tableName: redirectName, primaryKey: MD5), values: nil)
            }
            rsCacheout.close()
            rsRedirect.close()
        }
        dbQueue.close()
    }
    
    // MARK: - 私有属性
    private var timeout:Int = 60*2
    private var cacheSize:Double = 1024*1024*10
    private var cacheName = "Caches"
    private var redirectName = "RedirectURLS"
    private let documentPath = NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.documentDirectory, FileManager.SearchPathDomainMask.userDomainMask, true).last
    private var db : FMDatabase? = nil

    // MARK: - 私有方法
    private init() {
         _ = openDB()
    }
    
    deinit {
        db?.close()
    }
    
    /// 打开数据库
    fileprivate func openDB() -> Bool{
        db = FMDatabase(path: "\(documentPath!)/app.sqlite")
        print("路径:\(documentPath!)/app.sqlite")
        if !db!.open() {
            return false
        }
        createTable()
        return true
    }
    
    /// 根据名字建表,表中所有数据均为TEXT类型,即String,请自行转换
    fileprivate func createTable() {
        if !isTableExist(tableName: cacheName) {
            let createTable = SQLConstructor.createCacheSQL(name: cacheName)
            let dbQueue = FMDatabaseQueue(path: "\(documentPath!)/app.sqlite")
            dbQueue.inDatabase({ (db) in
                try!db.executeUpdate(createTable, values: nil)
            })
            dbQueue.close()
        }
        if !isTableExist(tableName: redirectName) {
            let createRedirect = SQLConstructor.createRedirectTableSQL(name: redirectName)
            let dbQueue = FMDatabaseQueue(path: "\(documentPath!)/app.sqlite")
            dbQueue.inDatabase({ (db) in
                try!db.executeUpdate(createRedirect, values: nil)
            })
            dbQueue.close()
        }
    }
    
    /// 判断缓存是否过期
    fileprivate func cacheIsOutDate(before: String, now: String) -> Bool {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyy-MM-dd HH:mm:ss"
        if let beforeTime = formatter.date(from: before), let nowTime = formatter.date(from: now) {
            let inter = nowTime.timeIntervalSince(beforeTime)
            if inter < TimeInterval(timeout) {
                return false
            } else {
                return true
            }
        }
        return false
    }
    
    /// 日期格式化
    fileprivate func formatterDateToString(date: Date) -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyy-MM-dd HH:mm:ss"
        return formatter.string(from: Date())
    }
    
    /// 查询表是否存在
    fileprivate func isTableExist(tableName name: String) -> Bool {
        var isExist = false
        let search = SQLConstructor.searchTableSQL(tableName: name)
        let dbQueue = FMDatabaseQueue(path: "\(documentPath!)/app.sqlite")
        dbQueue.inDatabase({ (db) in
            guard let result = try?db.executeQuery(search, values: nil) else {return}
            if result.next() {
                isExist = true
            }
            result.close()
        })
        dbQueue.close()
        return isExist
    }
}

参考

Tags: None
Archives QR Code
QR Code for this page
Tipping QR Code
Leave a Comment

已有 84 条评论
  1. More info...

  2. This message is posted here using XRumer + XEvil 3.0
    XEvil 3.0 is a revolutionary application that can bypass almost any anti-botnet protection.
    Captcha Recognition Google, Facebook, Yandex, VKontakte, Captcha Com and over 8.4 million other types!
    You read this - it means it works! ;)
    Details on the official website of XEvil.Net, there is a free demo version.

  3. Автовыкуп Киев
    https://vikupauto.club/avtovikup - автовыкуп
    Автовыкуп - это одно из направлений компании автовыкуп киев - это одно из ответвлений выкупа авто ориентированное на быстрый выкуп автомобиля у владельца,

    fdnjdsreg rbtdесли последнему срочно нужно продать авто. Понятно, что критерием отбора из многочисленных компаний, которые занимаются автовыкупом будет
    автовыкуп цена
    основной критерий- это цена выкупа авто в киеве и области. Но тут большую роль играет не только состояние авто, но и марка автомобиля, его год выпуска и модель.
    Одни марки продаются быстрее, другие не продаются долгое время, и тут играет в большей степени для рыночной цены автовыкупа автомобиля. На сегодняшний сложилась тенденция в том,
    что сложить рыночную или продажную цену автомобиля, практически не возможно. В виду того, что в основном оценка авто производится из доступных материалов таких,
    как многочисленные порталы по продаже автомобилей, но в этом и есть большая ошибка, когда продавец пытается понять сколько стоит его подержанный автомобиль на рынке перед продажей через автовыкуп киев.
    Ошибка в том, что все объявления на таких ресурсах частные и очень оторваны от реальности цены продажи авто. Каждый ставит цену за свой автомобиль такую, какую он хочет и в большинстве случаев она, как правило,
    завышена процентов на 30.

    https://vikupauto.club/avtovikup/avtovikup - fdnjdsreg
    В качестве эксперимента можно позвонить по любому из объявлений посмотреть цену и предложить продавцу на процентов 20 меньше указанной,
    в большинстве случаев владельцы соглашаются на предложенную им цену и готовы сами привести автомобиль для нового владельца.
    Поэтому слаживая цену для автовыкупа в киеве, наши специалисты ориентируются именно на продажную цену автовыкупа ,а не на заявленную.
    https://vikupauto.club/avtovikup - автовыкуп
    Автовыкуп
    Автовыкуп
    автовыкуп киев
    автовыкуп киев
    автовыкуп дорого
    автовыкуп дорого
    автовыкуп цена
    автовыкуп цена
    цена автовыкуп
    цена автовыкуп
    дорогой автовыкуп
    дорогой автовыкуп
    fdnjdsreg
    fdnjdsreg
    fdnjdsreg rbtd
    fdnjdsreg rbtd
    автовыкуп дорого
    автовыкуп дорого
    автовыкуп
    автовыкуп


    автовыкуп киев

  4. Типы Вагонка Киев
    Вагонка киев – это высоко-качественная вагонка,
    которая проходит строгий контроль качества.
    https://eco-les.club/news/tip_vagonka_kiev - вагонка киев ольха с сучком
    Работники компании эко лес строго следят за производством вагонки Киев.
    Большое значение при производстве вагонки Киев уделяется процессу сушки древесины на производстве.
    Вагонка киев
    вагонка киев ольха
    вагонка для бани
    вагонка для сауны
    все для сауны
    все для бани
    вагонка киев сосна
    вагонка киев липа
    вагонка для бани киев
    вагонка с сучком
    вагонка киев с сучком
    вагонка киев без сучка
    вагонка киев ольха без сучка
    вагонка киев ольха с сучком
    вагонка киев сосна без сучка
    вагонка киев сосна с сучком
    вагонка киев липа без сучка
    вагонка липа с сучком
    ЭкоЛес
    эко лес
    эко-лес
    вагонка
    вагонка липа
    вагонка сосна
    вагонка ольха

    Не маловажным аспектом является процесс обработки древесины при поступлении на производство.
    эко лес
    Вагонка киев изготавливается из разных пород древесины, таких как вагонка киев ольха которая может быть как, с сучком,
    который придает особую изюминку в интерьере помещений многие дизайнеры заказывают именно такую вагонку, так как запах древесины в помещении,
    а именно ольхи, придает особый шарм помещению.
    https://eco-les.club/news/tip_vagonka_kiev - вагонка киев липаТакже вагонка киев ольха может изготавливается из отборной древесины без сучков. Второй вариант изготовления вагонки киев из сосны эта вагонка имеет более смолянистую структуру и более выраженный запах сосны.
    Вагонка киев сосна, также широко используется дизайнерами как отличное решение дизайна внутри помещений при том что вагонка киев сосна имеет более выраженный запах сосны. Вагонка киев сосна так же может, изготавливается как с сучком, так и из отборной древесины без сучка.
    Третий вариант вагонки киев- это вагонка киев липа, которая имеет более мягкую структуру волокон, и очень проста в монтаже на стенах и потолках. Вагонка киев липа нашла свое широкое применение в отделе саун и широко используется в ассортименте все для саун, все для бани.
    Наши специалисты компании ЭкоЛес проходят международное обучение по стандартам SETAM, за рубежом покупая любую продукцию в компании Эколес вы будете уверены, что приобрели экологически чистую продукцию, которая отвечает международным стандартам качества и прошла, абсолютна все экологические тесты и экспертизы.
    При покупке древесины остерегайтесь не качественной продукции.
    https://eco-les.club/news/tip_vagonka_kiev - вагонка киев без сучка
    Вагонка – это пиломатериал идеально строганный, который сразу же применяется в декоре помещений как внутри так и снаружи строения.
    Она представляет собой не толстую, определенного размера длинны и ширины. Вагонку изготавливают как из дешевых сортов древесины так и с дорогих.
    Вагонка киев разделяется на сорта в зависимости от качества дерева и столярных работ.Низшим сортом является сучки на пиломатериале,
    их количество и размеры, смолянистые выделение на древесине, наличие коры, присутствие гнили или отверстия от жуков, неровность, вмятины и т.д.
    https://eco-les.club/news/vagonka
    Вагонка
    вагонка
    вагонка киев
    вагонка липа
    вагонка купить
    вагонка цена
    вагонка украина
    Вагонка киев
    вагонка киев ольха
    вагонка для бани
    вагонка для сауны
    все для сауны
    все для бани
    вагонка киев сосна
    вагонка киев липа
    вагонка для бани киев
    вагонка с сучком
    вагонка киев с сучком
    вагонка киев без сучка
    вагонка киев ольха без сучка
    вагонка киев ольха с сучком
    вагонка киев сосна без сучка
    вагонка киев сосна с сучком
    вагонка киев липа без сучка
    вагонка липа с сучком
    ЭкоЛес
    эко лес
    эко-лес
    вагонка
    вагонка липа
    вагонка сосна
    вагонка ольха

    https://eco-les.club/news/tip_vagonka_kiev - вагонка для бани киев
    вагонка киев сосна

  5. Bestporn Tube. rajwap.xyz

  6. Керівник ГО “Агенція протидії корупції” Франтовський Євгеній Миколайович

    НАгенція протидії корупції народився у 1985 в с. Губник Гайсинського району Вінницької області.

    Отримав вищу освіту в Одеській національній юридичній академії за спеціальністю правознавство.

    ОАгенція протидії корупції обіймав різні посади в органах прокуратури усіх рівнів. З прокуратури звільнився за власним бажанням,
    ппісля чого почав займатися власною підприємницькою діяльністю. Паралельно з веденням бізнесу,
    уАгенція протидії корупції 2017 році заснував Громадську організацію “Агенція протидії корупції”, основною метою якої є боротьба з корупцією в Україні.
    Государственная служба и противодействие коррупции

    Федеральное бюро по борьбе с коррупцией

    Національне антикорупційне бюро

    Знешкодити та запобігти

    новости Национального бюро

    Національне антикорупційне бюро

    Національне антикорупційне бюро

    Департамент Национального бюро по противодействию коррупции

    Антикоррупционная служба - Главная страница
    Национальное антикоррупционное бюро Украины

  7. https://evercar.biz/manheim
    История и обзор авто аукциона Manheim
    Manheim - крупнейший в мире, самый полный рынок оптовых автомобилей. Это лизинговый аукцион закрытого типа.
    Обладая более чем 100 операционными подразделениями по всему миру они
    привлекают квалифицированных покупателей и мотивированных продавцов на живые, конкурентные аукционы.
    Компания предоставляет множество продуктов и услуг, таких как восстановление, инспекции, дилерское финансирование,
    транспорт и прочее.
    Manheim была основана в 1945 году как оптовый рынок автомобилей.
    Фирма продолжает устанавливать отраслевой стандарт для покупки и продажи подержанных автомобилей сегодня.
    Manheim является ведущим поставщиком услуг ремаркетинга для автомобилей в Северной Америке,
    объединяющим покупателей и продавцов на крупнейший оптовый рынок подержанных автомобилей и самую обширную сеть аукционов.
    Благодаря 125 традиционным и мобильным аукционным сайтам и надежному цифровому рынку
    компания помогает коммерческим и некоммерческим клиентам достигать результатов бизнеса,
    предоставляя инновационные комплексные решения для инвентаризации.
    Примерно 18 000 сотрудников позволяют Мангейму регистрировать около 8 миллионов подержанных автомобилей в год,
    облегчая транзакции, составляющие почти 57 миллиардов долларов США, и приносят годовой доход более 2,6 миллиарда долларов.
    Дочерняя компания Cox Enterprises в Манхейме, основанная в Атланте,
    трансформирует опыт покупки и продажи оптовых транспортных средств за счет инвестиций в технологии
    и инновационные продукты и услуги. Манхеймские рынки, такие как Simulcast, Simulcast Everywhere, OVE.com,
    NextGear Capital, Total Resource Auctions, Manheim Frontline, Ready Logistics, Manheim Consulting и другие уважаемые бренды
    для индустрии ремаркетинга в 11 странах, включая Австралию, Турцию и Великобританию.
    Сколько стоит растаможка авто из США в Украине
    Пример расчета стоимости авто из США

    авто из сша в украину

    купить авто в сша б/у
    купить авто аукцион

    Низкие цены на аварийные

    Аукционы Америки

    авто из сша в украину

    авто из сша одесса киев

    авто из сша одесса киев

    evercar заказать автомобиль американский

    Американские аукционы и сайты по продаже авто и мото техники предлагает evercar

    купить авто в сша б/у

    авто из сша в украину отзывы

    авто аукцион онлайн

    Купить БУ автомобиль на аукционах Америки

    сша американские авто купить заказать продажа

    автоаукционы

    авто из сша в украину

    Аукционы США

    авто из сша в украину отзывы

  8. новости криптовалюты, актуальные курсы криптовалюты и прогнозы по росту биткоинами, эфириума.

    https://cryptocur.net

  9. похудение в домашних условиях от Дианы Кутаниной
    Оздоровительно-спортивные услуги,туры, составля­ют основную систему физической культуры и спорта.
    Способствуют физическо­му, духовному, социальному преобразованию человека, совер­шенствованию его физических,
    интеллектуальных, нравственных, волевых и других качеств формирования личтности.
    Под товарами подразумевается инвентарь и спортивное питание. Стоимость услуг на систему,
    направленную на формирование скульптуры и укрепления тела написаны ниже.
    Спортивные товары скоро появятся на сайте. Подробнее Вы сможете узнать по телефону или у администратора зала.
    Фитнесс клуб на Лесной
    похудение в домашних условиях от Дианы Кутаниной
    спортивные турыоздоровительно спортивные туры
    фитнесс центрфитнесс
    турыспортивные туры

    домашнее похудение

  10. Производствосветодиодных табло для спорта, бегущих строк, табло для АЗС

  11. GeorgiyAcesT GeorgiyAcesT

    Ночью анализировал материалы сети, и вдруг к своему удивлению увидел полезный ресурс. Вот посмотрите: автополив киев . Для моих близких вышеуказанный ресурс оказал незабываемое впечатление. Удачи!

  12. Сегодня вечером пересматривал материалы сети, вдруг к своему удивлению обнаружил важный веб-сайт. Я про него: умный дом доклад . Для нас данный ресурс показался очень неплохим. Успехов всем!

  13. Hurrah, that's what I was seeking for, what a data!
    existing here at this website, thanks admin of this web page.

  14. RobertBup RobertBup

    Super Creative Sounds. Behold: a EDM DJ mix

  15. Много пересматривал содержание инета, и вдруг к своему удивлению увидел познавательный вебсайт. Смотрите: тойота камри как включить круиз контроль . Для нас этот ресурс оказал радостное впечатление. Всего наилучшего!

  16. Josephkaf Josephkaf

    Забери 5000 руб. совершенно бесплатно! Всего лишь вступи в смешной паблик вконтакте https://vk.com/Vash_Pozitiv и сделай репост закреплённого поста. К тому же улучшишь своё настроение, потому, что в нём ежечасно публикуются отборные шутки :-)

  17. JamesSiz JamesSiz

    Manipulator Manuals a281 (copy year s41 to existent k27) and Parts b368 Catalogs (paragon year e501 to close f337) proper for John Deere z76 apparatus are convenient f981 in electronic layout d464 looking for the U.S. f691 at most at this z796 time. Note: Restricted practitioner's p497 manuals are available a883 in electronic dimensions a419 pro p440, l489, and q191 ideal t677 years.

    https://bitbucket.org/snippets/liarolocholun/kE7jy8

  18. GeorgiyAcesT GeorgiyAcesT

    Сегодня анализировал данные интернет, и вдруг к своему удивлению обнаружил неплохой вебсайт. Вот гляньте: автополив киев . Для моих близких вышеуказанный сайт произвел яркое впечатление. До встречи!

  19. Шлюхи не салон - Проститутки пышки, Проститутки СПб.

  20. すべての https://limtorrent.com/ 投稿者