#8 「iOSのバージョン間の溝を埋めるテクニック」 tech.kayac.com Advent Calendar 2012

こんにちは、iOSプログラマーの_ishkawaです。

このエントリは tech.kayac.com Advent Calendar 2012 8日目の記事です。
テーマは「私の中のマイイノベーション 2012」です。

12月。恋人たちが心の溝を埋めていく中、僕はiOSバージョンの溝を埋めております。
今日はそのテクニックを紹介したいと思います。
それと、紹介するテクニックを使ったマイイノベーションも紹介します。

基本中の基本

iOSでは、バージョンによってクラス/メソッドの有無やプロトコルへの適合状況が異なります。
これらの状況の違いは以下の方法で判別することができます。

  • メソッドが存在するかどうか: respondsToSelector:
  • クラスが存在するかどうか: [Class class]
  • プロトコルに適合しているか: conformsToProtocol:

これらの条件を以下のように利用することで、バージョンに応じた処理を行うことができます。

if ([hoge respondsToSelector:@selector(fuga)]) {
    // fugaメソッドがある場合の処理
} else {
    // ない場合の処理
}

Method Swizzling

Method Swizzlingは既存のメソッドを置き換えるテクニックで、Objective-Cハッカーにはお馴染みのものです。

このテクニックの面白いところは"動的"であるという点です。
iOS5未満ならメソッドを置き換えるが、iOS6以上なら置き換えないといったことが可能なのです。

利用手順

Method SwizzlingではObjective-C runtime APIを利用するので<objc/runtime.h>をインポートします。

#import <objc/runtime.h>

既存のセレクタ(original)と新しいセレクタ(alternative)を用意し、 実装状況に応じてclass_replaceMethodまたはmethod_exchangeImplementationsを呼ぶ関数を用意します。

static void MethodSwizzle(Class c, SEL original, SEL alternative)
{
    Method orgMethod = class_getInstanceMethod(c, original);
    Method altMethod = class_getInstanceMethod(c, alternative);

    if(class_addMethod(c, original, method_getImplementation(altMethod), method_getTypeEncoding(altMethod))) {
        class_replaceMethod(c, alternative, method_getImplementation(orgMethod), method_getTypeEncoding(orgMethod));
    } else {
        method_exchangeImplementations(orgMethod, altMethod);
    }
}

+ (void)loadなどのメソッドをオーバーライドし、メソッドの実装を差し替えます。

+ (void)load
{
    if ([[[UIDevice currentDevice] systemVersion] hasPrefix:@"4"]) {
        MethodSwizzle([NSObject class], @selector(hoge), @selector(fuga));
    }
}

このように実装すると、[object hoge]を呼び出したときに[object fuga]が実行されます。

実用例: UIWebViewscrollViewプロパティの差を埋める

iOS5以降ではUIWebViewscrollViewプロパティが用意されていますが、それ以前のバージョンでは用意されていません。 decelerationRatescrollsToTopを変更する機会は少なくないので、同じインターフェースで呼べるようにしたいものです。

これを実現するには以下のようなカテゴリを実装します。

@implementation UIWebView (iOS4ScrollView)

+ (void)load
{
    if ([[[UIDevice currentDevice] systemVersion] hasPrefix:@"4"]) {
        MethodSwizzle([UIWebView class], @selector(scrollView), @selector(iOS4_scrollView));
    }
}

- (UIScrollView *)iOS4_scrollView
{
    UIScrollView *scrollView = nil;
    for (UIView *subview in [self subviews]) {
        if ([subview isKindOfClass:[UIScrollView class]]) {
            scrollView = (UIScrollView *)subview;
            break;
        }
    }
    return scrollView;
}

@end

このカテゴリがロードされると、バージョンによらず以下の方法でscrollViewにアクセスできるようになります。

UIWebView *webView;
UIScrollView *scrollView = webView.scrollView;

Method Swizzlingを利用すると、この例の他にも以下のようなものも実現できます!

  • UIImageNSCodingプロトコルへの適合状況の差を埋める
  • UIViewControllerparentViewControllerの差を埋める

メリットと注意点

iOSバージョンの溝を埋めることには呼び出し側がバージョンの差を意識する必要がなくなるというメリットがあります。
一方で、複数人で作業する場合に思わぬ誤解を与える原因になるというデメリットもあります。
なので、このテクニックを利用する場合にはドキュメントやコメントに明記することを強く勧めます。

マイイノベーション: ISRefreshControl

iOS6からUIRefreshControlという非常に良い感じのUIコンポーネントが提供されました。

しかし、iOS5ユーザーもまだまだ多い現状では、これだけを理由にDeployment Targetを6.0にするわけにもいきません。
そこで、iOS5でもおおよそ同等の動きをするISRefreshControlというライブラリをつくりました。
先ほど説明したMethod Swizzlingを利用してiOS5, iOS6の溝を埋めています。

ISRefreshControl

利用手順

UITableViewControllerで以下のように設定を行います。
UIRefreshControlの使い方と2文字しか変わりません!(id型キャストを入れると6文字です。)

self.refreshControl = (id)[[ISRefreshControl alloc] init];
[self.refreshControl addTarget:self
                        action:@selector(refresh)
              forControlEvents:UIControlEventValueChanged];

どう動くか

  • iOS6: 本物のUIRefreshControlとして動作します。(コンストラクタがUIRefreshControlのインスタンスを返します。)
  • iOS5: ISRefreshControlとして、UIRefreshControlの真似をします。

どう実装したのか

  • iOS6: + (id)allocUIRefreshControlを返す。
  • iOS5: UITableViewControllerを拡張してrefreshControlプロパティを用意。
  • iOS5: tableViewcontentOffsetをキー値監視してISRefreshControlにスクロール量を通知。
  • iOS5: CGPathでスクロール量に応じた"びよーん"を描画。

コードの解説は長くなりそうなので控えますが、こんな感じでUIRefreshControlの溝を埋められるのです!

まとめ

  • iOSのバージョンの溝は頑張れば埋められることもある。
  • 頑張って溝を埋めたらドキュメントにちゃんと書く。

さて、明日はマッドプログラマーの@9reさんのお話です。
今日はなんだか地味なエントリーだな〜って思った方も明日は楽しめると思います!