[Swift] Keyboard Extensionから収容アプリを開く


大手のキーボードアプリは当たり前に実装している「収容アプリを開く」機能ですが、調べるとなかなか情報が出てきません。

普通のアプリ開発ではUIApplication.shared.openを使うのではないかと思うのですが、App ExtensionではそもそもUIApplication.sharedが取れないのでopen出来ないんです。

Stack OverflowでiOS14でも動作するコードを見つけたので共有します。

以下コードをUIInputViewControllerなどに書き、引数のURLに収容アプリのURL Schemeを入れてあげればOKです。URL Schemeの作り方はこちらが参考になりました。

    func openUrl(url: URL?) {
        let selector = sel_registerName("openURL:")
        var responder = self as UIResponder?
        while let r = responder, !r.responds(to: selector) {
            responder = r.next
        }
        _ = responder?.perform(selector, with: url)
    }

ただ、何をしているのか全くわからなかったので調べてみたことを付記します。

何をしているのか

ドキュメントによると

Registers a method with the Objective-C runtime system, maps the method name to a selector, and returns the selector value.

とのことです。Objective-Cは書けないので意味がわからなかったのですが、どうやら「OpenURLというメソッド名に対応するメソッドの内部表現」を予約して、その内部表現を表すセレクタを返す関数のようです。

次に行われているのがUIResponderを次々に代入していく操作です。UIInputViewControllerUIResponderを継承しているのでキャストは成功します。この部分は「Responder Chain」と呼ばれているらしく、「【Swift】Responder Chainの仕組み」という記事で詳しく説明されていました。

条件部分のrespondsという関数はドキュメントによると「Responderがセレクタに対応するメソッドを使えるか」のような情報を持っているらしいので、この部分では「openURLできるResponderを探している」ということになりそうです。実験してみたところ、このResponderUIApplicationでした。

最後の1行で取得したresponderを用いてセレクタを実行します。第二引数はopenURLの引数のurlです。返り値はUnmanaged<AnyObject>!という怖そうな型なのですが、使わないので破棄されています。

以上をまとめると、openURLに対応するセレクタを作り、それを使えるUIResponderを探し、openURLを実行する、という手順のようでした。

なぜそうしているのか

まずsel_registerNameは必ずしも用いなくて大丈夫です。現在のSwiftではもう少し現代的なセレクタの記法が使えるので次のように書くことができます。

    @objc func openURL(_ url: URL) {}  //#selector(openURL(_:))はこの関数がないと作れない

    func openUrl(url: URL?) {
        let selector = #selector(openURL(_:))
        var responder = (self as UIResponder).next
        while let r = responder, !r.responds(to: selector) {
            responder = r.next
        }
        _ = responder?.perform(selector, with: url)
    }

このほうが若干現代的になった気がしますね。

ところで最終的に取得できるresponderは結局UIApplicationでした。結局UIApplicationが取れるならわざわざセレクタを経由する必要はなさそうですよね。しかしそうではありません。

    @objc func openURL(_ url: URL) {}  //#selector(openURL(_:))はこの関数がないと作れない

    func openUrl(url: URL?) {
        let selector = #selector(openURL(_:))
        var responder = self as UIResponder?
        while let r = responder, !r.responds(to: selector) {
            responder = r.next
        }
        if let uiApplication = responder as? UIApplication{
            uiApplication.open
        }
    }

試しにこう書いてみると、エラーが出ます。

'open(_:options:completionHandler:)' is unavailable in application extensions for iOS

とのこと。なるほど、これを回避するためにわざわざセレクタを使って迂回するんですね。

何にしても、まどろっこしい書き方の動機は「UIApplicationUIResponderとして扱ったまま、openURLを呼びたい!」という部分にありそうです。

気になった点

めちゃくちゃ黒魔術な気がします。こういうものでしょうか。

openURLdeprecatedなのが気になります。iOS10から既にdeprecatedなので、そろそろ廃止されてもおかしくないはずです。そうなるとopenの場合はopen:options:completionHandler:となってしまうため、引数が3つになります。残念ながらUIResponderには3つ以上の引数を取る場合のperformが無いため、詰みます。

ただ理解が甘いためか、下のようにセレクタを書いてもresponder自体が取得できませんでした。

let selector = #selector(open(_:options:completionHandler:))

今後使えなくなってしまうとかなり問題なので、何かご存知の方がいらっしゃったら教えていただけると嬉しいです。