Help us understand the problem. What is going on with this article?

UITableviewのImageViewへの画像の非同期ダウンロードをアスペクト指向的に解決した話

More than 5 years have passed since last update.

世のiOSプログラマーの大半はUITableViewとのキャットファイトに日々の作業の大半を費やしていると思うのだけど、その中でもUITableViewCellのimageViewへ画像を非同期で読み込む処理というのは煩雑で面倒な処理だと思う。

何が面倒なのかは割愛するけど、決定的に面倒なのがこの処理が容易にモジュール化できないという点にある。

https://developer.apple.com/library/ios/samplecode/LazyTableImages/Introduction/Intro.html

AppleのLazyTableImageはこの手の処理のお手本的なサンプルコードなのだけど、唯一の欠点が、UIScrollViewDelegate内での処理を必要としている点だ。

// -------------------------------------------------------------------------------
// scrollViewDidEndDragging:willDecelerate:
// Load images for all onscreen rows when scrolling is finished.
// -------------------------------------------------------------------------------
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
    if (!decelerate)
    {
        [self loadImagesForOnscreenRows];
    }
}

// -------------------------------------------------------------------------------
// scrollViewDidEndDecelerating:
// -------------------------------------------------------------------------------
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
    [self loadImagesForOnscreenRows];
}

ここ。この処理は、スクロール中はセルが表示されていてもダウンロードを開始しない(ちらつきを防止する)というエレガントな処理の一部なのだが、非常にこまったことになる。というのは、通常、UITableViewのscrollViewのデリゲートはUITableViewDelegateと同じ場所に実装されていなければならないからだ(メソッドフォワーディングをすれば分離できる気もするが)。そして、UITableViewControllerを使用している場合、UIScrollViewはViewControllerと癒着してしまい、分離ができない。

// -------------------------------------------------------------------------------
// startIconDownload:forIndexPath:
// -------------------------------------------------------------------------------
- (void)startIconDownload:(AppRecord *)appRecord forIndexPath:(NSIndexPath *)indexPath
{
IconDownloader *iconDownloader = [self.imageDownloadsInProgress objectForKey:indexPath];
if (iconDownloader == nil) 
{
    iconDownloader = [[IconDownloader alloc] init];
    iconDownloader.appRecord = appRecord;
    [iconDownloader setCompletionHandler:^{

        UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];

            // Display the newly loaded image
            cell.imageView.image = appRecord.appIcon;

                // Remove the IconDownloader from the in progress list.
                // This will result in it being deallocated.
                [self.imageDownloadsInProgress removeObjectForKey:indexPath];

            }];
        [self.imageDownloadsInProgress setObject:iconDownloader forKey:indexPath];
        [iconDownloader startDownload]; 
    }
}

// -------------------------------------------------------------------------------
// loadImagesForOnscreenRows
// This method is used in case the user scrolled into a set of cells that don't
// have their app icons yet.
// -------------------------------------------------------------------------------
- (void)loadImagesForOnscreenRows
{
if ([self.entries count] > 0)
{
    NSArray *visiblePaths = [self.tableView indexPathsForVisibleRows];
        for (NSIndexPath *indexPath in visiblePaths)
        {
            AppRecord *appRecord = [self.entries objectAtIndex:indexPath.row];

            if (!appRecord.appIcon)
            // Avoid the app icon download if the app already has an icon
            {
                [self startIconDownload:appRecord forIndexPath:indexPath];
            }
        }
    }
}

上記はLazyTableImageのダウンロード処理の実装だが、VCとDelegateが癒着していると、本来モジュール化が可能なはずのダウンロード処理が分離できなくなってしまうのだ。なので、泣く泣くすべてのUITableViewControllerのサブクラスにこのような処理を書くことになる。

が、それではあまりにもばかみたいなのでUITableViewControllerのスーパークラスを作って継承しようとすると、更に悲惨なことになる。
まず、スーパクラスでデリゲートを実装すると、継承先でデリゲートメソッドを実行する場合、superメソッドの呼び出しが必須になるという点。これは極力避けたい。なぜなら継承先では常に親で何をやっているか見なくてはいけなくなるため、子の子くらいになるとスーパークラスでの副作用の影響が大きくなりすぎることがあるからだ。これは継承というシステムの欠点でもある。

また、UITableViewControllerでスーパークラスを作ってしまうと、DataSourceの責任問題が発生する。具体的には、cellForRowAtIndexPath:メソッドの実装をどこで誰が責任を持って処理するかという問題だ。

スーパークラスを作る以上、同じような処理は極力上のほうで処理したい。しかし、そもそも表示する場所によってcellのクラスやtextLabelにバインドする値が全く異なる以上、viewの処理は書けない。しかし、viewとdelegateと画像の非同期処理が完全に癒着している状態を考えるとやはり下位クラスで同じような処理が発生することは否めない。

そして誰もがみなダークサイド(コピペ)に落ちたのであった……

のが、1年位前。でも、ふとしたことからアスペクト指向という概念を知って、Objective-Cのダイナミックな性質を使えば実現できるのではないかと思い立って実装してみた。

アスペクト指向とは?

詳細な定義は知らないけど、クラス指向でのオブジェクトの表現がクリスマスツリー型なのに対し、アスペクト指向でのオブジェクトの表現はパッチ的だと思う。Rubyにはモジュールとmix-inという概念があるけど、あれはあるクラスにモジュール化された処理を組み込むための機構で、モジュールには縦の継承関係がないから必要に応じてオプショナルなオブジェクトを作ることができる便利な機能だ。

Objective-Cにも似たような機能としてカテゴリというものがある。これは、既存のクラスにメソッドを追加することで機能を拡張するができる仕組みだ。様々な場面で使うことが多いだろう。ただし、カテゴリにはいくつかの制約と危険性がある。

まず、プライベートメソッド、プライベート変数にはアクセスできないという点。
これはカプセル化の原則からいって無理なのだけど、モジュール化という観点から見るとちょっと痛い。なぜならあるクラスの処理を完全に細切れにすることができないからだ。が、しかしこれは別にどうでも良い。なぜかというとモジュールの方で拡張先のことを考える必要はないからだ。

次に変数を持つことができないという点。
これもモジュールの概念からすると当然なのだけど、アスペクト指向からすると少し面倒だ。変数が保存できないということは状態を記憶できないということであり、aspect-object-orientedとしてはちょっと面倒だ。が、これも一応は解決できて、objc/runtime.hに存在するobjc_set/getAssociatedObjectを使えばカテゴリの中でも変数を持つことができる。より正確に言うと、変数はプロパティとして宣言し、セッタとゲッタの実装を実行時の処理として扱うということになる。

そして危険性。
それは既存のメソッドと同名のメソッドをカテゴリの中に実装することで上書きが可能だということだ。通常そういうことはやってはいけないし、やる必要もないのだが、危険性という点では留意しておく必要がある。

さて、これらの条件に気をつけながらobjcでアスペクト指向を実現するにはどうすればいいか。アスペクト指向の条件として、アスペクトを纏うオブジェクトが束縛されるのは縦の継承関係だけで、アスペクトに癒着してはいけない。また、カテゴリとしてアスペクトを実現する以上汎用的かつフレキシブルな実装、つまり、アスペクトの纏い方によって処理を変えられる(=単なるモジュールではない)ようにしなくてはいけない。

UITableViewでの画像の非同期ダウンロード処理で単純なモジュール化ができそうなのは画像のダウンロード処理の部分だ。これはサンプルではNSOperationのImageDownloadOperationで表現されているが、通常はAFHttpRequestOperationで代用が可能だ。urlなどは引数で与えてやればよい。 では、単純にモジュール化できないのはどの部分だろうか。

それは、UIScrollViewDelegate、具体的には-scrollViewDidEndDecelerating, -scrollViewDidEndDragging:deceleratingの部分だ。ほとんどのUITableViewControllerはこれらのメソッドを実装しないが、まれにスクロール処理に関することでメソッドを実装することがある。なので、先の危険性で挙げた「カテゴリでの既存メソッドの上書き」をやってしまうと、元の処理が消えてしまうため、アスペクトとしては失格だ。ではどうするか?

method swizzlingを使う

method swizzlingはobjcのruntimeで定義されているmethod_exchangeImplementationで実現できるメタプログラミングの一種で、既存のクラス/オブジェクトのメソッド実装を別のメソッドと入れ替えるというものだ。今回の問題としては、デリゲート内での画像の非同期処理に関連する処理は同じであるので、すでにデリゲートが実装されたオブジェクトに関しては、その処理をラップしたメソッドに入れ替えてやればいい。その処理が以下。

- (void)useLazyTableImageAspect
{
    // exchange delegate methods
    [self swizzleMethod:@selector(scrollViewDidEndDecelerating:) withMethod:@selector(_scrollViewDidEndDecelerating:)];
    [self swizzleMethod:@selector(scrollViewDidEndDragging:willDecelerate:) withMethod:@selector(_scrollViewDidEndDragging:willDecelerate:)];
}


- (void)swizzleMethod:(SEL)method1 withMethod:(SEL)method2
{
    Method from_m = class_getInstanceMethod([self class], method1);
    Method to_m = class_getInstanceMethod([self class], method2);
    if (from_m) {
        // exchange two methods if the reciever has an impl of method1
        method_exchangeImplementations(from_m, to_m);
    }else{
        // unless no impl of method1, adding it and swizzling again
        IMP imp = method_getImplementation(to_m);
        void (^block)() = ^{};
        imp = imp_implementationWithBlock(block);
        const char *type = method_getTypeEncoding(to_m);
        class_addMethod([self class], method1, imp, type);
        [self swizzleMethod:method1 withMethod:method2];
    }
}

- (void)_scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
    [self _scrollViewDidEndDecelerating:scrollView];
    if ([scrollView isKindOfClass:[UITableView class]]) {
        [self loadImagesOnScreenRows:(UITableView*)scrollView];
    }
}

- (void)_scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
    [self _scrollViewDidEndDragging:scrollView willDecelerate:decelerate];
    if (!decelerate && [scrollView isKindOfClass:[UITableView class]]) {
        [self loadImagesOnScreenRows:(UITableView*)scrollView];
    }
}

userLazyTableImageAspectは、実行時にアスペクトの使用を明示的に宣言するメソッドで、実際にメソッドの入れ替えを行う。ミソなのは、swizzoleMethod:withMethod:の中で、入れ替え元のメソッドが実際に実装されているかどうかを確認している点だ。もしアスペクトの使用者がUIScrollViewDelegateの対象メソッドを実装していれば、このカテゴリの中で実装された_付きのメソッドと実装を入れ替え、もし実装されていなければ空のメソッドを実装して中身を入れ替える。こうすることで、アスペクトの使用者がどのような実装をしていても同じようにアスペクトを使用できるようになった。実際の使用はこんな感じ。

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];
    // 対応するappレコードを取得
    AppRecord *app = [self modelForTableView:tableView atIndexPath:indexPath];
    // データをassign
    cell.textLabel.text = [app appName];
    cell.detailTextLabel.text = [app artist];
    if (app.appIcon) {
        // iconがすでにダウンロード済みならassign
        cell.imageView.image = app.appIcon;
    }else{
        // まだならplaceholderを代入してlazy download を開始
        [self startImageDownloadForURL:app.appIconURL tableView:tableView atIndexPath:indexPath completaion:^(UIImage *image, NSError *error) {
            if (!error) {
                if ([[tableView indexPathsForVisibleRows] containsObject:indexPath]) {
                    UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
                    AppRecord *app = [self modelForTableView:tableView atIndexPath:indexPath];
                    app.appIcon = image;
                    [UIView transitionWithView:cell.imageView duration:0.5 options:UIViewAnimationOptionTransitionCrossDissolve animations:^{
                        cell.imageView.image = image;
                    } completion:NULL];
                }
            }else{
                // error処理
                }];
            }
        }];
        cell.imageView.image = [UIImage imageNamed:@"ph"];
    }
    return cell;
}

ブロック形式で直感的に非同期ダウンロード処理ができる。handlerを与えなかった場合は自動的にcellに画像がアサインされるようになっている。

便利なのでぜひ使ってみてください。

https://github.com/keroxp/KXLazyTableImage

※CocoaPodsの不具合でDemoターゲットがビルドできないのでPodsプロジェクトのKXLazyTableImageターゲットのコンパイル対象からPod_Dummy_KXLazyTableImage.mを削除してください。そのうちcocoapodsに公開します。

keroxp
iOS/Android/Unity/Node.js/Rails/Go/フロントエンド/SRE プログラマ
http://scrapbox.io/keroxp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした