Chun 是 葉純俊 在 Github 上開源的一個圖片緩存庫,基於 Swift 編寫。學習 Swift 有一段時間了,記錄一些閱讀源碼的一些收獲。
代碼組織
Swift 中通過 extension 組織代碼會讓整個類更加清晰可讀,尤其是對於 UITableViewDataSource 和 UITableViewDelegate 這種情況。在 Chun 這個項目中的 Demo 文件就是這樣的:
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() ... } } extension ViewController: UITableViewDataSource, UITableViewDelegate { func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { ... } func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { ... } func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat { ... } }
在 viewDidLoad 中,為了避免初始化代碼過長導致難以閱讀,可以通過內嵌函數將代碼分段:
override func viewDidLoad() { super.viewDidLoad() func loadTableView() { ... } loadTableView() }
添加屬性
在給 UIImageView 加載圖片的時候,我們最好可以在對象中存儲它所要加載的 URL ,可以通過 AssociatedObject 來實現。在 Swift 中,可以用一個私有計算量來封裝一下:
private var imageURLForChun: NSURL? { get { return objc_getAssociatedObject(self, &key) as? NSURL } set (url) { objc_setAssociatedObject(self, &key, url, UInt(OBJC_ASSOCIATION_RETAIN_NONATOMIC)) } }
這樣在調用的時候就和真實屬性沒什麼區別了:
if let imageURL = self.imageURLForChun { ... }
weak 和 unowned
在避免循環強引用的時候,如果某些時候引用沒有值,那就用 weak ,如果引用總是有值,則用 unowned 。
在 Chun 這個項目中,獲取圖片之後的回調裡用的是 weak ,因為有可能圖片加載完了但是 UIImageView 已經銷毀了:
Chun.sharedInstance.fetchImageWithURL(url, complete: { [weak self](result: Result) -> Void in ... })
然後在查詢本地緩存的時候,用的是 unowned ,因為這裡的 self 是單例,永遠不會銷毀:
cache.diskImageExistsWithKey(key, completion: { [unowned self](exist: Bool, diskURL: NSURL?) -> Void in ... })
枚舉的正確打開方式
使用枚舉來表示返回結果是個不錯的方案,在面向軌道編程 - Swift 中的異常處理中有過詳細的探討。在 Chun 中是這樣使用的:
public enum Result { case Success(image: UIImage, fetchedImageURL: NSURL) case Error(error: NSError) }
加載圖片完成之後的回調則是這樣:
public func fetchImageWithURL(url: NSURL, complete: (Result) -> Void) { let key = cacheKeyForRemoteURL(url) if let image = cache.imageForMemeoryCacheWithKey(key) { let result = Result.Success(image: image, fetchedImageURL: url) complete(result) } else { ... } }
圖片渲染
直接從網上下載獲取到的圖片並不能直接使用,先解碼成位圖然後再渲染可以減少開銷:
func decodedImageWithImage(image: UIImage) -> UIImage { if image.images != nil { return image } let imageRef = image.CGImage let imageSize: CGSize = CGSizeMake(CGFloat(CGImageGetWidth(imageRef)), CGFloat(CGImageGetHeight(imageRef))) let imageRect = CGRectMake(0, 0, imageSize.width, imageSize.height) let colorSpace = CGColorSpaceCreateDeviceRGB() let originalBitmapInfo = CGImageGetBitmapInfo(imageRef) let alphaInfo = CGImageGetAlphaInfo(imageRef) var bitmapInfo = originalBitmapInfo switch (alphaInfo) { case .None: bitmapInfo &= ~CGBitmapInfo.AlphaInfoMask bitmapInfo |= CGBitmapInfo(CGImageAlphaInfo.NoneSkipFirst.rawValue) case .PremultipliedFirst, .PremultipliedLast, .NoneSkipFirst, .NoneSkipLast: break case .Only, .Last, .First: return image } if let context = CGBitmapContextCreate(nil, CGImageGetWidth(imageRef), CGImageGetHeight(imageRef), CGImageGetBitsPerComponent(imageRef), 0 , colorSpace, bitmapInfo) { CGContextDrawImage(context, imageRect, imageRef) let decompressedImageRef = CGBitmapContextCreateImage(context) if let decompressedImage = UIImage(CGImage: decompressedImageRef, scale: image.scale, orientation: image.imageOrientation) { return decompressedImage } else { return image } } else { return image } }
從 NSData判斷圖片類型
在判斷圖片格式的時候,通過不同格式的第一個字節進行判斷,在 contentTypeForImageData(data: NSData) -> String? 方法裡實現了獲取 NSData 類型的方法:
func contentTypeForImageData(data: NSData) -> String? { var value : Int16 = 0 if data.length >= sizeof(Int16) { data.getBytes(&value, length:1) switch (value) { case 0xff: return "image/jpeg" case 0x89: return "image/png" case 0x47: return "image/gif" case 0x49: return "image/tiff" case 0x4D: return "image/tiff" case 0x52: if (data.length < 12) { return nil } if let temp = NSString(data: data.subdataWithRange(NSMakeRange(0, 12)), encoding: NSASCIIStringEncoding) { if (temp.hasPrefix("RIFF") && temp.hasSuffix("WEBP")) { return "image/webp" } } return nil default: return nil } } else { return nil } }
判斷的依據是不同圖片格式的前幾個字節都是特殊且唯一的,具體在 File magic numbers 裡有個比較完整的表,可以對照看下。比如 jpeg 的前四個字節都是 ff d8 ff e0 。
Fetcher 的玩兒法
在獲取圖片的時候都是通過 Fetcher 獲取,根據任務不同,區分是從服務器下載還是從本地加載。
首先是 ImageFetcher 這個大基類,封裝了一些基本的屬性和方法:
class ImageFetcher { typealias CompeltionClosure = (FetcherResult) -> Void let imageURL: NSURL init(imageURL: NSURL) { self.imageURL = imageURL } deinit { self.completion = nil } var cancelled = false var completion: CompeltionClosure? static func fetchImage(url: NSURL, completion: CompeltionClosure?) -> ImageFetcher { var fetcher: ImageFetcher if url.fileURL { fetcher = DiskImageFetcher(imageURL: url) } else { fetcher = RemoteImageFetcher(imageURL: url) } fetcher.completion = completion fetcher.startFetch() return fetcher } func cancelFetch() { self.cancelled = true } func startFetch() { fatalError("Subclass need to override this method called: \"startFetch\" ") } final func failedWithError(error: NSError) { } final func succeedWithData(imageData: NSData) { } }
在 fetchImage 這個方法裡,通過 url.fileURL 判斷是網絡請求還是本地請求,然後初始化不同的 fetcher 。然後對於一定需要子類實現的方法,用 fatalError 報錯提醒;對於一定不能讓子類重寫的方法,用 final 保護起來。比如請求成功之後的回調方法 succeedWithData(imageData: NSData) :
final func succeedWithData(imageData: NSData) { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), { [weak self]() -> Void in if let strongSelf = self { var finalImage: UIImage! if let image = imageWithData(imageData) { finalImage = scaledImage(image) finalImage = decodedImageWithImage(finalImage) dispatch_main_async_safe { if !strongSelf.cancelled { if let completionClosure = strongSelf.completion { let result = FetcherResult.Success(image: finalImage, imageData: imageData) completionClosure(result) } } } } else { let error = NSError(domain: CHUN_ERROR_DOMAIN, code: 404, userInfo: [NSLocalizedDescriptionKey: "create Image with data failed"]) strongSelf.failedWithError(error) } } }) }
不管是從本地加載還是從遠程獲取的,最終的返回結果都是 NSData ,所以在這裡統一處理。然後對於取消了的事件,其實並沒有取消下載任務,而是在下載成功之後通過 strongSelf.cancelled 判斷是不是要調用加載成功的回調方法。
然後再分別看下本地加載和網絡獲取的部分。本地加載相對而言簡單一些,通過 NSData(contentsOfURL: self.imageURL) 就可以加載圖片了。然後對於網絡請求則使用了 NSURLSession 來實現。 對 NSURLSession 不熟悉的同學可以閱讀《從 NSURLConnection 到 NSURLSession》了解一下。
網絡請求成功之後做了如下操作:
檢查 self 是否還活著
檢查當前任務是否被取消了
檢查回調的 error 是否不為空
獲取 response 並查看狀態碼是否為 200
在一切正常的前提下,還進行了如下操作:
let expected = response.expectedContentLength var validateLengthOfData: Bool { if expected > -1 { if Int64(data!.length) >= expected { return true } else { return false } } return true } if validateLengthOfData { strongSelf.succeedWithData(data!) return } else { let error = NSError(domain: CHUN_ERROR_DOMAIN, code: response.statusCode, userInfo: [NSLocalizedDescriptionKey: "Received bytes are not fit with expected"]) strongSelf.failedWithError(error) return }
主要是檢查實際獲取到的數據大小是否等於應有大小,通過 validateLengthOfData 這個計算量標記是否校驗通過。
緩存
圖片的緩存都是通過 ImageCache 這個類進行統一處理。初始化的時候新建了 ioQueue 這個用來專門進行 IO 操作的隊列,然後用 NSCache 在內存中緩存圖片。對於 NSCache 在 NSHipster 上有些吐槽,但這並沒有太大影響,基本可以滿足日常開發的需要。
系統事件的處理
在收到 UIApplicationDidEnterBackgroundNotification 的通知的時候,做了 backgroundCleanDisk 的處理:
private func backgroundCleanDisk() { let application = UIApplication.sharedApplication() var backgroundTask: UIBackgroundTaskIdentifier! backgroundTask = application.beginBackgroundTaskWithExpirationHandler { application.endBackgroundTask(backgroundTask) backgroundTask = UIBackgroundTaskInvalid } self.cleanDisk { application.endBackgroundTask(backgroundTask) backgroundTask = UIBackgroundTaskInvalid } }
通過 beginBackgroundTaskWithExpirationHandler 在退到後台之後清空了本地的過期文件。
過期文件
判斷過期文件的關鍵在於這個方法:
let expirationDate = NSDate(timeIntervalSinceNow: ImageCache.defaultCacheMaxAge) let modificationDate = resourceValues[NSURLContentModificationDateKey] as! NSDate if modificationDate.laterDate(expirationDate).isEqualToDate(expirationDate) { ... }
通過遍歷檢查所有的過期文件,存到 cacheFiles 數組中,然後統一刪除。
小結
通過 Chun 這個項目學習了如何實現一個簡單的圖片緩存庫,包括圖片加載和本地緩存兩個核心功能。然後通過 public class 把一些公用接口封裝並暴露出去。也看到了很多 Swift 中的小技巧,總之就是, Excited 嗯!