MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Objective-C中的缓存策略与NSCache使用技巧

2024-02-297.8k 阅读

缓存策略在 Objective-C 中的重要性

在软件开发领域,尤其是在内存管理较为关键的应用程序开发中,缓存策略起着举足轻重的作用。在 Objective-C 语言环境下,合理运用缓存策略能够显著提升应用程序的性能,减少资源的不必要浪费,特别是在处理重复数据获取或复杂计算结果频繁使用的场景时。

想象一个场景,比如一个图片浏览应用。每次用户浏览到一张图片时,如果每次都从磁盘或者网络重新加载图片数据,这不仅会消耗大量的时间,还会占用较多的系统资源,包括网络带宽、磁盘 I/O 等。但如果我们使用缓存策略,将已经加载过的图片数据缓存起来,当下次用户再次浏览到同一张图片时,直接从缓存中获取数据,就能极大地提升图片加载速度,为用户带来更流畅的体验。

从本质上讲,缓存策略就是一种以空间换时间的技术手段。它通过在内存中开辟一定的空间来存储那些可能会被再次使用的数据,这样在需要这些数据时,就可以绕过相对耗时的数据源获取过程,直接从缓存中快速获取。然而,缓存策略并非简单的存储与获取,还涉及到诸多方面的考量,如缓存的有效期、缓存的容量管理、缓存数据的淘汰策略等。

缓存的基本原理

  1. 缓存数据结构 在 Objective-C 中,我们可以使用多种数据结构来实现缓存的存储功能。最常见的是 NSDictionary,它以键值对的形式存储数据,能快速通过键来查找对应的值。例如,我们要缓存用户的一些基本信息,以用户 ID 作为键,用户信息对象作为值,就可以很方便地实现数据的存储与获取。
NSDictionary *userCache = @{
    @"user1": userInfo1,
    @"user2": userInfo2
};
UserInfo *retrievedUser = userCache[@"user1"];

NSDictionary 本身并没有缓存管理的能力,它只是单纯的数据存储结构。我们需要在此基础上构建缓存逻辑。

  1. 缓存命中与未命中 当应用程序请求数据时,首先会检查缓存中是否存在该数据。如果存在,就称为缓存命中(Cache Hit),此时可以直接从缓存中获取数据并返回,大大提高了数据获取的效率。例如,在上述图片浏览应用中,如果图片已经在缓存中,直接从缓存读取,无需重新从磁盘或网络加载。

反之,如果缓存中不存在所需数据,则称为缓存未命中(Cache Miss)。这时就需要从原始数据源(如磁盘文件、网络接口等)获取数据,获取后可以选择将数据存入缓存,以便下次使用。例如,图片第一次被浏览时,缓存中没有,从磁盘读取后,将图片数据存入缓存,下次浏览就可能命中缓存。

不同场景下的缓存策略

  1. 基于时间的缓存策略 这种策略适用于数据会随时间变化的场景。比如,一些实时性要求较高的金融数据,每过一段时间数据就会更新。我们可以为缓存数据设置一个有效期,在有效期内,缓存命中时直接返回数据;超过有效期,则认为缓存无效,需要重新获取数据并更新缓存。

在 Objective-C 中,我们可以通过记录数据存入缓存的时间来实现基于时间的缓存策略。以下是一个简单的示例,假设我们要缓存一个股票价格数据:

@interface StockPriceCache : NSObject

@property (nonatomic, strong) NSDictionary *cache;
@property (nonatomic, strong) NSDictionary *timeStampCache;

- (NSNumber *)cachedStockPriceForSymbol:(NSString *)symbol;
- (void)cacheStockPrice:(NSNumber *)price forSymbol:(NSString *)symbol;

@end

@implementation StockPriceCache

- (instancetype)init {
    self = [super init];
    if (self) {
        _cache = [NSMutableDictionary dictionary];
        _timeStampCache = [NSMutableDictionary dictionary];
    }
    return self;
}

- (NSNumber *)cachedStockPriceForSymbol:(NSString *)symbol {
    NSDate *currentDate = [NSDate date];
    NSDate *timestamp = self.timeStampCache[symbol];
    NSTimeInterval cacheDuration = 60; // 缓存有效期 60 秒
    if (timestamp && [currentDate timeIntervalSinceDate:timestamp] < cacheDuration) {
        return self.cache[symbol];
    }
    return nil;
}

- (void)cacheStockPrice:(NSNumber *)price forSymbol:(NSString *)symbol {
    self.cache[symbol] = price;
    self.timeStampCache[symbol] = [NSDate date];
}

@end
  1. 基于容量的缓存策略 当缓存的容量有限时,就需要采用基于容量的缓存策略。比如,在移动设备上,内存资源相对紧张,我们不能无限制地往缓存中添加数据。一旦缓存达到设定的容量上限,就需要采取某种淘汰策略来移除一些旧的数据,为新数据腾出空间。

常见的淘汰策略有先进先出(FIFO,First In First Out)、最近最少使用(LRU,Least Recently Used)和最不经常使用(LFU,Least Frequently Used)等。

先进先出(FIFO)策略

FIFO 策略就像排队一样,先进入缓存的数据先被淘汰。我们可以使用 NSMutableArray 来辅助实现 FIFO 策略的缓存。

@interface FIFOCache : NSObject

@property (nonatomic, strong) NSMutableArray *keys;
@property (nonatomic, strong) NSMutableDictionary *cache;
@property (nonatomic, assign) NSUInteger capacity;

- (instancetype)initWithCapacity:(NSUInteger)capacity;
- (id)objectForKey:(id)key;
- (void)setObject:(id)object forKey:(id)key;

@end

@implementation FIFOCache

- (instancetype)initWithCapacity:(NSUInteger)capacity {
    self = [super init];
    if (self) {
        _keys = [NSMutableArray array];
        _cache = [NSMutableDictionary dictionary];
        _capacity = capacity;
    }
    return self;
}

- (id)objectForKey:(id)key {
    return self.cache[key];
}

- (void)setObject:(id)object forKey:(id)key {
    if (self.cache[key]) {
        return;
    }
    if (self.keys.count >= self.capacity) {
        id oldestKey = self.keys.firstObject;
        [self.keys removeObjectAtIndex:0];
        [self.cache removeObjectForKey:oldestKey];
    }
    [self.keys addObject:key];
    self.cache[key] = object;
}

@end

最近最少使用(LRU)策略

LRU 策略是基于这样一个假设:最近使用过的数据在未来一段时间内再次被使用的可能性较大。因此,当缓存满时,淘汰最久未使用的数据。我们可以结合 NSMutableDictionaryNSMutableOrderedSet 来实现 LRU 缓存。

@interface LRUCache : NSObject

@property (nonatomic, strong) NSMutableDictionary *cache;
@property (nonatomic, strong) NSMutableOrderedSet *accessOrder;
@property (nonatomic, assign) NSUInteger capacity;

- (instancetype)initWithCapacity:(NSUInteger)capacity;
- (id)objectForKey:(id)key;
- (void)setObject:(id)object forKey:(id)key;

@end

@implementation LRUCache

- (instancetype)initWithCapacity:(NSUInteger)capacity {
    self = [super init];
    if (self) {
        _cache = [NSMutableDictionary dictionary];
        _accessOrder = [NSMutableOrderedSet orderedSet];
        _capacity = capacity;
    }
    return self;
}

- (id)objectForKey:(id)key {
    id object = self.cache[key];
    if (object) {
        [self.accessOrder removeObject:key];
        [self.accessOrder addObject:key];
    }
    return object;
}

- (void)setObject:(id)object forKey:(id)key {
    if (self.cache[key]) {
        [self.accessOrder removeObject:key];
    } else if (self.accessOrder.count >= self.capacity) {
        id leastRecentlyUsedKey = self.accessOrder.firstObject;
        [self.accessOrder removeObjectAtIndex:0];
        [self.cache removeObjectForKey:leastRecentlyUsedKey];
    }
    [self.accessOrder addObject:key];
    self.cache[key] = object;
}

@end

最不经常使用(LFU)策略

LFU 策略记录每个数据项的使用频率,当缓存满时,淘汰使用频率最低的数据项。实现 LFU 策略相对复杂一些,我们需要额外记录每个键的使用频率。

@interface LFUCache : NSObject

@property (nonatomic, strong) NSMutableDictionary *cache;
@property (nonatomic, strong) NSMutableDictionary *frequency;
@property (nonatomic, assign) NSUInteger capacity;

- (instancetype)initWithCapacity:(NSUInteger)capacity;
- (id)objectForKey:(id)key;
- (void)setObject:(id)object forKey:(id)key;

@end

@implementation LFUCache

- (instancetype)initWithCapacity:(NSUInteger)capacity {
    self = [super init];
    if (self) {
        _cache = [NSMutableDictionary dictionary];
        _frequency = [NSMutableDictionary dictionary];
        _capacity = capacity;
    }
    return self;
}

- (id)objectForKey:(id)key {
    id object = self.cache[key];
    if (object) {
        NSNumber *freq = self.frequency[key];
        self.frequency[key] = @(freq.integerValue + 1);
    }
    return object;
}

- (void)setObject:(id)object forKey:(id)key {
    if (self.cache[key]) {
        NSNumber *freq = self.frequency[key];
        self.frequency[key] = @(freq.integerValue + 1);
        return;
    }
    if (self.cache.count >= self.capacity) {
        NSArray *sortedFreqs = [self.frequency keysSortedByValueUsingComparator:^NSComparisonResult(id  _Nonnull obj1, id  _Nonnull obj2) {
            return [obj1 compare:obj2];
        }];
        id leastFrequentKey = sortedFreqs.firstObject;
        [self.cache removeObjectForKey:leastFrequentKey];
        [self.frequency removeObjectForKey:leastFrequentKey];
    }
    self.cache[key] = object;
    self.frequency[key] = @(1);
}

@end

NSCache 简介

NSCacheFoundation 框架提供的一个用于缓存数据的类,它在很多方面简化了缓存的实现过程。NSCache 是线程安全的,这意味着可以在多个线程中安全地访问它,无需额外的锁机制。它还会根据系统内存情况自动释放缓存中的对象,有助于避免内存峰值问题。

NSCache 的使用方式与 NSDictionary 类似,都是以键值对的形式存储数据。但与 NSDictionary 不同的是,NSCache 不会持有键对象的强引用,而是使用弱引用。这意味着如果键对象在其他地方不再被持有强引用,它会被自动释放,相应的缓存项也会从 NSCache 中移除。

NSCache 的基本使用

  1. 创建与添加缓存项 要使用 NSCache,首先需要创建一个 NSCache 对象,然后使用 setObject:forKey: 方法来添加缓存项。
NSCache *imageCache = [[NSCache alloc] init];
UIImage *image = [UIImage imageNamed:@"example.jpg"];
[imageCache setObject:image forKey:@"example.jpg"];
  1. 获取缓存项 通过 objectForKey: 方法可以从 NSCache 中获取缓存项。
UIImage *retrievedImage = [imageCache objectForKey:@"example.jpg"];
if (retrievedImage) {
    // 使用图片
} else {
    // 从其他地方加载图片
}
  1. 移除缓存项 可以使用 removeObjectForKey: 方法移除指定键的缓存项,或者使用 removeAllObjects 方法移除所有缓存项。
[imageCache removeObjectForKey:@"example.jpg"];
// 移除所有缓存项
[imageCache removeAllObjects];

NSCache 的高级特性

  1. 缓存容量限制 虽然 NSCache 会根据系统内存情况自动释放缓存对象,但我们也可以手动设置缓存的容量限制。NSCache 提供了 totalCostLimitcountLimit 属性来实现这一点。

totalCostLimit 用于设置缓存的总代价限制,每个缓存对象在添加时可以指定一个代价。例如,如果我们缓存图片,可以将图片的大小作为代价。当缓存的总代价超过 totalCostLimit 时,NSCache 会自动移除一些缓存项。

NSCache *imageCache = [[NSCache alloc] init];
imageCache.totalCostLimit = 1024 * 1024; // 设置总代价限制为 1MB
UIImage *image1 = [UIImage imageNamed:@"largeImage.jpg"];
UIImage *image2 = [UIImage imageNamed:@"smallImage.jpg"];
// 假设我们有一个方法可以获取图片大小作为代价
NSUInteger cost1 = [self costForImage:image1];
NSUInteger cost2 = [self costForImage:image2];
[imageCache setObject:image1 forKey:@"largeImage.jpg" cost:cost1];
[imageCache setObject:image2 forKey:@"smallImage.jpg" cost:cost2];

countLimit 则用于设置缓存中最多允许的对象数量。当缓存中的对象数量达到 countLimit 时,NSCache 会开始移除一些缓存项。

NSCache *cache = [[NSCache alloc] init];
cache.countLimit = 100; // 设置最多允许 100 个对象
  1. 缓存对象的自动释放 NSCache 会在系统内存不足时自动释放缓存中的对象。这是通过 NSCache 内部的机制实现的,它会监听系统的内存警告通知,并在收到通知时主动释放一些缓存对象,以帮助系统回收内存。

我们可以通过实现 NSCacheDelegate 协议来监听 NSCache 释放对象的事件。NSCacheDelegate 协议只有一个方法 cache:willEvictObject:,当 NSCache 即将移除一个对象时,会调用这个方法。

@interface MyCacheDelegate : NSObject <NSCacheDelegate>

@end

@implementation MyCacheDelegate

- (void)cache:(NSCache *)cache willEvictObject:(id)obj {
    NSLog(@"即将移除对象: %@", obj);
}

@end

// 使用示例
NSCache *imageCache = [[NSCache alloc] init];
MyCacheDelegate *delegate = [[MyCacheDelegate alloc] init];
imageCache.delegate = delegate;

NSCache 使用技巧

  1. 选择合适的键 在使用 NSCache 时,选择合适的键非常重要。由于 NSCache 使用弱引用来持有键对象,所以键对象必须在其他地方有强引用,否则可能会导致意外的缓存行为。通常,使用不可变对象(如 NSStringNSNumber 等)作为键是一个不错的选择,因为它们的哈希值在对象生命周期内不会改变,有助于提高缓存查找的效率。

例如,在缓存用户信息时,使用用户 ID(通常是 NSString 类型)作为键:

NSCache *userCache = [[NSCache alloc] init];
UserInfo *userInfo = [[UserInfo alloc] init];
[userCache setObject:userInfo forKey:@"user1"];
  1. 处理缓存未命中 当缓存未命中时,需要从原始数据源获取数据。在获取数据后,应该尽快将数据存入缓存,以便下次使用。同时,要注意处理获取数据过程中可能出现的错误,比如网络请求失败、文件读取错误等。
UIImage *image = [imageCache objectForKey:@"example.jpg"];
if (!image) {
    // 从磁盘或网络加载图片
    NSURL *imageURL = [NSURL URLWithString:@"http://example.com/image.jpg"];
    NSData *imageData = [NSData dataWithContentsOfURL:imageURL];
    if (imageData) {
        image = [UIImage imageWithData:imageData];
        [imageCache setObject:image forKey:@"example.jpg"];
    } else {
        // 处理加载失败
    }
}
  1. 结合其他缓存策略 虽然 NSCache 本身提供了一些缓存管理功能,但在实际应用中,我们可以结合前面提到的其他缓存策略,如基于时间的缓存策略。例如,我们可以在 NSCache 的基础上,为每个缓存项记录一个时间戳,在获取缓存项时检查时间戳是否超过有效期。
@interface TimeBasedCache : NSObject

@property (nonatomic, strong) NSCache *cache;
@property (nonatomic, strong) NSMutableDictionary *timeStampCache;

- (instancetype)init;
- (id)objectForKey:(id)key;
- (void)setObject:(id)object forKey:(id)key;

@end

@implementation TimeBasedCache

- (instancetype)init {
    self = [super init];
    if (self) {
        _cache = [[NSCache alloc] init];
        _timeStampCache = [NSMutableDictionary dictionary];
    }
    return self;
}

- (id)objectForKey:(id)key {
    NSDate *currentDate = [NSDate date];
    NSDate *timestamp = self.timeStampCache[key];
    NSTimeInterval cacheDuration = 3600; // 缓存有效期 1 小时
    if (timestamp && [currentDate timeIntervalSinceDate:timestamp] < cacheDuration) {
        return [self.cache objectForKey:key];
    }
    return nil;
}

- (void)setObject:(id)object forKey:(id)key {
    [self.cache setObject:object forKey:key];
    self.timeStampCache[key] = [NSDate date];
}

@end
  1. 在多线程环境下使用 由于 NSCache 是线程安全的,在多线程环境下可以直接使用。但需要注意的是,虽然 NSCache 本身的操作是线程安全的,但如果在获取或设置缓存项前后还有其他与缓存相关的逻辑,可能需要额外的同步机制。

例如,在多线程环境下更新缓存时,如果需要先检查缓存中是否存在某个键,然后根据结果进行不同的操作,就需要使用锁来保证操作的原子性。

NSCache *sharedCache = [[NSCache alloc] init];
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(queue, ^{
    @synchronized(sharedCache) {
        id object = [sharedCache objectForKey:@"key"];
        if (object) {
            // 对已存在的对象进行操作
        } else {
            // 获取新数据并设置到缓存
            id newObject = [[SomeObject alloc] init];
            [sharedCache setObject:newObject forKey:@"key"];
        }
    }
});

总结 NSCache 的优势与注意事项

  1. 优势
    • 线程安全NSCache 内部已经处理了线程同步问题,在多线程环境下使用非常方便,无需开发者手动添加锁机制,减少了因锁竞争带来的性能开销和死锁风险。
    • 自动内存管理NSCache 会根据系统内存情况自动释放缓存对象,有助于避免应用程序因缓存占用过多内存而导致的内存峰值问题,提高了应用程序的稳定性和响应性。
    • 弱引用键NSCache 对键对象使用弱引用,这在某些场景下非常有用,比如当键对象在其他地方不再被需要时,它可以被正常释放,而不会因为 NSCache 的强引用而导致内存泄漏。
  2. 注意事项
    • 键的选择:由于 NSCache 使用弱引用来持有键对象,键对象必须在其他地方有强引用,否则可能会导致缓存项意外丢失。同时,为了保证缓存查找的效率,应选择不可变且哈希值稳定的对象作为键。
    • 缓存策略结合:虽然 NSCache 本身提供了一些缓存管理功能,但在实际应用中,可能需要结合其他缓存策略(如基于时间、基于容量等)来满足更复杂的业务需求。
    • 多线程操作逻辑:尽管 NSCache 本身是线程安全的,但如果在对 NSCache 进行操作前后还有其他与缓存相关的复杂逻辑,可能需要额外的同步机制来保证数据的一致性和操作的原子性。

在 Objective-C 开发中,合理运用缓存策略和熟练掌握 NSCache 的使用技巧,对于提升应用程序的性能、优化资源利用以及提供良好的用户体验都具有至关重要的意义。无论是简单的小型应用还是复杂的大型项目,缓存策略的精心设计和 NSCache 的正确使用都能为开发工作带来显著的效益。通过对不同缓存策略的深入理解和实践,开发者可以根据具体的业务场景和需求,选择最合适的缓存方案,打造出高效、稳定的应用程序。