관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] [오픈소스 까보기] SDWebImage - 캐싱 처리 로직 (NSCache, NSMapTable, fileManager) 본문

오픈소스 까보기

[iOS - swift] [오픈소스 까보기] SDWebImage - 캐싱 처리 로직 (NSCache, NSMapTable, fileManager)

jake-kim 2023. 8. 26. 01:52

1. SDWebImage 오픈소스 까보기 - 캐싱 처리 로직

2. SDWebImage 오픈소스 까보기 - 메모리 캐싱, 디스크 캐싱

SDWebImage 오픈소스

  • SDWebImage Github
  • 포스팅 글 작성 시점 기준으로 24.7k 스타를 가지고 있고, 또 다른 유명한 이미지처리 오픈소스 Kingfisher (21.5k)보다 많은 스타 수를 가지고 있는 오픈소스

  • UIImageView의 extension으로 sd_setImageWithURL 메소드로 확장되어 쉽게 사용할 수 있도록 구현되어 있는 상태
import SDWebImage

imageView.sd_setImage(with: URL(string: "http://www.domain.com/path/to/image.jpg"), placeholderImage: UIImage(named: "placeholder.png"))
  • gif도 제공 (SDAnimatedImage)
let imageView = SDAnimatedImageView()
let animatedImage = SDAnimatedImage(named: "image.gif")
imageView.image = animatedImage

SDWebImage 캐싱 구조

  • 캐싱 코드를 보면 MemoryCache, DiskCache 두 개를 별도로 만들고 이를 Config에서 주입받아 사용하는 형태

(코드 링크)

//  SDImageCacheConfig.m

#import "SDImageCacheConfig.h"
#import "SDMemoryCache.h"
#import "SDDiskCache.h"

...

- (instancetype)init {
    if (self = [super init]) {
        _shouldDisableiCloud = YES;
        _shouldCacheImagesInMemory = YES;
        _shouldUseWeakMemoryCache = NO;
        _shouldRemoveExpiredDataWhenEnterBackground = YES;
        _shouldRemoveExpiredDataWhenTerminate = YES;
        _diskCacheReadingOptions = 0;
        _diskCacheWritingOptions = NSDataWritingAtomic;
        _maxDiskAge = kDefaultCacheMaxDiskAge;
        _maxDiskSize = 0;
        _diskCacheExpireType = SDImageCacheConfigExpireTypeModificationDate;
        _fileManager = nil;
        _ioQueueAttributes = DISPATCH_QUEUE_SERIAL; // NULL
        _memoryCacheClass = [SDMemoryCache class];
        _diskCacheClass = [SDDiskCache class];
    }
    return self;
}

SDWebImage 캐싱 컨피그

  • config가 없으면 defaultConfig를 사용하여 config 세팅
  • SDImageCache.m의 init를 보면 내부적으로 queue를 선언하여 해당 queue를 통해 I/O 수행
- (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns
                       diskCacheDirectory:(nullable NSString *)directory
                                   config:(nullable SDImageCacheConfig *)config {

    if ((self = [super init])) {
        NSAssert(ns, @"Cache namespace should not be nil");
        
        if (!config) {
            config = SDImageCacheConfig.defaultCacheConfig;
        }
        _config = [config copy];
        
        // Create IO queue
        dispatch_queue_attr_t ioQueueAttributes = _config.ioQueueAttributes;
        _ioQueue = dispatch_queue_create("com.hackemist.SDImageCache.ioQueue", ioQueueAttributes);
        NSAssert(_ioQueue, @"The IO queue should not be nil. Your configured `ioQueueAttributes` may be wrong");
        
        ...
  • defaultConfig를 보면 1week동안 disk에 저장되도록 설정되어 있는 형태
static SDImageCacheConfig *_defaultCacheConfig;
static const NSInteger kDefaultCacheMaxDiskAge = 60 * 60 * 24 * 7; // 1 week

@implementation SDImageCacheConfig

+ (SDImageCacheConfig *)defaultCacheConfig {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _defaultCacheConfig = [SDImageCacheConfig new];
    });
    return _defaultCacheConfig;
}

- (instancetype)init {
    if (self = [super init]) {
        _shouldDisableiCloud = YES;
        _shouldCacheImagesInMemory = YES;
        _shouldUseWeakMemoryCache = NO;
        _shouldRemoveExpiredDataWhenEnterBackground = YES;
        _shouldRemoveExpiredDataWhenTerminate = YES;
        _diskCacheReadingOptions = 0;
        _diskCacheWritingOptions = NSDataWritingAtomic;
        _maxDiskAge = kDefaultCacheMaxDiskAge;
        _maxDiskSize = 0;
        _diskCacheExpireType = SDImageCacheConfigExpireTypeModificationDate;
        _fileManager = nil;
        _ioQueueAttributes = DISPATCH_QUEUE_SERIAL; // NULL
        _memoryCacheClass = [SDMemoryCache class];
        _diskCacheClass = [SDDiskCache class];
    }
    return self;
}
  • init에서 disk 캐싱 디렉토리를 받기 때문에 이 디렉토리 disk와 메모리를 초기화
- (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns
                       diskCacheDirectory:(nullable NSString *)directory
                                   config:(nullable SDImageCacheConfig *)config {
    if ((self = [super init])) {
        NSAssert(ns, @"Cache namespace should not be nil");
        
        if (!config) {
            config = SDImageCacheConfig.defaultCacheConfig;
        }
        _config = [config copy];
		
        ...
        
        // Init the memory cache
        NSAssert([config.memoryCacheClass conformsToProtocol:@protocol(SDMemoryCache)], @"Custom memory cache class must conform to `SDMemoryCache` protocol");
        _memoryCache = [[config.memoryCacheClass alloc] initWithConfig:_config];
        
        // Init the disk cache
        if (!directory) {
            // Use default disk cache directory
            directory = [self.class defaultDiskCacheDirectory];
        }
        _diskCachePath = [directory stringByAppendingPathComponent:ns];
        
        ...
  • 또 init 안에서 앱 라이프 사이클에 따라서, date를 체크하여 데이터를 지우는 코드를 호출
        // Subscribe to app events
        [[NSNotificationCenter defaultCenter] addObserver:self
                                                 selector:@selector(applicationWillTerminate:)
                                                     name:UIApplicationWillTerminateNotification
                                                   object:nil];

        [[NSNotificationCenter defaultCenter] addObserver:self
                                                 selector:@selector(applicationDidEnterBackground:)
                                                     name:UIApplicationDidEnterBackgroundNotification
                                                   object:nil];
                                                   

- (void)applicationWillTerminate:(NSNotification *)notification {
    // On iOS/macOS, the async opeartion to remove exipred data will be terminated quickly
    // Try using the sync operation to ensure we reomve the exipred data
    if (!self.config.shouldRemoveExpiredDataWhenTerminate) {
        return;
    }
    dispatch_sync(self.ioQueue, ^{
        [self.diskCache removeExpiredData];
    });
}

- (void)applicationDidEnterBackground:(NSNotification *)notification {
    if (!self.config.shouldRemoveExpiredDataWhenEnterBackground) {
        return;
    }
    Class UIApplicationClass = NSClassFromString(@"UIApplication");
    if(!UIApplicationClass || ![UIApplicationClass respondsToSelector:@selector(sharedApplication)]) {
        return;
    }
    UIApplication *application = [UIApplication performSelector:@selector(sharedApplication)];
    __block UIBackgroundTaskIdentifier bgTask = [application beginBackgroundTaskWithExpirationHandler:^{
        // Clean up any unfinished task business by marking where you
        // stopped or ending the task outright.
        [application endBackgroundTask:bgTask];
        bgTask = UIBackgroundTaskInvalid;
    }];

    // Start the long-running task and return immediately.
    [self deleteOldFilesWithCompletionBlock:^{
        [application endBackgroundTask:bgTask];
        bgTask = UIBackgroundTaskInvalid;
    }];
}

이미지 캐싱 - 저장방법

  • (SDImageCache.m 코드의 storeImage 메소드)
  • 메모리 캐싱
    • 이미지가 주어진 경우, toMemory가 참이고 메모리 캐시 활성화 여부인 shouldCacheImagesInMemory가 참인 경우에 메모리 캐싱을 수행
    • 이미지의 메모리 비용을 계산하여 메모리 캐시에 이미지를 저장
  • 디스크 캐싱
    • 이미지 데이터가 제공된 경우나 이미지가 애니메이션 이미지인 경우, 이미지 데이터를 디스크에 저장
    • 이미지 데이터의 형식을 결정하기 위해 이미지 포맷을 획득
    • 이미지 데이터를 디스크에 저장하고, 이미지를 아카이브하여 두 가지 방식으로 디스크 캐싱을 수행
    • 작업이 완료되면 completionBlock을 호출
- (void)storeImage:(nullable UIImage *)image
         imageData:(nullable NSData *)imageData
            forKey:(nullable NSString *)key
           options:(SDWebImageOptions)options
           context:(nullable SDWebImageContext *)context
         cacheType:(SDImageCacheType)cacheType
        completion:(nullable SDWebImageNoParamsBlock)completionBlock {
    if ((!image && !imageData) || !key) {
        if (completionBlock) {
            completionBlock();
        }
        return;
    }
    BOOL toMemory = cacheType == SDImageCacheTypeMemory || cacheType == SDImageCacheTypeAll;
    BOOL toDisk = cacheType == SDImageCacheTypeDisk || cacheType == SDImageCacheTypeAll;
    // if memory cache is enabled
    if (image && toMemory && self.config.shouldCacheImagesInMemory) {
        NSUInteger cost = image.sd_memoryCost;
        [self.memoryCache setObject:image forKey:key cost:cost];
    }
    
    if (!toDisk) {
        if (completionBlock) {
            completionBlock();
        }
        return;
    }
    NSData *data = imageData;
    if (!data && [image respondsToSelector:@selector(animatedImageData)]) {
        // If image is custom animated image class, prefer its original animated data
        data = [((id<SDAnimatedImage>)image) animatedImageData];
    }
    SDCallbackQueue *queue = context[SDWebImageContextCallbackQueue];
    if (!data && image) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            // Check image's associated image format, may return .undefined
            SDImageFormat format = image.sd_imageFormat;
            if (format == SDImageFormatUndefined) {
                // If image is animated, use GIF (APNG may be better, but has bugs before macOS 10.14)
                if (image.sd_imageFrameCount > 1) {
                    format = SDImageFormatGIF;
                } else {
                    // If we do not have any data to detect image format, check whether it contains alpha channel to use PNG or JPEG format
                    format = [SDImageCoderHelper CGImageContainsAlpha:image.CGImage] ? SDImageFormatPNG : SDImageFormatJPEG;
                }
            }
            NSData *data = [[SDImageCodersManager sharedManager] encodedDataWithImage:image format:format options:context[SDWebImageContextImageEncodeOptions]];
            dispatch_async(self.ioQueue, ^{
                [self _storeImageDataToDisk:data forKey:key];
                [self _archivedDataWithImage:image forKey:key];
                if (completionBlock) {
                    [(queue ?: SDCallbackQueue.mainQueue) async:^{
                        completionBlock();
                    }];
                }
            });
        });
    } else {
        dispatch_async(self.ioQueue, ^{
            [self _storeImageDataToDisk:data forKey:key];
            [self _archivedDataWithImage:image forKey:key];
            if (completionBlock) {
                [(queue ?: SDCallbackQueue.mainQueue) async:^{
                    completionBlock();
                }];
            }
        });
    }
}

이미지 캐싱 - 가져오기

  • 메모리 캐시에 이미지가 있으면 가져오기
    • 이미지가 존재하면서, SDImageCacheDecodeFirstFrameOnly 플래그가 설정되어 있는 경우, 이미지가 애니메이션 이미지인 경우 첫 번째 프레임을 정적 이미지로 변환
  • 메모리 캐시에 이미지가 존재하지 않으면 디스크 캐시를 조회
    • 디스크 캐시에서 키에 해당하는 이미지를 획득
- (nullable UIImage *)imageFromCacheForKey:(nullable NSString *)key options:(SDImageCacheOptions)options context:(nullable SDWebImageContext *)context {
    // First check the in-memory cache...
    UIImage *image = [self imageFromMemoryCacheForKey:key];
    if (image) {
        if (options & SDImageCacheDecodeFirstFrameOnly) {
            // Ensure static image
            if (image.sd_imageFrameCount > 1) {
#if SD_MAC
                image = [[NSImage alloc] initWithCGImage:image.CGImage scale:image.scale orientation:kCGImagePropertyOrientationUp];
#else
                image = [[UIImage alloc] initWithCGImage:image.CGImage scale:image.scale orientation:image.imageOrientation];
#endif
            }
        } else if (options & SDImageCacheMatchAnimatedImageClass) {
            // Check image class matching
            Class animatedImageClass = image.class;
            Class desiredImageClass = context[SDWebImageContextAnimatedImageClass];
            if (desiredImageClass && ![animatedImageClass isSubclassOfClass:desiredImageClass]) {
                image = nil;
            }
        }
    }
    
    // Since we don't need to query imageData, return image if exist
    if (image) {
        return image;
    }
    
    // Second check the disk cache...
    image = [self imageFromDiskCacheForKey:key options:options context:context];
    return image;
}

SDWebImage 메모리 캐시

  • SDMemoryCache.m 코드를 보면 SDMemoryCache를 선언해 놓았으며 이것은 NSCache 타입이므로 일반적으로 swift에서 메모리 캐싱을 사용하는 방법과 유사 (NSCache를 통해 메모리 캐싱하는 방법은 이전 포스팅 글 참고)
@interface SDMemoryCache <KeyType, ObjectType> () {
#if SD_UIKIT
    SD_LOCK_DECLARE(_weakCacheLock); // a lock to keep the access to `weakCache` thread-safe
#endif
}

...
@interface SDMemoryCache <KeyType, ObjectType> : NSCache <KeyType, ObjectType> <SDMemoryCache>
  • 이 NSCache 타입의 SDMemoryCache 내부를 재정의하고 사용하는 상태이고, 여기에 더해서 NSMapTable을 통해 weakCache까지 사용
@property (nonatomic, strong, nonnull) NSMapTable<KeyType, ObjectType> *weakCache; // strong-weak cache
  • NSCache에 있는 setObject, objectForKey를 재정의하여 안에서 weakCache를 사용
// `setObject:forKey:` just call this with 0 cost. Override this is enough
- (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(_weakCacheLock);
        [self.weakCache setObject:obj forKey:key];
        SD_UNLOCK(_weakCacheLock);
    }
}

- (id)objectForKey:(id)key {
    id obj = [super objectForKey:key];
    if (!self.config.shouldUseWeakMemoryCache) {
        return obj;
    }
    if (key && !obj) {
        // Check weak cache
        SD_LOCK(_weakCacheLock);
        obj = [self.weakCache objectForKey:key];
        SD_UNLOCK(_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;
}
  • KVO를 사용하여 메모리 캐싱 정책이 변경될때마다 실시간으로 변경
#pragma mark - KVO

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if (context == SDMemoryCacheContext) {
        if ([keyPath isEqualToString:NSStringFromSelector(@selector(maxMemoryCost))]) {
            self.totalCostLimit = self.config.maxMemoryCost;
        } else if ([keyPath isEqualToString:NSStringFromSelector(@selector(maxMemoryCount))]) {
            self.countLimit = self.config.maxMemoryCount;
        }
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

@end

메모리 캐싱에서 NSMaptable을 별도로 사용하여 weakCache로 두는 이유?

  • NSMapTable개념은 이전 포스팅 글 참고
  • strong 참조가 아닌 weak 참조로 이미지를 메모리에 저장하여 메모리 관리를 더욱 효율적으로 할 수 있기 때문
  • didReceiveMemoryWarning: NSCache 캐시를 제거하지만, weakCache에 저장된 이미지 데이터는 유지
    • weakCache는 NSCache안의 인스턴스가 아닌, 전역적으로 선언된 상태이므로 weakCache는 제거 안됨
    • 메모리 경고가 발생하면 NSCache는 날아가지만 weakCache에 남아 있으므로 원복이 쉽게 가능
- (void)didReceiveMemoryWarning:(NSNotification *)notification {
    // Only remove cache, but keep weak cache
    [super removeAllObjects];
}

디스크 캐싱

  • SDDiskCache.m 파일
  • NSFileManager을 사용하여 데이터 저장
  • 디스크 캐싱은 memoryCache처럼 weakCache를 별도로 두지 않으며 fileManager하나로만 제어
@property (nonatomic, strong, nonnull) NSFileManager *fileManager;

...

- (void)setData:(NSData *)data forKey:(NSString *)key {
    NSParameterAssert(data);
    NSParameterAssert(key);
    
    // get cache Path for image key
    NSString *cachePathForKey = [self cachePathForKey:key];
    // transform to NSURL
    NSURL *fileURL = [NSURL fileURLWithPath:cachePathForKey isDirectory:NO];
    
    [data writeToURL:fileURL options:self.config.diskCacheWritingOptions error:nil];
}

 

* 참고

https://github.com/SDWebImage/SDWebImage/blob/c614dffdfd3097ebfb1055a37beee1883eb9f2de/SDWebImage/Core/SDImageCacheConfig.m#L11

https://github.com/onevcat/Kingfisher

https://github.com/SDWebImage/SDWebImage/tree/c614dffdfd3097ebfb1055a37beee1883eb9f2de

Comments