iOS組み込みのキャッシュモジュールNSCacheについて発表しました - ninjinkun's diary
@k_katsumi キャッシュを分ける方のはわかりやすくて良いですね。後から読む人の参考になりそうなので、URL と URL の発言、ブログに引用させていただいても良いでしょうか。
2012-03-26 16:42:44 via web to @k_katsumi
@ninjinkun はい。ぜひぜひー。せっかくなので便乗して僕がいつも使ってる画像キャッシュのコードを共有したりしてみます。
2012-03-26 16:45:05 via YoruFukurou to @ninjinkun
@k_katsumi お、それは楽しみです!この手のものはみんな独自に作ってる感じだと思うので、参考にさせていただきたいですー。
2012-03-26 16:48:23 via web to @k_katsumi
@ninjinkun ですよね〜。僕もなんとなくもやっと合ってるよなあ。。。?と思ってたので今夜ちょちょいっとやります。
2012-03-26 16:50:56 via Echofon to @ninjinkun
上のような感じでせっかく話を振ってもらったので私がいつも使ってるキャッシュの実装を公開してみます。
だいたい誰が書いてもこんな感じになると思っているのですが、こうしたら便利だよ、とか、それはおかしいとか突っ込んでもらえるとうれしいです。
インターフェースと実装はだいたい下記のようになります。
#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