iOSアプリの大規模なCustom URL Schemeを支える技術

こんにちは。技術部モバイル基盤グループの@です。

今回は、iOSアプリでCustom URL Schemeを簡単に処理するライブラリを公開しましたので紹介します。

Custom URL Schemeは、アプリの特定の画面に遷移させることができるリンク(ディープリンク)を提供する機能です。

f:id:gigi-net:20180530205333g:plain

アプリ開発をしていると、Custom URL Schemeを用いたディープリンクを実装したい需要は多いでしょう。 特にクックパッドのような、ブラウザ版を提供するWebサービスですと、アプリとWebページの行き来のため非常に多くのCustom URL Schemeを処理する必要が出てきます。

現に、クックパッドアプリでは、30以上のパターンが遷移先として実装されています。

渡ってきたURLのパーサーを愚直に書いていくのは、コードの記述量も増えますし、どのようなURL Schemeが有効なのか簡単に見通すことは難しいです。

Crossroad

そこで、複雑なCustom URL Schemeのルーティングを簡単に実現するライブラリをOSSとして公開しました。

例えば、あなたがiOS上で「ポケモンずかん」を実装する仕事を請け負ったとしましょう。

Crossroadを用いると、以下のような記述でCustom URL Schemeのルーティングが行えます。

let router = DefaultRouter(scheme: "pokedex")
router.register([
    ("pokedex://pokemons", { context in 
        let type: Type? = context.parameter(for: "type")
        presentPokedexListViewController(for: type)
        return true 
    }),
    ("pokedex://pokemons/:pokedexID", { context in 
        guard let pokedexID: Int = try? context.arguments(for: "pokedexID") else {
            return false
        }

        guard let pokemon = Pokedex.find(by: pokedexID) else {
            return false
        }

        presentPokemonDetailViewController(of: pokemon)
        return true
    }),
])
router.openIfPossible(url)

このように、Ruby on Railsのroutes.rbのようなルーティングを記述することができます。

この仕組みをクックパッドアプリでは、1年以上前から運用していたのですが、今回、別のアプリでも使いやすい形で提供するためにOSS化しました。

同様のライブラリはいくつか公開されていますが、Crossroadはこれらに比べ、パラメータをType-Safeに、そして簡単に取り扱うことができます。

使い方

Crossroadの基本的な使い方を見ていきましょう。

URLのルーティング

iOSでは、Custom URL Schemeからアプリが起動されると、UIApplicationDelegateapplication(_:open:options:) が呼び出されます。

基本的な使い方は、AppDelegateで、ルーティングを定義した Router を生成し、そこでopenIfPossibleを呼び出すだけです。

import Crossroad

class AppDelegate: UIApplicationDelegate {
    let router: DefaultRouter = {
        let router = DefaultRouter(scheme: "scheme")
        router.register([
            ("scheme://search", { _ in return true }),
            // ...
        ])
        return router
    }()

    func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey: Any]) -> Bool {
        return router.openIfPossible(url, options: options)
    }
}

:から始まるパスは任意の文字列にマッチし、あとでマッチした値を参照できます。 URLパターンは定義順に上からマッチするかどうかが判定され、ブロックから true を返された時点で探索を終了します。 複数のURLパターンにマッチしうる場合も、最初にtrueを返した物のみが実行されます。

パラメータの取得

:から始まるパスにマッチした文字列は Argument として扱われ、ブロックから取得することができます。

("pokedex://pokemons/:pokedexID", { context in
    // URLからポケモンずかん番号を取得
    guard let pokedexID: Int = try? context.arguments(for: "pokedexID") else {
        return false
    }

    // 該当するポケモンを取得する
    guard let pokemon = Pokedex.find(by: pokedexID) else {
        return false
    }

    // ポケモン詳細画面を表示する
    presentPokemonDetailViewController(of: pokemon)
    return true
})

Argument はGenericsを利用しているので、任意の型として受け取ることができます。

例えば、pokedex://pokemons/25のURL Schemeからアプリを起動した場合、ずかん番号25番のポケモンが表示されます。

enumの値を取得する

Argumentを利用することで、それぞれのポケモンの詳細画面へ遷移するURL Schemeを実装することができました。

今度はポケモンを検索する画面を作ってみましょう。

URLのクエリとして渡された値は Parameter として扱われ、Argumentと同様にContextから取得することができます。

ここで、ポケモンのタイプを示すenum Typeを定義してみましょう。 Crossroadでは、Extractableというプロトコルに準拠させることで、任意の型をContextから返却することができます。

enum Type: String, Extractable {
    case normal // ノーマルタイプ
    case fire // ほのおタイプ
    case water // みずタイプ
    case grass // くさタイプ
    // ...
}

enumを表す型であるRawRepresentableは、すでにExtractableに準拠しているため、これだけで文字列をenumにマッピングすることができます。

("pokedex://pokemons", { context in
    let type: Type? = context.parameters(for: "type")

    // ポケモン一覧画面を表示する
    presentPokemonListViewController(of: type)
    return true
})

これで、pokedex://pokemons?type=fire というURL Schemeからアプリを起動すると、ほのおポケモンのみを表示する画面へ遷移することができます。

一般的な検索画面を実装する場合は、キーワードや並び順などをパラメータで受け取る実装が考えられるでしょう。

pokedex://search?keyword=ピカチュウ&order=asc

複数の値を取得する

ポケモンずかんを実装するに当たって、今度は複合タイプのポケモンをURL Schemeから検索したいという需要が出てくるでしょう。

Crossroadは、パラメータに渡されたカンマ区切りの文字列を配列としてマッピングする機能も提供しています。

// pokedex://pokemons?types=water,grass
let types: [Type]? = context.parameters(for: "types") // [.water, .grass]

これは、Swift 4.1から利用可能になった、Conditional Conformanceを用いて、[Extractable]Extractableに準拠させることで実現しています。

extension Array: Extractable where Array.Element: Extractable {
    static func extract(from string: String) -> [Element]? {
        let components = string.split(separator: ",")
        return components
            .map { String($0) }
            .compactMap(Element.extract(from:))
    }
}

独自の型を取得する

もちろん、独自の型を取得することもできます。Contextから取得したい型をExtractableに準拠させましょう。

struct Pokemon: Extractable {
    let name: String

    static func extract(from string: String) -> Pokemon? {
        return Pokemon(name: string)
    }
}
// pokedex://pokemons/:name
let pokemon: Pokemon = try? context.arguments(for: "name")

このように、Crossroadでは、柔軟にパスやクエリパラメータの取得を行うことができます。

Dynamic Member Lookupを使ったインターフェイス

最後に、Swift 4.2から実装される新たな言語機能であるDynamic Member Lookupを使ったインターフェイスの構想を紹介します。

Dynamic Member Lookupは、動的なプロパティ生成を提供するシンタックスシュガーです。 クラスや構造体に@dynamicMemberLookupを宣言することで、ランタイムで評価されるプロパティを生成することができます。

Dynamic Member Lookupを宣言すると、subscript(dynamicMember:) の実装が要求され、プロパティアクセスを行ったときに、プロパティ名が引数に渡され実行されます。

@dynamicMemberLookup
struct Container {
    let values: [String: Any]

    subscript<T>(dynamicMember member: String) -> T? {
        if let value = values[member] {
            return value as? T
        }
    }
}

let container = Container(values: ["name": "Pikachu"])
let name: String = container.name // Pikachu

本稿執筆時点では、Swift 4.2の正式版はまだリリースされていませんが、Swift.orgからdevelopmentのToolchainをダウンロードすることで、Xcode 9.3でも利用することができました *1

この機能をCrossroad.Contextに適用してみると、以下のように Argument を取得できるようになりました。

// match pokedex://pokemons/:pokedexID
let pokedexID: Int? = context.arguments.pokedexID

この実装はまだmasterへマージしていませんが、別ブランチで公開しているので、興味のある方は見てみてください。

まとめ

今回はCustom URL Schemeを簡単にルーティングするライブラリを紹介しました。 ぜひ利用を検討してみてください。もちろんPull Requestもお待ちしております。

技術部モバイル基盤グループでは、OSSを通して問題解決をしていきたいエンジニアを募集しています。

iOS アプリケーションエンジニア(開発基盤)

Android アプリケーションエンジニア(開発基盤)

*1:普通にビルドすることはできますが、静的解析の時点ではシンタックスエラーが発生します

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/