Firebase iOS SDK でも使われている Objective-C の †黒魔術†

iOSDC 2018 で @_bannzai_ さんの ~~ †††† 漆黒の魔法 Objecitve-C Runtime API †††† ~~ というセッションを聞いたので、 Objective-C の Method Swizzling について書いてみます。

セッションの資料はこちらです

Method Swizzling とは何か

一言で言うと、「メソッドのセレクタと実装の紐づけを変更する」機能です。

簡単な例を見てみましょう。

@implementation ViewController

- (void)viewDidLoad {
    [self originalMethod];

    Method from = class_getInstanceMethod([self class], @selector(originalMethod));
    Method to   = class_getInstanceMethod([self class], @selector(swizzledMethod));
    method_exchangeImplementations(from, to);

    [self originalMethod];
}

- (void)originalMethod {
    NSLog(@"This is original method");
}

- (void)swizzledMethod {
    NSLog(@"This is SWIZZLED method");
    [self swizzledMethod];
}

@end

このコードを実行すると、以下のように出力されます。

2018-08-31 22:34:29.945 Runtime[35929:890092] This is original method
2018-08-31 22:34:29.946 Runtime[35929:890092] This is SWIZZLED method
2018-08-31 22:34:29.946 Runtime[35929:890092] This is original method

method_exchangeImplementations で originalMethod と swizzledMethod のセレクタと実装を入れ替えたので、二度目の [self originalMethod]; 呼び出しで swizzledMethod の実装が呼ばれています。

図で表すとこのようになります。

f:id:takasfz:20180831225918j:plain

メソッドを入れ替える前は、 originalMethod のセレクタに対して originalMethod の実装、 swizzledMethod のセレクタに対して swizzledMethod の実装が紐付いていました。

f:id:takasfz:20180831225921j:plain

メソッドを入れ替えると、 originalMethod のセレクタに対して swizzledMethod の実装、 swizzledMethod のセレクタに対して originalMethod の実装が紐付けられます。

swizzledMethod の実装で [self swizzledMethod]; を呼んでいるため無限ループしそうに見えますが、実際には originalMethod の実装が呼ばれるのでループは起こりません。

Firebase iOS SDK で Method Swizzling している例

Firebase Cloud Messaging で APNs のデバイストークンを取得する箇所を例に挙げます。

Firebase/Messaging/FIRMessagingRemoteNotificationsProxy.m#L136

- (void)swizzleAppDelegateMethods:(id<UIApplicationDelegate>)appDelegate {
    Class appDelegateClass = [appDelegate class];

    SEL registerForAPNSSuccessSelector =
    @selector(application:didRegisterForRemoteNotificationsWithDeviceToken:);

    // Receive APNS token
    [self swizzleSelector:registerForAPNSSuccessSelector
                  inClass:appDelegateClass
       withImplementation:(IMP)FCM_swizzle_appDidRegisterForRemoteNotifications
               inProtocol:@protocol(UIApplicationDelegate)];
}

まず、 AppDelegate の application:didRegisterForRemoteNotificationsWithDeviceToken: と FIRMessagingRemoteNotificationsProxy の FCM_swizzle_appDidRegisterForRemoteNotifications を入れ替えます。

Firebase/Messaging/FIRMessagingRemoteNotificationsProxy.m#L372

- (void)swizzleSelector:(SEL)originalSelector
                inClass:(Class)klass
     withImplementation:(IMP)swizzledImplementation
             inProtocol:(Protocol *)protocol {
  Method originalMethod = class_getInstanceMethod(klass, originalSelector);

  if (originalMethod) {
    // This class implements this method, so replace the original implementation
    // with our new implementation and save the old implementation.

    IMP __original_method_implementation =
        method_setImplementation(originalMethod, swizzledImplementation);

    IMP __nonexistant_method_implementation = [self nonExistantMethodImplementationForClass:klass];

    if (__original_method_implementation &&
        __original_method_implementation != __nonexistant_method_implementation &&
        __original_method_implementation != swizzledImplementation) {
      [self saveOriginalImplementation:__original_method_implementation
                           forSelector:originalSelector];
    }
  } else {
    // The class doesn't have this method, so add our swizzled implementation as the
    // original implementation of the original method.
    struct objc_method_description method_description =
        protocol_getMethodDescription(protocol, originalSelector, NO, YES);

    BOOL methodAdded = class_addMethod(klass,
                                       originalSelector,
                                       swizzledImplementation,
                                       method_description.types);
    if (!methodAdded) {
      FIRMessagingLoggerError(kFIRMessagingMessageCodeRemoteNotificationsProxyMethodNotAdded,
                              @"Could not add method for %@ to class %@",
                              NSStringFromSelector(originalSelector),
                              NSStringFromClass(klass));
    }
  }
  [self trackSwizzledSelector:originalSelector ofClass:klass];
}

Swizzling 処理の中身です。 originalMethod が存在する場合、 originalMethod の実装と swizzledImplementation を入れ替えます。 originalMethod が存在しない場合は、入れ替えではなく単に swizzledImplementation を追加します。

Firebase/Messaging/FIRMessagingRemoteNotificationsProxy.m#L709

void FCM_swizzle_appDidRegisterForRemoteNotifications(id self,
                                                      SEL _cmd,
                                                      UIApplication *app,
                                                      NSData *deviceToken) {
  // Pass the APNSToken along to FIRMessaging (and auto-detect the token type)
  [FIRMessaging messaging].APNSToken = deviceToken;

  IMP original_imp =
      [[FIRMessagingRemoteNotificationsProxy sharedProxy] originalImplementationForSelector:_cmd];
  if (original_imp) {
    ((void (*)(id, SEL, UIApplication *, NSData *))original_imp)(self, _cmd, app, deviceToken);
  }
}

FCM_swizzle_appDidRegisterForRemoteNotificationsトークンを設定したあと、元々の実装があればそれを呼んでいます。

これによって、アプリ側に意識させずにデバイストークンを取得することが可能になっています。

まとめ

Method Swizzling は強力な機能ですが、ぱっと見で挙動を把握することが難しいため、安易に使用すると思わぬ事故の元になります。 ご利用は計画的に。