Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

cookpad://search/ण࢘ 7

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

ྫɿϙέϞϯ͔ͣΜ 9

Slide 10

Slide 10 text

pokedex://pokemons/25 10

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

! 12

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

աڈͷ঺հهࣄ • 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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

ॻ͖΍͍͢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

Slide 27

Slide 27 text

ॻ͖΍͍͢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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

ྫ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

Slide 44

Slide 44 text

ྫ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

Slide 45

Slide 45 text

ྫ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

Slide 46

Slide 46 text

ྫ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

Slide 47

Slide 47 text

Future works 34

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

• ͜ΕʹΑͬͯ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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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