iOS 如何進行內存上的緩存

簡介

主要緩存圖片方式針對經常使用的主流庫: SDWebImage、 Kingfisher、 AFNetworking(AlamofireImage)以及YYCache作分析。node

Kingfisher

預備知識—NSCache

NSCache是一個可變的集合類型,用於臨時存放鍵值對,當資源不足時會被移除。算法

class NSCache<KeyType, ObjectType> : NSObject where KeyType : AnyObject, ObjectType : AnyObject 這裏要注意的是,鍵值對必須是一個AnyObject,因此swift中的String類型不能當作Key用,須要轉換爲NSString。swift

NSCache還有幾點與普通集合不一樣:數組

  • NSCache類包含各類自動收回策略,這些策略能夠確保緩存不會佔用過多的系統內存。若是其餘應用程序須要內存,則這些策略會從Cache中刪除某些項目,從而最大程度地減小其內存佔用量。緩存

  • 您能夠從不一樣的線程添加,刪除和查詢Cache中的項目,沒必要本身對Cache加鎖。bash

  • 與NSMutableDictionary對象不一樣,Cache不會複製放入其中的鍵對象。網絡

另外還有兩個關鍵屬性:數據結構

var countLimit: Int
能最大存放對象的數量
var totalCostLimit: Int
在開始釋放對象以前,cache最多能持有cost的數量
複製代碼

須要注意的是totalCostLimit中的cost是根據setObject(ObjectType, forKey: KeyType, cost: Int)函數中的cost參數進行計算,也就是說cost須要使用者計算後存入。多線程

源碼

在初始化中,使用Config配置NSCache的緩存容量,以及清除週期。併發

public struct Config {

    public var totalCostLimit: Int

    public var countLimit: Int = .max

    //單個緩存持續時間
    public var expiration: StorageExpiration = .seconds(300)

    //緩存清理間隔時間
    public let cleanInterval: TimeInterval

    public init(totalCostLimit: Int, cleanInterval: TimeInterval = 120) {
        self.totalCostLimit = totalCostLimit
        self.cleanInterval = cleanInterval
    }
    }

...
 public init(config: Config) {
    self.config = config
    storage.totalCostLimit = config.totalCostLimit
    storage.countLimit = config.countLimit

    cleanTimer = .scheduledTimer(withTimeInterval: config.cleanInterval, repeats: true) { [weak self] _ in
        guard let self = self else { return }
        self.removeExpired()
    }
  }
複製代碼

存放的對象遵照CacheCostCalculable協議,對Image採用默認擴展的方式,直接計算圖片大小。

/// Represents types which cost in memory can be calculated.
public protocol CacheCostCalculable {
    var cacheCost: Int { get }
}
...
extension KFCrossPlatformImage: CacheCostCalculable {
    /// Cost of an image
    public var cacheCost: Int { return kf.cost }
}
...
 public class Backend<T: CacheCostCalculable> {
        let storage = NSCache<NSString, StorageObject<T>>()

...

storage.setObject(object, forKey: key as NSString, cost: value.cacheCost)
複製代碼

對於在NSCache中存放的value,加上StorageObject封裝,將過時策略也寫入,方便在clearTimer清除時,移除過時對象。

let object = StorageObject(value, key: key, expiration: expiration)
storage.setObject(object, forKey: key as NSString, cost: value.cacheCost)
...
class StorageObject<T> {
    let value: T
    let expiration: StorageExpiration
    let key: String
    
    private(set) var estimatedExpiration: Date
    
    init(_ value: T, key: String, expiration: StorageExpiration) {
        self.value = value
        self.key = key
        self.expiration = expiration
        
        self.estimatedExpiration = expiration.estimatedExpirationSinceNow
    }
    
    func extendExpiration(_ extendingExpiration: ExpirationExtending = .cacheTime) {
        switch extendingExpiration {
        case .none:
            return
        case .cacheTime:
            self.estimatedExpiration = expiration.estimatedExpirationSinceNow
        case .expirationTime(let expirationTime):
            self.estimatedExpiration = expirationTime.estimatedExpirationSinceNow
        }
    }
    
    var expired: Bool {
        return estimatedExpiration.isPast
    }
}
複製代碼

思考

在官方文檔中,已經指出對於NSCache的操做能夠不用加鎖,那麼爲何做者仍是使用了NSLock?

SDWebImage

預備知識--NSMapTable

NSMapTable與字典類似,可是有更多的關於內存的意義。

NSMapTable是根據NSDictionary建模的,但具備如下差別:

  • key/value可使用弱引用方式保存,以便在回收對象時刪除。

  • 它的key/value能夠在輸入時複製,也可使用指針標識進行相等性和哈希處理。

  • 它能夠包含任意指針(其內容不限於對象)。

在SD中,NSMapTable進行了這樣的初始化:

[[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsWeakMemory capacity:0];
複製代碼

初始化時須要對key與value的option進行設定,這裏將key值設爲強引用,value設爲弱引用。

若對NSPointerFunctions.Options進行深刻查看,還有更多選項。

主要分爲Memory Options, Personality Options,與Copy Option。使用時Memory Options, Personality Options只能各使用最多一個, 不能NSPointerFunctionsWeakMemory | NSPointerFunctionsStrongMemory或是NSPointerFunctionsCStringPersonality | NSPointerFunctionsIntegerPersonality

源碼

與kingfisher相同,SDWebImage也有一個Config文件,裏面條目衆多,但事實上對於SDMemoryCache來講,只有三個選項是有用的:

@interface SDImageCacheConfig : NSObject <NSCopying>

@property (assign, nonatomic) BOOL shouldUseWeakMemoryCache;

@property (assign, nonatomic) NSUInteger maxMemoryCost;

@property (assign, nonatomic) NSUInteger maxMemoryCount;

@end
複製代碼

maxMemoryCost與maxMemoryCount已經在前文中講過,那麼shouldUseWeakMemoryCache是用來幹嗎的呢?


相對於kf中直接持有一個NSCache,sd是直接寫了一個子類繼承了NSCache:

@interface SDMemoryCache <KeyType, ObjectType> : NSCache <KeyType, ObjectType> <SDMemoryCache>

@property (nonatomic, strong, nonnull, readonly) SDImageCacheConfig *config;

@end
複製代碼

而且在m文件中,聲明瞭兩個屬性,分別是NSMapTable與dispatch_semaphore_t

@property (nonatomic, strong, nonnull) NSMapTable<KeyType, ObjectType> *weakCache; // strong-weak cache
@property (nonatomic, strong, nonnull) dispatch_semaphore_t weakCacheLock; // a lock to keep the access to `weakCache`
複製代碼

在get、set、remove時override以前的方法:

- (void)setObject:(id)obj forKey:(id)key cost:(NSUInteger)g {
    [super setObject:obj forKey:key cost:g];
    if (!self.config.shouldUseWeakMemoryCache) {
        return;
    }
    if (key && obj) {
        // Store weak cache
        SD_LOCK(self.weakCacheLock);
        [self.weakCache setObject:obj forKey:key];
        SD_UNLOCK(self.weakCacheLock);
    }
}

- (id)objectForKey:(id)key {
    id obj = [super objectForKey:key];
    if (!self.config.shouldUseWeakMemoryCache) {
        return obj;
    }
    if (key && !obj) {
        // Check weak cache
        SD_LOCK(self.weakCacheLock);
        obj = [self.weakCache objectForKey:key];
        SD_UNLOCK(self.weakCacheLock);
        if (obj) {
            // Sync cache
            NSUInteger cost = 0;
            if ([obj isKindOfClass:[UIImage class]]) {
                cost = [(UIImage *)obj sd_memoryCost];
            }
            [super setObject:obj forKey:key cost:cost];
        }
    }
    return obj;
}
複製代碼

因此shouldUseWeakMemoryCache實際上是一個控制是否使用NSMapTable的開關,若是打開了,在添加圖片時,會在NSMapTable中也緩存一份。這樣作的目的是,若是由於超過了cache數量的上線或者容量的上線,那麼緩存中先存入的對象則會被清除。這時再從緩存中獲取不到數據,須要從網絡上從新加載,可能會產生白屏的現象。而NSMapTable就是爲了這種狀況才使用的。

若沒有從cache中找到value,則會從NSMapTable中再找一次,若是有就再塞進cache中。

問題

kf用了NSLock,sd用了dispatch_semaphore_t,它們的區別是什麼?

AFNetworking

預備知識--GCD

在處理圖片時,AF沒有用NSLock或者dispatch_semaphore_t去加鎖,只是經過建立GCD的方式訪問。

因爲GCD比較常見,因此就快速複習一遍。

在GCD中隊列的概念,分爲串行隊列與並行隊列。

在串行隊列中,丟進去的任務只能在單個線程中順序處理,在並行隊列中,會丟給不一樣的線程同時處理。

操做隊列時也有同步與異步的概念。

同步是指sync,須要等待隊列中任務處理結束,纔會走以後的處理。

異步是指async,不須要等待隊列中任務處理結束,也能走以後的處理。

在整個AFAutoPurgingImageCache文件中,屢次用到了dispatch_barrier的概念,主要功能是但願在併發隊列中,單獨處理一個任務,將其與以前以後的任務隔開,就意味着,當以前全部隊列中的任務執行完成以後,才能執行dispatch_barrier的任務,當dispatch_barrier的任務完成以後,才能執行隊列以後的任務。

源碼

在SD中存放的value爲UIImage,KF中是對Image封裝一層,爲了統一過時策略記錄了緩存持續時間,這AF中,也是 與以前KF相同的是,這裏使用了AFCachedImage對Image封裝,更可能是記錄了lastAccessDate字段,也就是上一次的訪問日期,清理緩存時,也是根據這個字段作處理。

@interface AFCachedImage : NSObject

@property (nonatomic, strong) UIImage *image;
@property (nonatomic, copy) NSString *identifier;
@property (nonatomic, assign) UInt64 totalBytes;
@property (nonatomic, strong) NSDate *lastAccessDate;
@property (nonatomic, assign) UInt64 currentMemoryUsage;

@end
複製代碼

在整個AFAutoPurgingImageCache文件中,用NSMutableDictionary保存圖片,而且建立了一個併發隊列synchronizationQueue,管理圖片的訪問,增長與刪除。

self.cachedImages = [[NSMutableDictionary alloc] init];
NSString *queueName = [NSString stringWithFormat:@"com.alamofire.autopurgingimagecache-%@", [[NSUUID UUID] UUIDString]];
self.synchronizationQueue = dispatch_queue_create([queueName cStringUsingEncoding:NSASCIIStringEncoding], DISPATCH_QUEUE_CONCURRENT);
複製代碼

在初始化時,設定了最大內存大小,與推薦內存大小。

- (instancetype)init {
    return [self initWithMemoryCapacity:100 * 1024 * 1024 preferredMemoryCapacity:60 * 1024 * 1024];
}
複製代碼

在添加圖片時會用dispatch_barrier方式,防止多線程訪問同一資源,每次添加圖片時,對其管理的當前內存大小更新,在更新結束後,查看是否超過最大內存大小限定,若是超過,則對已有的資源排序,從舊到新進行刪除,直到小於推薦使用內存大小。

- (void)addImage:(UIImage *)image withIdentifier:(NSString *)identifier {
    dispatch_barrier_async(self.synchronizationQueue, ^{
        AFCachedImage *cacheImage = [[AFCachedImage alloc] initWithImage:image identifier:identifier];

        AFCachedImage *previousCachedImage = self.cachedImages[identifier];
        if (previousCachedImage != nil) {
            self.currentMemoryUsage -= previousCachedImage.totalBytes;
        }

        self.cachedImages[identifier] = cacheImage;
        self.currentMemoryUsage += cacheImage.totalBytes;
    });

    dispatch_barrier_async(self.synchronizationQueue, ^{
        if (self.currentMemoryUsage > self.memoryCapacity) {
            UInt64 bytesToPurge = self.currentMemoryUsage - self.preferredMemoryUsageAfterPurge;
            NSMutableArray <AFCachedImage*> *sortedImages = [NSMutableArray arrayWithArray:self.cachedImages.allValues];
            NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"lastAccessDate"
                                                                           ascending:YES];
            [sortedImages sortUsingDescriptors:@[sortDescriptor]];

            UInt64 bytesPurged = 0;

            for (AFCachedImage *cachedImage in sortedImages) {
                [self.cachedImages removeObjectForKey:cachedImage.identifier];
                bytesPurged += cachedImage.totalBytes;
                if (bytesPurged >= bytesToPurge) {
                    break;
                }
            }
            self.currentMemoryUsage -= bytesPurged;
        }
    });
}
複製代碼

AlamofireImage與AF中緩存思想基本相同,因此再也不單獨解釋。

問題

爲何這裏使用了併發隊列+barrier的組合,直接使用串行隊列進行訪問資源很差麼?

YYCache

YYCache 設計思路

預備知識--LRU算法

百度百科

LRU是Least Recently Used的縮寫,即最近最少使用,是一種經常使用的頁面置換算法,選擇最近最久未使用的頁面予以淘汰。該算法賦予每一個頁面一個訪問字段,用來記錄一個頁面自上次被訪問以來所經歷的時間 t,當須淘汰一個頁面時,選擇現有頁面中其 t 值最大的,即最近最少使用的頁面予以淘汰。

須要注意的是,這個算法,增刪改查的時間複雜度都是O(1)。

經常使用的實現方式是經過字典+雙向鏈表的方式,存儲數據。

class LRUCache {
    
    //單個節點的數據結構
    class Node {
        var pre: Node?
        var next: Node?
        var value: Int?
        var key: Int?
    }
    //最大存儲數量
    let maxSize: Int
    //字典模型
    var dic = [Int: Node]()
    //頭節點
    var head: Node?
    //尾節點
    var tail: Node?
    //當前存儲內容的大小
    var size = 0
    
    init(_ capacity: Int) {
        maxSize = capacity
        head = Node()
        tail = Node()
        head?.next = tail
        tail?.pre = head
    }
    
    func get(_ key: Int) -> Int {
        if let node = dic[key] {
        //若是訪問的key有值,則移動到前排
            moveTohead(node)
        }
        return dic[key]?.value ?? -1
    }
    
    func put(_ key: Int, _ value: Int) {
        if let node = dic[key] {
        //若是放入的內容已經保存過了,則直接移動到前排
            node.value = value
            moveTohead(node)
            return
        }
        let node = Node()
        node.key = key
        node.value = value
        dic[key] = node
        size += 1
        add(node)
        if size > maxSize {
            removeLast()
            size -= 1
        }
    }
    
    private func moveTohead(_ node: Node) {
        node.pre?.next = node.next
        node.next?.pre = node.pre
        add(node)
    }
    
    private func add(_ node: Node) {
        node.pre = head
        node.next = head?.next
        head?.next?.pre = node
        head?.next = node
    }
    
    private func removeLast() {
        let last = tail?.pre
        last?.pre?.next = tail
        tail?.pre = last?.pre
        if let key = last?.key {
            dic.removeValue(forKey: key)
        }
        
    }
}
複製代碼

想練習算法的同窗能夠去leetcode上練手。leetcode-LRU算法

預備知識--pthread_mutex_t

pthread_mutex_t是一種自旋鎖。

pthread_mutex_t _lock; \\聲明
pthread_mutex_init(&_lock, NULL);\\初始化
pthread_mutex_lock(&_lock);\\加鎖
pthread_mutex_unlock(&_lock);\\解鎖
pthread_mutex_trylock(&_lock) == 0\\查詢是否能加鎖
複製代碼

當互斥鎖已經被鎖定,這是再調用pthread_mutex_lock會形成線程堵塞。

函數pthread_mutex_trylock是pthread_mutex_lock的非阻塞版本。若是mutex參數所指定的互斥鎖已經被鎖定的話,調用pthread_mutex_trylock函數不會阻塞當前線程,而是當即返回一個值來描述互斥鎖的情況。

源碼

初始化時,聲明一個串行隊列以及互斥鎖管理線程,以及countLimit、costLimit、ageLimit管理緩存的生命週期,以及autoTrimInterval管理清理週期。

- (instancetype)init {
    self = super.init;
    pthread_mutex_init(&_lock, NULL);
    _lru = [_YYLinkedMap new];
    _queue = dispatch_queue_create("com.ibireme.cache.memory", DISPATCH_QUEUE_SERIAL);
    
    _countLimit = NSUIntegerMax;
    _costLimit = NSUIntegerMax;
    _ageLimit = DBL_MAX;
    _autoTrimInterval = 5.0;
    ......
    [self _trimRecursively];
}
複製代碼

在初始化完成後,會直接調用_trimRecursively方法,在定義的串行隊列中清除緩存。

- (void)_trimRecursively {
    __weak typeof(self) _self = self;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_autoTrimInterval * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
        __strong typeof(_self) self = _self;
        if (!self) return;
        [self _trimInBackground];
        [self _trimRecursively];
    });
}

- (void)_trimInBackground {
    dispatch_async(_queue, ^{
        [self _trimToCost:self->_costLimit];
        [self _trimToCount:self->_countLimit];
        [self _trimToAge:self->_ageLimit];
    });
}
複製代碼

雖然根據三個參數清除緩存,但主要邏輯差很少,因此只舉一個例子。

首先設定一個flag->finish,標記是否清除完成,若緩存消耗最大值爲0則直接移除全部緩存,若當前消耗小於要求的最大值則直接return。

再經過循環的方式將對象從lru中移除,並保存到新建的數組中。 最後的dispatch_async中調用[holder count],是爲了一直持有holder,不讓它提早釋放。

- (void)_trimToCost:(NSUInteger)costLimit {
    BOOL finish = NO;
    pthread_mutex_lock(&_lock);
    if (costLimit == 0) {
        [_lru removeAll];
        finish = YES;
    } else if (_lru->_totalCost <= costLimit) {
        finish = YES;
    }
    pthread_mutex_unlock(&_lock);
    if (finish) return;
    
    NSMutableArray *holder = [NSMutableArray new];
    while (!finish) {
        if (pthread_mutex_trylock(&_lock) == 0) {
            if (_lru->_totalCost > costLimit) {
                _YYLinkedMapNode *node = [_lru removeTailNode];
                if (node) [holder addObject:node];
            } else {
                finish = YES;
            }
            pthread_mutex_unlock(&_lock);
        } else {
            usleep(10 * 1000); //10 ms
        }
    }
    if (holder.count) {
        dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
        dispatch_async(queue, ^{
            [holder count]; // release in queue
        });
    }
}
複製代碼
相關文章
相關標籤/搜索