24/7 twenty-four seven

iOS/OS X application programing topics.

ダウンロードした画像をキャッシュするクラスの設計と実装について

iOS組み込みのキャッシュモジュールNSCacheについて発表しました - ninjinkun's diary


上のような感じでせっかく話を振ってもらったので私がいつも使ってるキャッシュの実装を公開してみます。
だいたい誰が書いてもこんな感じになると思っているのですが、こうしたら便利だよ、とか、それはおかしいとか突っ込んでもらえるとうれしいです。


インターフェースと実装はだいたい下記のようになります。

#import <Foundation/Foundation.h>

typedef void (^ImageCacheResultBlock)(UIImage *image, NSError *error);

@interface ImageCache : NSObject

+ (ImageCache *)sharedInstance;

- (UIImage *)imageWithURL:(NSString *)URL
                    block:(ImageResultBlock)block;
- (UIImage *)imageWithURL:(NSString *)URL 
             defaultImage:(UIImage *)defaultImage 
                    block:(ImageResultBlock)block;

- (void)clearMemoryCache;
- (void)deleteAllCacheFiles;

@end
〜(略)〜

- (UIImage *)imageWithURL:(NSString *)URL 
                    block:(ImageResultBlock)block {    
    return [self imageWithURL:URL defaultImage:nil block:block];
}

- (UIImage *)imageWithURL:(NSString *)URL 
             defaultImage:(UIImage *)defaultImage 
                    block:(ImageResultBlock)block {    
    if (!URL) {
        return defaultImage;
    }
    
    UIImage *cachedImage = [self cachedImageWithURL:URL];
    if (cachedImage) {
        return cachedImage;
    } else {
        cachedImage = defaultImage;
    }
    
    __block ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:URL]];
    
    [request setCompletionBlock:^{
        NSData *data = [request responseData];
        UIImage *image = [UIImage imageWithData:data];
        if (image) {
            [self storeImage:image data:data URL:URL];
            block(image, nil);
        } else {
            block(nil, [NSError errorWithDomain:@"ImageCacheErrorDomain" code:0 userInfo:nil]);
        }
    }];
    [request setFailedBlock:^{
        block(nil, request.error);
    }];
    
    [networkQueue addOperation:request];
    
    return cachedImage;
}

設計の勘所

この例だと、キャッシュとダウンローダが一体になっていて、キャッシュに無かったら自動的にダウンロードしますが、場合に応じてダウンローダは別にすることもあります。

だいたい、以下の3パターンをプロジェクトに応じて使い分けています。


使い分けの目安としては、画像以外にもAPIアクセスがある場合(たいていはそうでしょうけど)は別にして、APIのアクセスを含むネットワークのアクセスを別のクラスにまとめてしまって、キャッシュクラスではネットワークアクセスは行わないようにするほうがスッキリすることが多いですね。


または、テーブルビューに表示する場合は、スクロール中にはダウンロードを実行したくないとか、画面に表示されている行の画像を先にダウンロードしたいとか、細かく挙動を制御したくなることがけっこうありますので、キャッシュからの取得とネットワークからの取得がコントロールできるように分けたほうがうまくいきます。


まあ、よっぽど小規模のアプリケーションでなければ、ダウンローダとキャッシュは分けたほうが融通が利いていいと思います。

2番目と3番目の違いは、キャッシュクラスに直接アクセスするかどうかです。キャッシュクラスに直接アクセスせずにダウンローダやAPIアクセスのクラスを通してのみ画像を取得するようにすると、コードがシンプルになるのですが、先に述べたようにダウンロードのタイミングを細かく制御する場合は3番目の設計にすることが多いです。


使い方は下のようになります。

UIImage *cachedImage = 
 [[ImageCache sharedInstance] imageWithURL:photoURL 
                                     block:^(UIImage *image, NSError *error)
 {
     cell.photo = image;
 }];

cell.photo = cachedImage;


たいていはこんなにシンプルにはならなくて実際は下のようになることが多いです。
キャッシュに無くてスクロール中やドラッグ中でない場合のみネットワークからダウンロードします。

UIImage *cachedImage = 
 [[ImageCache sharedInstance] cachedImageWithURL:photoURL];
if (!cachedImage) {
    if (!tableView.dragging && !tableView.decelerating) {
        [ImageDownloader downloaderWithURL:photoURL
                                  delegate:self 
                                  userInfo:[NSDictionary dictionaryWithObject:indexPath forKey:@"indexPath"]];
    }
}

cell.photo = cachedImage;

実装のTips

デフォルト画像をパラメータで指定できるようにしておく

キャッシュクラスあるいはAPIクラスの画像取得のメソッドには、デフォルト画像として無ければそれ自身をそのまま返すパラメータを用意しておくと便利です。
どう便利かというと、ユーザーアイコンなどで画像が未ダウンロード、あるいは設定されてないときにデフォルトアイコンを表示する、という要件はよくあると思いますが、それをシンプルに実装することができます。

UIImage *cachedIconImage = 
 [[ImageCache sharedInstance] imageWithURL:userIconURL 
                              defaultImage:[UIImage imageNamed:@"defaultIcon.png"]
                                     block:^(UIImage *image, NSError *error)
 {
     cell.userIcon = image;
 }];

cell.userIcon = cachedIconImage;
NSCacheを使う

キャッシュはディスクキャッシュをメインで使いつつ、メモリキャッシュと併用しますが、メモリキャッシュにはNSCacheを使うのが便利です。

NSCacheについては冒頭でも引用した下記の記事が詳しいです。
iOS組み込みのキャッシュモジュールNSCacheについて発表しました - ninjinkun's diary


簡単にいうとNSCacheはキャッシュ用に便利は機能が追加されたNSMutableDictionaryです。
格納できるオブジェクト数の上限や容量の上限を決めて超えたぶんは自動的に削除されるとか、自動削除のタイミングで処理を実行することができるなどです。


私は数の上限だけを設定して、容量の制限は使いません。
サムネイル画像やアイコン画像とメイン画像で容量が全然違うよ、っていう場合はNSCacheのインスタンスを複数使ってそれぞれに上限を設定して使い分けます。

下記は、NSCacheを複数使い分ける例です。

cache = [[NSCache alloc] init];
cache.countLimit = 20;

thumbnailCache = [[NSCache alloc] init];
thumbnailCache.countLimit = 100;
ディスクキャッシュのファイル名はMD5ハッシュ値を使う

ダウンロードした画像データをディスクに保存するときのファイル名ですが、ダウンロード先のURLはスラッシュ"/"やコロン":"がパスの文字列としてジャマになったり、日本語が含まれていたりと面倒なのでURLからMD5ハッシュ値を計算して、それをファイル名に使います。

ついでに1つのディレクトリに大量のファイルを入れると速度低下が心配なので適当にバラけるようにハッシュ値の最初の2文字を使ってサブディレクトリに小分けします。


実装は次のようになります。

+ (NSString *)keyForURL:(NSString *)URL {
	if ([URL length] == 0) {
		return nil;
	}
	const char *cStr = [URL UTF8String];
	unsigned char result[16];
	CC_MD5(cStr, (CC_LONG)strlen(cStr), result);
	return [NSString stringWithFormat:@"%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X",
            result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7],result[8], result[9], result[10], result[11],result[12], result[13], result[14], result[15]]; 	
}

- (NSString *)pathForKey:(NSString *)key {
    NSString *path = [NSString stringWithFormat:@"%@/%@/%@", cacheDirectory, [key substringToIndex:2], key];
    return path;
}

- (void)storeImage:(UIImage *)image data:(NSData *)data URL:(NSString *)URL {
    NSString *key = [KDImageCache keyForURL:URL];
    [cache setObject:image forKey:key];
    
    [data writeToFile:[self pathForKey:key] atomically:NO];
}


以上です。
今回用いたサンプルの完全なコードを下に掲載しておきます。
ツッコミ歓迎です。

#import "ImageCache.h"
#import "ASIHTTPRequest.h"
#import <CommonCrypto/CommonHMAC.h>

@interface ImageCache() {
    NSFileManager *fileManager;
    NSString *cacheDirectory;
    
    NSCache *cache;
    
    NSOperationQueue *networkQueue;
}

@end

@implementation ImageCache

+ (ImageCache *)sharedInstance {
    static ImageCache *sharedInstance;
    static dispatch_once_t pred;
    dispatch_once(&pred, ^{
        sharedInstance = [[ImageCache alloc] init];
    });
    
    return sharedInstance;
}

- (id)init {
    self = [super init];
    if (self) {
        [[NSNotificationCenter defaultCenter] 
          addObserver:self 
             selector:@selector(didReceiveMemoryWarning:)
                 name:UIApplicationDidReceiveMemoryWarningNotification 
               object:nil];
        
        cache = [[NSCache alloc] init];
        cache.countLimit = 20;
        
        fileManager = [[NSFileManager alloc] init];
        
        NSArray *paths = 
         NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
        cacheDirectory = [[[paths lastObject] 
                            stringByAppendingPathComponent:@"Images"] retain];
        
        [self createDirectories];
        
        networkQueue = [[NSOperationQueue alloc] init];
        [networkQueue setMaxConcurrentOperationCount:1];
    }
    return self;
}

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    [fileManager release];
    [cacheDirectory release];
    [cache release];
    [networkQueue release];
    [super dealloc];
}

- (void)didReceiveMemoryWarning:(NSNotification *)notif {
    [self clearMemoryCache];
}

- (void)createDirectories {
    BOOL isDirectory = NO;
    BOOL exists = [fileManager fileExistsAtPath:cacheDirectory 
                                    isDirectory:&isDirectory];
    if (!exists || !isDirectory) {
        [fileManager createDirectoryAtPath:cacheDirectory 
               withIntermediateDirectories:YES 
                                attributes:nil 
                                     error:nil];
    }
    for (int i = 0; i < 16; i++) {
        for (int j = 0; j < 16; j++) {
            NSString *subDir = 
             [NSString stringWithFormat:@"%@/%X%X", cacheDirectory, i, j];
            BOOL isDir = NO;
            BOOL existsSubDir = 
             [fileManager fileExistsAtPath:subDir isDirectory:&isDir];
            if (!existsSubDir || !isDir) {
                [fileManager createDirectoryAtPath:subDir 
                       withIntermediateDirectories:YES 
                                        attributes:nil 
                                             error:nil];
            }
        }
    }
}

#pragma mark -

+ (NSString *)keyForURL:(NSString *)URL {
	if ([URL length] == 0) {
		return nil;
	}
	const char *cStr = [URL UTF8String];
	unsigned char result[16];
	CC_MD5(cStr, (CC_LONG)strlen(cStr), result);
	return [NSString stringWithFormat:@"%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X",
            result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7],
            result[8], result[9], result[10], result[11],result[12], result[13], result[14], result[15]]; 	
}

- (NSString *)pathForKey:(NSString *)key {
    NSString *path = [NSString stringWithFormat:@"%@/%@/%@", cacheDirectory, [key substringToIndex:2], key];
    return path;
}

#pragma mark -

- (UIImage *)cachedImageWithURL:(NSString *)URL {
    NSString *key = [ImageCache keyForURL:URL];
    UIImage *cachedImage = [cache objectForKey:key];
    if (cachedImage) {
        return cachedImage;
    }
    
    cachedImage = [UIImage imageWithContentsOfFile:[self pathForKey:key]];
    if (cachedImage) {
        [cache setObject:cachedImage forKey:key];
    }
    
    return cachedImage;
}

#pragma mark -

- (void)storeImage:(UIImage *)image data:(NSData *)data URL:(NSString *)URL {
    NSString *key = [ImageCache keyForURL:URL];
    [cache setObject:image forKey:key];
    
    [data writeToFile:[self pathForKey:key] atomically:NO];
}

- (void)clearMemoryCache {
    [cache removeAllObjects];
}

- (void)deleteAllCacheFiles {
    [cache removeAllObjects];
    
    if ([fileManager fileExistsAtPath:cacheDirectory]) {
        if ([fileManager removeItemAtPath:cacheDirectory error:nil]) {
            [self createDirectories];
        }
    }
    
    BOOL isDirectory = NO;
    BOOL exists = [fileManager fileExistsAtPath:cacheDirectory isDirectory:&isDirectory];
    if (!exists || !isDirectory) {
        [fileManager createDirectoryAtPath:cacheDirectory 
               withIntermediateDirectories:YES 
                                attributes:nil 
                                     error:nil];
    }
}

#pragma mark -

- (UIImage *)imageWithURL:(NSString *)URL 
                    block:(ImageResultBlock)block {    
    return [self imageWithURL:URL defaultImage:nil block:block];
}

- (UIImage *)imageWithURL:(NSString *)URL 
             defaultImage:(UIImage *)defaultImage 
                    block:(ImageResultBlock)block {    
    if (!URL) {
        return defaultImage;
    }
    
    UIImage *cachedImage = [self cachedImageWithURL:URL];
    if (cachedImage) {
        return cachedImage;
    }
    
    __block ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:URL]];
    
    [request setCompletionBlock:^{
        NSData *data = [request responseData];
        UIImage *image = [UIImage imageWithData:data];
        if (image) {
            [self storeImage:image data:data URL:URL];
            block(image, nil);
        } else {
            block(nil, [NSError errorWithDomain:@"ImageCacheErrorDomain" code:0 userInfo:nil]);
        }
    }];
    [request setFailedBlock:^{
        block(nil, request.error);
    }];
    
    [networkQueue addOperation:request];
    
    return defaultImage;
}

@end