MENU

WKWebView缓存设计

July 28, 2017 • Read: 31925 • 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

已有 86 条评论
  1. Alicetab Alicetab

    Unique gifts for all occasions you will love at great low prices.
    Click Here: https://www.redbubble.com/people/Goshadron/shop
    Worldwide shipping.

  2. This message is posted here using XRumer + XEvil 4.0
    XEvil 4.0 is a revolutionary application that can bypass almost any anti-botnet protection.
    Captcha Recognition Google (ReCaptcha-1, ReCaptcha-2), 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. imukowo

  4. https://www.rusalochka.asia/forum/kuplyu-dom-v-tailande - паттайя онлайн экскурсии, паттайя экскурсия обзорная.

  5. It�s remarkable for me to have a web site, which is helpful for my know-how. thanks admin

  6. Regards, Numerous stuff!