大数据

Kingfisher源码阅读(三)

上一篇地址:Kingfisher源码阅读(二)
第一篇地址:Kingfisher源码阅读(一)

上篇看完下载模块了,这篇主要是看一下缓存模块。我们是从KingfisherManager中的downloadAndCacheImageWithURL为入口进入到下载模块的,缓存模块也从这里进入。再贴一下downloadAndCacheImageWithURL吧:

func downloadAndCacheImageWithURL(URL: NSURL,
    forKey key: String,
    retrieveImageTask: RetrieveImageTask,
    progressBlock: DownloadProgressBlock?,
    completionHandler: CompletionHandler?,
    options: Options,
    targetCache: ImageCache,
    downloader: ImageDownloader)
{
    //下载图片
    downloader.downloadImageWithURL(URL, retrieveImageTask: retrieveImageTask, options: options,
        progressBlock: { receivedSize, totalSize in
            progressBlock?(receivedSize: receivedSize, totalSize: totalSize)
        },
        completionHandler: { image, error, imageURL, originalData in
            //304 NOT MODIFIED,尝试从缓存中取数据
            if let error = error where error.code == KingfisherError.NotModified.rawValue {
                // Not modified. Try to find the image from cache.
                // (The image should be in cache. It should be guaranteed by the framework users.)
                targetCache.retrieveImageForKey(key, options: options, completionHandler: { (cacheImage, cacheType) -> () in
                    completionHandler?(image: cacheImage, error: nil, cacheType: cacheType, imageURL: URL)

                })
                return
            }

            if let image = image, originalData = originalData {
                targetCache.storeImage(image, originalData: originalData, forKey: key, toDisk: !options.cacheMemoryOnly, completionHandler: nil)
            }

            completionHandler?(image: image, error: error, cacheType: .None, imageURL: URL)
        }
    )
}

在下载完图片之后的完成闭包中(会在下载请求结束后调用),如果服务器返回状态码304,说明服务器图片未更新,我们可以从缓存中取得图片数据,就是调用targetCache(ImageCache的一个实例)retrieveImageForKey。我们进入到ImageCache中看看这个方法的具体逻辑:

  • 给完成闭包进行解包,若为空则提前返回:
    // No completion handler. Not start working and early return.
    guard let completionHandler = completionHandler else {
        return nil
    }
  • 如果内存中有缓存则直接从内存中取图片;再判断图片是否需要解码,若需要,则先解码再调用完成闭包,否则直接调用完成闭包:

    //如果内存中有缓存,则直接从内存中读取图片
    if let image = self.retrieveImageInMemoryCacheForKey(key) {
    
      //Found image in memory cache.
      if options.shouldDecode {
          dispatch_async(self.processQueue, { () -> Void in
              let result = image.kf_decodedImage(scale: options.scale)
              dispatch_async(options.queue, { () -> Void in
                  completionHandler(result, .Memory)
              })
          })
      } else {
          completionHandler(image, .Memory)
      }
    }
  • 如果内存中没有缓存,则从文件中取图片,并判断是否需要进行解码,若需要则先解码再将它缓存到内存中然后执行完成闭包,否则直接缓存到内存中然后执行完成闭包,这里有一些关于GCD和避免retain cycle的技术细节,我写在注释中了:

    //会在回调中置空(为了避免retain cycle?)
    var sSelf: ImageCache! = self
    //创建一个调度对象块(可以使用dispatch_block_cancle(block)在对象块执行前取消对象块),DISPATCH_BLOCK_INHERIT_QOS_CLASS这个flag表明块从它进入的队列那里继承Qos等级
    block = dispatch_block_create(DISPATCH_BLOCK_INHERIT_QOS_CLASS) {
    
      // Begin to load image from disk
      dispatch_async(sSelf.ioQueue, { () -> Void in
          //通过key从文件中获取缓存图片
          if let image = sSelf.retrieveImageInDiskCacheForKey(key, scale: options.scale) {
              //需要先解码
              if options.shouldDecode {
                  dispatch_async(sSelf.processQueue, { () -> Void in
                      let result = image.kf_decodedImage(scale: options.scale)
                      sSelf.storeImage(result!, forKey: key, toDisk: false, completionHandler: nil)
    
                      dispatch_async(options.queue, { () -> Void in
                          completionHandler(result, .Memory)
                          sSelf = nil
                      })
                  })
              } else {
                  sSelf.storeImage(image, forKey: key, toDisk: false, completionHandler: nil)
                  dispatch_async(options.queue, { () -> Void in
                      completionHandler(image, .Disk)
                      sSelf = nil
                  })
              }
          } else {
              // No image found from either memory or disk
              dispatch_async(options.queue, { () -> Void in
                  completionHandler(nil, nil)
                  sSelf = nil
              })
          }
      })
    }
    //调度到主线程队列,retrieveImageForKey函数本身是在主线程中的,所以block会在retrieveImageForKey返回之后执行,而在执行之前,还可以被取消。
    dispatch_async(dispatch_get_main_queue(), block!)

获取图片就是这样了,这个方法里调用了storeImage这个方法,显然是用来缓存图片的,来看一下它的具体逻辑:

  • 缓存到内存:
    //内存缓存,memoryCache是一个NSCache,cost是图片尺寸(像素)
    memoryCache.setObject(image, forKey: key, cost: image.kf_imageCost)
  • 如果方法参数toDisktrue则先将其缓存到文件(如果图片数据存在并能被正确解析的话),然后调用完成闭包:

    if toDisk {
      dispatch_async(ioQueue, { () -> Void in
          let imageFormat: ImageFormat
          //取图片数据
          if let originalData = originalData {
              //解析图片格式
              imageFormat = originalData.kf_imageFormat
          } else {
              imageFormat = .Unknown
          }
    
          let data: NSData?
          switch imageFormat {
          case .PNG: data = UIImagePNGRepresentation(image)
          case .JPEG: data = UIImageJPEGRepresentation(image, 1.0)
          case .GIF: data = UIImageGIFRepresentation(image)
              //若originalData为nil,重绘图片后解析成PNG数据
          case .Unknown: data = originalData ?? UIImagePNGRepresentation(image.kf_normalizedImage())
          }
    
          if let data = data {
              //如果目录不存在则创建一个目录
              if !self.fileManager.fileExistsAtPath(self.diskCachePath) {
                  do {
                      try self.fileManager.createDirectoryAtPath(self.diskCachePath, withIntermediateDirectories: true, attributes: nil)
                  } catch _ {}
              }
    
              //创建图片文件
              self.fileManager.createFileAtPath(self.cachePathForKey(key), contents: data, attributes: nil)
              //在主线程执行回调(一般是UI操作吧)
              callHandlerInMainQueue()
          } else {
              callHandlerInMainQueue()
          }
      })
    }

整个缓存逻辑就是这样,这里有一个用来解析图片格式的属性kf_imageFormat,它是NSData的一个扩展属性:

extension NSData {
    //图片格式解析
    var kf_imageFormat: ImageFormat {
        var buffer = [UInt8](count: 8, repeatedValue: 0)
        //获取前8个Byte
        self.getBytes(&buffer, length: 8)
        if buffer == pngHeader {
            return .PNG
        } else if buffer[0] == jpgHeaderSOI[0] &&
            buffer[1] == jpgHeaderSOI[1] &&
            buffer[2] == jpgHeaderIF[0]
        {
            return .JPEG
        } else if buffer[0] == gifHeader[0] &&
            buffer[1] == gifHeader[1] &&
            buffer[2] == gifHeader[2]
        {
            return .GIF
        }

        return .Unknown
    }
}

至于pngHeaderjpgHeaderSOIjpgHeaderIFgifHeader这几个东西么,是几个常量:

private let pngHeader: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
private let jpgHeaderSOI: [UInt8] = [0xFF, 0xD8]
private let jpgHeaderIF: [UInt8] = [0xFF]
private let gifHeader: [UInt8] = [0x47, 0x49, 0x46]

它们虽然是全局的,但因为访问权限是private所以只能在当前文件内使用。这段代码思路很清晰,就是通过读取图片数据的头几个字节然后和对应图片格式标准进行比对。对图片格式感兴趣的同学可以看看这篇文章——移动端图片格式调研,作者是最近风头正劲的YYKit的作者ibireme。

ImageCache中还有一个删除过期缓存的方法cleanExpiredDiskCacheWithCompletionHander,我觉得也挺关键的,来看一下它的具体逻辑吧:

  • 一些准备工作,取缓存路径,过期时间等:

    let diskCacheURL = NSURL(fileURLWithPath: self.diskCachePath)
    let resourceKeys = [NSURLIsDirectoryKey, NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey]
    //过期日期:当期日期减去缓存时限,缓存时限默认为一周
    let expiredDate = NSDate(timeIntervalSinceNow: -self.maxCachePeriodInSecond)
    var cachedFiles = [NSURL: [NSObject: AnyObject]]()
    var URLsToDelete = [NSURL]()
    var diskCacheSize: UInt = 0
  • 遍历缓存图片(跳过隐藏文件和文件夹),如果图片过期,则加入待删除队列:

    //遍历缓存文件,跳过隐藏文件
    if let fileEnumerator = self.fileManager.enumeratorAtURL(diskCacheURL, includingPropertiesForKeys: resourceKeys, options: NSDirectoryEnumerationOptions.SkipsHiddenFiles, errorHandler: nil),
          urls = fileEnumerator.allObjects as? [NSURL] {
      for fileURL in urls {
    
          do {
              let resourceValues = try fileURL.resourceValuesForKeys(resourceKeys)
              //跳过目录
              // If it is a Directory. Continue to next file URL.
              if let isDirectory = resourceValues[NSURLIsDirectoryKey] as? NSNumber {
                  if isDirectory.boolValue {
                      continue
                  }
              }
              //若文件最新更新日期超过过期日期,则放入待删除队列
              // If this file is expired, add it to URLsToDelete
              if let modificationDate = resourceValues[NSURLContentModificationDateKey] as? NSDate {
                  if modificationDate.laterDate(expiredDate) == expiredDate {
                      URLsToDelete.append(fileURL)
                      continue
                  }
              }
    
              if let fileSize = resourceValues[NSURLTotalFileAllocatedSizeKey] as? NSNumber {
                  diskCacheSize += fileSize.unsignedLongValue
                  cachedFiles[fileURL] = resourceValues
              }
          } catch _ {
          }
    
      }
    }
  • 删除待删除队列中的图片:
    for fileURL in URLsToDelete {
      do {
          try self.fileManager.removeItemAtURL(fileURL)
      } catch _ {
      }
    }
  • 若剩余缓存内容超过预设的最大缓存尺寸,则删除存在时间较长的缓存,并将已删除图片的URL也加大删除队列中(为了一会儿的广播),直到缓存尺寸到达预设最大尺寸的一半:

    //若当前缓存内容超过预设的最大缓存尺寸,则先将文件根据时间排序(旧的在前),然后开始循环删除,直到尺寸降到最大缓存尺寸的一半。
    if self.maxDiskCacheSize > 0 && diskCacheSize > self.maxDiskCacheSize {
      let targetSize = self.maxDiskCacheSize / 2
    
      // Sort files by last modify date. We want to clean from the oldest files.
      let sortedFiles = cachedFiles.keysSortedByValue({ (resourceValue1, resourceValue2) -> Bool in
          /*
          下面这段可以这样吧?
          if let date1 = resourceValue1[NSURLContentModificationDateKey] as? NSDate, let date2 = resourceValue2[NSURLContentModificationDateKey] as? NSDate {
          return date1.compare(date2) == .OrderedAscending
          }
          */
          if let date1 = resourceValue1[NSURLContentModificationDateKey] as? NSDate {
              if let date2 = resourceValue2[NSURLContentModificationDateKey] as? NSDate {
                  return date1.compare(date2) == .OrderedAscending
              }
          }
          // Not valid date information. This should not happen. Just in case.
          return true
      })
    
      for fileURL in sortedFiles {
    
          do {
              try self.fileManager.removeItemAtURL(fileURL)
          } catch {
    
          }
    
          URLsToDelete.append(fileURL)
    
          if let fileSize = cachedFiles[fileURL]?[NSURLTotalFileAllocatedSizeKey] as? NSNumber {
              diskCacheSize -= fileSize.unsignedLongValue
          }
    
          if diskCacheSize < targetSize {
              break
          }
      }
    }
  • 在主线程广播已删除的缓存图片,如果有传入完成闭包的话,就调用它:

    dispatch_async(dispatch_get_main_queue(), { () -> Void in
    
      //将已删除的所有文件名进行广播
      if URLsToDelete.count != 0 {
    
          let cleanedHashes = URLsToDelete.map({ (url) -> String in
              return url.lastPathComponent!
          })
    
          NSNotificationCenter.defaultCenter().postNotificationName(KingfisherDidCleanDiskCacheNotification, object: self, userInfo: [KingfisherDiskCacheCleanedHashKey: cleanedHashes])
      }
    
      if let completionHandler = completionHandler {
          completionHandler()
      }
    })

缓存模块的主要内容就这些了,其他还有一些辅助方法像计算缓存尺寸啊、图片的排序啊、把图片URL进行MD5加密作为缓存文件名啊等等,我就不具体写了,有兴趣的同学可以直接去看源码。在UIImage+Extension文件中还有一些处理图片的扩展方法,诸如标准化图片格式、GIF图片的存储、GIF图片的展示等等我也不细讲了,这些都算是一些套路上的东西,正确调用苹果给的API就好了。

Kingfisher中还用到了很多小技巧,比如对关联对象(Associated Object)的使用,解决了extension不能扩展存储属性的问题:

//全局变量,用来作为关联对象(估计是因为extension里面不能添加储存属性,只能通过关联对象配合计算属性和方法的方式来hack)
// MARK: - Associated Object
private var lastURLKey: Void?
...
public extension UIImageView {
    /// Get the image URL binded to this image view.
    public var kf_webURL: NSURL? {
        return objc_getAssociatedObject(self, &lastURLKey) as? NSURL
    }
    private func kf_setWebURL(URL: NSURL) {
        objc_setAssociatedObject(self, &lastURLKey, URL, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }
}

最后还是小结一下知识点吧:

  • GCD的调度对象块(dispatch_block_t),可以在执行前取消(dispatch_block_cancel)
  • 文件操作相关知识(遍历文件、跳过隐藏文件、按日期排序文件等等)
  • 图片处理相关知识(判断图片格式、处理GIF等等)
  • MD5摘要算法(这个我并没有仔细看)
  • Associated Object的运用

对了,最后的最后,Swift已经开源啦!