Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Crossroad - 最高のCustom URL Schemeルーティングを支える技術2021

giginet
PRO
December 15, 2021

Crossroad - 最高のCustom URL Schemeルーティングを支える技術2021

[iPhone Dev Sapporo feat. HAKATA.swift - connpass](https://hakata-swift.connpass.com/event/232775/)

giginet
PRO

December 15, 2021
Tweet

More Decks by giginet

Other Decks in Technology

Transcript

  1. ࠷ߴͷCustom URL Scheme
    ϧʔςΟϯάΛࢧ͑Δٕज़2021
    @giginet
    2021/12/15 iPhone Dev Sapporo feat. HAKATA.swift
    1

    View Slide

  2. ࣗݾ঺հ
    • @giginet (Twitter/GitHub)
    • ΫοΫύου iOSςοΫϦʔυ
    • ٕज़ސ໰ɿtaskey / MoneyForward
    • Core Contributor: fastlane / Carthage /
    XcodeGen etc...
    2

    View Slide

  3. iPhone Dev Sapporo
    • ֶੜ࣌୅ɺࡳຈʹ͍ͨͷͰੲࢀՃ͍ͯͨ͠
    • ॳࢀՃ͸2010೥ࠒͷݹࢀ
    !
    • iPhone 4 + iOS 4ͷࠒ
    • Swift?
    • ͭ·Γζϒζϒ΍ͬͯΔ
    "
    3

    View Slide

  4. HAKATA.swift
    • աڈʹԿ౓͔തଟʹߦͬͯࢀՃ
    • ࠷ۙ͸MoneyForward෱ԬࢧࣾͷHAKATA.swiftӡӦͷΈͳ͞·
    ʹ͓ੈ࿩ʹͳ͍ͬͯΔ
    • ͭ·Γζϒζϒ΍ͬͯΔ
    !
    4

    View Slide

  5. ࠓ೔࿩͢͜ͱ
    • Crossroadͱ͍͏Custom URL SchemeͷϧʔςΟϯάΛྑ͍ײ
    ͡ʹ͢ΔϥΠϒϥϦΛ࠷ۙେܕΞοϓσʔτͨ͠ͷͰ঺հ͠·
    ͢
    • - https://github.com/giginet/Crossroad
    5

    View Slide

  6. Custom URL Schemeͱ͸
    • ΧελϜͨ͠URL͔ΒΞϓϦΛىಈͤ͞Δ͘͠Έ
    Defining a Custom URL Scheme for Your App | Apple Developer
    Documentation
    6

    View Slide

  7. cookpad://search/ण࢘
    7

    View Slide

  8. Custom URL SchemeΛऔΓר͘໰୊
    • ϧʔςΟϯά͕૿͑ΔͱϋϯυϦϯά͕ΊͪΌͪ͘Όେม
    • ࢓༷Λύοͱ೺Ѳͮ͠Β͍
    • ͳΜ͔͍Ζ͍Ζ༻ޠ΍ભҠํ๏͕͋ͬͯ೉͍͠
    • Deep Link?, Universal Link?, Custom URL Scheme?, Dynamic
    Links?
    8

    View Slide

  9. ྫɿϙέϞϯ͔ͣΜ
    9

    View Slide

  10. pokedex://pokemons/25
    10

    View Slide

  11. Before
    class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    let url = URL(string: "pokedex://pokemons/25")!
    let components = url.pathComponents
    if url.scheme == "pokedex" {
    if url.host == "pokemons" {
    if components.count == 2, let pokedexID: Int = Int(components[1]) {
    presentPokemonDetailViewController(of: pokedexID)
    return true
    } else if ( ... ) {
    } else {
    // ...
    }
    }
    }
    }
    }
    11

    View Slide

  12. Before
    class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    let url = URL(string: "pokedex://pokemons/25")!
    let components = url.pathComponents
    if url.scheme == "pokedex" {
    if url.host == "pokemons" {
    if components.count == 2, let pokedexID: Int = Int(components[1]) {
    presentPokemonDetailViewController(of: pokedexID)
    return true
    } else if ( ... ) {
    } else {
    // ...
    }
    }
    }
    }
    }
    11

    View Slide

  13. Before
    class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    let url = URL(string: "pokedex://pokemons/25")!
    let components = url.pathComponents
    if url.scheme == "pokedex" {
    if url.host == "pokemons" {
    if components.count == 2, let pokedexID: Int = Int(components[1]) {
    presentPokemonDetailViewController(of: pokedexID)
    return true
    } else if ( ... ) {
    } else {
    // ...
    }
    }
    }
    }
    }
    11

    View Slide

  14. !
    12

    View Slide

  15. After
    import Crossroad
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    }
    class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    router.openIfPossible(url, options: options)
    }
    }
    13

    View Slide

  16. After
    import Crossroad
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    }
    class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    router.openIfPossible(url, options: options)
    }
    }
    13

    View Slide

  17. After
    import Crossroad
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    }
    class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    router.openIfPossible(url, options: options)
    }
    }
    13

    View Slide

  18. After
    import Crossroad
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    }
    class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    router.openIfPossible(url, options: options)
    }
    }
    13

    View Slide

  19. After
    import Crossroad
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    }
    class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    router.openIfPossible(url, options: options)
    }
    }
    13

    View Slide

  20. After
    import Crossroad
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    }
    class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    router.openIfPossible(url, options: options)
    }
    }
    13

    View Slide

  21. • URL͔Βͷม਺ͷύʔε
    • ෳ਺URLεΩʔϜͷϧʔςΟϯά
    ͳͲ͕؆୯ʹߦ͑Δ
    !
    14

    View Slide

  22. ࠾༻ࣄྫ
    • ݟౕ͚͚ͭͨͩͳͷͰଞʹ͋ͬͨΒڭ͍͑ͯͩ͘͞
    15

    View Slide

  23. աڈͷ঺հهࣄ
    • iOSΞϓϦͷେن໛ͳCustom URL SchemeΛࢧ͑Δٕज़ - Ϋο
    Ϋύου։ൃऀϒϩά
    • https://techlife.cookpad.com/entry/2018/06/01/113000
    • Custom URL SchemeΛࢧ͑Δٕज़ - Speaker Deck
    • https://speakerdeck.com/giginet/custom-url-schemewozhi-
    eruji-shu?slide=10
    16

    View Slide

  24. ެ։͔Β3೥൒ɺ࠷ۙόʔδϣϯ4.0Λग़ͨ͠
    !
    17

    View Slide

  25. ৽ػೳ
    1. ॻ͖΍͍͢DSL
    2. Universal Link΍Deferred Deep Linkͱͷܦ࿏ڞ༗
    3. ఆٛϛεΛ๷͙ͨΊͷ࢓૊Έ
    18

    View Slide

  26. ॻ͖΍͍͢DSL
    Before
    let router = DefaultRouter(scheme: "pokedex")
    router.register([
    ("pokedex://pokemons/:pokedexID", { context in
    guard let pokedexID: Int = try? context.arguments(for: "pokedexID") else {
    return false
    }
    presentPokemonDetailViewController(of: pokedexID)
    return true
    })
    ])
    • [(String, (Context) -> Bool)]Ͱهड़
    • ੒ޭɺࣦഊΛBoolͰฦ͍ͯͨ͠
    19

    View Slide

  27. ॻ͖΍͍͢DSL
    Before
    let router = DefaultRouter(scheme: "pokedex")
    router.register([
    ("pokedex://pokemons/:pokedexID", { context in
    guard let pokedexID: Int = try? context.arguments(for: "pokedexID") else {
    return false
    }
    presentPokemonDetailViewController(of: pokedexID)
    return true
    })
    ])
    • [(String, (Context) -> Bool)]Ͱهड़
    • ੒ޭɺࣦഊΛBoolͰฦ͍ͯͨ͠
    19

    View Slide

  28. After
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    }
    • ResultBuilderΛ࢖ͬͯ௚ײతʹॻ͚ΔΑ͏ʹͳͬͨ
    20

    View Slide

  29. Result Builder
    • Swift 5.4͔Βར༻Մೳʹͳͬͨ৽͍͠ݴޠػೳ
    • એݴతͳAPIઃܭΛ࣮ݱ͢Δ͜ͱ͕ग़དྷΔ
    • SwiftUIͷએݴతUIΛ࣮ݱ͢ΔͨΊʹݴޠػೳʹ૊Έࠐ·Εͨ
    VStack(alignment: .leading) {
    Text("Turtle Rock")
    .font(.title)
    HStack {
    Text("Joshua Tree National Park")
    .font(.subheadline)
    Spacer()
    Text("California")
    .font(.subheadline)
    }
    }
    21

    View Slide

  30. Result BuilderͰDSLΛઃܭ͠Α͏
    • ResultBuilderͷ࢖͍ํΛ࿩͠ग़͢ͱ1͙࣌ؒΒ͍͔͔Δ
    • Write a DSL in Swift using result builders - WWDC21 - Videos -
    Apple Developer
    • WWDCͷ͜ͷηογϣϯ͕Φεεϝ
    22

    View Slide

  31. Router {
    Route("/pokemon/:pokedexID") { context in
    // do something
    }
    Route("/pokemons") { context in
    // do something
    }
    // ...
    }
    • ϒϩοΫͷதͷRouteͷཏྻΛ [Route] ͱͯ͠औΓ͍ͨ
    23

    View Slide

  32. @resultBuilder
    struct RouteBuilder {
    static func buildBlock(_ components: Route...) -> [Route] {
    components
    }
    }
    struct Router {
    init(@RouteBuilder _ routeBuilder: () -> [Route]) {
    self.routes = routeBuilder()
    }
    }
    24

    View Slide

  33. @resultBuilder
    struct RouteBuilder {
    static func buildBlock(_ components: Route...) -> [Route] {
    components
    }
    }
    struct Router {
    init(@RouteBuilder _ routeBuilder: () -> [Route]) {
    self.routes = routeBuilder()
    }
    }
    24

    View Slide

  34. • CrossroadͷDSL͸΋ͬͱෳࡶ
    • ܕύϥϝʔλʔͷӅṭ
    • Group(ޙड़)ͷଘࡏɹ
    • ࣮૷ྫΛ͝ཡ͍ͩ͘͞ https://github.com/giginet/Crossroad/
    blob/master/Sources/Crossroad/DSL.swift
    • ResultBuilderΛ࢖ͬͨDSLઃܭͷ஌ݟ͕ཷ·͔ͬͨΒ·ͨͲ͜
    ͔Ͱ࿩͍ͨ͠
    25

    View Slide

  35. 2. Universal Link΍Deferred Deep Linkͱͷܦ࿏ڞ༗
    • ݱ࣮ͷϏδωεʹ͸Custom URL SchemeͷଞʹɺUniversal
    Link΍Firebase Dynamic Links(Deferred Deep Link)ͳͲɺ༷ʑͳ
    ΞϓϦૹ٬ख๏͕͋Δ
    • ࠓ·ͰͷCrossroad͸ɺෳ਺ͷྲྀೖܦ࿏Λѻ͏͜ͱ͕Ͱ͖ͳ
    ͔ͬͨ
    26

    View Slide

  36. ෳ਺ͷྲྀೖܦ࿏ͷϧʔςΟϯάΛଋͶͯɺಉ͡ڍಈΛ͍ͤͨ͞
    • Custom URL Scheme
    • pokedex://pokemons/25
    • Universal Link
    • https://my-pokedex.com/pokemons/25
    27

    View Slide

  37. Router͕ड͚෇͚ΔϦϯΫݩΛෳ਺औΕΔΑ͏ʹ
    let router = try! DefaultRouter(accepting: [
    .customURLScheme("pokedex"),
    .universalLink(URL(string: "https://my-pokedex.com")!)
    ]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    }
    28

    View Slide

  38. Router͕ड͚෇͚ΔϦϯΫݩΛෳ਺औΕΔΑ͏ʹ
    let router = try! DefaultRouter(accepting: [
    .customURLScheme("pokedex"),
    .universalLink(URL(string: "https://my-pokedex.com")!)
    ]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    }
    28

    View Slide

  39. Group
    let router = try! DefaultRouter(accepting: [
    .customURLScheme("pokedex"),
    .universalLink(URL(string: "https://my-pokedex.com")!)
    ]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    // ͜ͷGroupʹғ·Εͨϧʔτఆٛ͸Custom URL Schemeܦ༝Ͱ͔͠ΞΫηεͰ͖ͳ͍
    registry.group(accepting: [.customURLScheme("pokedex")]) { group in
    group.route("/moves/:moveID") { context in
    // ...
    }
    }
    }
    29

    View Slide

  40. Group
    let router = try! DefaultRouter(accepting: [
    .customURLScheme("pokedex"),
    .universalLink(URL(string: "https://my-pokedex.com")!)
    ]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    // ͜ͷGroupʹғ·Εͨϧʔτఆٛ͸Custom URL Schemeܦ༝Ͱ͔͠ΞΫηεͰ͖ͳ͍
    registry.group(accepting: [.customURLScheme("pokedex")]) { group in
    group.route("/moves/:moveID") { context in
    // ...
    }
    }
    }
    29

    View Slide

  41. • ෳ਺ͷϦϯΫݩΛ1ͭͷϧʔλʔͰѻ͑ΔΑ͏ʹͳͬͨ
    • ϦϯΫݩຖʹΞΫηεͰ͖Δ͔Ͳ͏͔Λࡉ੍͔͘ޚͰ͖ΔΑ͏
    ʹͳͬͨ(Group)
    30

    View Slide

  42. 3. ఆٛϛεΛ๷͙ͨΊͷ࢓૊Έ
    • Routerͷੜ੒࣌ʹෆਖ਼ͳϧʔτఆٛΛݕ஌ͯ͠ྫ֎Λൃੜ͢Δ
    Α͏ʹͨ͠
    • Routerͷinitilizer͕throws
    • ؒҧͬͨRouter͸ͦ΋ͦ΋ੜ੒Ͱ͖ͳ͘ͳͬͨͨΊɺఆٛϛε
    ʹΑΔϥϯλΠϜͰͷෆ۩߹͕๷͛Δ
    31

    View Slide

  43. ྫ1ɿෳ਺ͷϧʔτ͕ॏෳ͍ͯ͠Δέʔε
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    // ...
    // ͏͔ͬΓಉ͡ఆ͕ٛॏෳ
    registry.route("/pokemons/:pokedexID") { context in
    }
    }
    32

    View Slide

  44. ྫ1ɿෳ਺ͷϧʔτ͕ॏෳ͍ͯ͠Δέʔε
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID)
    }
    // ...
    // ͏͔ͬΓಉ͡ఆ͕ٛॏෳ
    registry.route("/pokemons/:pokedexID") { context in
    }
    }
    32

    View Slide

  45. ྫ2ɿͦ΋ͦ΋ड͚෇͚ͳ͍ϦϯΫݩΛGroupʹ͍ͯ͠
    Δέʔε
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.group(accepting: [.customURLScheme("unknown")]) { group in
    group.route("/moves/:moveID") { context in
    // ...
    }
    }
    }
    33

    View Slide

  46. ྫ2ɿͦ΋ͦ΋ड͚෇͚ͳ͍ϦϯΫݩΛGroupʹ͍ͯ͠
    Δέʔε
    let router = try! DefaultRouter(accepting: [.customURLScheme("pokedex")]) { registry in
    registry.group(accepting: [.customURLScheme("unknown")]) { group in
    group.route("/moves/:moveID") { context in
    // ...
    }
    }
    }
    33

    View Slide

  47. Future works
    34

    View Slide

  48. 1. Swift ConcurrencyʹΑΔϝΠϯεϨουอূ
    • جຊతʹURLεΩʔϜͷల։͸ɺଞͷϏϡʔʹભҠ͢ΔͳͲɺ
    UIͷૢ࡞Λߦ͏ͨΊɺϝΠϯεϨουͰಈ͘΂͖
    registry.route("/pokemons/:pokedexID") { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID) // ϝΠϯεϨουͰಈ͍ͯཉ͍͠
    }
    35

    View Slide

  49. • @MainActor Λ࢖͏͜ͱͰΩϨΠʹ࣮ݱͰ͖Δ
    • Xcode 13.2(ࡢ೔ϦϦʔεʂʂʂ)͔ΒSwift Concurrencyͷ
    backport͕དྷͨͷͰiOS 13.0+Ҏ্Ͱಈ࡞Մೳʹ
    36

    View Slide


  50. HandlerͱopenIfPossibleͷܕΛ @MainActor ʹ͢Δ
    typealias Handler = @MainActor (Context) throws -> Void
    @MainActor public func openIfPossible(_ url: URL, userInfo: UserInfo) -> Bool
    37

    View Slide

  51. • ͜ΕʹΑͬͯopenIfPossible͸ϝΠϯεϨου͔Β͔࣮͠ߦͰ
    ͖ͳ͘ͳΔͨΊɺAppDelegateࣗମ΋@MainActorͱ͢Δ
    @MainActor
    class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    return router.openIfPossible(url, options: options)
    }
    }
    • Routerʹ౉͞Εͨhandler͕ϝΠϯεϨουͰಈ͘͜ͱ͕อূ͞
    ΕΔΑ͏ʹͳΓ҆શ
    !
    • https://github.com/giginet/Crossroad/pull/43
    38

    View Slide

  52. 2. DocCରԠ
    • Documentation Compiler
    • ؆୯ʹΦϯϥΠϯυΩϡϝϯτ͕࡞ΕΔͷͰ༻ҙ͍ͨ͠
    • ӳޠͱ͍͏೉͍͠ݴޠ͕ॻ͚ͳ͍ͷͰ͕͔͔͍࣌ؒͬͯΔ
    • Θ͍Θ͍DocC ~ waiwai-docc ~ - Speaker Deck
    • https://speakerdeck.com/giginet/waiwaidocc-waiwai-docc
    39

    View Slide

  53. 3. ෳ਺ϧʔςΟϯάରԠ
    registry.route(["/pokemons/:pokedexID", "/:pokedexID"]) { context in
    let pokedexID: Int = try context.argument(named: "pokedexID")
    presentPokedexDetailViewController(of: pokedexID) // ϝΠϯεϨουͰಈ͍ͯཉ͍͠
    }
    • pokedex://pokemons/25Ͱ΋pokedex://25Ͱ΋ड͚෇͚ΔΑ͏ʹ
    • ๭ϨγϐΞϓϦͷϧʔςΟϯά͕ෳࡶ͗ͯ͢࢖͍͍ͨ
    40

    View Slide

  54. 4. υΩϡϝϯςʔγϣϯࣗಈੜ੒
    • SwiftSyntaxͰRouterͷDSLΛղੳͯ͠υΩϡϝϯτΛ࡞Δख๏
    ͳͲΛߟ͍͑ͯΔ
    • ๭ϨγϐΞϓϦͰ͸ϧʔςΟϯά͕ଟ͗ͯ͢େมͳ͜ͱʹͳͬ
    ͍ͯͯ༗༻
    41

    View Slide


  55. • ελʔ͍ͩ͘͞
    • https://github.com/giginet/Crossroad
    • ͥͻ͝ར༻͍ͩ͘͞
    42

    View Slide

  56. ͝ਗ਼ௌ͋Γ͕ͱ͏͍͟͝·ͨ͠
    !
    ࣭ٙԠ౴
    43

    View Slide