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

xib で作った Custom View / ViewController を Storyboard とコードの両方から扱う

まとめ

Custom View / Custom ViewController どちらの場合も、 Storyboard とコードの両方から扱うためには、 View や ViewController そのものを xib で定義するのではなくて root view を xib で定義してinstantiate するのが良さそうです。

Custom View

まず MyView.xib を作成し、ルートの view の下に UI 部品を配置していきます。 このとき、ルートの view は MyView クラスではなく UIView のままです。

次に、 File's Owner に MyView クラスを設定します。 すると MyView.xib と MyView クラスが紐付けられ、 UI 部品の Outlet や Action を接続できるようになります。

f:id:takasfz:20180806175123p:plain

MyView クラスでは、ルートになる UIView を MyView.xib から生成し、 addSubview します。 Storyboard から生成された場合 ( init(coder:) ) とコードから生成された場合 ( init(frame:) ) のどちらで初期化された場合でも 同じように処理されるようにします。

import Foundation
import UIKit

class MyView: UIView {

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        instantiateView()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        instantiateView()
    }

    private func instantiateView() {
        // MyView.xib からインスタンスを生成し、 IBOutlet / IBAction を self と接続する
        let nib = UINib(nibName: "MyView", bundle: .main)
        let rootView = nib.instantiate(withOwner: self).first as! UIView

        // 生成した rootView の位置とサイズを self にぴったり合わせて addSubview
        rootView.frame = self.bounds
        self.addSubview(rootView)
    }

    @IBOutlet weak var label: UILabel!

    @IBAction func buttonDidTouch(_ sender: Any) {
        // do something
    }
}

Custom ViewController

MyViewController.xib を作成し、ルートの view の下に UI 部品を配置していきます。 ルートを UIViewController ではなく UIView にするのがポイントです。

次に、 File's Owner に MyViewController クラスを設定します。 すると MyViewController.xib と MyViewController クラスが紐付けられ、 UI 部品の Outlet や Action を接続できるようになります。

f:id:takasfz:20180806175126p:plain

MyViewController クラスでは、 loadView() をオーバーライドして、 MyViewController.xib から生成した UIView を self.view に設定します。

import Foundation
import UIKit

class MyViewController: UIViewController {

    override func loadView() {
        // MyViewController.xib からインスタンスを生成し root view に設定する
        let nib = UINib(nibName: "MyViewController", bundle: .main)
        self.view = nib.instantiate(withOwner: self).first as! UIView
    }

    @IBOutlet weak var label: UILabel!

    @IBAction func buttonDidTouch(_ sender: Any) {
        // do something
    }
}

出展:

Objective-C からの見え方を意識した Swift の Error

TL; DR

Objective-C からも利用できる Swift コードを書く場合、

  • throws キーワード付きのメソッドは戻り値の型に制限がある
  • 引数や戻り値にエラーを指定する際はカスタムエラー型ではなく Swift.Error 型を使う

の 2 点に気をつけたほうがよいです。

Objective-C → Swift の連携で困った

Swift でライブラリを書いていて、 Objective-C から呼ぶときのエラーの扱われ方にハマってしまいました。

この記事によると、 Swift のエラーを Objective-C でもハンドリングできるようにするには 列挙型の raw value に Int を指定したカスタムエラーを定義すると良いようです。 qiita.com

しかし、それだけではうまくいかないケースがあったので、もう少し実験してみました。

準備

参考記事と同じように、 raw value に Int を指定したカスタムエラーを定義しておきます。

@objc public enum CustomError: Int, Swift.Error {
    case error1 = 1
    case error2 = 2
}

この Swift コードをビルドすると、以下のような Objective-C コードが生成されます。

typedef SWIFT_ENUM(NSInteger, CustomError) {
  CustomErrorError1 = 1,
  CustomErrorError2 = 2,
};
static NSString * _Nonnull const CustomErrorDomain = @"MySwiftFramework.CustomError";

ケース 1: throws キーワード

Swift ライブラリに、以下のように throws キーワードの付いたメソッドを作成します。

@objc public func throwError() throws {
    throw CustomError.error1
}

Objective-C アプリからはこのように見えます。

- (BOOL)throwErrorAndReturnError:(NSError * _Nullable * _Nullable)error;

引数に out パラメータの NSError が追加され、戻り値が BOOL 型になりました。 メソッド名も ~AndReturnError と変化しています。

このとき、ライブラリ側の処理が成功すると引数 error は nil@YES が返り、 エラーが発生すると引数 error にエラーオブジェクトが渡され @NO が返ります。

ケース 2: 戻り値ありの throws キーワード

戻り値があり throws キーワードが付いたメソッドの場合、戻り値の型に制限がつきます。

@objc public func throwsWithReturnValue() throws -> Data {
    throw CustomError.error1
}

Objective-C アプリからの見え方はこうなります。

- (NSData * _Nullable)throwsWithReturnValueAndReturnError:(NSError * _Nullable * _Nullable)error;

Swift 側の戻り値が NonNull Data 型なのに対し、 Objective-C では Nullable NSData 型に変化しています。 このとき、ライブラリ側の処理が成功すると NSData オブジェクトが返り、エラーが発生すると nil が返ります。

nil を返すことで処理の成否を表現するため、 Swift 側の戻り値を Optional 型やプリミティブ型にすることはできません。

f:id:takasfz:20180702200132p:plain

nilObjective-C で失敗を表すので戻り値に optional 型は指定できないよ

f:id:takasfz:20180702200150p:plain

戻り値は Void か Objective-C のクラスにブリッジされる型しか指定できないよ

ケース 3: 戻り値に Error

throws キーワードではなく、戻り値の型を Error とした場合、 Objective-C アプリからの見え方は Swift と変わりません。

@objc public func returnError() -> Error {
    return CustomError.error1
}
- (NSError * _Nonnull)returnError;

Swift 側で CustomError を返すと、 Objective-C 側では適切な domain / code が設定された NSError オブジェクトが返ります。

ケース 4: 戻り値に CustomError

戻り値を CustomError 型とした場合、 Objective-C では戻り値が NSError ではなく列挙型の CustomError になりました。

@objc public func returnCustomError() -> CustomError {
    return CustomError.error1
}
- (enum CustomError)returnCustomError;

CustomError はただの NSInteger な列挙型なので、 NSError オブジェクトのように扱うことはできません。

ケース 5: 引数に Error

クロージャ等で引数を Error 型とする場合、 Objective-C アプリからの見え方は Swift と変わりません。

@objc public func passError(to closure: (Error) -> Void) {
    closure(CustomError.error1)
}
- (void)passErrorTo:(SWIFT_NOESCAPE void (^ _Nonnull)(NSError * _Nonnull))closure;

Swift 側で引数に CustomError を渡すと、ケース 3 と同じく Objective-C 側では適切な domain / code が設定された NSError オブジェクトが返ります。

ケース 6: 引数に CustomError

引数を CustomError 型とした場合、ケース 4 と同じく Objective-C では引数が NSError ではなく列挙型の CustomError になりました。

@objc public func passCustomError(to closure: (CustomError) -> Void) {
    closure(CustomError.error1)
}
- (void)passCustomErrorTo:(SWIFT_NOESCAPE void (^ _Nonnull)(enum CustomError))closure;

この場合、 Objective-C 側では CustomError を NSError オブジェクトのように扱うことはできません。

まとめ

Objective-C からも利用できる Swift コードを書く場合、 throws キーワード付きのメソッドは戻り値の型に制限があることに注意する必要があります。 また引数や戻り値にエラーを指定する際は、特定のカスタムエラー型しか返さない場合でも Swift.Error 型を指定するほうがよいでしょう。


出典: - Swift3時代のErrorとNSErrorに関するいくつかの実験

WKWebView を継承したカスタムクラスで Web ページが表示されなくなった

表題の通りで、 WKWebView を継承したカスタムクラスを作って色々いじっていたら、 Web ページが表示されなくなってしまいました。

  • 何も表示されず真っ白
  • スクロールやピンチイン・ピンチアウトはできる
  • スクロールできる量は表示したい Web ページと同じっぽい
f:id:takasfz:20180413194035p:plain

原因は、 didMoveToWindow をオーバーライドしたときに、スーパークラスのメソッドを呼んでいないことでした。 UIView のドキュメントには「このメソッドのデフォルト実装は何もしない」と書いてあったのと WKWebView のドキュメントでも特に触れられていなかったので油断していたんですが、 WKWebView の実装ではこのメソッドで状態の変更を通知しているのですね。

didMoveToWindow() - UIView | Apple Developer Documentation

The default implementation of this method does nothing. Subclasses can override it to perform additional actions whenever the window changes.

WKWebView.mm - WebKit/webkit - Sourcegraph

- (void)didMoveToWindow
{
    _page->activityStateDidChange(WebCore::ActivityState::AllFlags);
}

オーバーライドしたメソッドで super.didMoveToWindow() を呼ぶと、ちゃんと表示されるようになりました。

f:id:takasfz:20180413194048p:plain

「 window オブジェクトの変更を通知してくれるメソッド」くらいの理解だったのであんまり深く考えていなかったのですが、処理を上書きしたい明確な意図がない限りは、メソッドをオーバーライドしたらちゃんとスーパークラスのメソッドを呼ぼうと思いました。


出典:

アプリのコミットハッシュを取得する

開発中のアプリを複数の端末に入れたり消したりしながら動作確認していると、「これはどの時点のコミットからビルドしたんだっけ?」を知りたくなることが増えてきたので、 git のコミットハッシュを取得して Android / iOS アプリに表示する方法を調べました。

HEAD のコミットハッシュを取得する

下記どちらかのコマンドで取得できます。体感値ですが rev-parse のほうが若干速い気がします。

  • git rev-parse --short HEAD
    • HEAD が指す先の、コミットハッシュの短縮形を表示
  • git log -1 --format='%h'
    • コミット履歴の先頭 1 件だけ、コミットハッシュの短縮形を表示

Android アプリに表示する

アプリの versionName の末尾にコミットハッシュを付加します。

アプリモジュールの build.gradle に下記の設定を追加します。

def commitHash = ["sh",  "-c",  "cd ${project.rootDir}; git rev-parse --short HEAD"].execute().in.text.trim()

android {
    buildTypes {
        debug {
            versionNameSuffix "_" + commitHash
        }
    }
}

アプリのコードで PackageInfo を取得すると、 versionName に 1.0.0_abcd1234 のような値が入っています。

val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val versionName = packageInfo.versionName

サンプルコードはこちら github.com

iOS アプリに表示する

iOS の CFBundleShortVersionString は x.y.z 形式以外の文字列を受け付けないため、コミットハッシュをそのまま付加することはできません。したがって、 Info.plist に独自のキーを定義し、そこにコミットハッシュを設定するようにします。

まず、 Xcode の Targets > Info に以下の key-value pair を追加します。

Key Type Value
CommitHash String undefined

次に Build Settings の Preprocess Info.plist File を YES に変更します。

Build Phases の Compile Sources の前に Run Script Phase を追加し、下記のコマンドを設定します。

plistBuddy="/usr/libexec/PlistBuddy"
infoPlistFile="${TEMP_DIR}/Preprocessed-Info.plist"

commitHash=$(git rev-parse --short HEAD)
$plistBuddy -c "Set :CommitHash $commitHash" $infoPlistFile

アプリのコードで CFBundleShortVersionString と CommitHash をそれぞれ取得し、連結すると 1.0.0_abcd1234 のような値を得ることができます。

let info = Bundle.main.infoDictionary!
let versionName = "\(info["CFBundleShortVersionString"]!)_\(info["CommitHash"]!)"

Info.plist のプリプロセスを有効にし、もとのファイルは書き換えず中間ファイルにだけ値を設定するのがポイントです。

サンプルコードはこちら github.com

JS デバッグに便利な GCS の Static Website Hosting

最近はもっぱら JavaScript ( TypeScript ) を書いています。ドメインを跨ぐときの挙動をデバッグするのに Google Cloud Storage の静的ホスティングが便利でした。

f:id:takasfz:20171102200456p:plain

やりたかったこと

ドメインが複数必要なのにドメインを取得したくない、そんなワガママを GCS が叶えてくれました。

Web サイトを公開するまでの手順

1. ストレージバケットを作成する

ストレージ バケットの作成  |  Cloud Storage ドキュメント  |  Google Cloud Platform

公式ドキュメントを参考に、ストレージバケットを作成します。 データ量が少なく、データへのアクセスがそれなりに発生するので、ストレージクラスは Regional 、リージョンは asia-northeast1 を選択しました。

2. 公開するファイルをアップロードする

作成したバケットを開き、コンソールの上部にある ファイルをアップロード または フォルダをアップロード から公開するファイルを選択し、アップロードします。 公開するファイルをドラッグ&ドロップでアップロードすることもできます。

f:id:takasfz:20171102195246p:plain

3. キャッシュ設定を変更する

公開するファイルは初期設定で 1 時間キャッシュが有効になっているため、頻繁にファイルを変更してデバッグするには不便なことがあります。そこで、アップロードしたファイルの その他のオプション (︙) から メタデータを編集 をクリックし、 Cache-Control の値を設定してキャッシュの有効期限を短くします。

f:id:takasfz:20171102195303p:plain
f:id:takasfz:20171102195309p:plain

4. ファイルを公開する

アップロードしたファイルの 一般公開で共有するチェックボックスを ON にします。ファイルが公開されるとチェックボックスの横に 公開リンク が表示され、これをクリックすると公開されたファイルが新しいウィンドウで表示されます。

f:id:takasfz:20171102195255p:plain

公開されたファイルは http(s)://storage.googleapis.com/{バケット名}/{ファイル名} または http(s)://{バケット名}.storage.googleapis.com/{ファイル名} でアクセスできます。

5. ファイルのアップロード ~ 公開設定を一発で

手順 4 までで Web サイトの公開はできるのですが、複数のファイルに対して 1 つずつキャッシュの設定をしたり、ファイルを更新する度にキャッシュを設定し直したりするのは正直面倒です。 gsutil コマンドを使用すると、ファイルのアップロードとキャッシュ設定、一般公開を一度に行うことができます。

gsutil -h 'Cache-Control:max-age=1' cp -a public-read ${ファイル名} gs://${バケット名}/${パス}

gsutil コマンドについて、詳しくは gsutil ツール  |  Cloud Storage ドキュメント  |  Google Cloud Platform を参照ください。

できたもの

クロスオリジンアクセスの簡単なデモを作ってみました。

location.html

<!doctype html>
<meta charset="utf-8"/>
<meta name='viewport' content='width=device-width,initial-scale=1.0,minimum-scale=1.0' />
<style>
    body { margin: 8px; }
    dl { word-wrap: break-word; }
    iframe { display: block; margin: 8px auto; width: 100%; height: 100%; border: 1px solid black; }
</style>
<script>
    window.addEventListener('DOMContentLoaded', function() {
        var html  = '<dl><dt>window.location :</dt><dd>' + window.location + '</dd></dl>';
        document.body.insertAdjacentHTML('afterbegin', html);
    });
</script>
<iframe src="https://storage.googleapis.com/takasfz/location_iframe.html"></iframe>
<iframe src="https://takasfz.storage.googleapis.com/location_iframe.html"></iframe>

location_iframe.html

<!doctype html>
<meta charset="utf-8"/>
<meta name='viewport' content='width=device-width,initial-scale=1.0,minimum-scale=1.0' />
<style>
    dl { width: 100%; word-wrap: break-word; }
</style>
<script>
    window.addEventListener('DOMContentLoaded', function() {
        var html  = '<dl><dt>window.location :</dt><dd>' + window.location + '</dd></dl>';
        document.body.insertAdjacentHTML('afterbegin', html);
        document.body.style.background = (!!window.frameElement) ? "lightskyblue" : "pink";
    });
</script>

iframe の src に 2 通りの URL を指定し、同一オリジンであれば背景色を青、クロスオリジンであれば背景色をピンクに設定する、というものです。

f:id:takasfz:20171102193142p:plain

まとめ

簡単な設定しか行っていませんが、特に不自由なく、わりと便利に使えています。 独自ドメインを取得しなくても、サードパーティ JavaScript やクロスオリジンアクセスの挙動を確認できるのが嬉しいですね。

ほかにも Tips があれば教えてください!


出典:

iOS 11 の SFSafariViewController は Cookie が共有されない

iOS 11 で SFSafariViewController の仕様が変わり、 Cookie 等のデータが Safari とは独立した領域に保存されるようになりました。 これによってユーザの行動がアプリをまたいで追跡されるのを防ぎ、閲覧履歴の公開範囲を個々のアプリに限定することができるとしています。

新・ Web サービスの認証方法

SFSafariViewController の認証情報が Safari と共有されなくなるため、 SFSafariViewController で Web サービスにログインしているときに「 Safari で開く」をタップすると、 Safari に遷移したあと改めてログインする必要があります。

In iOS 9 and 10, it shares cookies and other website data with Safari. ( 中略 ) If you would like to share data between your app and Safari in iOS 11 and later, so it is easier for a user to log in only one time, use SFAuthenticationSession instead.

そこで、 OAuth / Single Sign-On の認証情報を共有するために新たに SFAuthenticationSession クラスが追加されました。 SFAuthenticationSession で認証リクエストを行うと、以下のように認証情報の利用を確認するダイアログが表示されます。

f:id:takasfz:20170922233903p:plain

「続ける」をタップすると Web サービスの認証ページが表示され、認証が成功するとアプリへリダイレクトします。

f:id:takasfz:20170922233921p:plain

認証済みの場合、「続ける」をタップすると認証ページを表示せず、すぐにアプリへリダイレクトします。 SFAuthenticationSession と Safari で認証情報が共有されるため、どちらかで認証済みであれば OK です。

プライベートブラウズモードは削除された..?

また、iOS 11 では SFSafariViewController からプライベートブラウズ機能が削除された模様です。 iOS 10 までは Safari をプライベートブラウズモードにしていると SFSafariViewController も Safari と同じ黒ベースの配色に変わっていたのですが、 iOS 11 では Safari をプライベートブラウズモードにしていても SFSafariViewController には Cookie が保存され、配色も変わりませんでした。

Safari とは独立した領域に Cookie を保存するようにしたことで、プライベートブラウズという概念は不要になったという判断でしょうか..?


出典: