Crossroad

Build Status Language SwiftPM compatible Carthage compatible CocoaPods Compatible Platform License

轻松路由 URL scheme。

Crossroad 是一个 URL 路由器,专注于处理自定义 URL Schemes 或 Universal Links。 当然,你也可以将其用于 Firebase Dynamic Link 或其他类似服务。

使用它,你可以轻松地路由多个 URL scheme 并获取参数。

这个库是在 Cookpad 的工作时间内开发的。

基本用法

你可以使用 DefaultRouter 来定义路由定义。

想象一下在 iOS 上实现一个 Pokédex (宝可梦图鉴)。 你可以通过 URL scheme 访问某些地方。

import Crossroad

let customURLScheme: LinkSource = .customURLScheme("pokedex")
let universalLink: LinkSource = .universalLink(URL(string: "https://my-awesome-pokedex.com")!)

do {
    let router = try DefaultRouter(accepting: [customURLScheme, universalLink]) { registry in
        registry.route("/pokemons/:pokedexID") { context in 
            let pokedexID: Int = try context.argument(named: "pokedexID") // Parse 'pokedexID' from URL
            if !Pokedex.isExist(pokedexID) { // Find the Pokémon by ID
                throw PokedexError.pokemonIsNotExist(pokedexID) // If Pokémon is not found. Try next route definition.
            }
            presentPokedexDetailViewController(of: pokedexID)
        }
        registry.route("/pokemons") { context in 
            let type: Type? = context.queryParameters.type // If URL contains &type=fire, you can get Fire type.
            presentPokedexListViewController(for: type)
        }

        // ...
    }
} catch {
    // If route definitions have some problems, routers fail initialization and raise reasons.
    fatalError(error.localizedDescription)
}

// Pikachu(No. 25) is exist! so you can open Pikachu's page.
let canRespond25 = router.responds(to: URL(string: "pokedex://pokemons/25")!) // true
// No. 9999 is missing. so you can't open this page.
let canRespond9999 = router.responds(to: URL(string: "pokedex://pokemons/9999")!) // false
// You can also open the pages via universal links.
let canRespondUniversalLink = router.responds(to: URL(string: "https://my-awesome-pokedex.com/pokemons/25")!) // true

// Open Pikachu page
router.openIfPossible(URL(string: "pokedex://pokemons/25")!)
// Open list of fire Pokémons page
router.openIfPossible(URL(string: "pokedex://pokemons?type=fire")!)

使用 AppDelegate

在常见的用例中,你应该在 UIApplicationDelegate 方法中调用 router.openIfPossible

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

使用 SceneDelegate

或者,如果你正在使用具有现代应用程序的 SceneDelegate

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let context = URLContexts.first else {
        return
    }
    router.openIfPossible(context.url, options: context.options)
}

使用 NSApplicationDelegate (macOS)

如果你开发 macOS 应用程序

class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        let appleEventManager = NSAppleEventManager.shared()
        appleEventManager.setEventHandler(self,
                                          andSelector: #selector(handleURLEvent(event:replyEvent:)),
                                          forEventClass: AEEventClass(kInternetEventClass),
                                          andEventID: AEEventID(kAEGetURL))
    }

    @objc func handleURLEvent(event: NSAppleEventDescriptor?, replyEvent: NSAppleEventDescriptor?) {
        guard let urlString = event?.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue else { return }
        guard let url = URL(string: urlString) else { return }
        router.openIfPossible(context.url, options: [:])
    }
}

参数和查询参数

参数

URL 模式中带有 : 前缀的组件表示**参数**。

例如,如果传递的 URL 匹配 pokedex://search/:keyword,你可以从 Context 获取 keyword

// actual URL: pokedex://search/Pikachu
let keyword: String = try context.arguments(named: "keyword") // Pikachu

查询参数

此外,你可以获取存在的查询参数。

// actual URL: pokedex://search/Pikachu?generation=1
let generation: Int? = context.queryParameters["generation"] // 1
// or you can also get value using DynamicMemberLookup
let generation: Int? = context.queryParameters.generation // 1

你可以将参数/查询参数强制转换为任何类型。 Crossroad 尝试将每个 String 值强制转换为该类型。

// expected pattern: pokedex://search/:pokedexID
// actual URL: pokedex://search/25
let pokedexID: Int = try context.arguments(named: "keyword") // 25

目前支持的类型是 IntInt64FloatDoubleBoolStringURL

枚举参数

你可以通过遵循 Parsable 协议将枚举用作参数。

enum Type: String, Parsable {
    case normal
    case fire
    case water
    case grass
    // ....
}

// matches: pokedex://pokemons?type=fire
let type: Type? = context.queryParameters.type // .fire

逗号分隔的列表

你可以将逗号分隔的查询字符串视为 ArraySet

// matches: pokedex://pokemons?types=water,grass
let types: [Type]? = context.queryParameters.types // [.water, .grass]

自定义参数

你还可以通过实现 Parsable 来定义自己的参数。 这是一个解析自定义结构的示例。

struct User {
    let name: String
}
extension User: Parsable {
    init?(from string: String) {
        self.name = string
    }
}

支持多个链接来源

你可以定义如下的复杂路由定义

let customURLScheme: LinkSource = .customURLScheme("pokedex")
let pokedexWeb: LinkSource = .universalLink(URL(string: "https://my-awesome-pokedex.com")!)
let anotherWeb: LinkSource = .universalLink(URL(string: "https://kanto.my-awesome-pokedex.com")!)

let router = try DefaultRouter(accepting: [customURLScheme, pokedexWeb, anotherWeb]) { registry in
    // Pokémon detail pages can be opened from all sources.
    registry.route("/pokemons/:pokedexID") { context in 
        let pokedexID: Int = try context.argument(named: "pokedexID") // Parse 'pokedexID' from URL
        if !Pokedex.isExist(pokedexID) { // Find the Pokémon by ID
            throw PokedexError.pokemonIsNotExist(pokedexID)
        }
        presentPokedexDetailViewController(of: pokedexID)
    }

    // Move related pages can be opened only from Custom URL Schemes
    registry.group(accepting: [customURLScheme]) { group in
        group.route("/moves/:move_name") { context in 
            let moveName: String = try context.argument(named: "move_name")
            presentMoveViewController(for: moveName)
        }
        group.route("/pokemons/:pokedexID/move") { context in 
            let pokedexID: Int = try context.argument(named: "pokedexID")
            presentPokemonMoveViewController(for: pokedexID)
        }
    }

    // You can pass acceptPolicy for a specific page.
    registry.route("/regions", accepting: .only(for: pokedexWeb)) { context in 
        presentRegionListViewController()
    }
}

此路由器可以处理三个链接来源。

自定义 Router

你可以向 Router 添加任何 payload。

struct UserInfo {
    let userID: Int64
}
let router = try Router<UserInfo>(accepting: customURLScheme) { registry in
    registry.route("pokedex://pokemons") { context in 
        let userInfo: UserInfo = context.userInfo
        let userID = userInfo.userID
    }
    // ...
])
let userInfo = UserInfo(userID: User.current.id)
router.openIfPossible(url, userInfo: userInfo)

解析 URL 模式

如果你维护一个复杂的应用程序,并且希望在没有 Router 的情况下使用独立的 URL 模式解析器。 你可以使用 ContextParser

let parser = ContextParser()
let context = parser.parse(URL(string: "pokedex:/pokemons/25")!, 
                           with: "pokedex://pokemons/:id")

安装

Swift Package Manager

CocoaPods

use_frameworks!

pod 'Crossroad'

Carthage

github "giginet/Crossroad"

Demo

  1. 在 Xcode 上打开 Demo/Demo.xcodeproj
  2. 构建 Demo schema。

支持的版本

最新版本的 Crossroad 需要 Swift 5.2 或更高版本。

在 Swift 4.1 或更低版本上使用 1.x。

Crossroad 版本 Swift 版本 Xcode 版本
4.x 5.4 Xcode 13.0
3.x 5.0 Xcode 10.3
2.x 5.0 Xcode 10.2
1.x 4.0 ~ 4.2 ~ Xcode 10.1

许可证

Crossroad 在 MIT 许可证下发布。

标题 logo 在 CC BY 4.0 许可证下发布。 原始设计由 @Arslanshn 提供。