Objective-C
Mac
iOS
Block

あらゆるdelegateをBlockで実装するためのクラスの作り方

More than 3 years have passed since last update.

==============================================

Objective-Cではdelegateがよく使われます。ここでのdelegateとは、〜Delegateはもちろん、プロトコルでメソッドを定義しcallbackするもの全般(UITableViewDataSource等)を指すこととします。

UITableViewDelageteはdelageteでもあまり問題を感じません。しかしUIAlertViewのdelegateなどは少々使いにくい気がします。同じクラス内で複数のUIAlertViewを扱う場合、delegateメソッド内でどのUIAlertViewからのcallbackかの判定が必要です。またUIAlertViewを使用する箇所とcallbackがソースコード上で離れてしまいコードを追いにくくなります。

昔はBlockがなかったため仕方ありませんが、今設計すればBlockになるだろうというものは色々あります。

この記事では標準SDKや自作クラスも含め、あらゆるdelegate(プロトコルを使ったcallback)をBlockで実装するためのクラスを作成します。

前提

この記事は以下の記事を前提としています。

まずBlockで動的にメソッドを実装できるクラスの作り方のSIAExpandableObjectを継承してクラスを作ります。そのクラスではプロトコルの情報を使ってSIAExpandableObjectより少し簡単にメソッドを実装できる機能を追加します。

次にカテゴリを使ってNSObjectにメソッドを追加し、既存クラスのdelegateをBlockで実装するためのメソッドを作成します。

プロトコルを活用して動的にメソッドを実装する

SIAExpandableObjectには

  1. 動的にサブクラスを作りそのインスタンスを返す
  2. メソッド名と型情報を指定し、Blockでメソッドを実装する

という機能があります。これを継承しプロトコルを活用するクラスを作成します。そのクラスは

  1. 動的にサブクラスを作り動的にプロトコルを採用して、そのインスタンスを返す
  2. メソッド名と採用しているプロトコルからメソッドの型情報を取得し、Blockでメソッドを実装する

という機能を実装します。プロトコルは定義されているメソッドの情報(名前と型情報)を保持しています。そのためメソッド名がわかれば、プロトコルからメソッドの型情報を取得できます。

runtime関数

runtime関数を使う場合以下のファイルをインポートします。

#import <objc/runtime.h>

今回は以下の関数を使用します。

// クラスにプロトコルを追加する(採用する)。
BOOL class_addProtocol(Class cls, Protocol *protocol);

// クラスが実装しているプロトコル一覧(配列)を取得する。
Protocol ** class_copyProtocolList(Class cls, unsigned int *outCount);

/*
  プロトコルに実装されているSELと名前が一致メソッドのstruct objc_method_descriptionを取得する。
  この構造体には型情報が含まれています。
  isRequiredMethodはメソッドが必須かオプションか、isInstanceMethodはメソッドがインスタンスメソッドかクラスメソッドかを指定します。
*/
struct objc_method_description protocol_getMethodDescription(Protocol *p, SEL aSel, BOOL isRequiredMethod, BOOL isInstanceMethod);

サブクラスにプロトコルを採用しインスタンス生成

SIAExpandableObjectはサブクラスを動的に登録する機能があります。この機能でサブクラスを生成し、プロトコルを採用するコードを書きます。あとはインスタンスを生成すれば完了です。

SIABlockProtocol.h
@interface SIABlockProtocol : SIAExpandableObject

+ (instancetype)createSubclassInstanceForProtocol:(Protocol *)protocol;

@end
SIABlockProtocol.m
@implementation SIABlockProtocol

+ (instancetype)createSubclassInstanceForProtocol:(Protocol *)protocol
{
    Class subclass = [self registerSubclass];
    if (protocol != NULL) {
        class_addProtocol(subclass, protocol);
    }
    id object = [[subclass alloc] init];
    return object;
}

@end

プロトコルとメソッド名(SEL)から型情報を取得してメソッドを実装する

上記の変更でオブジェクトがプロトコルを採用するようになりました。そのプロトコルを使えば、メソッド名から型情報を取得できます。メソッド名を受け取り、型情報はプロトコルから取得して、SIAExpandableObjectの機能でメソッドを動的に実装するようにします。

SIABlockProtocol.h
@interface SIABlockProtocol : SIAExpandableObject

+ (instancetype)createSubclassInstanceForProtocol:(Protocol *)protocol;
- (void)addMethodWithSelector:(SEL)selector block:(id)block;
- (void)addMethodWithSelector:(SEL)selector protocol:(Protocol *)protocol block:(id)block;

@end
SIABlockProtocol.m
- (void)addMethodWithSelector:(SEL)selector block:(id)block
{
    [self addMethodWithSelector:selector protocol:nil block:block];
}

- (void)addMethodWithSelector:(SEL)selector protocol:(Protocol *)protocol block:(id)block
{
    unsigned int count = 0;
    struct objc_method_description method_description;
    SEL name = NULL;
    if (protocol) {
        // protocol指定時はそのprotocolからmethod_descriptionを取得する
        method_description = protocol_getMethodDescription(protocol, selector, YES, YES);
        if (method_description.name == NULL) {
            method_description = protocol_getMethodDescription(protocol, selector, NO, YES);
        }
        name = method_description.name;
    }
    else {
        // protocol未指定時は実装しているprotocolからmethod_descriptionを取得する
        Protocol __unsafe_unretained **protocol_list = class_copyProtocolList(self.class, &count);
        for (int i = 0; i < count; i++) {
            method_description = protocol_getMethodDescription(protocol_list[i], selector, YES, YES);
            if (method_description.name == NULL) {
                method_description = protocol_getMethodDescription(protocol_list[i], selector, NO, YES);
            }
            if (method_description.name != NULL) {
                name = method_description.name;
                break;
            }
        }
        free(protocol_list);
    }

    if (name != NULL) {
        // selectorに対応するmethod_descriptionがある場合はメソッドを追加する
        [self addMethodWithSelector:selector types:method_description.types block:block];
    }
}

addMethodWithSelector:block: はprotocolをnilとして addMethodWithSelector:protocol:block: を呼び出します。

addMethodWithSelector:protocol:block: はプロトコルからメソッドの情報を取得します。引数のprotocolが指定されればそのprotocolから、nilの場合は採用しているprotocolが探す対象となります。どちらの場合も、最初に必須メソッドから名前が同じメソッド情報を探し、見つからなければオプショナルメソッドから探します。

無事メソッド情報が見つかった場合、selecterとblock、そしてメソッド情報の型情報を使ってSIAExpandableObjectの機能でメソッドを実装します。

既存のクラスのdelegateをBlockで実装する

delegateのオブジェクトは誰が保持するか?

プロトコルの情報を元に、Blockで簡単にメソッドを実装する準備は整いました。動的にcallback用の各種メソッドを実装し、setDelegate:すればdelegateの各種callbackがBlockで受け取れるようになります。しかしここで解決しないといけない問題がひとつあります。 このdelegateのオブジェクトは誰が保持するか? ということです。

通常、delegateは循環参照にならないようにweak(やassigneやunsafe_unretaind)となっています。SIABlockProtocolのインスタンスをsetDelegate:しても、いつの間にかnilになってcallbackされなくなります。(assigneならクラッシュ)

callbackを受け取る側のクラスでdelegateを保持することももちろん可能です。しかしその場合、callbackする側とされる側の生存期間が異なるため、片方が消えるときににもう片方で後始末するなどが必要になるかもしれません。

そこでcallbackする側のインスタンスでdelegateのインスタンスを保持させてしまいます。こうするとcallbackする側とされる側の生存期間が同じになり、delegateがいつのまにか消えたり、callbackする側がいないのにcallbackをいつまでも待ち続けるといったことがなくなります。

setDelegate:してもその引数は保持されないので、setDelegate:とは別にAssociatedObjectを使って保持するようにします。

delegateを実装するためのSIABlockProtocolを既存のクラスに保持する

カテゴリの機能を使い、NSObjectに対して各protocol用SIABlockProtocolを生成・保持する機能を追加します。

SIABlockProtocol.h
@interface NSObject (NSObjectSIABlockProtocolExtensions)

- (SIABlockProtocol *)sia_blockProtocolForProtocol:(Protocol *)protocol;
- (SIABlockProtocol *)sia_blockDelegate;
- (SIABlockProtocol *)sia_blockDataSource;

@end
SIABlockProtocol.m
@implementation NSObject (NSObjectSIABlockProtocolExtensions)

- (SIABlockProtocol *)sia_blockProtocolForProtocol:(Protocol *)protocol
{
    if (protocol == nil) {
        return nil;
    }
    SIABlockProtocol *blockObject = nil;
    @synchronized(self) {
        blockObject = [self sia_associatedObjectForKey:(const void *)protocol];
        if (blockObject == nil) {
            blockObject = [SIABlockProtocol createSubclassInstanceForProtocol:protocol];
            [self sia_setAssociatedObject:blockObject forKey:(const void *)protocol];
        }
    }
    return blockObject;
}

- (SIABlockProtocol *)sia_blockDelegate
{
    Protocol *protocol = [self sia_searchProtocolWithSuffix:@"Delegate"];
    if (protocol != nil) {
        return [self sia_blockProtocolForProtocol:protocol];
    }
    return nil;
}

- (SIABlockProtocol *)sia_blockDataSource
{
    Protocol *protocol = [self sia_searchProtocolWithSuffix:@"DataSource"];
    if (protocol != nil) {
        return [self sia_blockProtocolForProtocol:protocol];
    }
    return nil;
}

- (Protocol *)sia_searchProtocolWithSuffix:(NSString *)suffix
{
    Class class = self.class;
    while (class != nil) {
        NSString *protocolName = [NSStringFromClass(class) stringByAppendingString:suffix];
        Protocol *protocol = NSProtocolFromString(protocolName);
        if (protocol != nil) {
            return protocol;
        }
        class = class.superclass;
    }
    return nil;
}

@end

メインはsia_blockProtocolForProtocol:でその他メソッドは最終的にこれを呼び出すことになります。

sia_blockProtocolForProtocol:は引数のProtocolのアドレスをキーとして、SIABlockProtocolをAssociatedObjectで保持・取得します。Protocolに対応するSIABlockProtocolがなければ生成し設定します。

iOS標準クラスのdelegateを実装する場合の罠

ここまででdelegateをBlockで実装するための準備は整いました。ただ1点、このまま使うと罠に引っかかってしまう可能性があります(悩みました)。

まず罠に掛かってしまったソースコードです。

    UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"title" message:@"message" delegate:nil cancelButtonTitle:nil otherButtonTitles:@"OK", nil];

    SIABlockProtocol *blockProtocol = [alertView sia_blockDelegate];
    [alertView setDelegate:blockProtocol];

    [blockProtocol addMethodWithSelector:@selector(alertView:clickedButtonAtIndex:) block:^(SIABlockProtocol *blockProtocol, NSInteger buttonIndex) {
        NSLog(@"%d", buttonIndex);
    }];

    [alertView show];

これを実行した時、alertViewからのcallbackは呼ばれません。ただblockProtocolに対してalertView:clickedButtonAtIndex:を直接呼べば、きちんと動くことが確認できます。

期待通りに動くコードはこちら。

    UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"title" message:@"message" delegate:nil cancelButtonTitle:nil otherButtonTitles:@"OK", nil];

    SIABlockProtocol *blockProtocol = [alertView sia_blockDelegate];

    [blockProtocol addMethodWithSelector:@selector(alertView:clickedButtonAtIndex:) block:^(SIABlockProtocol *blockProtocol, NSInteger buttonIndex) {
        NSLog(@"%d", buttonIndex);
    }];

    [alertView setDelegate:blockProtocol];

    [alertView show];

ポイントは メソッド実装後にsetDelegate:しているところ。内部のコードを見たわけではありませんが、標準のクラスのsetDelegate:は呼ばれた時にdelegateオブジェクトが実装しているメソッドをチェックし、フラグを保持しているようです。よって後からメソッドを実装しても、 setDelegate:のタイミングで実装していないのならcallbackメソッドを呼んでくれません

必ず最後にsetDelegate:を呼ぶ

必ず最後にsetDelegate:等を呼ぶことを強制するように以下のようなメソッドをNSObjectのカテゴリに追加しました。

SIABlockProtocol.m
- (void)sia_implementProtocol:(Protocol *)protocol
               setterSelector:(SEL)setterSelector
                   usingBlock:(void (^)(SIABlockProtocol *protocol))block
{
    SIABlockProtocol *blockProtocol = [self sia_blockProtocolForProtocol:protocol];
    if (block) {
        block(blockProtocol);
    }

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    [self performSelector:setterSelector withObject:blockProtocol];
#pragma clang diagnostic pop
}

第一引数は実装するプロトコルで、第二引数はdelegateオブジェクトのsetterを指定します。 @selector(setDelegate:)@selector(setDataSource:)のようなメソッドです。

protocolに対応したSIABlockProtocolを生成し、それを引数にblockを呼び出します。blockではユーザーが実装したい複数のメソッドを登録する想定です。それらが終わった後、setterSelectorのメソッドを呼び出してsetDelegate:を行います。

このメソッドを使った場合、callbackメソッドの実装後に必ずきちんとsetDelegate:してくれます。

#pragmaについては http://ameblo.jp/xcc/entry-11076019667.html あたりがとても参考になります。

使用例

この記事はBlocksKitもどきの作り方の一部として書いています。興味のあるかたはこちらも御覧ください。
この記事の機能の利用し、標準のクラス(UIAlertView等)をより簡単にBlockで書けるようにする方法等を記載しています。