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
        });
    }
}
复制代码
相关文章
相关标签/搜索