返回

iOS Deep Link 参数丢失?排查 SceneDelegate/onOpenURL 问题

IOS

iOS Deep Link疑难杂症:为何我的 App 打得开,参数却不见了?

搞 iOS 开发的你,可能遇到过这么个情况:你给 App 配置了自定义 URL Scheme,比如 pl.myapp.test://,然后满心欢喜地在 Safari 里敲入 pl.myapp.test://test?param1=1。嘿,App 确实启动了!但紧接着你就发现,说好的参数 param1=1 呢?在 AppDelegate 里死活捞不着,就像石沉大海。

代码明明是这样写的:

Info.plist 配置没问题:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLName</key>
        <string>pl.myapp.test</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>pl.myapp.test</string>
        </array>
    </dict>
</array>

AppDelegate.swift 里也放了日志:

func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
  // App 运行时,通过 URL Scheme 打开会尝试调用这里
  NSLog("[AppDelegate] open url: %@", url.absoluteString)
  print("[AppDelegate] open url: \(url)")
  // logToFile("[AppDelegate] open url: \(url.absoluteString)") // 假设有日志记录
  return true // 记得返回 true 表示你处理了这个 URL
}

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // logToFile("App did finish launching") // 假设有日志记录

    // 检查 App 是否因为 Deep Link 而启动
    if let url = launchOptions?[.url] as? URL {
      // 只有 App 没在运行,通过 URL Scheme 首次启动时才会在这里拿到 URL
      NSLog("[AppDelegate] Launched with URL: %@", url.absoluteString)
      print("[AppDelegate] Launched with URL: \(url)")
      // logToFile("[AppDelegate] Launched with URL: \(url.absoluteString)") // 假设有日志记录
      // 在这里处理 URL 逻辑...
    }
    return true
}

怪事来了,Safari 里点链接,App 启动了,或者从后台切换到前台了,但上面这两个 AppDelegate 方法里的日志就是没动静(或者只有 didFinishLaunchingWithOptions 在冷启动时被调用了一次,后续再点链接就没反应了)。

但你又发现,如果用的是 SwiftUI 的 App Life Cycle,下面这段代码却能稳稳地捕捉到 URL:

@main
struct iOSApp: App {
   @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate // 可能仍然需要 AppDelegate 处理其他事务
    var body: some Scene {
        WindowGroup {
            MyView()
                .onOpenURL { url in // 啊哈!这里能抓到!
                NSLog("[SwiftUI App] App opened deeplink via onOpenURL: \(url.absoluteString)")
                print("[SwiftUI App] App opened deeplink via onOpenURL: \(url)")
                // logToFile("[SwiftUI App] App opened deeplink via onOpenURL: \(url.absoluteString)") // 假设有日志记录
                // 在这里处理 URL 和参数
            }
        }
    }
}

这就让人纳闷了:凭什么 AppDelegate 的方法失灵了?

问题出在哪?AppDelegate 并非唯一入口

问题的根源在于 iOS 应用生命周期管理方式的演变,特别是 iOS 13 引入 SceneDelegate 以及 SwiftUI App Life Cycle 的出现。

  1. application(_:didFinishLaunchingWithOptions:) 的局限: 这个方法如其名,只在应用“完成启动时”调用。如果你的 App 是因为用户点击了一个 Deep Link 而从“未运行”状态启动的,launchOptions 字典里才会包含 .url 键,你才能在这里拿到 URL。一旦 App 启动起来了(无论是在前台还是后台挂起),后续再通过 Deep Link 打开,这个方法是不会再次被调用的,自然也拿不到新的 URL。

  2. application(_:open:options:) 的变迁: 在 iOS 13 之前,或者对于没有启用 Scene (场景) 的老项目,这个方法是处理应用运行时(已启动,包括后台挂起)接收到的 Deep Link 的标准途径。但是 ,如果你采用了:

    • UIKit App with SceneDelegate: 当你的应用使用了 Scene (通常是新建项目默认配置),处理运行时 Deep Link 的职责就转移到了 SceneDelegatescene(_:openURLContexts:) 方法。AppDelegateapplication(_:open:options:) 可能不再被稳定调用,或者根本不被调用来处理 URL。
    • SwiftUI App Life Cycle (@main App): 对于纯 SwiftUI 应用生命周期(像上面例子那样使用 @mainApp 协议),系统会将 URL 事件直接路由到相应的 Scene。这时,处理 Deep Link 的首选方式是在你的 Scene (通常是 WindowGroup) 上使用 .onOpenURL 修饰符。AppDelegateapplication(_:open:options:) 同样可能被绕过。

所以,你遇到的情况很可能是:你的项目要么是配置了 SceneDelegate 的 UIKit 应用,要么是使用了 SwiftUI App Life Cycle。当 App 已经在运行时,你从 Safari 点击 Deep Link,系统把这个“打开 URL”的事件交给了活跃的 Scene 或 SwiftUI 的 Scene,而不是像以前那样总是交给 AppDelegate

解决方案:抓住 Deep Link 参数的三种姿势

既然知道了原因,解决起来就思路清晰了。根据你的项目架构,选择合适的姿势来接收和处理 Deep Link URL。

姿势一:拥抱 SwiftUI 的 onOpenURL (现代推荐)

如果你的 App 主要使用 SwiftUI 构建,并且采用了 @main App 结构,那 .onOpenURL 就是最直接、最符合 SwiftUI 风格的方式。

  • 原理: .onOpenURL 是一个视图修饰符 (View Modifier),你可以把它附加到你的根视图或者任何合适的 Scene 上。当这个 Scene 接收到通过 URL Scheme 打开的事件时,它会执行你提供的闭包,并将 URL 对象作为参数传入。
  • 代码示例:
import SwiftUI

@main
struct iOSApp: App {
   @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate // 如果还需要 AppDelegate 处理其他事务

    var body: some Scene {
        WindowGroup {
            ContentView() // 你的根视图
                .onOpenURL { url in
                    print("[SwiftUI App] Received URL via onOpenURL: \(url.absoluteString)")
                    // 在这里解析 URL,提取参数,然后导航或更新状态
                    handleIncomingURL(url)
                }
        }
    }

    func handleIncomingURL(_ url: URL) {
        // 解析 URL 参数的逻辑,见后文“解析 URL 参数”部分
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            print("Invalid URL components")
            return
        }

        print("Scheme: \(components.scheme ?? "nil")") // 应该是 "pl.myapp.test"
        print("Host/Path: \(components.host ?? components.path)") // 应该是 "test"

        if let queryItems = components.queryItems {
            let params = queryItems.reduce(into: [String: String]()) { (result, item) in
                result[item.name] = item.value
            }
            print("Parameters: \(params)") // 应该打印 ["param1": "1"]

            // 拿到参数后,可以更新 App状态,或者进行页面跳转等
            // 比如:你可以用 NotificationCenter 发通知,或者更新一个共享的 StateObject/EnvironmentObject
            if let param1Value = params["param1"] {
                print("Got param1: \(param1Value)")
                // 更新 ViewModel 或触发导航...
            }
        }
    }
}
  • 进阶使用技巧:
    • 路由管理: 可以在 handleIncomingURL 函数里实现一个简单的路由逻辑,根据 URL 的 path 和参数决定跳转到哪个页面,或者执行什么操作。
    • 状态传递: 将解析出的参数或需要执行的操作,通过 @StateObject@EnvironmentObject 或者 Combine/NotificationCenter 传递给相关的视图或 ViewModel。
    • 多个 Scene: 如果你的应用支持多个窗口 (Scene),确保 .onOpenURL 附加在正确的 Scene 定义上,或者在 App 层面统一处理再分发。
  • 安全建议:
    • 校验参数: 永远不要假设外部传入的 URL 参数是有效或安全的。检查参数是否存在、类型是否正确(需要时进行转换)、数值是否在预期范围内。
    • 避免敏感信息: 尽量不要在 URL 参数中传递高度敏感的信息(如密码、未加密的 token 等),因为 URL 可能会被记录在浏览器历史、系统日志或其他地方。

姿势二:SceneDelegate (适用于 UIKit + Scene)

如果你的应用是基于 UIKit 构建,并且项目配置中包含了 SceneDelegate.swift 文件(通常 iOS 13 之后新建的 UIKit 项目默认就有),那么你应该在这里处理运行时传入的 URL。

  • 原理: SceneDelegate 负责管理一个 UI 场景(通常是一个窗口实例)的生命周期。scene(_:openURLContexts:) 方法是专门用来处理当场景接收到需要打开的 URL 时系统调用的。系统会传入一个包含 UIOpenURLContext 对象的 Set,每个 context 里都有一个 URL。
  • 代码示例:
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    // ... 其他 SceneDelegate 方法,如 scene(_:willConnectTo:options:) ...

    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        print("[SceneDelegate] Received URL contexts")

        // 通常只有一个 URL context,但最好还是遍历一下
        guard let urlContext = URLContexts.first else {
            print("No URL context found")
            return
        }

        let url = urlContext.url
        print("[SceneDelegate] Handling URL: \(url.absoluteString)")

        // 解析和处理 URL 的逻辑
        handleIncomingURL(url)

        // (可选) 还可以检查 sourceApplication 和 annotation
        let sourceApp = urlContext.options.sourceApplication
        print("Source Application: \(sourceApp ?? "Unknown")")
    }

    func handleIncomingURL(_ url: URL) {
        // 解析 URL 参数的逻辑,与 SwiftUI 示例类似
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
              let host = components.host else { // 或者用 path,取决于你的 Scheme 结构
            print("Invalid URL or missing host/path")
            return
        }

        print("Host/Path: \(host)") // "test"

        if let queryItems = components.queryItems {
            let params = queryItems.reduce(into: [String: String?]()) { (result, item) in
                result[item.name] = item.value
            }
            print("Parameters: \(params)") // ["param1": Optional("1")]

            // 根据参数决定如何响应
            // 比如,获取根视图控制器,然后进行导航
            if let rootVC = window?.rootViewController {
                 // 执行导航操作,或者通知相关 ViewController 更新
                 if let param1Value = params["param1"] as? String {
                     print("Got param1: \(param1Value)")
                     // 让 rootVC 或其子 VC 处理这个值...
                 }
            }
        }
    }

    // ... 其他 SceneDelegate 方法 ...
}
  • 操作步骤:
    1. 确保你的项目里有 SceneDelegate.swift 文件。
    2. 确保 Info.plist 文件里有 UIApplicationSceneManifest 键,并且配置了 Scene Configuration。
    3. 实现 scene(_:openURLContexts:) 方法。
    4. (如果之前依赖 AppDelegate 的 window 属性) 确保 AppDelegate.swift 文件里没有 var window: UIWindow? 这行,主窗口的管理现在由 SceneDelegate 负责。
  • 安全建议: 同样,严格校验 URL 和参数的合法性,避免在参数中传输敏感信息。

姿势三:坚守 AppDelegate (传统或特定场景)

虽然现代 iOS 开发更倾向于使用 SceneDelegate 或 SwiftUI 的 .onOpenURL,但在某些情况下,AppDelegate 的方法仍然是你需要关注的地方:

  • 仅在冷启动时获取 URL: application(_:didFinishLaunchingWithOptions:) 始终是处理应用因 Deep Link 首次启动 时获取 URL 的地方,无论你用不用 Scene 或 SwiftUI。

  • 未使用 Scene 的老项目: 如果你的项目非常老,没有迁移到 Scene-based life cycle(Info.plist 中没有 UIApplicationSceneManifest),那么 application(_:open:options:) 依旧是处理应用运行时 Deep Link 的主要阵地。

  • 作为后备或处理特定逻辑: 有些复杂的场景或者第三方 SDK 可能仍旧依赖 AppDelegate 的这些方法。不过,依赖它来处理运行时的常规 Deep Link 并非最佳实践(如果你的应用支持 Scene)。

  • 原理回顾:

    • didFinishLaunchingWithOptions: 只有 App 首次 因 URL 而启动时触发。
    • open:options:: 在 无 Scene 的应用中,App 运行时(前后台)接收 URL 时触发;在有 Scene 的应用中,其行为可能不被保证或被 SceneDelegate 覆盖。
  • 代码示例(再次强调适用条件):

// AppDelegate.swift

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // ... 其他启动代码 ...
    if let url = launchOptions?[.url] as? URL {
      print("[AppDelegate] Launched with URL (Cold Start): \(url.absoluteString)")
      handleIncomingURL(url) // 统一处理逻辑
    }
    return true
}

func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
  print("[AppDelegate] open url called (Runtime - Check if really works for your setup!): \(url.absoluteString)")
  // 这个方法在 Scene-based 或 SwiftUI App Life Cycle 中可能不被调用来处理 URL
  let handled = handleIncomingURL(url) // 统一处理逻辑
  return handled // 返回 true 表明你处理了 URL
}

// 建议把处理逻辑提取出来
func handleIncomingURL(_ url: URL) -> Bool {
    print("[AppDelegate] Handling URL: \(url.absoluteString)")
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
        print("Invalid URL components")
        return false
    }

    print("Scheme: \(components.scheme ?? "nil")")
    print("Host/Path: \(components.host ?? components.path)")

    if let queryItems = components.queryItems {
        let params = queryItems.reduce(into: [String: String?]()) { (result, item) in
            result[item.name] = item.value
        }
        print("Parameters: \(params)")
        // 进行参数处理和App内响应...
        if let param1 = params["param1"] as? String {
            print("Got param1 value: \(param1)")
            // 做点什么...
        }
    }
    return true // 假设总是能处理
}
  • 安全建议: 同样需要校验、清理输入,避免敏感数据。

解析 URL 参数:拿到链接只是第一步

无论你在哪个方法里捕获到了 URL 对象,下一步都是解析它,特别是提取查询参数(Query Parameters)。手动分割字符串当然可以,但容易出错,特别是处理 URL 编码和特殊字符时。推荐使用 URLComponents

  • URLComponents 的好处: 它可以帮你轻松地把 URL 分解成各个部分:scheme (协议), host (主机), path (路径), query (查询字符串), fragment (片段) 等,并且能自动处理 URL 编码解码。

  • 提取参数的通用函数示例:

import Foundation

// 一个辅助函数,用于从 URL 中提取查询参数字典
func getQueryParameters(from url: URL) -> [String: String]? {
    // 确保 URL 有效,并能创建 URLComponents
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
        print("Error: Could not create URLComponents from \(url)")
        return nil
    }

    // 检查是否有查询项 (queryItems)
    guard let queryItems = components.queryItems else {
        // URL 中可能没有 '?' 后面的部分
        print("No query items found in URL.")
        return [:] // 返回空字典表示没有参数,而不是 nil
    }

    // 将 queryItems 数组转换成 [String: String] 字典
    // 注意:item.value 可能为 nil,如果参数只有键没有值(如 ...?flag 而不是 ...?flag=true)
    // 这里简化处理,只取有值的参数。如果需要处理无值参数,可以调整为 [String: String?]
    var parameters = [String: String]()
    for item in queryItems {
        parameters[item.name] = item.value
    }

    return parameters
}

// 如何在你的处理函数中使用:
func handleIncomingURL(_ url: URL) { // 这个函数可以是 AppDelegate, SceneDelegate 或 SwiftUI App 里的
    print("Handling URL: \(url.absoluteString)")

    if let params = getQueryParameters(from: url) {
        print("Parsed Parameters: \(params)") // 输出: ["param1": "1"]

        if let param1Value = params["param1"] {
            print("Successfully retrieved param1: \(param1Value)")
            // 在这里使用参数值...
        } else {
            print("Parameter 'param1' not found or has no value.")
        }
    } else {
        print("Could not parse query parameters.")
    }
}

把这个 getQueryParameters 函数放到你的项目里,然后在 .onOpenURL, scene(_:openURLContexts:), 或者 AppDelegate 的相应方法里调用它,就能方便地拿到参数字典了。

别忘了测试!

搞定了代码,最后一步也是很重要的一步:测试!确保你的 Deep Link 在各种情况下都能按预期工作。

  • 测试方法:

    • Safari: 最直接的方式,在地址栏输入你的 URL Scheme 链接,如 pl.myapp.test://test?param1=1&param2=hello%20world (注意 URL编码)。
    • 命令行 (模拟器): 使用 xcrun simctl openurl < booted | device_udid > <your_url_scheme_link>。例如 xcrun simctl openurl booted pl.myapp.test://test?param1=1 可以直接在当前启动的模拟器上触发 Deep Link。
    • Notes 应用 / 邮件 / 消息: 在这些应用里输入链接并点击,也能触发。
    • 其他 App: 如果你的 App 需要被其他 App 通过 Deep Link 调起,确保进行相关集成测试。
  • 测试场景:

    • App 未运行 (Cold Start): 确保 didFinishLaunchingWithOptions (如果适用) 或启动流程能正确处理链接。
    • App 在后台: 按 Home 键或切换到其他 App,然后通过链接调起,看运行时处理逻辑 (.onOpenURLscene(_:openURLContexts:)) 是否生效。
    • App 在前台: 保持 App 打开状态,切换到 Safari 等触发链接,看运行时处理逻辑是否正常工作。
    • 不同参数: 测试带参数、不带参数、带多个参数、带特殊字符(需要 URL 编码)的链接。
    • 无效链接: 测试非预期的 path 或格式错误的 URL,看 App 是否能优雅处理,不崩溃。

通过这番排查和调整,你应该就能解决 iOS Deep Link 参数丢失的问题,让你的 App 丝滑地响应来自外部的召唤了!