ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity手游接入iOS Deep Link:从URL Scheme到Universal Links的完整指南

2026/10/2 4:46:06 拓冰建站 浏览量
Unity手游接入iOS Deep Link:从URL Scheme到Universal Links的完整指南 Unity 手游接 iOS Deep Link看起来是个不大不小的活儿配置不难网上教程一堆但真正做起来从 Xcode 工程配置到 C# 层拿到参数中间每一步都藏着几个经典的坑。尤其是现在 iOS 上 Universal Links 成了主流而很多老项目还留着 URL Scheme 的历史包袱两种方式怎么共存、冷启动和热启动时序怎么处理、参数带中文要不要解码、iOS 13 的 Scene 生命周期会不会把回调吃掉了——这些才是实际开发里真正让人头疼的地方。这篇文章我就完整走一遍 Unity 手游接入 iOS Deep Link 的流程从原理到配置、从原生代码到 C# 层投递最后把线上的坑和调试方法一起整理了。适合正在接深度链接、或者在维护老项目想补全唤醒能力的同学参考。1. 先厘清一个核心问题为什么手游要比工具类 App 更重视 Deep Link很多人对 Deep Link 的认知停留在用户点了链接能打开 App这个层面但手游对这个需求的敏感度跟普通工具类 App 完全不在一个量级。手游的生命周期高度依赖买量投放、活动运营和社交裂变一个完整的 Deep Link 链路直接影响获客成本和活动转化率。1.1 手游场景下 Deep Link 的典型业务用途以一个常见的运营活动为例用户在浏览器里看到一条广告点击之后期望的行为是——如果没装游戏跳到 App Store 下载页如果装了游戏直接唤起游戏并且自动进入指定活动页面同时带上渠道标识、邀请人 ID、活动参数。这一整套动作在买量归因里叫投放落地在社交裂变里叫邀请追踪。具体到 Unity 手游项目Deep Link 通常承担三件事渠道归因参数传递比如?channeladwordscampaignsummer2025uid10234游戏启动后要能拿到这些参数上报给数据分析平台活动页直达不仅是打开游戏还要自动跳转到某个 UI 面板比如签到页、限时活动页冷启动首启补齐用户下载安装后第一次打开 App 时系统会把点击链接时的参数通过 Deep Link 再投递一次这个时机处理不好归因数据就丢了。1.2 URL Scheme 与 Universal Links 的实际差异我把两套机制摊开对比一下这决定了你后面怎么选方案。URL Scheme 是 iOS 最早支持的唤起方式靠自定义协议名比如mygame://play?room_id123。系统收到这个 scheme 后会去找注册了这个协议名的 App 并唤起。它的优点是接入简单缺点是未安装时无法降级跳转 App Store在微信等封闭生态里很容易被拦截而且 iOS 系统对未知 scheme 会弹无法打开的警告体验不太自然。Universal Links 是 Apple 大力推的标准方式它的核心逻辑是用普通的 HTTPS 链接唤起 App。用户在浏览器里点https://www.mygame.com/play?room_id123如果设备上装了 App系统直接拉起如果没装就正常加载这个网页网页里可以做前往 App Store提示。它天然解决了两大问题点击体验是正常网页而不是弹窗警告以及未安装时的降级兜底。对 Unity 手游项目我建议是 Universal Links 为主、URL Scheme 为辅。主推 Universal Links 保证分享链接的转化体验保留 URL Scheme 是为了兼容老版本客户端和部分不支持 Universal Links 的场景。1.3 Unity 引擎在链路中的位置这里需要澄清一个容易误解的点Unity 引擎本身并不知道 iOS 原生层发生了什么事Deep Link 的系统回调是发给原生层AppDelegate / SceneDelegate的Unity 的 C# 层完全感知不到。所以整个链路的本质是iOS 系统收到 Deep Link - 调用原生回调方法 - 原生层把参数通过 UnitySendMessage 或延迟读取的方式交给 C# - C# 解析并分发到游戏逻辑这个原生 - 托管的桥接是整个工程的真正核心。开发者在 C# 侧写的Application.deepLinkReceived其实是对这个桥接的一层封装知道这点对后面理解时序问题特别重要。2. URL Scheme 路线从 Info.plist 到 UnitySendMessage 的完整过程URL Scheme 虽然技术上比较老但代码量少、链路直观很适合先把桥接逻辑跑通。我就按从配置到代码的顺序走一遍。2.1 Info.plist 里注册 Scheme打开 Xcode 工程或者 Unity 导出的 Xcode 工程找到 Info.plist添加 CFBundleURLTypes。关键字段如下keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourgame.deepLink/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array这里有个小细节CFBundleURLName 不参与匹配只是名字标识但 Apple 审核时建议用反域名格式CFBundleURLSchemes 才是真正的协议名。不要在 schemes 里填大写字母iOS 虽然不强制区分大小写但实际匹配时容易出问题统一小写是安全做法。如果项目里有多个玩法需要用不同 scheme 区分可以在数组里加多个 string但我不建议一个 App 注册太多 scheme一个主 scheme 加一个备用就够用了。2.2 原生回调ObjC 的 AppDelegate 处理逻辑在 Unity 导出的 Xcode 工程里AppDelegate 已经存在直接在 application:openURL:options: 里加处理代码即可。- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id *)options { if (url nil) { return NO; } NSString *urlStr url.absoluteString; NSLog([DeepLink] URL Scheme received: %, urlStr); const char *urlCString [urlStr UTF8String]; UnitySendMessage(DeepLinkListener, OnURLSchemeReceived, urlCString); return YES; }这个方法在当前 Unity 生成的项目里会被自动调用吗不一定。如果项目之前自定义过 openURL 方法可能会走你自己的分支如果没动过Unity 内部有自己的处理但不会转发给我们。所以要明确添加这个方法并确保它返回 YES 表示 App 能处理该 URL。Swift 版本的写法逻辑相同只是语法差异。如果你的主工程用的是 Swift记得在objc方法里同样调用UnitySendMessage。2.3 C# 侧的接收与参数解析原生层通过UnitySendMessage(DeepLinkListener, OnURLSchemeReceived, urlCString)把链接字符串传给 UnityEngine。这里的DeepLinkListener是 C# 侧一个 GameObject 的名字第二个参数是 GameObject 上挂载的脚本组件里的方法名第三个参数是字符串参数。C# 侧脚本如下public class DeepLinkListener : MonoBehaviour { private static DeepLinkListener _instance; public static DeepLinkListener Instance _instance; private void Awake() { if (_instance ! null _instance ! this) { Destroy(gameObject); return; } _instance this; DontDestroyOnLoad(gameObject); } public void OnURLSchemeReceived(string url) { Debug.Log($[DeepLink] C# received URL: {url}); DeepLinkData data ParseURL(url); if (data ! null) { GameEntry.HandleDeepLink(data); } } private DeepLinkData ParseURL(string url) { // 简单示例解析逻辑生产环境请用 Uri 类 int questionIndex url.IndexOf(?); if (questionIndex 0) return null; string query url.Substring(questionIndex 1); var parameters new Dictionarystring, string(); foreach (string pair in query.Split()) { string[] kv pair.Split(); if (kv.Length 2) { parameters[kv[0]] Uri.UnescapeDataString(kv[1]); } } return new DeepLinkData(parameters); } }这段代码里最重要的一个点是Uri.UnescapeDataString——URL 里的参数经过多层编码中文、空格、特殊符号都被 percent-encoding 了不反解直接存后面组包上报时会一坨乱码。2.4 这个方案的边界与补丁URL Scheme 方案最大的缺口是未安装场景。用户点mygame://时系统找不到注册 App会直接弹错误提示用户大概率会流失。所以我的做法是URL Scheme 只用于 App 已安装的召回场景比如老用户分享、消息推送唤醒新用户拉新一律走 Universal Links。3. Universal Links 路线从 apple-app-site-association 到 Unity 的完整落地Universal Links 现在基本是标配了配置项比 URL Scheme 多但只要思路清楚没太多玄学成分。核心就是三个东西Associated Domains 权限、服务器上的配置文件、iOS 回调代码。3.1 服务器端的 apple-app-site-association 文件这一步是绝大多数人卡住的地方。Universal Links 要求你的 HTTPS 域名根目录或 .well-known 目录下放一个 JSON 文件文件名必须是 apple-app-site-association不带任何后缀。文件内容如下{ applinks: { apps: [], details: [ { appID: ABCDE12345.com.yourcompany.yourgame, paths: [/play/*, /invite/*] } ] } }appID 是 Team ID 加 Bundle Identifier中间没有空格。Team ID 在 Apple Developer 后台 Member Details 页面可以看到是一串 10 位大写字母数字。path 字段支持通配符*匹配任意路径?匹配单字符也可以列出精确路径。文件放上去之后用浏览器直接访问https://yourdomain.com/apple-app-site-association看到的应该是 JSON 内容而不是 404 或别的错误页。这一步必须在真机 Safari 里验证因为很多代理工具或浏览器缓存会导致旧文件残留。3.2 Xcode 开启 Associated Domains在 Xcode 的 Signing Capabilities 里添加 Associated Domains capability然后添加域名条目格式必须是applinks:yourdomain.com。注意格式不能写https://yourdomain.com也不带路径。比如你的网页是https://www.example.com/play?id1那这里写applinks:www.example.com就够了。Unity 导出的工程里配置 Associated Domains 有个坑如果 Unity 侧的 Player Settings 里已经做了签名配置Xcode 里手动加 capability 后下次从 Unity 重新导出工程这个配置可能会被覆盖。建议在 Unity 的 Xcode 工程后处理脚本PostProcessBuildAttribute里用代码方式自动添加 Associated Domains capability这样每次导出都不用担心丢配置。3.3 iOS 原生回调的完整处理Universal Links 的回调入口有两个历史阶段。iOS 13 之前统一在 AppDelegate 的application:continueUserActivity:restorationHandler:里处理iOS 13 及以后如果 App 使用了 SceneDelegate 生命周期回调会进入 SceneDelegate 的scene:continueUserActivity:。很多项目的 Unity 导出工程默认没开 Scene走的是 AppDelegate 老路线。但如果你项目里开了 Scene两个地方都要处理只写一个的话另一个生命周期下链接就丢了。AppDelegate 处理版本- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; NSString *urlStr url.absoluteString; NSLog([DeepLink] Universal Link received: %, urlStr); const char *urlCString [urlStr UTF8String]; UnitySendMessage(DeepLinkListener, OnUniversalLinkReceived, urlCString); return YES; } return NO; }SceneDelegate 处理版本- (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; NSString *urlStr url.absoluteString; NSLog([DeepLink] Scene Universal Link received: %, urlStr); const char *urlCString [urlStr UTF8String]; UnitySendMessage(DeepLinkListener, OnUniversalLinkReceived, urlCString); } }C# 侧的OnUniversalLinkReceived在上一节里其实已经有了直接从OnURLSchemeReceived里拆出一个通用的ParseURL方法两个入口共用即可。3.4 未安装场景的降级Universal Links 的优雅之处就在这里。用户点击链接时系统先检查设备上有没有对应 App有就唤起没有就静默加载网页。网页内容由你自己决定一般放一个在 App Store 中查看的按钮或者引导用户复制邀请码。Apple 对 Universal Links 的降级页面有个硬性要求页面不能自动刷新跳转不能搞那种一进来就 JS 跳转 App Store的强制行为否则审核会被打回。页面上放一个明显的下载按钮是最稳妥的做法。4. 唤醒到参数投递的链路设计冷启动、热启动与后台存活的三种时序这一节是真正的分水岭。很多人发现配置和代码都对但实际使用时要么冷启动拿不到参数要么页面已经跳过了又突然弹出来一个回调问题全出在时序上。4.1 三种启动状态下的回调时序我把用户点击 Deep Link 时 App 所处状态分成三种冷启动App 进程不存在。系统启动 App - 原生层初始化 - Unity 引擎初始化 - C# 侧脚本 Awake/Start 执行 - 回调进入原生层 - UnitySendMessage 调用 C# 接收方法。热启动前台切换App 进程存在且当前处于前台。系统直接把回调送进前台 App接收几乎实时。后台存活进程存在但界面不在前台系统把 App 从后台拉起来回调进入时 Unity 已经初始化完成但 C# 侧的接收方法可能还没有注册取决于挂载时机。三种状态下C# 侧能稳定拿到回调的前提是接收方法的 GameObject 和脚本已经在场景里存在并且原生层调用 UnitySendMessage 时能成功解析到目标对象。4.2 冷启动竞态UnitySendMessage 可能早于 Awake冷启动最大的坑是Unity 引擎初始化需要时间原生层在收到 Deep Link 时 Unity 的 C# 脚本可能还没就绪UnitySendMessage 发过去的字符串没有接收者直接丢掉。解决思路是原生层在收到 URL 时先存一份到本地NSUserDefaults 或静态变量等到 Unity 发起我准备好了的通知后再补发一次。原生层代码增加一个保存逻辑static NSString *pendingDeepLink nil; - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; pendingDeepLink url.absoluteString; if (UnityReady) { // Unity 已就绪立即投递 UnitySendMessage(DeepLinkListener, OnDeepLinkReceived, [pendingDeepLink UTF8String]); } return YES; } return NO; }C# 侧把数据分为缓冲投递与延迟拉取两步。场景初始化完成后显式调用原生方法获取缓冲的 Deep Linkprivate void Start() { // 主动向原生层拉取一次挂起的 Deep Link #if UNITY_IOS DeepLinkNative.GetPendingDeepLink(PendingCallback); #endif } private void PendingCallback(string url) { if (!string.IsNullOrEmpty(url)) { HandleDeepLink(url); } }还要用Application.isFocused配合OnApplicationFocus监听前后台切换保证从后台切换回前台时也能正确触发 Deep Link 相关的引导逻辑。最终 C# 侧的接收入口统一为一个 HandleDeepLink里面做幂等处理防止同一链接被投递两遍。4.3 幂等与场景跳转Deep Link 到达 C# 层后游戏逻辑可能要跳转场景或弹出活动页。如果玩家当前正在战斗中直接切场景会非常粗暴。好的做法是把 Deep Link 解析结果放进一个待处理队列等到当前场景处于安全切换点比如战斗结束、回到主界面时再消费。public void HandleDeepLink(string url) { var data ParseURL(url); if (data null) return; // 避免重复处理同一链接 if (_lastProcessedURL url) return; _lastProcessedURL url; if (GameStateManager.Instance.IsInBattle) { PendingDeepLink data; } else { GameEntry.HandleDeepLink(data); _lastProcessedURL null; } }5. 调试、边缘场景与线上排障的实测经验这套链路就算全部写对了上线前你也值得花一整天做真机验证。以下几个问题是实测中遇到过的直接列出来当体检清单用。5.1 调试方法终端命令行辅助验证Universal Links 最实用的调试命令是xcrun simctl openurl booted https://yourdomain.com/play?channeltest这条命令可以在模拟器上模拟点击 Universal Link省去手动敲链接的麻烦。iOS 模拟器从某个版本开始支持 Universal Links但仍建议真机验证。因为模拟器的 Associated Domains 验证经常和真机行为不完全一致遇到过模拟器可以唤起但真机死活不行的情况基本是证书或 AppID 配置问题。用 Safari 地址栏手动输入链接并长按跳转也可以模拟用户点击行为但注意区分直接从 Safari 地址栏输入的 URL 即使 App 已安装也不一定会走 Universal Links 唤起逻辑。5.2 高频踩坑清单我把历年群里讨论的高频问题汇总成一个表格方便对应排查现象根因排查手段Universal Link 点击无反应apple-app-site-association 404 或 JSON 格式错误浏览器直接访问 JSON 文件检查格式点击链接跳转浏览器而非 AppAssociated Domains 没加或格式错误检查 Capabilities 配置真机开关飞行模式重试之前能唤起突然不行证书/签名变了导致验证失败确认 Team ID 与 Bundle ID 匹配检查证书链冷启动后收不到参数UnitySendMessage 时机早于 C# 脚本初始化原生层做 pending 缓存C# 侧主动拉取iOS 13 上拿到参数但场景不跳转SceneDelegate 生命周期回调没写两个入口都补上参数里的中文出现乱码没有执行 percent-encoding 反解C# 侧用 Uri.UnescapeDataString微信里打不开链接微信内置浏览器对 Universal Links 支持不完整提示用户使用 Safari 打开5.3 参数安全与校验Deep Link 参数在传输链路上是可被篡改的。线上环境里来自未知来源的 URL 参数可能携带恶意 payload 或虚假归因数据。所以 C# 侧拿到参数后至少要校验来源合法性比如对关键参数做签名验证渠道 ID 做白名单校验避免直接把参数拼进 SQL 或存储过程。另外URL 长度也要限制。iOS 对 openURL 的参数长度没有硬性规定但 URL 过长时部分浏览器会截断而且服务器日志对超长 URL 也不友好。建议把游戏内逻辑参数控制在 2KB 以内超长内容改为传一个短标识如 room ID再用业务接口拿详情。5.4 从热启动的 live conversion 场景看应用留存热启动还有个容易被忽略的场景用户已经打开游戏此时后台收到推送或另一条链接预期是游戏内弹窗跳转。这种情况下 Unity 引擎和 C# 脚本都处于完全就绪状态回调会直接触发 C# 方法。但是要注意UIApplicationState的判断如果 App 处于后台Inactive 或 Background你仍然可以在原生层收到回调此时如果直接 UnitySendMessage游戏切到前台时会发现 UI 已经被弹出了体验不好。正确处理是原生层判断application.applicationState UIApplicationStateBackground时只存数据不立即发送App 回到前台时通过applicationDidBecomeActive补发。这跟冷启动的 pending 思路一脉相承。6. 一些阶段性的个人经验总结这套链路我自己接过的项目里最花时间的不是配置代码而是把冷启动补发和SceneDelegate 兼容做稳。很多细节不跑一遍真机根本发现不了尤其是 iOS 版本迭代后回调和生命周期入口的变化老教程里写的代码经常跟不上系统更新。Unity 的版本升级也会影响桥接方式。老版本 Unity 导出的 AppDelegate 里UnitySendMessage 的调用时机相对固定新版 Unity 如果开启了 Scene 支持类名和入口会变化。所以每次 Unity 升级后我都建议跑一遍冷启动和热启动的 Deep Link 回归测试。最后分享一个小技巧把 Deep Link 的日志单独拉一个 tag无论是原生层还是 C# 层输出统一格式比如[DeepLink]开头这样线上出了问题可以快速从日志平台检索整条链路从系统回调一路查到 C# 分发很快就能定位是原生层丢了还是 C# 层没消费。