diff --git a/CLAUDE.md b/CLAUDE.md index 2cdcdf7..53394b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,69 +5,194 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands ```bash -# Build -swift build +# Build (must target iOS SDK) +xcodebuild -scheme ModuleRoute -destination "platform=iOS Simulator,name=iPhone 17" -sdk iphonesimulator build # Run all tests -swift test +xcodebuild test -scheme ModuleRoute -destination "platform=iOS Simulator,name=iPhone 17" -sdk iphonesimulator -# Run a single test -swift test --filter ModuleRouteTests/ +# Run a single test by name +xcodebuild test -scheme ModuleRoute -destination "platform=iOS Simulator,name=iPhone 17" -sdk iphonesimulator -only-testing:ModuleRouteTests/ ``` +> Note: `swift build` / `swift test` will fail because UIKit is unavailable outside the iOS SDK. + ## Architecture -ModuleRoute is a Swift Package (iOS 14+) providing a modular routing framework. It has three main concerns: **routing**, **navigation**, and **dependency injection**. +ModuleRoute is a Swift Package (iOS 14+) providing a modular routing framework. It has three concerns: **routing**, **navigation**, and **dependency injection**. ### Core Protocols (`Sources/ModuleRoute/ModuleRoute.swift`) -- **`MRRoute`** — A route definition. Each concrete route is a struct with a static `name`, a `params: [String: Any]` dict, and an optional `callback`. -- **`MRModule`** — A feature module. Declares `supportedRoutes` (the route types it handles) and implements `handle(route:) -> RouteResult`. -- **`RouteResult`** — What a module returns: `.navigator(UIViewController)` triggers UI navigation; `.handler(() -> Void)` executes a closure; `.service(Any)` / `.value(Any)` return data; `.none` is a no-op. -- **`NavigationType`** — How to navigate: `.push`, `.present`, `.modal`, `.replace`, or `.custom((UIViewController, UIViewController) -> Void)`. +**`MRRoute`** — Route definition. Declare only the static `name` and any strongly-typed properties specific to the route. `params` and `callback` have default implementations via protocol extension and do **not** need to be re-declared in concrete routes: + +```swift +// Minimal route — params and callback come for free +struct DetailRoute: MRRoute { + static var name: String { "detail" } +} + +// Route with typed parameters +struct UserRoute: MRRoute { + static var name: String { "user" } + let userId: String + let showEdit: Bool +} +``` + +**`MRModule`** — A feature module. Declares `supportedRoutes` and implements `handle(route:) -> RouteResult`. Modules can also carry a service protocol (`protocol DetailService: MRModule`) so they can be injected and called directly for non-navigation use cases. + +**`RouteResult`** — What a module returns: +- `.navigator(UIViewController)` — triggers UI navigation +- `.handler(() -> Void)` — executes a closure (no navigation) +- `.value(Any)` / `.service(Any)` — returns data (use with `navigator.resolve()`) +- `.none` — no-op + +**`NavigationType`** — `.push`, `.present`, `.modal`, `.replace`, `.custom((UIViewController, UIViewController) -> Void)` ### Navigator (`Sources/ModuleRoute/MRNavigator.swift`) -`MRNavigator` is the central coordinator. It holds the `ServiceLocator` and an internal `[ObjectIdentifier: (ServiceLocator) -> MRModule]` map linking route types to their module factories. +`MRNavigator` is the central coordinator. It holds the `ServiceLocator` and a `[ObjectIdentifier: (ServiceLocator) -> MRModule]` map linking route types to module factories. -Route processing pipeline (in order): -1. **Permission check** via `MRPermissionChecker` — can short-circuit the entire flow. -2. **Middleware chain** (`MRMiddleware`) — each middleware calls `next(route)` to continue; can short-circuit by returning a result directly. -3. **Interceptors** (`MRInterceptor`) — checked after all middlewares pass; the first interceptor whose `shouldIntercept` returns `true` handles the route. -4. **Module dispatch** — looks up the registered module for the route type and calls `handle(route:)`. +#### Global instance -Modules are registered on the navigator (not directly on the service locator): ```swift -navigator.register(MyModule.self, routes: [MyRoute.self]) { MyModule() } +// AppDelegate — initialize once +let navigator = MRNavigator.setup(serviceLocator: startServiceLocator()) + +// Anywhere else — access the shared instance +MRNavigator.default.navigate(to: DetailRoute()) ``` -This registers the module as a singleton in the `ServiceLocator` and maps each route type to that module. -### Service Locator (`Sources/ModuleRoute/ServiceLocator/`) +#### Module registration -Thread-safe DI container with two registration modes: -- `single(_:factory:)` — eager singleton; built during `await serviceLocator.build()`. -- `factory(_:factory:)` — creates a new instance on each `resolve()`. +```swift +navigator.register(DetailModule.self, routes: DetailModule.supportedRoutes) { + DetailModule() +} +``` -`ServiceLocatorModule` is an open class to group registrations; override `build()` and pass it to `startServiceLocator { MyModule() }`. +Registered modules are stored as singletons in the `ServiceLocator`. Resolution is lazy: singletons are built on first `resolve()` if `build()` hasn't been called yet. -Dependency injection via property wrappers: -- `@Inject` (from `ServiceLocator/Inject.swift`) — resolves `T` from the locator passed at init. -- `@MRInject` (from `MRInject.swift`) — resolves `T` from the global `MRNavigatorLocator.shared.serviceLocator`, which is set automatically when `MRNavigator` is initialized. +#### Navigation + +```swift +// With explicit source (for precise push/present context) +navigator.navigate(to: UserRoute(userId: "123"), from: self, navigationType: .push) + +// Without source — navigator auto-finds topViewController +navigator.navigate(to: UserRoute(userId: "123")) + +// Service call — resolves route synchronously, no UI side effect +let result = navigator.resolve(UserRoute(userId: "123")) +if case .value(let info) = result { ... } +``` + +#### Route processing pipeline (in order) + +1. **Permission check** (`MRPermissionChecker`) — can short-circuit the entire flow +2. **Middleware chain** (`MRMiddleware`) — each calls `next(route)` to continue; returning early short-circuits +3. **Interceptors** (`MRInterceptor`) — first matching `shouldIntercept` wins +4. **Module dispatch** — looks up registered module by route type, calls `handle(route:)` ### Middleware vs. Interceptors | | `MRMiddleware` | `MRInterceptor` | |---|---|---| | Position in pipeline | Before module dispatch | After all middlewares | -| Receives `next` closure | Yes — must call it to continue | No | +| Receives `next` closure | Yes — must call to continue | No | | Use case | Auth gates, logging, route transformation | Conditional overrides (A/B, feature flags) | A built-in `LoggingMiddleware` is provided. -### Deep Linking (`Sources/ModuleRoute/MRDeepLinkParser.swift`) +### Service Locator (`Sources/ModuleRoute/ServiceLocator/`) + +Thread-safe DI container. Two registration modes: +- `single(_:factory:)` — singleton; built lazily on first `resolve()` or eagerly via `await build()` +- `factory(_:factory:)` — new instance on each `resolve()` + +Group registrations using `ServiceLocatorModule` (override `build()`): +```swift +class AppModule: ServiceLocatorModule { + override func build() { + single(MyService.self) { MyServiceImpl() } + } +} +let locator = startServiceLocator { AppModule() } +``` + +#### Dependency injection + +```swift +// Resolves from MRNavigator.default's ServiceLocator (set automatically on MRNavigator.setup) +@MRInject var navigator: MRNavigator +@MRInject var detail: DetailInterface // DetailInterface: MRModule +``` + +`@Inject` is the lower-level wrapper that takes an explicit `ServiceLocator` at init — prefer `@MRInject` in app code. + +### Deep Linking & Universal Links (`Sources/ModuleRoute/MRURLRoutable.swift`, `MRURLMatcher.swift`, `MRDeepLinkParser.swift`) + +Route 遵循 `MRURLRoutable` 后,**注册 Module 时自动加入 URL 路由表**,无需在 AppDelegate 手动注册解析 handler。 + +#### 让 Route 支持 URL 匹配 + +```swift +struct DetailRoute: MRRoute, MRURLRoutable { + static var name: String { "detail" } + // Deep Link pattern + static var urlPattern: String { "myapp://detail/:detailId" } + // Universal Link pattern(同一 Route 选择一种;两种都支持则拆成两个 Route) + // static var urlPattern: String { "https://example.com/detail/:detailId" } + + let detailId: String + + // 从 URL 解析结果构造自身;返回 nil 表示参数不完整,框架继续尝试下一个 pattern + init?(urlComponents: MRURLComponents) { + guard let id = urlComponents.string("detailId"), !id.isEmpty else { return nil } + self.detailId = id + } +} +``` + +Pattern 语法: +- `:key` — 具名路径参数,匹配单段非空字符串 +- `*` — 通配符,消耗剩余所有路径段 +- query string 参数通过 `urlComponents.string("key")` 读取,无需在 pattern 中声明 + +优先级(高→低):字面量段 > 具名参数段 > 通配符段 + +#### AppDelegate 接入 + +```swift +// Deep Link(Custom URL Scheme) +func application(_ app: UIApplication, open url: URL, ...) -> Bool { + return MRNavigator.default.handleDeepLink(url) +} + +// Universal Link(NSUserActivity) +func application(_ application: UIApplication, continue userActivity: NSUserActivity, ...) -> Bool { + return MRNavigator.default.handleUniversalLink(userActivity) +} +``` + +#### MRURLComponents 类型安全读取 + +```swift +init?(urlComponents: MRURLComponents) { + urlComponents.string("key") // path 参数优先,其次 query 参数 + urlComponents.int("page") + urlComponents.bool("preview") + urlComponents.double("lat") + urlComponents.fragment // URL # 之后的部分 +} +``` + +#### 兜底 handler(可选) -Register URL scheme handlers on the navigator; incoming URLs are parsed into `MRRoute` instances and dispatched normally: +自动路由匹配失败时才触发,用于无法用 pattern 描述的场景: ```swift -navigator.registerDeepLinkHandler(scheme: "myapp") { url in ... } -navigator.handleDeepLink(url) // call from application(_:open:options:) +navigator.registerFallbackDeepLinkHandler(scheme: "myapp") { url in + // 返回 nil 则放弃处理 + return nil +} ``` diff --git a/Example/ModuleRouteExample/AppDelegate.swift b/Example/ModuleRouteExample/AppDelegate.swift index 3e2e732..9c0814b 100644 --- a/Example/ModuleRouteExample/AppDelegate.swift +++ b/Example/ModuleRouteExample/AppDelegate.swift @@ -11,67 +11,60 @@ import ModuleRoute @main class AppDelegate: UIResponder, UIApplicationDelegate { - public lazy var myServiceLocator = startServiceLocator() -// { -//// AppModule() -// } - private var navigator: MRNavigator! - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - setupRoute() - + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + setupNavigator() return true } - private func setupServiceLocator() async { - await myServiceLocator.build() - } - - private func setupRoute() { - navigator = MRNavigator(serviceLocator:myServiceLocator) - navigator.register(DetailInterface.self, routes: DetailModule.supportedRoutes) { + private func setupNavigator() { + let navigator = MRNavigator.setup(serviceLocator: startServiceLocator()) + + // 注册模块——同时自动注册 DetailRoute、ChatRoute 的 URL pattern + // DetailRoute.urlPattern = "myapp://detail/:detailId" + // ChatRoute.urlPattern = "myapp://chat/:chatId" + navigator.register(DetailModule.self, routes: DetailModule.supportedRoutes) { DetailModule() } - navigator.register(ChatInterface.self, routes: ChatModule.supportedRoutes) { + navigator.register(ChatModule.self, routes: ChatModule.supportedRoutes) { ChatModule() } - -// navigator.register(interfaceType: DetailInterface.self, moduleType: DetailModule.self) -// navigator.register(moduleType: DetailModule.self) -// navigator.register(moduleType: ChatModule.self) - } + // 兜底 handler:仅在自动路由匹配失败时触发 + // 适用于无法用 pattern 描述的逻辑,如需要鉴权再决定跳转目标 + navigator.registerFallbackDeepLinkHandler(scheme: "myapp") { url in + // 自定义兜底逻辑,返回 nil 则放弃处理 + return nil + } + } - // MARK: UISceneSession Lifecycle + // MARK: - Custom URL Scheme (Deep Link) - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + func application(_ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // 一行处理所有 Deep Link,MRURLRoutable 路由自动匹配 + return MRNavigator.default.handleDeepLink(url) } - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + // MARK: - Universal Link (NSUserActivity) + + func application(_ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // 一行处理所有 Universal Link + return MRNavigator.default.handleUniversalLink(userActivity) } - - -} + // MARK: UISceneSession Lifecycle -class AppModule: ServiceLocatorModule { + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } - override func build() { - single(DetailInterface.self) { - DetailModule() - } -// single { -// ChatModule() -// } - single(ChatInterface.self) { - ChatModule() - } + func application(_ application: UIApplication, + didDiscardSceneSessions sceneSessions: Set) { } } diff --git a/Example/ModuleRouteExample/Chat/ChatModule.swift b/Example/ModuleRouteExample/Chat/ChatModule.swift index 85794e2..44f9aa9 100644 --- a/Example/ModuleRouteExample/Chat/ChatModule.swift +++ b/Example/ModuleRouteExample/Chat/ChatModule.swift @@ -9,23 +9,19 @@ import UIKit import ModuleRoute protocol ChatInterface: MRModule { - } class ChatModule: ChatInterface { - - static var supportedRoutes: [MRRoute.Type] = [ - ChatRoute.self - ] - + + static var supportedRoutes: [MRRoute.Type] = [ChatRoute.self] + public init() {} - + public func handle(route: MRRoute) -> RouteResult { - // 根据具体路由做出响应 switch route { case is ChatRoute: - let detail = ChatViewController() - return .navigator(detail) + let chat = ChatViewController() + return .navigator(chat) default: return .none } diff --git a/Example/ModuleRouteExample/Detail/DetailModule.swift b/Example/ModuleRouteExample/Detail/DetailModule.swift index bf896a5..5f7fb20 100644 --- a/Example/ModuleRouteExample/Detail/DetailModule.swift +++ b/Example/ModuleRouteExample/Detail/DetailModule.swift @@ -10,26 +10,27 @@ import ModuleRoute import UIKit protocol DetailInterface: MRModule { - } class DetailModule: DetailInterface { - - @MRInject var navigator: MRNavigator - + static var supportedRoutes: [MRRoute.Type] = [ - DetailRoute.self + DetailRoute.self, + ArticleRoute.self, // Universal Link 路由也由 DetailModule 处理 ] - public init() { - - } - public func handle(route: MRRoute) -> RouteResult { + public init() {} + public func handle(route: MRRoute) -> RouteResult { switch route { - case is DetailRoute: - let detail = DetailViewController() - return .navigator(detail) + case let r as DetailRoute: + let vc = DetailViewController() + // vc.configure(detailId: r.detailId) + return .navigator(vc) + case let r as ArticleRoute: + let vc = DetailViewController() + // vc.configure(articleId: r.articleId, preview: r.preview) + return .navigator(vc) default: return .none } diff --git a/Example/ModuleRouteExample/DetailRoute.swift b/Example/ModuleRouteExample/DetailRoute.swift index c072bf1..575fa9f 100644 --- a/Example/ModuleRouteExample/DetailRoute.swift +++ b/Example/ModuleRouteExample/DetailRoute.swift @@ -8,19 +8,66 @@ import Foundation import ModuleRoute -struct DetailRoute: MRRoute { - var params: [String : Any] = [:] - - var callback: ((Any?) -> Void)? - - static var name: String = "detail" +/// 详情页路由 +/// - Deep Link: myapp://detail/:detailId +/// - Universal Link: https://example.com/detail/:detailId +struct DetailRoute: MRRoute, MRURLRoutable { + static var name: String { "detail" } + + // 支持两种 pattern,分别对应 Deep Link 和 Universal Link + // 注册时选择其中一个;如需同时支持两种,可拆分为两个 Route + static var urlPattern: String { "myapp://detail/:detailId" } + + let detailId: String + + init(detailId: String = "") { + self.detailId = detailId + } + + init?(urlComponents: MRURLComponents) { + guard let id = urlComponents.string("detailId"), !id.isEmpty else { return nil } + self.detailId = id + } } +/// 聊天页路由 +/// - Deep Link: myapp://chat/:chatId?topic=xxx +struct ChatRoute: MRRoute, MRURLRoutable { + static var name: String { "chat" } + static var urlPattern: String { "myapp://chat/:chatId" } + + let chatId: String + let topic: String? + + init(chatId: String = "", topic: String? = nil) { + self.chatId = chatId + self.topic = topic + } + + init?(urlComponents: MRURLComponents) { + guard let id = urlComponents.string("chatId"), !id.isEmpty else { return nil } + self.chatId = id + self.topic = urlComponents.string("topic") // 来自 query string,可选 + } +} + +/// Universal Link 示例路由(https scheme) +/// - Universal Link: https://example.com/article/:articleId +struct ArticleRoute: MRRoute, MRURLRoutable { + static var name: String { "article" } + static var urlPattern: String { "https://example.com/article/:articleId" } + + let articleId: String + let preview: Bool + + init(articleId: String, preview: Bool = false) { + self.articleId = articleId + self.preview = preview + } -struct ChatRoute: MRRoute { - var params: [String : Any] = [:] - - var callback: ((Any?) -> Void)? - - static var name: String = "chat" + init?(urlComponents: MRURLComponents) { + guard let id = urlComponents.string("articleId"), !id.isEmpty else { return nil } + self.articleId = id + self.preview = urlComponents.bool("preview") ?? false + } } diff --git a/Example/ModuleRouteExample/PluginInject.swift b/Example/ModuleRouteExample/PluginInject.swift deleted file mode 100644 index 554d98a..0000000 --- a/Example/ModuleRouteExample/PluginInject.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// PluginInject.swift -// ModuleRouteExample -// -// Created by GIKI on 2025/2/17. -// - -import Foundation -import ModuleRoute -import UIKit - -@propertyWrapper -internal final class PluginInject: Dependency { - - public var wrappedValue: T { - resolvedWrappedValue() - } - - public init() { - guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { - fatalError("Could not access AppDelegate or cast it to the correct type.") - } - super.init(appDelegate.myServiceLocator) - } -} diff --git a/Example/ModuleRouteExample/ViewController.swift b/Example/ModuleRouteExample/ViewController.swift index 9525e4b..0cfc96b 100644 --- a/Example/ModuleRouteExample/ViewController.swift +++ b/Example/ModuleRouteExample/ViewController.swift @@ -9,52 +9,39 @@ import UIKit import ModuleRoute class ViewController: UIViewController { - - @MRInject var navigator: MRNavigator -// @Inject(DetailInterface) var detail: DetailInterface - - + // MARK: - Properties private var collectionView: UICollectionView! private let cellIdentifier = "Cell" - - // 可配置的数据源 + private var items: [ItemModel] = [ ItemModel(title: "Item 1", color: .blue), ItemModel(title: "Item 2", color: .green), ItemModel(title: "Item 3", color: .orange), ItemModel(title: "Item 4", color: .purple) ] - + // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() setupCollectionView() } - + // MARK: - Setup private func setupCollectionView() { - // 创建布局 let layout = UICollectionViewFlowLayout() layout.itemSize = CGSize(width: (view.frame.width - 40) / 2, height: 100) layout.minimumInteritemSpacing = 10 layout.minimumLineSpacing = 10 layout.sectionInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) - - // 初始化 CollectionView + collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.backgroundColor = .white collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - // 注册 cell collectionView.register(CustomCell.self, forCellWithReuseIdentifier: cellIdentifier) - - // 设置代理 collectionView.delegate = self collectionView.dataSource = self - view.addSubview(collectionView) - } } @@ -63,11 +50,10 @@ extension ViewController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return items.count } - + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) as! CustomCell - let item = items[indexPath.item] - cell.configure(with: item) + cell.configure(with: items[indexPath.item]) return cell } } @@ -75,21 +61,17 @@ extension ViewController: UICollectionViewDataSource { // MARK: - UICollectionViewDelegate extension ViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let item = items[indexPath.item] + let navigator = MRNavigator.default if indexPath.item == 1 { - @MRInject var detail: DetailInterface - let result = detail.handle(route: DetailRoute()) - switch result { - case .navigator(let vc): - self.navigationController?.pushViewController(vc, animated: true) - default: break - + // 服务调用:resolve 路由获取 VC,自行控制展示方式 + let result = navigator.resolve(DetailRoute()) + if case .navigator(let vc) = result { + navigationController?.pushViewController(vc, animated: true) } - print(result) } else { + // 普通导航:navigator 自动找 topViewController navigator.navigate(to: ChatRoute(), from: self) } - } } @@ -101,27 +83,23 @@ class CustomCell: UICollectionViewCell { label.textColor = .white return label }() - + override init(frame: CGRect) { super.init(frame: frame) - setupUI() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupUI() { contentView.addSubview(titleLabel) titleLabel.frame = contentView.bounds titleLabel.autoresizingMask = [.flexibleWidth, .flexibleHeight] + layer.cornerRadius = 8 + clipsToBounds = true } - + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + func configure(with item: ItemModel) { titleLabel.text = item.title contentView.backgroundColor = item.color - layer.cornerRadius = 8 - clipsToBounds = true } } @@ -130,4 +108,3 @@ struct ItemModel { let title: String let color: UIColor } - diff --git a/Sources/ModuleRoute/MRDeepLinkParser.swift b/Sources/ModuleRoute/MRDeepLinkParser.swift index 7259130..37d8238 100644 --- a/Sources/ModuleRoute/MRDeepLinkParser.swift +++ b/Sources/ModuleRoute/MRDeepLinkParser.swift @@ -1,5 +1,5 @@ // -// File.swift +// MRDeepLinkParser.swift // ModuleRoute // // Created by GIKI on 2025/2/15. @@ -7,30 +7,69 @@ import Foundation +// MARK: - DeepLinkParser + +/// URL → MRRoute 解析器 +/// +/// 解析优先级: +/// 1. **自动路由**:遍历所有已注册的 `MRURLRoutable` pattern,使用 `MRURLMatcher` 匹配 +/// 2. **兜底 handler**:按 scheme 注册的自定义解析闭包,用于无法用 pattern 描述的场景 +/// +/// 通常只需使用自动路由,兜底 handler 用于: +/// - 需要异步鉴权的路由 +/// - pattern 无法描述的复杂匹配逻辑 +/// - 第三方 SDK 强制要求特定解析格式 public struct DeepLinkParser { - private var schemeHandlers: [String: (URL) -> MRRoute?] = [:] - - public mutating func register(scheme: String, handler: @escaping (URL) -> MRRoute?) { - schemeHandlers[scheme] = handler + + /// URL pattern 自动匹配引擎(由 MRNavigator 在注册 module 时自动填充) + internal var matcher = MRURLMatcher() + + /// scheme 粒度的兜底解析 handler + private var fallbackHandlers: [String: (URL) -> MRRoute?] = [:] + + public init() {} + + // MARK: - Registration + + /// 注册某个遵循 MRURLRoutable 的 Route 类型到自动匹配引擎 + internal mutating func registerRoutable(_ routeType: T.Type) { + matcher.register(routeType) } - + + /// 注册兜底解析 handler(当自动路由无法匹配时触发) + /// - Parameters: + /// - scheme: URL scheme,如 "myapp"、"https" + /// - handler: 接收 URL,返回对应 Route 或 nil + public mutating func registerFallback(scheme: String, handler: @escaping (URL) -> MRRoute?) { + fallbackHandlers[scheme.lowercased()] = handler + } + + // MARK: - Parsing + + /// 尝试将 URL 解析为 MRRoute + /// 先走自动匹配,失败则尝试 scheme 对应的兜底 handler public func parse(url: URL) -> MRRoute? { - guard let scheme = url.scheme else { return nil } - return schemeHandlers[scheme]?(url) + // 1. 自动路由匹配 + if let route = matcher.match(url: url) { + return route + } + // 2. 兜底 handler + guard let scheme = url.scheme?.lowercased() else { return nil } + return fallbackHandlers[scheme]?(url) } } -extension MRRoute { +// MARK: - MRRoute URL Convenience + +public extension MRRoute { + /// 从 URL 提取 query 参数构造 BasicRoute(向后兼容的便捷方法) static func from(url: URL) -> MRRoute? { - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { - return nil + guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { + return BasicRoute() } - - var params: [String: Any] = [:] - components.queryItems?.forEach { item in - params[item.name] = item.value + let params = items.reduce(into: [String: Any]()) { dict, item in + dict[item.name] = item.value ?? "" } - return BasicRoute(params: params) } } diff --git a/Sources/ModuleRoute/MRNavigator.swift b/Sources/ModuleRoute/MRNavigator.swift index f2c579c..ff9889a 100644 --- a/Sources/ModuleRoute/MRNavigator.swift +++ b/Sources/ModuleRoute/MRNavigator.swift @@ -8,15 +8,36 @@ import UIKit public class MRNavigator { - + + // MARK: - Global Default Instance + + /// 全局共享实例,在 AppDelegate 中通过 MRNavigator.setup() 初始化 + public private(set) static var `default`: MRNavigator = MRNavigator(serviceLocator: ServiceLocator()) + + /// 使用指定的 ServiceLocator 初始化并替换全局默认实例 + /// 应在 AppDelegate.didFinishLaunching 中调用 + @discardableResult + public static func setup(serviceLocator: ServiceLocator) -> MRNavigator { + let navigator = MRNavigator(serviceLocator: serviceLocator) + Self.`default` = navigator + return navigator + } + + // MARK: - Properties + public let serviceLocator: ServiceLocator - + private var middlewares: [MRMiddleware] = [] private var interceptors: [MRInterceptor] = [] - private var deepLinkParser = DeepLinkParser() + internal var deepLinkParser = DeepLinkParser() private var logger: MRLogger = DefaultLogger() private var permissionChecker: MRPermissionChecker = DefaultPermissionChecker() - + + /// 路由类型 → 模块工厂的映射表 + private var routeToModuleTypeMap: [ObjectIdentifier: (ServiceLocator) -> MRModule] = [:] + + // MARK: - Init + public init(serviceLocator: ServiceLocator) { self.serviceLocator = serviceLocator MRNavigatorLocator.shared.serviceLocator = serviceLocator @@ -25,101 +46,180 @@ public class MRNavigator { } buildServiceLocator() } - - private var routeToModuleTypeMap: [ObjectIdentifier: (ServiceLocator) -> MRModule] = [:] - - public func register(_ type: T.Type = T.self, routes: [MRRoute.Type], _ factory: @escaping () -> T) -> Void { + + // MARK: - Registration + + /// 注册模块及其支持的路由 + /// + /// 自动完成两件事: + /// 1. 将 routes 中实现了 `MRURLRoutable` 的类型注册到 URL 匹配引擎(Deep Link / Universal Link) + /// 2. 将所有 routes 绑定到该 module,供 `navigate` / `resolve` 调用 + /// + /// - Parameters: + /// - type: 模块类型(须遵循 MRModule) + /// - routes: 该模块处理的路由类型列表 + /// - factory: 模块工厂闭包 + public func register(_ type: T.Type = T.self, routes: [MRRoute.Type], _ factory: @escaping () -> T) { serviceLocator.single(type, factory) routes.forEach { routeType in + // 绑定路由类型 → 模块工厂 let key = ObjectIdentifier(routeType) routeToModuleTypeMap[key] = { locator in guard let instance = try? locator.resolve() as T else { fatalError("Failed to resolve type \(T.self)") } - return instance as! MRModule + return instance } - } - buildServiceLocator() - } - - private func buildServiceLocator() { - if #available(iOS 13.0, *) { - Task{ - await serviceLocator.build() + // 若 Route 实现了 MRURLRoutable,自动注册到 URL 匹配引擎 + if let routableType = routeType as? any MRURLRoutable.Type { + deepLinkParser.registerRoutable(routableType) } } + buildServiceLocator() } - + + // MARK: - Middleware & Interceptor + public func addMiddleware(_ middleware: MRMiddleware) { middlewares.append(middleware) } - + public func addInterceptor(_ interceptor: MRInterceptor) { interceptors.append(interceptor) } - + + // MARK: - Navigation + + /// 导航到指定路由,自动查找 topViewController 作为来源 public func navigate(to route: MRRoute, - from viewController: UIViewController? = nil, navigationType: NavigationType = .push, animated: Bool = true, completion: (() -> Void)? = nil) { - + navigate(to: route, from: nil, navigationType: navigationType, animated: animated, completion: completion) + } + + /// 导航到指定路由,明确指定来源控制器 + public func navigate(to route: MRRoute, + from viewController: UIViewController?, + navigationType: NavigationType = .push, + animated: Bool = true, + completion: (() -> Void)? = nil) { if let permissionResult = checkPermission(for: route) { handleResult(permissionResult, from: viewController, navigationType: navigationType, animated: animated, completion: completion) return } - + let result = processMiddlewares(route: route) handleResult(result, from: viewController, navigationType: navigationType, animated: animated, completion: completion) } - - public func registerDeepLinkHandler(scheme: String, handler: @escaping (URL) -> MRRoute?) { - deepLinkParser.register(scheme: scheme, handler: handler) + + /// 同步解析路由并返回结果,不执行 UI 跳转 + /// 适用于跨模块服务调用场景(返回 .service / .value / .handler) + public func resolve(_ route: MRRoute) -> RouteResult { + if let permissionResult = checkPermission(for: route) { + return permissionResult + } + return processMiddlewares(route: route) + } + + // MARK: - Deep Link / Universal Link + + /// 注册兜底解析 handler,在自动路由匹配失败时触发 + /// + /// **通常不需要调用此方法**——实现了 `MRURLRoutable` 的 Route 在 `register(module:routes:)` 时自动注册。 + /// 仅在以下场景使用: + /// - 需要异步鉴权才能决定跳转目标 + /// - pattern 无法描述的复杂匹配逻辑 + /// + /// - Parameters: + /// - scheme: URL scheme,如 "myapp"、"https" + /// - handler: 接收 URL,返回对应 Route 或 nil + public func registerFallbackDeepLinkHandler(scheme: String, handler: @escaping (URL) -> MRRoute?) { + deepLinkParser.registerFallback(scheme: scheme, handler: handler) } - + + /// 处理 Deep Link / Universal Link + /// + /// 调用时机: + /// - `application(_:open:options:)` — 处理 Custom URL Scheme + /// - `scene(_:continue:)` — 处理 Universal Link + /// + /// - Returns: 是否成功解析并发起导航 + @discardableResult public func handleDeepLink(_ url: URL) -> Bool { guard let route = deepLinkParser.parse(url: url) else { + logger.log(level: .warning, message: "No route matched for URL: \(url)", metadata: nil) return false } - - if let topvc = topViewController() { - navigate(to: route, from: topvc) - return true + navigate(to: route) + return true + } + + /// 处理 Universal Link(NSUserActivity 场景) + /// + /// 调用时机:`scene(_:continue:userActivity:)` 或 `application(_:continue:restorationHandler:)` + /// + /// - Returns: 是否成功解析并发起导航 + @discardableResult + public func handleUniversalLink(_ userActivity: NSUserActivity) -> Bool { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let url = userActivity.webpageURL else { + return false } - return false + return handleDeepLink(url) + } + + // MARK: - Custom Components + + public func setLogger(_ logger: MRLogger) { + self.logger = logger } - + + public func setPermissionChecker(_ checker: MRPermissionChecker) { + self.permissionChecker = checker + } + + // MARK: - Private + + private func buildServiceLocator() { + if #available(iOS 13.0, *) { + Task { + await serviceLocator.build() + } + } + } + private func processMiddlewares(route: MRRoute, index: Int = 0) -> RouteResult { if index >= middlewares.count { return handleRouteDirectly(route: route) } - + let middleware = middlewares[index] return middleware.process(route: route, navigator: self) { [weak self] route in guard let self = self else { return .none } return self.processMiddlewares(route: route, index: index + 1) } } - + private func handleRouteDirectly(route: MRRoute) -> RouteResult { for interceptor in interceptors { if interceptor.shouldIntercept(route: route) { return interceptor.handleInterception(route: route) } } - + let routeTypeKey = ObjectIdentifier(type(of: route)) guard let moduleFactory = routeToModuleTypeMap[routeTypeKey] else { logger.log(level: .warning, message: "No module found for route: \(type(of: route))", metadata: nil) return .none } - + let module = moduleFactory(serviceLocator) let result = module.handle(route: route) logRoute(route, result: result) return result } - + private func handleResult(_ result: RouteResult, from viewController: UIViewController?, navigationType: NavigationType, @@ -135,20 +235,18 @@ public class MRNavigator { case .handler(let handler): handler() completion?() - case .service(_), - .value(_), - .none: + case .service(_), .value(_), .none: completion?() } } - + private func perform(navigation type: NavigationType, from: UIViewController?, to: UIViewController, animated: Bool, completion: (() -> Void)?) { - var temp = (from != nil) ? from : topViewController() - guard let base = temp else { + let base = (from != nil) ? from : topViewController() + guard let base = base else { completion?() return } @@ -181,14 +279,14 @@ public class MRNavigator { completion?() } } - + private func checkPermission(for route: MRRoute) -> RouteResult? { guard permissionChecker.hasPermission(for: route) else { return permissionChecker.handleUnauthorized(route: route) } return nil } - + private func logRoute(_ route: MRRoute, result: RouteResult) { logger.log(level: .info, message: "Processing route: \(type(of: route))", @@ -196,12 +294,13 @@ public class MRNavigator { } } +// MARK: - UIViewController Helpers public extension MRNavigator { - - /// Get the top most view controller from the base view controller; default param is UIWindow's rootViewController + + /// 获取当前最顶层的 ViewController func topViewController(_ from: UIViewController? = nil) -> UIViewController? { - var base = (from != nil) ? from : compatibleKeyWindow?.rootViewController + let base = (from != nil) ? from : compatibleKeyWindow?.rootViewController if let nav = base as? UINavigationController { return topViewController(nav.visibleViewController) } @@ -226,14 +325,14 @@ public extension MRNavigator { return UIApplication.shared.keyWindow } } + /// 重置应用到根视图控制器 func resetToRootViewController(_ animated: Bool = false) { guard let window = compatibleKeyWindow, let rootViewController = window.rootViewController else { return } - - // 1. 先处理当前显示的模态视图 + if let presentedVC = rootViewController.presentedViewController { presentedVC.dismiss(animated: false) { self.resetToRootHelper(rootViewController, animated: animated) @@ -242,22 +341,18 @@ public extension MRNavigator { resetToRootHelper(rootViewController, animated: animated) } } - + private func resetToRootHelper(_ rootViewController: UIViewController, animated: Bool) { - // 2. 处理 UINavigationController if let navigationController = rootViewController as? UINavigationController { navigationController.popToRootViewController(animated: animated) } - - // 3. 处理 UITabBarController + if let tabBarController = rootViewController as? UITabBarController { - // 重置所有 tab 的导航栈 tabBarController.viewControllers?.forEach { viewController in if let navigationController = viewController as? UINavigationController { navigationController.popToRootViewController(animated: animated) } } - // 切换到第一个 tab tabBarController.selectedIndex = 0 } } diff --git a/Sources/ModuleRoute/MRURLMatcher.swift b/Sources/ModuleRoute/MRURLMatcher.swift new file mode 100644 index 0000000..fca57cb --- /dev/null +++ b/Sources/ModuleRoute/MRURLMatcher.swift @@ -0,0 +1,197 @@ +// +// MRURLMatcher.swift +// ModuleRoute +// +// Created by GIKI on 2025/2/15. +// + +import Foundation + +// MARK: - MRURLPattern + +/// 编译后的 URL pattern,用于高效匹配 +struct MRURLPattern { + let rawPattern: String + let scheme: String? + let host: String? + /// path 被 "/" 分割后的 segment 列表 + let pathSegments: [Segment] + /// 对应的 Route 工厂(接收解析结果,返回 MRRoute 实例) + let factory: (MRURLComponents) -> MRURLRoutable? + + enum Segment { + case literal(String) // 固定字符串,必须完全匹配 + case parameter(String) // :key,匹配任意非空字符串并捕获 + case wildcard // *,匹配任意内容(贪婪,消耗剩余所有 segments) + } +} + +// MARK: - MRURLMatcher + +/// URL pattern 匹配引擎 +/// +/// 负责: +/// 1. 将 pattern 字符串编译为 `MRURLPattern` +/// 2. 对输入 URL 依次尝试所有已注册 pattern,返回第一个匹配成功的 Route +/// +/// 匹配优先级(从高到低): +/// - 完全字面量 pattern 优先于含参数的 pattern +/// - 含参数的 pattern 优先于含通配符的 pattern +/// - 注册顺序作为同优先级的最终决胜 +struct MRURLMatcher { + + // MARK: - Storage + + /// 已注册的 pattern 列表,按优先级排序(字面量 > 参数 > 通配符) + private var patterns: [MRURLPattern] = [] + + // MARK: - Registration + + /// 编译并注册一个 URL pattern + mutating func register(_ routeType: T.Type) { + guard let pattern = compile(routeType.urlPattern, factory: { components in + T(urlComponents: components) + }) else { + return + } + insert(pattern) + } + + // MARK: - Matching + + /// 尝试将 URL 匹配到已注册的某个 Route + func match(url: URL) -> MRURLRoutable? { + let queryParams = extractQueryParams(from: url) + let fragment = url.fragment + let inputSegments = pathSegments(from: url) + + for pattern in patterns { + // scheme 检查(大小写不敏感) + if let patternScheme = pattern.scheme, + patternScheme.lowercased() != url.scheme?.lowercased() { + continue + } + // host 检查(大小写不敏感) + if let patternHost = pattern.host, + patternHost.lowercased() != url.host?.lowercased() { + continue + } + + // path segment 匹配,提取具名参数 + guard let pathParams = matchSegments(pattern.pathSegments, + against: inputSegments) else { + continue + } + + let components = MRURLComponents(url: url, + pathParams: pathParams, + queryParams: queryParams, + fragment: fragment) + + if let route = pattern.factory(components) { + return route + } + } + return nil + } + + // MARK: - Private: Compile + + private func compile(_ rawPattern: String, + factory: @escaping (MRURLComponents) -> MRURLRoutable?) -> MRURLPattern? { + guard let url = URL(string: rawPattern) else { return nil } + + let scheme = url.scheme + // 对于 myapp://path 格式,host 是第一个 path component + // 对于 https://example.com/path 格式,host 是 example.com + let host = url.host + + let segments = pathSegments(from: url).map { raw -> MRURLPattern.Segment in + if raw == "*" { + return .wildcard + } else if raw.hasPrefix(":") { + return .parameter(String(raw.dropFirst())) + } else { + return .literal(raw) + } + } + + return MRURLPattern(rawPattern: rawPattern, + scheme: scheme, + host: host, + pathSegments: segments, + factory: factory) + } + + /// 按优先级插入 pattern(字面量权重最高,通配符最低) + private mutating func insert(_ pattern: MRURLPattern) { + let score = self.priority(of: pattern) + let index = patterns.firstIndex { self.priority(of: $0) < score } ?? patterns.endIndex + patterns.insert(pattern, at: index) + } + + private func priority(of pattern: MRURLPattern) -> Int { + // 分段计分:literal=2, parameter=1, wildcard=0 + return pattern.pathSegments.reduce(0) { score, seg in + switch seg { + case .literal: return score + 2 + case .parameter: return score + 1 + case .wildcard: return score + 0 + } + } + } + + // MARK: - Private: Matching + + /// 将 pattern segments 与 URL segments 逐一比对,成功返回具名参数字典,失败返回 nil + private func matchSegments(_ patternSegs: [MRURLPattern.Segment], + against inputSegs: [String]) -> [String: String]? { + var params: [String: String] = [:] + var pi = 0 // pattern index + var ii = 0 // input index + + while pi < patternSegs.count { + let seg = patternSegs[pi] + switch seg { + case .literal(let expected): + guard ii < inputSegs.count, inputSegs[ii] == expected else { return nil } + pi += 1 + ii += 1 + + case .parameter(let key): + guard ii < inputSegs.count, !inputSegs[ii].isEmpty else { return nil } + params[key] = inputSegs[ii] + pi += 1 + ii += 1 + + case .wildcard: + // 通配符消耗剩余所有输入 segments,直接结束 + pi += 1 + ii = inputSegs.count + } + } + + // pattern 消耗完后,输入也必须消耗完(通配符已提前设 ii = count) + guard ii == inputSegs.count else { return nil } + return params + } + + // MARK: - Private: URL Parsing Helpers + + /// 提取 URL 的 path segments(过滤空字符串) + private func pathSegments(from url: URL) -> [String] { + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + } + + /// 提取 URL query 参数为字典 + private func extractQueryParams(from url: URL) -> [String: String] { + guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { + return [:] + } + return items.reduce(into: [:]) { dict, item in + if let value = item.value { + dict[item.name] = value + } + } + } +} diff --git a/Sources/ModuleRoute/MRURLRoutable.swift b/Sources/ModuleRoute/MRURLRoutable.swift new file mode 100644 index 0000000..796894b --- /dev/null +++ b/Sources/ModuleRoute/MRURLRoutable.swift @@ -0,0 +1,83 @@ +// +// MRURLRoutable.swift +// ModuleRoute +// +// Created by GIKI on 2025/2/15. +// + +import Foundation + +// MARK: - MRURLComponents + +/// URL 解析结果,提供类型安全的参数读取接口 +/// - pathParams:URL path 中的具名参数,如 /user/:userId 匹配后 "userId" → "123" +/// - queryParams:URL query string 中的参数,如 ?keyword=swift → "keyword" → "swift" +public struct MRURLComponents { + public let url: URL + /// Path 具名参数(来自 pattern 中的 :key 占位符) + public let pathParams: [String: String] + /// Query string 参数 + public let queryParams: [String: String] + /// URL fragment(# 之后的部分) + public let fragment: String? + + public init(url: URL, + pathParams: [String: String], + queryParams: [String: String], + fragment: String?) { + self.url = url + self.pathParams = pathParams + self.queryParams = queryParams + self.fragment = fragment + } + + // MARK: - Typed Accessors + + /// 先从 pathParams 取,取不到再从 queryParams 取 + public func string(_ key: String) -> String? { + pathParams[key] ?? queryParams[key] + } + + public func int(_ key: String) -> Int? { + string(key).flatMap { Int($0) } + } + + public func double(_ key: String) -> Double? { + string(key).flatMap { Double($0) } + } + + public func bool(_ key: String) -> Bool? { + guard let value = string(key) else { return nil } + switch value.lowercased() { + case "true", "1", "yes": return true + case "false", "0", "no": return false + default: return nil + } + } +} + +// MARK: - MRURLRoutable + +/// Route 遵循此协议后,可自动参与 Deep Link / Universal Link 的 URL 路由匹配。 +/// +/// 无需在 AppDelegate 手动注册解析 handler,只需: +/// 1. 声明 `urlPattern`(支持具名参数 `:key` 和通配符 `*`) +/// 2. 实现 `init?(urlComponents:)` 从解析结果构造自身 +/// +/// 注册 Module 时,框架自动扫描 supportedRoutes 中实现此协议的 Route,将其加入 URL 路由表。 +/// +/// **Pattern 语法示例:** +/// ``` +/// "myapp://user/:userId" → path 具名参数 +/// "https://example.com/article/:id" → Universal Link +/// "myapp://search" → 纯 query 参数(从 queryParams 取) +/// "myapp://item/:categoryId/:itemId" → 多段具名参数 +/// ``` +public protocol MRURLRoutable: MRRoute { + /// URL 匹配模式。支持 scheme://host/path/:param/* 格式 + static var urlPattern: String { get } + + /// 从 URL 解析结果构造路由实例。 + /// 返回 nil 表示虽然 pattern 匹配但参数不完整,框架将继续尝试下一个匹配项。 + init?(urlComponents: MRURLComponents) +} diff --git a/Sources/ModuleRoute/ModuleRoute.swift b/Sources/ModuleRoute/ModuleRoute.swift index 4f34209..d4f618a 100644 --- a/Sources/ModuleRoute/ModuleRoute.swift +++ b/Sources/ModuleRoute/ModuleRoute.swift @@ -9,12 +9,23 @@ import Foundation import UIKit // MARK: - Core Protocols + +/// 路由协议:只需声明 name,以及路由特有的强类型属性 +/// params 和 callback 有默认实现,无需在每个路由中重复声明 public protocol MRRoute { static var name: String { get } var params: [String: Any] { get } var callback: ((Any?) -> Void)? { get } } +// MARK: - MRRoute Default Implementations +public extension MRRoute { + /// 默认返回空字典,路由无需重复声明此属性 + var params: [String: Any] { [:] } + /// 默认返回 nil,路由无需重复声明此属性 + var callback: ((Any?) -> Void)? { nil } +} + public enum RouteResult { case navigator(UIViewController) case handler(() -> Void) @@ -23,7 +34,6 @@ public enum RouteResult { case none } - public protocol MRModule { static var supportedRoutes: [MRRoute.Type] { get } func handle(route: MRRoute) -> RouteResult @@ -34,7 +44,7 @@ public struct BasicRoute: MRRoute { public static var name: String { "" } public let params: [String: Any] public let callback: ((Any?) -> Void)? - + public init(params: [String: Any] = [:], callback: ((Any?) -> Void)? = nil) { self.params = params self.callback = callback diff --git a/Sources/ModuleRoute/ServiceLocator/ServiceLocator.swift b/Sources/ModuleRoute/ServiceLocator/ServiceLocator.swift index 8ada1e0..caafcb1 100644 --- a/Sources/ModuleRoute/ServiceLocator/ServiceLocator.swift +++ b/Sources/ModuleRoute/ServiceLocator/ServiceLocator.swift @@ -59,14 +59,32 @@ public class ServiceLocator { public func resolve(_ file: String = #file, _ fileId: String = #fileID, _ function: String = #function, _ line: Int = #line) throws -> T { let key = String(describing: T.self) Self.log("[\(file)][resolve] \(key) by \(fileId)#\(function):\(line)") - + + singleLock.lock() + defer { singleLock.unlock() } + + // 已构建的单例 if let singletonInstance = singletons[key] as? T { return singletonInstance - } else if let factoryInstance = factories[key]?() as? T { + } + + // 懒构建:如果在 singletonFactories 中找到但尚未构建,同步构建并缓存 + if let index = singletonFactories.firstIndex(where: { $0.key == key }) { + let instance = singletonFactories[index].factory() + singletons[key] = instance + if let typed = instance as? T { + return typed + } + } + + // 非单例工厂 + factoryLock.lock() + defer { factoryLock.unlock() } + if let factoryInstance = factories[key]?() as? T { return factoryInstance - } else { - throw ResolutionError(message: "No registered factory for \(key)") } + + throw ResolutionError(message: "No registered factory for \(key)") } /// Registers a module with the service locator diff --git a/Tests/ModuleRouteTests/ModuleRouteTests.swift b/Tests/ModuleRouteTests/ModuleRouteTests.swift index 2171246..79ac87e 100644 --- a/Tests/ModuleRouteTests/ModuleRouteTests.swift +++ b/Tests/ModuleRouteTests/ModuleRouteTests.swift @@ -1,6 +1,524 @@ import Testing +import Foundation @testable import ModuleRoute -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. +// MARK: - Test Routes + +struct PushRoute: MRRoute { + static var name: String { "push" } +} + +struct ServiceRoute: MRRoute { + static var name: String { "service" } + let value: String +} + +struct ParamRoute: MRRoute { + static var name: String { "param" } + var params: [String: Any] { ["key": "hello"] } +} + +struct UnregisteredRoute: MRRoute { + static var name: String { "unregistered" } +} + +// MARK: - Test Modules + +class ServiceModule: MRModule { + static var supportedRoutes: [MRRoute.Type] { [ServiceRoute.self] } + + func handle(route: MRRoute) -> RouteResult { + guard let r = route as? ServiceRoute else { return .none } + return .value(r.value) + } +} + +class HandlerModule: MRModule { + static var supportedRoutes: [MRRoute.Type] { [PushRoute.self] } + var handleCalled = false + + func handle(route: MRRoute) -> RouteResult { + handleCalled = true + return .handler {} + } +} + +// MARK: - MRRoute Protocol Tests + +@Test func routeDefaultParams() { + let route = PushRoute() + #expect(route.params.isEmpty) + #expect(route.callback == nil) +} + +@Test func routeCustomParams() { + let route = ParamRoute() + #expect(route.params["key"] as? String == "hello") +} + +@Test func routeStaticName() { + #expect(PushRoute.name == "push") + #expect(ServiceRoute.name == "service") +} + +@Test func routeCallbackDefault() { + let route = PushRoute() + var called = false + if let cb = route.callback { + cb(nil) + called = true + } + #expect(!called) // callback 默认为 nil,不应被调用 +} + +// MARK: - ServiceLocator Tests + +@Test func serviceLocatorSingletonResolution() async throws { + let locator = ServiceLocator() + locator.single(ServiceModule.self) { ServiceModule() } + await locator.build() + let a = try locator.resolve() as ServiceModule + let b = try locator.resolve() as ServiceModule + #expect(a === b) +} + +@Test func serviceLocatorResolutionFailure() { + let locator = ServiceLocator() + #expect(throws: (any Error).self) { + let _: ServiceModule = try locator.resolve() + } +} + +@Test func serviceLocatorReset() async throws { + let locator = ServiceLocator() + locator.single(ServiceModule.self) { ServiceModule() } + await locator.build() + locator.reset() + #expect(throws: (any Error).self) { + let _: ServiceModule = try locator.resolve() + } +} + +// MARK: - MRNavigator Registration & Resolve Tests + +@Test func navigatorResolvesHandlerRoute() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + navigator.register(HandlerModule.self, routes: [PushRoute.self]) { HandlerModule() } + + let result = navigator.resolve(PushRoute()) + if case .handler(_) = result { + // pass + } else { + Issue.record("Expected .handler result, got \(result)") + } +} + +@Test func navigatorReturnsNoneForUnregisteredRoute() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + + let result = navigator.resolve(UnregisteredRoute()) + if case .none = result { } else { + Issue.record("Expected .none for unregistered route, got \(result)") + } +} + +@Test func navigatorResolveReturnsValue() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + navigator.register(ServiceModule.self, routes: [ServiceRoute.self]) { ServiceModule() } + + let result = navigator.resolve(ServiceRoute(value: "hello")) + if case .value(let v as String) = result { + #expect(v == "hello") + } else { + Issue.record("Expected .value(\"hello\"), got \(result)") + } +} + +@Test func navigatorMultipleModulesResolveCorrectly() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + navigator.register(HandlerModule.self, routes: [PushRoute.self]) { HandlerModule() } + navigator.register(ServiceModule.self, routes: [ServiceRoute.self]) { ServiceModule() } + + let pushResult = navigator.resolve(PushRoute()) + if case .handler(_) = pushResult { } else { + Issue.record("PushRoute should resolve to .handler") + } + + let serviceResult = navigator.resolve(ServiceRoute(value: "test")) + if case .value(_) = serviceResult { } else { + Issue.record("ServiceRoute should resolve to .value") + } +} + +// MARK: - Middleware Tests + +@Test func middlewareIsCalledBeforeModule() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + navigator.register(HandlerModule.self, routes: [PushRoute.self]) { HandlerModule() } + + class TestMiddleware: MRMiddleware { + var called = false + func process(route: MRRoute, navigator: MRNavigator, next: @escaping (MRRoute) -> RouteResult) -> RouteResult { + called = true + return next(route) + } + } + let mw = TestMiddleware() + navigator.addMiddleware(mw) + _ = navigator.resolve(PushRoute()) + #expect(mw.called) +} + +@Test func middlewareCanShortCircuit() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + navigator.register(HandlerModule.self, routes: [PushRoute.self]) { HandlerModule() } + + class BlockMiddleware: MRMiddleware { + func process(route: MRRoute, navigator: MRNavigator, next: @escaping (MRRoute) -> RouteResult) -> RouteResult { + return .none + } + } + navigator.addMiddleware(BlockMiddleware()) + let result = navigator.resolve(PushRoute()) + if case .none = result { } else { + Issue.record("Middleware should have short-circuited to .none") + } +} + +@Test func middlewareChainOrder() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + navigator.register(HandlerModule.self, routes: [PushRoute.self]) { HandlerModule() } + + var order: [Int] = [] + class OrderMiddleware: MRMiddleware { + let index: Int + var log: ([Int]) -> Void + init(_ index: Int, log: @escaping ([Int]) -> Void) { + self.index = index + self.log = log + } + func process(route: MRRoute, navigator: MRNavigator, next: @escaping (MRRoute) -> RouteResult) -> RouteResult { + log([index]) + return next(route) + } + } + navigator.addMiddleware(OrderMiddleware(1) { order += $0 }) + navigator.addMiddleware(OrderMiddleware(2) { order += $0 }) + _ = navigator.resolve(PushRoute()) + #expect(order == [1, 2]) +} + +// MARK: - Interceptor Tests + +@Test func interceptorCanHandleRoute() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + navigator.register(HandlerModule.self, routes: [PushRoute.self]) { HandlerModule() } + + class AlwaysInterceptor: MRInterceptor { + func shouldIntercept(route: MRRoute) -> Bool { true } + func handleInterception(route: MRRoute) -> RouteResult { .none } + } + navigator.addInterceptor(AlwaysInterceptor()) + let result = navigator.resolve(PushRoute()) + if case .none = result { } else { + Issue.record("Interceptor should have returned .none") + } +} + +@Test func interceptorSkipsWhenShouldInterceptFalse() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + navigator.register(HandlerModule.self, routes: [PushRoute.self]) { HandlerModule() } + + class NeverInterceptor: MRInterceptor { + func shouldIntercept(route: MRRoute) -> Bool { false } + func handleInterception(route: MRRoute) -> RouteResult { .none } + } + navigator.addInterceptor(NeverInterceptor()) + let result = navigator.resolve(PushRoute()) + if case .handler(_) = result { } else { + Issue.record("Route should pass through interceptor to module") + } +} + +// MARK: - MRNavigator.default Tests + +@Test func navigatorSetupReplacesDefault() { + let locator = ServiceLocator() + let navigator = MRNavigator.setup(serviceLocator: locator) + #expect(MRNavigator.default === navigator) +} + +// MARK: - Deep Link / DeepLinkParser Tests + +@Test func deepLinkParserReturnsNilForUnknownScheme() { + let parser = DeepLinkParser() + let url = URL(string: "unknown://some/path")! + #expect(parser.parse(url: url) == nil) +} + +@Test func deepLinkParserFallbackHandler() { + var parser = DeepLinkParser() + parser.registerFallback(scheme: "myapp") { _ in BasicRoute() } + let url = URL(string: "myapp://some/path")! + #expect(parser.parse(url: url) != nil) +} + +@Test func deepLinkParserMultipleSchemes() { + var parser = DeepLinkParser() + parser.registerFallback(scheme: "scheme1") { _ in BasicRoute(params: ["scheme": "1"]) } + parser.registerFallback(scheme: "scheme2") { _ in BasicRoute(params: ["scheme": "2"]) } + + #expect(parser.parse(url: URL(string: "scheme1://path")!) != nil) + #expect(parser.parse(url: URL(string: "scheme2://path")!) != nil) + #expect(parser.parse(url: URL(string: "other://path")!) == nil) +} + +// MARK: - MRURLComponents Tests + +@Test func urlComponentsTypedAccessors() { + let components = MRURLComponents( + url: URL(string: "myapp://test")!, + pathParams: ["id": "42", "flag": "true"], + queryParams: ["name": "hello", "count": "7"], + fragment: "section1" + ) + + // string — path 优先于 query + #expect(components.string("id") == "42") + #expect(components.string("name") == "hello") + #expect(components.string("missing") == nil) + + // int + #expect(components.int("id") == 42) + #expect(components.int("count") == 7) + #expect(components.int("name") == nil) + + // bool + #expect(components.bool("flag") == true) + + // fragment + #expect(components.fragment == "section1") +} + +@Test func urlComponentsPathParamTakesPrecedenceOverQuery() { + let components = MRURLComponents( + url: URL(string: "myapp://test")!, + pathParams: ["id": "path-value"], + queryParams: ["id": "query-value"], + fragment: nil + ) + // path 优先 + #expect(components.string("id") == "path-value") +} + +// MARK: - MRURLMatcher Tests + +// Test routes for URL matching +struct UserRoute: MRRoute, MRURLRoutable { + static var name: String { "user" } + static var urlPattern: String { "myapp://user/:userId" } + let userId: String + init(userId: String) { self.userId = userId } + init?(urlComponents: MRURLComponents) { + guard let id = urlComponents.string("userId"), !id.isEmpty else { return nil } + self.userId = id + } +} + +struct SearchRoute: MRRoute, MRURLRoutable { + static var name: String { "search" } + static var urlPattern: String { "myapp://search" } + let keyword: String + init(keyword: String) { self.keyword = keyword } + init?(urlComponents: MRURLComponents) { + guard let kw = urlComponents.string("keyword"), !kw.isEmpty else { return nil } + self.keyword = kw + } +} + +struct ArticleRoute: MRRoute, MRURLRoutable { + static var name: String { "article" } + static var urlPattern: String { "https://example.com/article/:articleId" } + let articleId: String + let preview: Bool + init(articleId: String, preview: Bool = false) { + self.articleId = articleId + self.preview = preview + } + init?(urlComponents: MRURLComponents) { + guard let id = urlComponents.string("articleId"), !id.isEmpty else { return nil } + self.articleId = id + self.preview = urlComponents.bool("preview") ?? false + } +} + +struct MultiSegmentRoute: MRRoute, MRURLRoutable { + static var name: String { "item" } + static var urlPattern: String { "myapp://category/:categoryId/item/:itemId" } + let categoryId: String + let itemId: String + init(categoryId: String, itemId: String) { + self.categoryId = categoryId + self.itemId = itemId + } + init?(urlComponents: MRURLComponents) { + guard let cat = urlComponents.string("categoryId"), + let item = urlComponents.string("itemId") else { return nil } + self.categoryId = cat + self.itemId = item + } +} + +struct WildcardRoute: MRRoute, MRURLRoutable { + static var name: String { "wildcard" } + static var urlPattern: String { "myapp://files/*" } + let url: URL + init(url: URL) { self.url = url } + init?(urlComponents: MRURLComponents) { + self.url = urlComponents.url + } +} + +@Test func urlMatcherPathParameter() { + var matcher = MRURLMatcher() + matcher.register(UserRoute.self) + + let url = URL(string: "myapp://user/123")! + let route = matcher.match(url: url) as? UserRoute + #expect(route?.userId == "123") +} + +@Test func urlMatcherNoMatchForDifferentPath() { + var matcher = MRURLMatcher() + matcher.register(UserRoute.self) + + let url = URL(string: "myapp://profile/123")! + #expect(matcher.match(url: url) == nil) +} + +@Test func urlMatcherQueryParameters() { + var matcher = MRURLMatcher() + matcher.register(SearchRoute.self) + + let url = URL(string: "myapp://search?keyword=swift")! + let route = matcher.match(url: url) as? SearchRoute + #expect(route?.keyword == "swift") +} + +@Test func urlMatcherUniversalLink() { + var matcher = MRURLMatcher() + matcher.register(ArticleRoute.self) + + let url = URL(string: "https://example.com/article/456?preview=true")! + let route = matcher.match(url: url) as? ArticleRoute + #expect(route?.articleId == "456") + #expect(route?.preview == true) +} + +@Test func urlMatcherMultiSegmentPath() { + var matcher = MRURLMatcher() + matcher.register(MultiSegmentRoute.self) + + let url = URL(string: "myapp://category/electronics/item/iphone15")! + let route = matcher.match(url: url) as? MultiSegmentRoute + #expect(route?.categoryId == "electronics") + #expect(route?.itemId == "iphone15") +} + +@Test func urlMatcherWildcard() { + var matcher = MRURLMatcher() + matcher.register(WildcardRoute.self) + + let url = URL(string: "myapp://files/documents/report.pdf")! + #expect(matcher.match(url: url) is WildcardRoute) +} + +@Test func urlMatcherSchemeMismatch() { + var matcher = MRURLMatcher() + matcher.register(UserRoute.self) // myapp scheme + + // https scheme 不匹配 myapp pattern + let url = URL(string: "https://user/123")! + #expect(matcher.match(url: url) == nil) +} + +@Test func urlMatcherPriorityLiteralOverParameter() { + struct ExactRoute: MRRoute, MRURLRoutable { + static var name: String { "exact" } + static var urlPattern: String { "myapp://user/profile" } + init?(urlComponents: MRURLComponents) { } + } + + var matcher = MRURLMatcher() + matcher.register(UserRoute.self) // myapp://user/:userId (含参数) + matcher.register(ExactRoute.self) // myapp://user/profile (全字面量) + + // 全字面量 pattern 优先级更高 + let url = URL(string: "myapp://user/profile")! + #expect(matcher.match(url: url) is ExactRoute) +} + +@Test func urlMatcherMultipleRoutesRegistered() { + var matcher = MRURLMatcher() + matcher.register(UserRoute.self) + matcher.register(ArticleRoute.self) + + let userURL = URL(string: "myapp://user/42")! + let articleURL = URL(string: "https://example.com/article/99")! + + #expect(matcher.match(url: userURL) is UserRoute) + #expect(matcher.match(url: articleURL) is ArticleRoute) +} + +// MARK: - MRNavigator Auto URL Registration Tests + +class URLHandlerModule: MRModule { + static var supportedRoutes: [MRRoute.Type] { [UserRoute.self, ArticleRoute.self] } + func handle(route: MRRoute) -> RouteResult { .handler {} } +} + +@Test func navigatorAutoRegistersURLRoutable() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + navigator.register(URLHandlerModule.self, routes: URLHandlerModule.supportedRoutes) { + URLHandlerModule() + } + + // Deep link 自动匹配,无需手动注册 handler + let userURL = URL(string: "myapp://user/789")! + #expect(navigator.handleDeepLink(userURL)) +} + +@Test func navigatorDeepLinkFallsBackToHandler() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + + var fallbackCalled = false + navigator.registerFallbackDeepLinkHandler(scheme: "myapp") { _ in + fallbackCalled = true + return BasicRoute() + } + + // 没有注册任何 MRURLRoutable,走兜底 handler + let url = URL(string: "myapp://unknown/path")! + // 注意:BasicRoute 没有对应 module,navigate 会走 .none,但 parse 成功 + _ = navigator.deepLinkParser.parse(url: url) + #expect(fallbackCalled) +} + +@Test func navigatorDeepLinkReturnsFalseForUnmatchedURL() { + let locator = ServiceLocator() + let navigator = MRNavigator(serviceLocator: locator) + + let url = URL(string: "unknown://some/path")! + #expect(!navigator.handleDeepLink(url)) }