
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 嗯!