読者です 読者をやめる 読者になる 読者になる

mixi engineer blog

ミクシィ・グループで、実際に開発に携わっているエンジニア達が執筆している公式ブログです。様々なサービスの開発や運用を行っていく際に得た技術情報から採用情報まで、有益な情報を幅広く取り扱っています。

iOS下位互換のための動的メソッド追加

iOS mixi Objective-C

どうも、佐野です。先日のWWDCでは大々的にiOS6が発表され、AppleのDev Centerからβ版のSDKもダウンロードできるようになっており、開発者の皆さんは新機能の利用に胸を踊らせていることかと思います。mixiのiPhoneアプリはiOS4ユーザも多いため旧環境もサポートしなければならず、一方で新環境のユーザには新機能を提供したいですしコードも UP TO DATE に保っておきたいものです。そこで今回はできるだけ新環境向けのコードに下位互換性を持たせられるように、クラスに対して動的にメソッドを追加する方法をご紹介します。

Objective-Cのメソッドコールは、Cのようにコンパイル時にリンクされるのではなく、アプリケーションの実行時にメソッドが検索されて実行されます。実はObjective-Cのメソッドは単なるC言語の関数ポインタであり、それを呼び出すためのセレクタも単なるC言語の文字列なのです。クラスはセレクタに対するメソッドのマップを持っていて、コール時に検索してインスタンスの実態としてのselfを渡して実行しているのです。Objective-Cはこのマップを操作するためのランタイムAPIを提供しています。

さて、例としてiOS5から追加されたUIViewControllerクラスのインスタンスメソッド:

- (void)presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)flag completion:(void (^)(void))completion

を見てみましょう。これはiOS2以降で使える:

- (void)presentModalViewController:(UIViewController *)modalViewController animated:(BOOL)animated

と似ていますが、モーダルの表示が完了した時点でブロックを呼び出せる点が異なっており、またiOS5以降ではこのメソッドは非推奨となっています。さて、以下のようにあるボタンを押すと赤い画面がモーダルで表示されるプログラムを考えます:

- (void)presentModalButtonTapped:(id)sender {
    UIViewController *vc = [[[UIViewController alloc] initWithNibName:nil bundle:nil] autorelease];
    vc.view.backgroundColor = [UIColor redColor];
    [self presentViewController:vc animated:YES completion:^{
        NSLog(@"complete");
    }];
}

これをBaseSDKをiOS5.1にしてビルドし、iPhone5.1 Simulatorで実行してみましょう。ちゃんと赤い画面が表示され、コンソールに"complete"と出力されます。しかしDeployment Targetを4.0にしてiPhone4.3 Simulatorで実行すると、以下のようなエラーが表示されアプリがクラッシュします:

2012-06-27 11:48:58.303 Sample[11021:b903] -[SViewController presentViewController:animated:completion:]: unrecognized selector sent to instance 0x4e098e0

iOS4では presentViewController:animated:completion: が実装されていないからですね。でもBase SDKはiOS5.1に設定してあるのでコード中に警告などは表示されません(そもそもXcodeではBase SDKは最新のものしか選べない)。だからiOS5ベースで開発を進めていて、テスト段階になってiOS4環境で実行してエラーが発覚してまた作り直しなんていうことはよく起きます。

こういう場合には presentModalViewController:animated: のみを使うようにしてiOS4でも動くコードに書き直すか、コード内でOSのバージョンによって処理を分岐させるか、はたまた presentViewController:animated:completion: をオーバーライドして同じ機能を自分で作ることもできますが、こういうことばかりしているとコードが下位互換だらけになってきますし、せっかくの新機能が使えなくて悲しい気持ちになってきます。そこで、iOS4で実行された場合のみ自分で定義した代替メソッドが呼ばれるようにランタイムAPIを使ってクラスを操作してみましょう。

まず UIViewController+iOS4Compatible という名前で UIViewController の拡張カテゴリを作り、<objc/runtime.h> をインポートして次のようなメソッドを定義します:

#import <objc/runtime.h>

@implementation UIViewController (iOS4Compatible)

- (void)iOS4_presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)flag completion:(void (^)(void))completion {
    NSLog(@"called iOS4_presentViewController:animated:completion:");

    [self presentModalViewController:viewControllerToPresent animated:flag];
    [self performSelector:@selector(callBlock:) withObject:completion afterDelay:(flag) ? 0.5 : 0];
}

- (void)callBlock:(void (^)(void))block {
    if(block)
        block();
}

...

これは iOS4 で動作する自作の presentViewController:animated:completion: もどきです。presentModalViewController:animated: を使ってモーダルを表示し、その0.5秒後にcompletionブロックを実行するメソッドを呼び出すようになっています(モーダル表示のアニメーションは0.4秒)。続けて iOS4 環境で presentViewController:animated:completion: を呼んだ場合にこの自作メソッドが呼ばれるようにするクラスメソッドを定義します:

...

+ (void)iOS4compatibilize {
    Method m1 = class_getInstanceMethod(self.class,
                                        @selector(iOS4_presentViewController:animated:completion:));
    class_addMethod(self.class, 
                    @selector(presentViewController:animated:completion:), 
                    method_getImplementation(m1), 
                    method_getTypeEncoding(m1));
}

@end

class_getInstanceMethod, class_addMethod, method_getImplementation, method_getTypeEncoding という見慣れない4つの関数が出てきました。これは runtime.h で宣言されている関数で、これらによって presentViewController:animated:completion: というセレクタに対して iOS4_presentViewController:animated:completion: というセレクタが対応するメソッドを関連づけることができるのです。

さて、この拡張カテゴリをインポートして、アプリケーションの実行後に:

[UIViewController iOS4compatibilize];

が呼ばれるようにソースコードを修正し、再び iPhone4.3 Simulatorで実行してみましょう。すると iOS5 の場合と全く同じように動作するではありませんか!そしてコンソールに "called iOS4_presentViewController:animated:completion:" と出力されることから、ちゃんと自分で定義したメソッドが呼ばれていることが確認できます。

ここで再びiPhone5.1 Simulatorで実行してみましょう。するとまた同じ動作が見られますが、上の "called iOS4_..." は出力されません。これは class_addMethod が、メソッドが既に定義されている場合には追加をしない仕様になっているためです。これで見事に presentViewController:animated:completion: がiOS4互換となりました!

このやり方の良いところは、プログラム内で下位互換サポートが拡張カテゴリとしてコードから分離できることと、新環境においては無駄なオーバーヘッドなく正式なメソッドを呼び出すことができる点です。Objective-Cではクラスも動的に追加できるので、同様のやり方で新環境で追加されたクラスの呼び出しにも対応することもできるかもしれません。

Objective-Cのランタイムの仕組みはこちらのコラムで詳しく解説されています。2005年の記事なので当時はiPhoneもまだ世に出ていない頃なのが趣き深いですね。それではまたお会いしましょう。