bricklifeのはてなブログ

気軽にブログ書くよ!

ないのなら作ってしまえpresentingViewController - Objective-Cで動的メソッド追加

Objective-Cランタイムシステムを直接操作して、iOS 5から新しく追加されたメソッドをiOS 4で動かしたときにも使えるようにしてしまえ、という話です。

例題:parentViewController問題

iOSでUIViewController「親」からUIViewController「子」をモーダル表示したとき、これまでは子のparentViewControllerに親が格納されていましたが、iOS 5からはそれがnilになって、代わりに新しくできたpresentingViewControllerに格納されるようになりました。iOS 5からUIViewControllerにコンテナ機能が追加されたので、その辺色々整理されたのでしょうか。

参考:http://cocoa.hatenablog.com/entry/2011/11/24/222140

なので子から親を呼びたい場合、iOS 5以前でも動かしたいなら iOS 5で挙動が変わったUI系APIまとめ - bricklifeのはてなブログ でとりあげたように条件分岐をする必要があります。例えば dismissModalViewControllerAnimated: ならこんな感じ*1

if ([self respondsToSelector:@selector(presentingViewController)]) {
    [self.presentingViewController dismissModalViewControllerAnimated:YES];
} else {
    [self.parentViewController dismissModalViewControllerAnimated:YES];
}
Cocoaの日々: iOS5 では UIViewController.parentViewController が nil

今後はpresentingViewControllerを使うことになったわけですし、作っているプログラムがiOS 5以前をサポートしなくなったらこんなコードはなくしたいので、いまのうちからpresentingViewControllerで統一できたらいいですよね!

というわけで、UIViewControllerにpresentingViewControllerメソッドが実装されていない場合、それを動的に追加してparentViewControllerと同じ値を返すようにしてみましょう。これを実現するためにはObjective-Cランタイムシステムを直接操作します。

実装例1

まずはpresentingViewControllerをparentViewControllerとまったく同じ動作をするメソッドとして追加してみます。

#import <objc/runtime.h>

...

// 以下をアプリケーション起動時に実行する
if (![UIViewController instancesRespondToSelector:@selector(presentingViewController)]) {
    Class cls = [UIViewController class];
    Method m = class_getInstanceMethod(cls, @selector(parentViewController));
    SEL name = @selector(presentingViewController);
    IMP imp = method_getImplementation(m);
    const char *types = method_getTypeEncoding(m);
    class_addMethod(cls, name, imp, types);
}

たったこれだけで、UIViewControllerにparentViewControllerと同等のメソッドpresentingViewControllerが追加できてしまいます。ここではclass_getInstanceMethod()でparentViewControllerのメソッド内容を取得して、class_addMethod()でメソッド名(セレクタ)だけ変更して追加しています。

最初にinstancesRespondToSelector:で実装されているかどうかチェックしているので、iOS 5のときはclass_addMethod()されません。また、一度class_addMethod()したあとにまたここを通っても、すでにpresentingViewControllerは実装されているので、再度class_addMethod()されることはありません。

実装例2

今度は実装内容を変更してみましょう。iOS 5ではUINavigationControllerにpushされた場合はpresentingViewControllerがnilになるので、それをまねてみます。

#import <objc/runtime.h>

UIViewController* presentingViewControllerFake(id self, SEL _cmd)
{
    UIViewController* vc = self;
    if (vc.navigationController)
        return nil;
    return vc.parentViewController;
}

...

// 以下をアプリケーション起動時に実行する
if (![UIViewController instancesRespondToSelector:@selector(presentingViewController)]) {
    Class cls = [UIViewController class];
    Method m = class_getInstanceMethod(cls, @selector(parentViewController));
    SEL name = @selector(presentingViewController);
    IMP imp = (IMP)presentingViewControllerFake; // ← New!
    const char *types = method_getTypeEncoding(m);
    class_addMethod(cls, name, imp, types);
}

こんな感じで、idとSELを持つ引数を関数として記述しておいて、class_addMethod()するときに実装内容として渡してあげるだけで、好きなセレクタを持つ任意のメソッドが追加できてしまいます。超簡単。今回はnavigationControllerのチェックしかしていませんが、いくらでも凝った実装をすることができます。

実装例3

追加するメソッドの書き方がC言語なのが気に入らないのなら、Objective-Cでカテゴリやサブクラスなどを使ってメソッドを定義しておいて、実装を横取りすればOKです。

#import <objc/runtime.h>

@implementation UIViewController (fake)
- (UIViewController*)presentingViewControllerFake
{
    UIViewController* vc = self;
    if (vc.navigationController)
        return nil;
    return vc.parentViewController;
}
@end

...

// 以下をアプリケーション起動時に実行する
if (![UIViewController instancesRespondToSelector:@selector(presentingViewController)]) {
    Class cls = [UIViewController class];
    Method m = class_getInstanceMethod(cls, @selector(presentingViewControllerFake));
    SEL name = @selector(presentingViewController);
    IMP imp = method_getImplementation(m);
    const char *types = method_getTypeEncoding(m);
    class_addMethod(cls, name, imp, types);
}

Objective-Cのメソッド機構の実装って、セレクタの文字列がキーの関数テーブルみたいなものなので、裏側を知っていればこんな芸当もできてしまうんですね。Objective-CかわいいよObjective-C。

カテゴリ使えばよくね?

既存クラスへメソッドを追加する方法として、まず思い浮かぶのはカテゴリを使っての実装です。ただ、既に実装されているメソッドと同じセレクタを持つメソッドをカテゴリで追加した場合は、挙動が不定になるそうです。条件分岐でカテゴリを追加したりしなかったりということもできないので、今回のような場合には使えません。

__IPHONE_OS_VERSION_MAX_ALLOWEDは?

アプリケーションのビルド時ではなく実行時に判断したいのでダメです。

まとめ

Objective-Cランタイムシステムを直接操作することで、必要に応じてメソッドを動的に追加することができました。

今回の例が実用的かどうかといったらあまり実用性はないと思いますが、手法自体は互換性維持だけでなく色々応用が利くと思います。裏で何やってるかわからないライブラリをデバッグ(たまにハッキング)するときなどに、こういう話*2を知っていると捗りますよ!

*1:dismissModalViewControllerAnimated: は子から[self dismissModalViewControllerAnimated:YES]でも動いちゃうのは気にしない

*2:method swizzlingとかも