
手游运营里最常见的一类需求用户点开一条 H5 链接或者别人的分享消息手机直接唤起我们的游戏并自动带到某个指定页面——邀请人、房间号、活动 Banner……这一条链路在 iOS 上叫 Deep Link实现起来牵扯原生、Unity、服务端三个端缺一个环节链接到了也不一定进得去。这篇就把 Unity 手游 iOS 端 Deep Link 唤醒全流程讲透从 URL Scheme / Universal Links 的方案选择到原生侧收到的链接怎么投递到 C# 层再到冷启动、热启动的坑一个不落适合正在做 Unity 客户端、需要对接渠道或自建拉活/分享体系的开发者参考。先说个结论Deep Link 不是某个平台的新鲜功能它只是在“App 被唤起”这件事上做文章。iOS 一直有Unity 从 2017.2 起也内置了部分 Deep Link 支持。但实际接起来你会发现真正复杂的不是能不能唤起而是唤起之后参数能不能完整、不重复、按正确顺序落到业务层。我把这条链路拆成几段来聊——先从方案选型讲起再走原生配置、引擎转发、C# 解析路由最后把最容易翻车的几个坑单独拎出来复盘。1. 为什么要做 Deep Link从拉活、分享到内容直达1.1 业务场景决定技术选型我最早接触 Deep Link 是接广告平台的需求广告主投了一波买量用户点击广告后要么进 App Store 下载要么直接唤起已安装的 App 并落到指定活动页。后来做社交分享用户在游戏里邀请好友组队希望好友点开消息链接后游戏自动弹出“接受邀请”的弹窗并且带上前端已经准备好的房间号。再后来客服系统接入客诉工单里直接挂了一个链接玩家点开App 内跳转到对应工单详情页。这三类需求看着不一样底层做的事情完全一致通过一个 URL 把活人带回游戏同时告诉他去哪个页面、做什么事情。这里有个容易误判的点很多人以为 Deep Link 只是为了“打开 App”。如果只是打开iOS 早就做得到。Deep Link 真正的价值在“参数”。用户点开链接服务器可以在这个链接后面拼上一串 query 参数比如https://link.mygame.com/invite?userId12345roomId67890ts1712345678sigabc123App 唤起后把这串参数解析出来就能定位到具体业务对象。没有参数传递链接就只是一张昂贵的“打开券”。1.2 URL Scheme 与 Universal Links 的差异以及我建议谁优先iOS 上主流的两种 Deep Link 方案业内都叫烂了但真正要落地的时候还是得掰开揉碎看清楚。URL Scheme是老牌方案本质是给 App 注册一个自定义协议比如mygame://。Safari 地址栏里输入mygame://invite系统发现这个 scheme 注册到了某个 App就直接唤起它。优点是接入简单、几乎所有 iOS 版本都支持也不会受 AASA 文件缓存影响缺点是 scheme 是全局的理论上可以重复注册而且它不受系统“合法性”校验——如果别的 App 抢注了同样的 scheme系统会让你选如果 App 没安装系统只会弹“无法打开链接”没有任何降级到网页的机会。Universal Links通用链接是 iOS 9 之后 Apple 主推的方式。它用真正的 HTTPS 域名做入口比如https://link.mygame.com/invite?...Apple 会去你服务器上拉取一个apple-app-site-association文件做校验校验通过才会唤起 App。如果 App 没安装Safari 会正常打开这个 HTTPS 地址可以引导用户去下载页。这就解决了 URL Scheme 最尴尬的“没装 App 怎么办”问题同时不易被劫持是现在外部投放渠道更愿意接的方案。我平时接第三方广告平台和分享系统新项目一律优先 Universal LinksURL Scheme 只作为兜底。比如某些 WebView 内发生跳转、部分老 SDK 只认 scheme 的场景才需要同时注册。两者不冲突可以并行存在后面 C# 层统一解析即可。2. 原生侧的硬配置Info.plist 与 Associated Domains2.1 URL Scheme 的注册细节URL Scheme 的注册在 Xcode 工程里对应Info.plist的CFBundleURLTypes字段。用 Unity 构建出的 iOS 工程正常情况下你不用手改工程可以在 Unity 编辑器里的Player Settings - iOS - Other Settings - Supported URL schemes中填上比如mygame。如果你接的是原生工程插件可能需要手动维护 plist结构如下keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.mygame.game/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array这里有两个实际经验。第一CFBundleURLName 只是给 URL type 起个名字不影响功能但建议填 Bundle ID 便于工程维护。第二scheme 最好起得足够独特。因为 iOS 的 scheme 是全局注册同名 scheme 可能引发冲突弹窗。比如mg这种太短的就容易撞mygame2024这类带年份/项目名的会稳很多。注册完成后你的 App 外链长这样mygame://invite?userId123roomId456。host 部分填什么都行但一般用invite这类语义化词后面 C# 层解析时可以直接把 host 当动作名。2.2 Universal Links 的三件套开发者后台、Entitlements、AASA 文件Universal Links 的配置比 scheme 多两步很多人第一次搞就是在这里卡住。配置链路要三处同时对齐Apple Developer 后台App ID 的 Capabilities 里打开 Associated Domains。Xcode 工程在 Signing Capabilities 里点 选 Associated Domains添加applinks:link.mygame.com。这里的域名必须是你自己的 HTTPS 域名系统会去这个域名下面找 AASA 文件。服务器端在这个域名的根目录或/.well-known/目录下放一个 JSON 文件文件名必须是apple-app-site-association不带.json后缀。AASA 文件内容长这样{ applinks: { apps: [], details: [ { appID: TEAMID12345.com.mygame.game, paths: [*] } ] } }appID的格式是TeamID . BundleID这个极其容易错。TeamID 在 Apple Developer 后台的 Membership 里能查到是一串 10 位左右的字母数字BundleID 必须和 Xcode 工程里的完全一致。大小写、连字符都不能差否则系统校验失败Universal Link 永远作为网页打开。paths字段控制哪些路径允许唤起 App。最省事是[*]表示所有路径都走 App 唤起。但有些团队要区分“H5 运营页”和“唤起路径”可以写得更细比如paths: [/invite/*, /room/*, NOT /internal/*]NOT前缀是排除语法表示/internal/开头的路径不准唤起 App强制走网页。这个能力在灰度阶段很实用比如你想让某个邀请活动页只在特定版本 App 内生效就可以在服务端动态调整 AASA 文件。2.3 验证 AASA 是否已经生效配置完服务器后先用 Safari 直接访问https://link.mygame.com/apple-app-site-association能看到 JSON 内容说明文件可访问。这里有个坑服务端响应头里 Content-Type 不要带 charset 之类的东西有些 iOS 版本对 Content-Type 比较敏感文件也不要放在需要登录跳转的 CDN 后面必须直接可访问。AASA 文件是有缓存的不是改完立刻全量生效。调试时经常出现“我文件改了但真机还是按旧规则来”。处理办法是让测试机删掉 App 重装或者等系统自动刷新。我建议正式发布前至少提前一天部署好 AASA给缓存留足时间。3. 原生层把链接转交给 Unity冷启动和热启动各走各的路3.1 AppDelegate 里的两套回调配置做完系统要不要唤起你的 App取决于系统。但唤起之后能不能把 URL 递到 Unity就完全看原生代码了。这一步经常被 Unity 开发者忽略因为 Unity 自带工程模板里其实已经有一部分默认实现很多人根本没往这里想过。先看 URL Scheme。App 运行状态下被唤起走的是- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id *)options { // url 形如 mygame://invite?userId123 return YES; }再看 Universal Links。App 运行状态下被唤起走的是 NSUserActivity 回调- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray * _Nullable))restorationHandler { NSURL *url userActivity.webpageURL; // url 形如 https://link.mygame.com/invite?userId123 return YES; }关键问题是如果 App 是被冷启动进程完全没运行的系统先把链接塞在launchOptions里等didFinishLaunchingWithOptions执行完再视情况调openURL或continueUserActivity。Unity 的默认 AppController 类UnityAppController会把冷启动时收到的 URL 先缓存起来等 Unity 引擎和 C# 环境初始化完成后再触发 C# 侧的事件。这也带来了第一个最典型的坑很多人在自己的 AppDelegate 子类里重写了continueUserActivity但忘了调用[super application:continueUserActivity:restorationHandler:]。一旦你没调 superUnity 引擎就不知道这个 URL 存在C# 侧Application.deepLinkArrived永远收不到。链接明明在原生层打印得出来但 Unity 那边一片安静排查半天才反应过来是 super 丢了。3.2 SceneDelegate 的兼容问题一个沉默的拦截者iOS 13 之后引入了UIScene生命周期。如果你的原生工程是旧版模板仍然走 AppDelegate 那套没问题。但如果你的主工程特别是合并进来的原生 App 工程启用了 SceneDelegate链接唤起 App 时系统可能不再回调 AppDelegate 的continueUserActivity而是回调 SceneDelegate 对应的方法func scene(_ scene: UIScene, continue userActivity: NSUserActivity)这个时候你的 UnityAppController 子类里的continueUserActivity根本不会触发。你查原生日志链接已经到了但 Unity 那边还是收不到。我的处理经验是一旦工程里出现 SceneDelegate就同时在 SceneDelegate 和 AppDelegate 里各写一遍 URL 转交逻辑用统一入口分发。别问“系统到底调哪个”不同启动状态下可能两个都会调也可能只调一个统一入口最稳。3.3 原始参数暂存UnitySendMessage 的时序陷阱如果你没有完全信赖 Unity 内置的 Deep Link 事件而是希望自己在原生层拿到 URL 后立即做点处理比如先原生解析一下、埋个点再决定要不要转给 C#那就要注意时序。冷启动时原生回调发生的时间点非常早早到 Unity 还在加载引擎、C# 脚本根本没跑。这时候你直接调UnitySendMessage是无效的因为接收消息的 GameObject 还没创建。稳妥的做法是在原生侧定义一个静态暂存变量把 URL 先存起来等 C# 侧回调原生宣告“我准备好了”再补发消息。伪代码大概是static NSString *g_pendingLink nil; - (void)handleDeepLink:(NSString *)link { if (g_unityReady) { UnitySendMessage(DeepLinkManager, OnNativeLink, link.UTF8String); } else { g_pendingLink [link copy]; } } // 由 C# 在初始化完成后调用 - (void)onUnityReady { g_unityReady YES; if (g_pendingLink ! nil) { UnitySendMessage(DeepLinkManager, OnNativeLink, g_pendingLink.UTF8String); g_pendingLink nil; } }但这里会引出一个新问题如果你在原生层也调了 super / 使用了 Unity 内置 Deep Link 支持那同一条链接很可能既触发Application.deepLinkArrived又通过UnitySendMessage到达你的OnNativeLink方法造成重复投递。解决办法是二选一要么彻底依赖内置事件收到原生链接后什么都不做只保证 super 被调用要么放弃内置事件完全走自己的原生转发通道。千万不要两条腿同时走除非你做了幂等去重。4. Unity C# 层统一收口缓存、解析、路由一次理清4.1 用 DontDestroyOnLoad 单例承载 Deep LinkC# 侧我习惯做一个常驻的单例对象挂到场景里的空 GameObject 上Awake里DontDestroyOnLoad。它主要做三件事注册事件、缓存未消费的链接、按需把链接推给业务模块。之所以要缓存是因为 Deep Link 到达的时点和业务层就绪的时点完全是两回事——用户点链接唤起 App 时主界面可能还没加载完登录态可能还没恢复这时候贸然路由只会失败。public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } private readonly Queuestring _pendingLinks new Queuestring(); private bool _ready; private void Awake() { if (Instance ! null) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); Application.deepLinkArrived OnDeepLinkArrived; // 冷启动时初始链接可能已经准备好 if (!string.IsNullOrEmpty(Application.absoluteURL)) { _pendingLinks.Enqueue(Application.absoluteURL); } } private void OnDeepLinkArrived(string url) { Debug.Log([DeepLink] arrived: url); _pendingLinks.Enqueue(url); if (_ready) ConsumePendingLinks(); } }这里Application.absoluteURL是 Unity 提供的启动时固有链接。它和Application.deepLinkArrived事件属于两条不同的取数通道事件负责运行中到达的链接absoluteURL 负责冷启动时初始 URL。为了不丢链接我通常两个都取但绝对要防重——同一 URL 可能既出现在 absoluteURL 里又通过 deepLinkArrived 再投一次。队列消费前可以先做一次去重或者直接判断如果队列尾和当前 URL 相同就不再入队。4.2 参数解析别小看 Uri 的细节拿到原始 URL 之后第一件事是解析成结构化数据。我的解析层长这样public class DeepLinkPayload { public string Action; public string RawUrl; public Dictionarystring, string Query new Dictionarystring, string(); } public static class DeepLinkParser { public static DeepLinkPayload Parse(string rawUrl) { var payload new DeepLinkPayload { RawUrl rawUrl }; var uri new Uri(rawUrl); string path uri.AbsolutePath.Trim(/); payload.Action string.IsNullOrEmpty(path) ? home : path; string query uri.Query.TrimStart(?); if (!string.IsNullOrEmpty(query)) { foreach (string pair in query.Split(, StringSplitOptions.RemoveEmptyEntries)) { string[] kv pair.Split(); if (kv.Length ! 2) continue; string key System.Net.WebUtility.UrlDecode(kv[0]); string value System.Net.WebUtility.UrlDecode(kv[1]); payload.Query[key] value; } } return payload; } }这套解析对 URL Scheme 和 Universal Links 是通用的区别只在于 Action 取法。Scheme 链接mygame://invite?userId1解析出 Action 来自 hostpath 为空Universal Linkhttps://link.mygame.com/invite?userId1Action 来自 path。为了统一我更倾向于把两种链接标准化成同一套规则无论 host 还是 path都归并到一个 action 字段里。上面这套写法对 Universal Link 自然兼容对 scheme 链接则需要额外取uri.Host作为 action 兜底。还有一个容易被坑的点URL 里的参数值是 URL 编码后的必须解码。但Uri.UnescapeDataString不会把当成空格而WebUtility.UrlDecode会。如果你参数里的空格是用编码的用错方法就会解出一个带的脏串。我的惯例是自己生成 URL 时强制全部用%20而不是这样解析侧就没有歧义。如果是第三方渠道给的链接对方用什么编码不一定那就两个都试或只信任%xx编码。4.3 等待策略与路由链接不是到了就得跳链接解析完不等于马上可以跳转。我见过很多新同学在这里写死收到链接立刻 LoadScene。但用户可能还没登录可能 App 还在初始化资源甚至可能主界面都还没起来。我平时的处理是把链接先入队等业务层主动调用MarkReady()或者等登录模块回调“更新完成”再统一消费队列。public void MarkReady() { _ready true; ConsumePendingLinks(); } private void ConsumePendingLinks() { while (_pendingLinks.Count 0) { string raw _pendingLinks.Dequeue(); var payload DeepLinkParser.Parse(raw); Route(payload); } } private void Route(DeepLinkPayload payload) { Debug.Log($[DeepLink] route action{payload.Action}); switch (payload.Action) { case invite: // 打开邀请页携带 Query[roomId] break; case room: // 跳转房间 break; default: // 主界面 break; } }路由的原则是只负责发命令不负责具体实现。比如 invite 这条 action真正跳转的代码应该放在业务 UI 模块里DeepLinkManager 只负责把参数送过去。这样才能避免后续加新入口时DeepLink 管理类越来越臃肿。另外建议加一条超时不消费的兜底如果用户点了链接但登录流程卡住了超过 20 秒还没消费就放弃这条链接避免玩家卡在一个奇怪的等待状态。5. 最容易翻车的场景完整排查链路复盘5.1 症状冷启动后 C# 层完全收不到 Universal Links 参数这是我被问得最多的一个问题。复现步骤非常标准杀掉 App 进程点击手机上的 Universal LinkApp 被拉起但Application.deepLinkArrived没触发absoluteURL也是空。日志一看原生层continueUserActivity确实被调了。排查链路我建议按下面顺序走第一确认 AASA 文件无误。用 Safari 打开https://link.mygame.com/apple-app-site-association看返回的 JSON 里 appID 是否和当前安装包的 TeamID BundleID 完全一致。这一步能排除 60% 的“为什么不唤起”问题。第二确认点击链接时系统真的把 App 当作目标。在 Safari 里打开 Universal Link 对应的 URL观察是否有“在 App 中打开”的横幅。如果没有基本可以判断 AASA 配置不对或域名不匹配。第三进原生断点。在continueUserActivity里打日志或者断点确认方法被调用。如果方法没被调检查工程是否启用了 SceneDelegate。启用了 SceneDelegate 的工程iOS 13 有可能走的是scene(_:continue:)而非 AppDelegate 回调。第四确认有没有调用 super。如果自定义 AppDelegate 重写了continueUserActivity但没有把 userActivity 传给 Unity 的默认实现Unity 引擎就收不到。最常见的写法是在重写方法里打日志、拿到 URL然后就 return YES 了完全忘了 super 这一行。这是 Unity 内置 Deep Link 事件失效的最大原因。第五如果自己走了UnitySendMessage通道检查消息目标 GameObject 是否存在。冷启动时 C# 对象还没创建必须做原生侧暂存等onUnityReady再补发。5.2 症状同一链接被投递两次另一种常见问题冷启动时收到一次链接热启动时又收到一次或者 absoluteURL 和 deepLinkArrived 各自触发一次。原因基本来自混合使用两套通道。比如你既让 Unity 内置事件处理又在原生层自定义UnitySendMessage转发系统冷启动时先被缓存引擎起来后内置事件投一次自定义通道再补一次重复就产生了。我的修复方案很粗暴全工程只保留一条链路。默认走 Unity 内置Application.deepLinkArrived原生代码里除了确保 super 被正确调用之外不做任何额外转发。如果一定要做原生侧预处理那就放弃内置事件改为纯自研链路并在 C# 入口加一个最近 N 秒内同 URL 去重的逻辑。两条路二选一不给自己留后患。5.3 症状首次安装后点 Universal Link 没有唤起这个问题在老版本 iOS 上尤其多用户从下载页装好 App第一次点击 Universal Link系统没唤起 App反而打开了网页。行业内俗称“首次唤醒失效”。早期 iOS 系统要求 App 至少被用户手动打开过一次才能在后续响应 Universal Link新版本系统有所改善但遇到用户反馈时这条仍是首先要排查的方向。解决思路要么接受这个系统限制在落地页上写清楚“请先打开一次 App”要么在 App 首次启动时主动向系统注册一次 UserActivity让系统尽快完成关联。后者实际效果受限所以大多数团队选择在落地页做兜底引导。对比来看URL Scheme 没有这个问题只要 scheme 被注册无论第几次安装点链接都能唤起只是没安装时体验较差异。这也是为什么很多广告投放渠道坚持 scheme Universal Link 双管齐下的原因。5.4 症状参数里的中文、特殊符号乱码如果你在 C# 层打印 URL 没问题但解析 Query 后中文变成%E4%BD%A0或者加号残留那就是解码方法选错了。再次提醒System.Net.WebUtility.UrlDecode是最接近浏览器行为的解码方式Uri.UnescapeDataString对的处理不符合 URL query 规范。另外解析时最好单独处理空值情况?roomId这种有 key 没 value 的参数按空字符串处理别让Split()越界报错。参数安全也要在这里提一嘴。Deep Link 的参数完全对用户可见容易被手改。如果业务涉及邀请奖励、礼包发放C# 侧解析后不要直接信任 userId应该把整个 URL 或关键参数发给服务端做二次校验。我的一般做法是在生成链接时加一个sig签名服务端用密钥对 URL 参数做 MD5/HMAC 校验客户端只负责展示和引导真正的权限判定全在后端。否则用户自己改个 roomId 就能进别人房间或者伪造邀请人赚奖励审计的时候哭都来不及。6. 工作流沉淀一套可复用的接入检查单最后把我这几套项目下来固定的操作流程整理一下。每次新项目接入 iOS Deep Link我都会按下面这个顺序跑一遍能省掉大量反复排查的时间。确认方案外部渠道要求 Universal Links 就主做 Universal Links同时注册一个独立 scheme 做兜底。渠道无特殊要求直接双开。配置服务器先放 AASA 文件确认 HTTPS 可访问Content-Type 合理路径支持*或所需规则。AppID 双查一遍 TeamID BundleID。配置 Xcode/Unity 构建URL Scheme 在 Player Settings 里填Universal Links 在原生工程 Capabilities 里添加 Associated Domains。确认原生回调openURL和continueUserActivity是否都走到了统一处理入口SceneDelegate 是否也需要挂接是否确保了 super 调用。C# 挂接DeepLinkManager 单例Awake注册Application.deepLinkArrived、读取Application.absoluteURL入队缓存所有业务模块就绪后MarkReady统一消费。验证冷/热启动杀进程冷启动一次切后台热启动一次。日志输出 URL、解析结果、路由动作三个关键节点。验证参数完整性中文、特殊字符、空参数、超长参数各造一条链接测一遍。服务端校验有利益关联的字段必须验签客户端不做最终信任。这套流程里我认为最值钱的一条经验是链接只要到了原生层就不该丢链接只要到了 C# 层就不该重复链接只要被业务处理就一定要幂等。把这三点守住Deep Link 接入就成功了一大半。实际开发中还会遇到很多渠道 SDK 自己封了一层链接分发比如某些广告平台会先走他们的中转域名再跳转到你的 Universal Link中间经历过 302 跳转。这时候 AASA 文件要关注最终落地域名跳转链路上的中间域名不需要配置但如果最终跳转切了 scheme 或者丢失参数那就得去跟渠道那边对文档不是客户端能独立解决的。另一个小技巧是我在调试时习惯在原生层和 C# 层各打一条时间戳日志对比两条日志的间隔。间隔超过 3 到 5 秒基本可以怀疑是某个渠道 SDK 在启动流程里做了耗时操作导致 Deep Link 参数延迟到达业务层用户体验上是“打开了但没反应”。Deep Link 这套东西本质上没有多么高深的技术门槛但涉及系统、原生、引擎、业务四个层面任何一个环节的“我以为没问题”都会变成线上事故。把配置和通道理顺把参数和时序管好后面业务方再提十万个新入口你都可以淡定地回一句链接发过来格式对就行。