ネットワーク上の画像表示の実装

今回は特に新しいトピックではないのですが、最近、身近でネットワーク上の遅延画像表示をどう実装するのがいいのかという話題が出たので書いてみました。アプリでネットワークを介して画像を表示するケースが多々あると思いますが、ベストプラクティスを考えると意外に難しいと思います。実際にSoichaを実装した際に苦労した点の1つにタイムライン上の大量のプロフィール画像の表示があります。単純にURL先のデータを取得して描画するだけならNSData#dataWithContentsOfURLを使えば簡単なのですが画面がロックされるキャンセルできないなど色々と問題があったりします。また、大量の画像データがある場合は通信帯域が限られているスマートフォンでは色々な工夫が必要になります。
画像表示でおおよそ出てくる要件としては下記のようなものです。

要件
  1. 非同期でデータを読み込みたい
  2. 同時通信数を3つに制限したい
  3. 画像データ取得を途中でキャンセルしたい
  4. 同じ画像データは再度通信しないようにキャッシュしたい
  5. 取得した画像データは永続化したい
  6. UITableViewやのCell上でも画像を表示したい


この要件を満たすアプリに組み込むとビジネスロジック以外の部分でそれなりに煩雑なロジックになってきます。今後、幸せになれるようにUIImageViewを拡張したIMCachedImageViewクラスとメモリ保持用のシングルトンインスタンスのIMImageManagerクラスのみで完結するように実装してみました。

IMCachedImageView.h
IMCachedImageView.m
CachedImage.xcdatamodeld

非同期通信の実装


非同期での画像データの取得方法は、

  • NSData#dataWithContentsOfURL: を非同期キューで読み込む
  • NSURLConnection#connectionWithRequest:delegate:で読み込む
  • NSURLConnection#sendAsynchronousRequest:queue:completionHandler で読み込む

など色々あるわけですが、
要件2のキューを制限することと要件3のキャンセルの要件を満たすために、非同期キューはNSOperationQueueを使い、キャンセル可能なようにNSURLConnection#connectionWithRequest:delegate を使用します。
NSOperationを継承したクラスで並列実行モードの実装を行い、startメソッド内でNSURLConnectionを発行します。注意点としてNSURLConnectionは通常メインスレッドでしか動かないので、NSOperation#start内のループではRunLoopを回しておく必要があります。(iOS4以降)

- (BOOL)isConcurrent {
    return YES;
}

- (void)start {
    if (self.isCancelled) {
        [self finish];
        return;
    }
    [self setValue:[NSNumber numberWithBool:YES] forKey:kObservingExecuting];
    _request = [NSURLRequest requestWithURL:[NSURL URLWithString:_url]
                                cachePolicy:NSURLRequestUseProtocolCachePolicy
                            timeoutInterval:TIMEOUT_INTERVAL];
    _connection = [NSURLConnection connectionWithRequest:_request delegate:self];
    if (_connection != nil) {
        do {
            if (self.isCancelled) {
                [_connection cancel];
                [self finish];
            }
            [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
        } while (_isExecuting);
    }
}

GCDやNSURLConnection#sendAsynchronousRequestなど色々と便利になったわけですが、大量アクセスがある場合は、やはりNSOperationQueue+NSURLConnectionのdelegateを使う方が小回りが効きそうです。

NSOperationの並列実行モードとNSURLConnectionについてはこちらの記事が参考になります。
http://d.hatena.ne.jp/glass-_-onion/20110706/1309909082

データ取得後はNSNotificationでIMCachedImageViewに通知を受信してレンダリングするようにします。

データキャッシュ機構

要件5の永続化はファイルで保存すると1000ファイルを超えたくらいからアクセスが一気に遅くなった経験があります。今回はインデックスが使えるようにSqliteを利用しようと思います。CoreDataはurlをキーにしてインデックスを貼るようにします。デフォルトのOptionalのチェックを外してIndexedにチェックを入れます。

キャッシュ機構は一度読み込んだデータはメモリ上とSqliteに保持するようにして、画像データロード時にはメモリ>DB>リクエスト発行とコストの低い順にアクセスするようにします。
具体的にはstaticなオブジェクトにNSDictionaryを持たせ、URL自体をユニークなキーとして扱いUIImageを保持するようにします。メモリへのアクセスは一瞬なのでメインスレッドで行いますが、DBアクセス、ネットワークアクセスは非同期スレッドで行います。

非同期取得データのレンダリング

要件6は意外と厄介です。
UITableViewはCellを再利用する機構があるため、上下にスクロールして画面から消えたCellのオブジェクトは別の行で再利用され、その行のデータソースで上書きされるようになります。行が表示されるタイミングでデータが存在していれば問題は発生しないのですが、非同期通信でデータ受信後に描画する際、どのオブジェクトに対してレンダリングするのかわからなくなるためです。それを回避するためにユニークなキーURLをデータソースに持たせ、拡張した全部のUIImageViewにデータ取得の通知を行い一致したキーのオブジェクトに対してのみレンダリングさせるようにします。

- (void)observingUpdateImage:(NSNotification *)notification {
    NSString *userInfoUrl = notification.userInfo[kUserInfoUrl];
    UIImage *userInfoImage = notification.userInfo[kUserInfoImage];
    if ([_url isEqualToString:userInfoUrl]) {
        
        __block __weak IMCachedImageView *weakSelf = self;
        dispatch_async(dispatch_get_main_queue(), ^{
            [weakSelf updateImage:userInfoImage];
            [weakSelf hideIndicator];
        });
    }
}

IMCachedImageViewの使い方

CoreDataを使うのでCoreData.frameworkをLink Binary with Librariesから追加します。
対象のソースは下記3つのみです。

  • IMCachedImageView.h
  • IMCachedImageView.m
  • CachedImage.xcdatamodeld

使い方は単純にIMCachedImageViewにdefaultImage、errorImage、urlをセットするだけです。

    IMCachedImageView *cachedImageView = [[IMCachedImageView alloc] initWithFrame:(CGRect){10, 10, 68, 68}];
    cachedImageView.defaultImage = [UIImage imageNamed:@"DefaultImage"];
    cachedImageView.errorImage = [UIImage imageNamed:@"ErrorImage"];
    cachedImageView.url = @"http://www.st-hatena.com/users/h_/h_mori/user.jpg";
    [self addSubview:cachedImageView];

urlをセットした段階でメモリ上、DB上に存在しない場合は勝手にリクエストキューにプールされます。defaultImageはローディング中に表示する画像、errorImageは画像が取得できなかった場合に表示する画像、username、passwordはダイジェスト認証時に使用するプロパティです。

IMCachedImageViewが破棄されリクエストが不要になった場合、キャッシュも不要の場合はIMImageManager#cancelAllOperationを実行します。

[[IMImageManager sharedManager] cancelAllOperation];

Sqliteの画像データをtruncateする場合はIMImageManager#truncateStoreを実行します。

[[IMImageManager sharedManager] truncateStore];

実装サンプルコード

https://github.com/hmori/CachedImageSample


Google画像検索APIから特定のキーワード画像URLを60件程度取得してUITableViewに遅延表示・キャッシュ・画像データを永続化するサンプルです。UITableViewCellにIMCachedImageViewを配置し、Cellが表示されたタイミングでメモリ上、DB上を参照し存在しない場合にリクエストのキューに順次スタックする実装です。画面が破棄された場合などを想定してリクエストキューのキャンセル、SqliteのTruncate、メモリの破棄もサンプルで実装しています。
本サンプルコードの著作権は行使しないのでご自由にお使いください。これで皆さんが幸せになれるのであれば幸いです。尚、サンプル上のLoadボタンのGoogle画像検索APIは非推奨APIなのでご注意ください。