
做手游买量和社交裂变的朋友应该都绕不开 Deep Link 这个词用户手机里明明装着你家的 App但此刻他在浏览器、在聊天框、在广告落地页你丢过去一个链接点一下能不能把游戏唤起还能把邀请码、渠道号一起带上。落到 Unity 手游加 iOS 这套技术栈上实际链路是这样iOS 系统把链接事件交给原生层原生层解析 URL再通过 UnitySendMessage 投递给 C#由 C# 做统一分发。看起来就三步但配置、时序、参数格式、生命周期每一步都有能让你调试到怀疑人生的坑。这篇文章完整拆解 URL Scheme 和 Universal Links 的配置流程、原生层到 C# 层的参数投递机制以及我在真实项目里踩过的几个高频问题。适合正在做 Unity SDK 集成、iOS 原生桥接或买量归因的客户端同学参考。1. 唤醒链路全景从点击链接到 C# 收到参数1.1 URL Scheme 与 Universal Links 的核心差异URL Scheme 是 iOS 很早就支持的机制。你在 Info.plist 里声明一个自定义协议比如mygame://当系统检测到类似mygame://invite?inviter123的链接时就唤起你的 App并把完整 URL 交给application:openURL:options:处理。优势是原理简单、支持面广从 iOS 3 到 iOS 17 全都能用。缺点是体验有点打断感用户在 Safari 或第三方浏览器里点这个链接时系统会弹一个确认框问“是否要在 App 中打开此页面”这一步就会带来一部分用户流失另外 URL Scheme 不唯一同一个协议名谁先抢着注册就是谁的存在被抢注的隐患劫持风险也更高。Universal Links 是 iOS 9 之后苹果主推的方案逻辑完全反过来你的 App 通过 Associated Domains 能力和一个 HTTPS 域名绑定并在域名根目录放一个apple-app-site-association简称 AASA文件作为“电子凭证”。用户点击的是带https://的普通链接如果设备上装了 App系统会在不弹窗的情况下直接拉起 AppSafari 顶部会出现一条“在 App 中打开”的返回条如果没装 App链接照常打开网页可以继续做下载引导。这种“有条件唤起”的特性在买量模型里特别关键。1.2 手游场景下怎么选型买量和裂变场景Universal Links 是绝对主力。原因很简单广告从点击到唤起中间每多一个弹窗、每多一次跳转转化率就掉一截。Universal Links 无感唤起用户几乎意识不到 App 是自己被拉起来的体验顺滑得多。而且它可以优雅处理“未安装”的分支——没装 App 就继续在落地页展示引导下载整个过程不需要客户端参与。但我的建议是两个都配上。Universal Links 在微信、QQ 这类内置浏览器里有拦截行为部分老系统对 AASA 的缓存刷新也确实慢这种时候 URL Scheme 就是兜底方案。尤其是国内安卓转 iOS 的玩家习惯上更接受 URL Scheme 的拉起方式。两者不是互斥关系配置上各管各的C# 层统一收敛解析即可。1.3 深链在手游里的几种真实用途深链在这些场景里会经常用到买量归因广告投放回传的链接带有campaign_id、adset_id等参数客户端收到后把这些字段上报给归因平台判断用户来自哪个渠道。邀请裂变老玩家发链接给新玩家链接带inviter_id新用户启动后读取参数在注册或绑定界面自动填上邀请人关系。活动跳转运营在社群里发带活动码的链接玩家唤起游戏后直接打开对应活动页而不是停留在主界面。加好友/进公会从公会群分享的链接点进来自动拉起公会界面并展示入会确认弹窗。这些场景里“参数”就是命根子。链接里带了什么参数、怎么安全地传给 App、什么时候送达 C#、业务层怎么消费整条链路都有讲究。2. Xcode 原生配置绕不开的基础功2.1 在 Info.plist 中注册 URL SchemeUnity 导出 Xcode 工程后用 Xcode 打开找到 Info.plist添加 URL types。这是最原始也最直观的方式keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.yourgame/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /arrayCFBundleURLName一般填 bundle identifier 或一个固定字符串系统不校验主要是内部标识用。CFBundleURLSchemes里填你要注册的协议名。注意协议名建议小写字母开头全部小写。你注册MyGame和用户点击mygame://系统匹配时大小写敏感容易翻车。协议名不要带特殊符号比如://、空格它只是 scheme 那一串。一个 App 可以注册多个 scheme但每多一个就多一份被抢注风险没必要不要乱加。2.2 配置 Universal Links 与 Associated Domains接下来是 Universal Links步骤固定在 Xcode 的 Signing Capabilities 标签页添加Associated Domains能力。添加域名格式是applinks:yourdomain.com。注意不要写https://也不要写路径。确保你的域名是 HTTPS证书有效服务器能正常响应对 AASA 文件的请求。把 AASA 文件放到https://yourdomain.com/apple-app-site-association或https://yourdomain.com/.well-known/apple-app-site-association这个位置。AASA 文件内容长这样{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.yourgame, paths: [/game/*, /invite/*] } ] } }appID是Team ID 加 Bundle Identifier拼接中间没有空格。paths数组用来控制哪些路径允许唤起 App。通配符支持两种*匹配任意多个字符?匹配单个字符。注意路径是大小写敏感的你在服务器上用/Invite/跳转AASA 里写/invite/*是不会命中的。这里有个容易踩坑的点AASA 不能用重定向。很多同学把文件放在某个对象存储的临时链接上再重定向到正式域名苹果拉取时会失败。AASA 请求必须直接返回 200 和 JSON 内容最好不要经过任何重定向、登录鉴权、WAF 拦截。还有一件事苹果对 AASA 文件大小有建议越精简越好。如果你的 details 数组里堆了大量 App文件膨胀到几百 KB虽然大多数情况下系统也能拉取但缓存和更新都可能变慢排查起来也麻烦。2.3 在 Unity 构建流程里自动化注入配置如果每个版本都从 Unity 重新导出 Xcode 工程那么上面所有手工操作都会在导出时被覆盖这是开发团队最容易吃暗亏的地方。所以强烈建议写一个 PostProcessBuild 脚本在每次构建 iOS 工程后自动注入配置。using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; public class iOSDeepLinkPostProcess { [PostProcessBuild(1000)] public static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject) { if (target ! BuildTarget.iOS) return; string plistPath pathToBuiltProject /Info.plist; PlistDocument plist new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementArray urlTypes plist.root.CreateArray(CFBundleURLTypes); PlistElementDict typeDict urlTypes.AddDict(); typeDict.SetString(CFBundleURLName, com.yourcompany.yourgame); PlistElementArray schemes typeDict.CreateArray(CFBundleURLSchemes); schemes.AddString(mygame); plist.WriteToFile(plistPath); } }UnityEditor.iOS.Xcode在 Unity 里已经内置不用额外引包。但要注意PlistDocument.root.CreateArray如果 key 已经存在会报错或覆盖所以脚本里最好先判断 key 是否已存在。Associated Domains 的自动化稍微麻烦一点因为你实际上操作的是 entitlements 文件。Unity 导出的工程里开启 Associated Domains 后通常会生成一个.entitlements文件。构建脚本可以这样处理string entitlementsPath pathToBuiltProject /Unity-iPhone/Unity-iPhone.entitlements; PlistDocument entitlements new PlistDocument(); entitlements.ReadFromFile(entitlementsPath); PlistElementArray domains entitlements.root.CreateArray(com.apple.developer.associated-domains); domains.AddString(applinks:yourdomain.com); entitlements.WriteToFile(entitlementsPath);不同 Unity 版本导出工程后的文件命名可能有差异建议在第一个脚本里先打印Directory.GetFiles查看真实结构再写死路径。这个自动化脚本写好后配合 CI/CD 打包每次出包都不用人工打开 Xcode 点来点去省心很多。3. 原生回调接力把参数安全送进 C# 层3.1 理解 iOS 生命周期变化iOS 12 及以前AppDelegate的application:openURL:options:是所有 Deep Link 回调的唯一入口。iOS 13 之后 App 引入了 Scene 生命周期如果工程使用UIScene系统会走scene:openURLContexts:而不是 AppDelegate 的回调。多数 Unity 导出工程默认不启用 Scene 生命周期回调只会落在 AppDelegate。但如果你在工程里集成了一些第三方 SDK它们可能会把生命周期切到 Scene 模式或者你自己在原生层面加了 SwiftUI 兼容代码那么回调入口就变了。我见过一个项目Deep Link 在 iOS 15 上一直正常升到 iOS 17 后突然不触发了排查三天最后发现是第三方统计 SDK 把 Scene 生命周期接管了。稳妥的做法是双入口都写。AppDelegate 收到回调后统一转发到一个DeepLinkManager单例如果检测到 Scene 代理回调也转发到同一个单例。这样不管系统走哪条路原生层的处理逻辑只有一个出口。3.2 原生层接收链接并投递的完整代码先看一段典型的 Objective-C 处理代码。Unity 导出的工程里UnityAppController是AppDelegate的子类你可以直接继承它或者写一个 Category 扩展它。我习惯新建一个专门的.mm文件在里面用一个单例管理所有深链逻辑#import UnityAppController.h #import UIKit/UIKit.h interface DeepLinkManager : NSObject (instancetype)shared; - (void)handleURL:(NSURL *)url; - (NSString *)takeCachedLink; // C# 侧主动拉取 property(nonatomic, copy) NSString *cachedLinkJSON; end implementation DeepLinkManager (instancetype)shared { static DeepLinkManager *instance; static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ instance [[DeepLinkManager alloc] init]; }); return instance; } - (void)handleURL:(NSURL *)url { NSMutableDictionary *parsed [NSMutableDictionary dictionary]; parsed[fullUrl] url.absoluteString ?: ; parsed[scheme] url.scheme ?: ; parsed[host] url.host ?: ; parsed[path] url.path ?: ; parsed[query] url.query ?: ; NSData *data [NSJSONSerialization dataWithJSONObject:parsed options:0 error:nil]; NSString *json [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; self.cachedLinkJSON json; // 如果 Unity 已经初始化完直接投递否则等 C# 侧来拉取 UnitySendMessage(DeepLinkBridge, OnNativeDeepLinkReceived, [json UTF8String]); } - (NSString *)takeCachedLink { NSString *link self.cachedLinkJSON; self.cachedLinkJSON nil; return link; } end然后在 UnityAppController 的扩展或者子类里补上系统回调implementation UnityAppController (DeepLink) - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id *)options { [[DeepLinkManager shared] handleURL:url]; return YES; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring *))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; [[DeepLinkManager shared] handleURL:url]; return YES; } return NO; } endUnitySendMessage有三个参数第一个是 GameObject 名称第二个是挂在它身上的组件方法名第三个是参数字符串。这个方法只能从主线程调用并且Unity 侧必须已经完成场景加载。如果 App 刚冷启动Unity 引擎还在初始化这时候调用UnitySendMessage会直接静默失败。所以上面的代码里不管 Unity 是否 ready都把 JSON 缓存了一份这就是兜底。3.3 C# 层接收与业务分发C# 侧创建一个常驻不销毁的 GameObject挂一个DeepLinkBridge脚本。这个脚本不需要每个场景都摆一个用单例初始化即可using System; using System.Collections.Generic; using UnityEngine; public class DeepLinkBridge : MonoBehaviour { private static DeepLinkBridge instance; private string pendingLinkJson; public static DeepLinkBridge Instance { get { if (instance null) { var go new GameObject(DeepLinkBridge); DontDestroyOnLoad(go); instance go.AddComponentDeepLinkBridge(); } return instance; } } private void Start() { // 主动拉取原生缓存防止 UnitySendMessage 调用过早丢失 PullPendingDeepLink(); } public void OnNativeDeepLinkReceived(string json) { if (string.IsNullOrEmpty(json)) return; var data DeepLinkData.Parse(json); DeepLinkDispatcher.Instance.Dispatch(data); } public void PullPendingDeepLink() { if (Application.platform ! RuntimePlatform.IPhonePlayer) return; // 调用原生方法拉取缓存 // 这里通过 extern 方法绑定到原生 DeepLinkManager 的 takeCachedLink string cached DeepLinkNativeBridge.TakeCachedLink(); if (!string.IsNullOrEmpty(cached)) { OnNativeDeepLinkReceived(cached); } } }业务层推荐做一个全局事件分发器public class DeepLinkDispatcher { public static DeepLinkDispatcher Instance { get; } new DeepLinkDispatcher(); public event ActionDeepLinkData OnDeepLinkReceived; private readonly ListDeepLinkData pendingQueue new ListDeepLinkData(); public void Dispatch(DeepLinkData data) { // 如果还没有任何业务注册监听先把数据缓存下来 if (OnDeepLinkReceived null) { pendingQueue.Add(data); return; } OnDeepLinkReceived?.Invoke(data); } public void Register(ActionDeepLinkData handler) { OnDeepLinkReceived handler; // 注册后立即回放缓存的深链 foreach (var data in pendingQueue) { handler(data); } pendingQueue.Clear(); } }这样邀请模块、买量归因模块、活动模块各注册各的监听互不干扰。比如邀请模块在初始化时调用Register收到数据后就弹邀请确认 UI归因模块收到数据后拼装上报参数。所有逻辑不集中在同一个巨型回调里后续排查也方便。3.4 参数解析的几个关键细节URL 参数两种形态都要兼容URL Scheme 形式mygame://invite?inviter123channelnewsUniversal Links 形式https://yourdomain.com/invite?inviter123channelnewsC# 解析推荐直接使用System.Uri不用自己造轮子public class DeepLinkData { public string RawUrl; public string Scheme; public string Host; public string Path; public string Query; public Dictionarystring, string Parameters new Dictionarystring, string(); public static DeepLinkData Parse(string url) { var data new DeepLinkData { RawUrl url }; var uri new Uri(url); data.Scheme uri.Scheme; data.Host uri.Host; data.Path uri.AbsolutePath; data.Query uri.Query; if (!string.IsNullOrEmpty(uri.Query)) { string trimmed uri.Query.TrimStart(?); foreach (string pair in trimmed.Split()) { if (string.IsNullOrEmpty(pair)) continue; int idx pair.IndexOf(); if (idx 0) { data.Parameters[pair] ; } else { string key pair.Substring(0, idx); string value pair.Substring(idx 1); data.Parameters[key] System.Uri.UnescapeDataString(value); } } } return data; } }注意Uri.UnescapeDataString会把%E4%B8%AD%E6%96%87解码成中文。如果值里本身还带了%字符解码逻辑要再包一层容错。我的习惯是解码失败时保留原串不能让一个异常参数把整条深链搞崩。关于frame里用什么 key 对接业务团队最好在项目里定一个规范。比如统一用inviter、channel、campaign_id而不是一半人写userId一半人写user_id。这个规范不在代码里强制但真等出问题的时候统一命名能少吵不少架。4. 实战中的高频坑与排查方法4.1 Universal Links 打不开的排查清单开发阶段最常遇到的问题就是点了链接Safari 老老实实打开了网页就是不唤起 App。排查顺序很有讲究按这个列表来基本能覆盖 90% 的情况确认 AASA 文件可访问性手机直接访问https://yourdomain.com/apple-app-site-association看看返回的 JSON 里appID是否和工程里的 Team ID Bundle ID 完全一致。确认 Associated Domains 格式Xcode 里写的必须是applinks:yourdomain.com有人会手滑写成https://yourdomain.com直接失效。确认路径匹配AASA 里写的是/invite/*你测试链接却是/game?foobar肯定不唤起。路径不区分参数?后面的部分不影响匹配。确认系统缓存iOS 对 AASA 有缓存机制改完文件后短则几分钟长则一两天才会重新拉取。开发阶段可以重启手机一般重启后缓存会刷新。确认域名不是 IPiOS 16 之后AASA 里的域名不能是裸 IP必须有真实域名。确认没有经过重定向AASA 请求一旦被服务端重定向苹果拉取就会失败。4.2 冷启动时序问题Unity 还没准备好参数就丢了这个坑我真实踩过而且是在线上环境踩的。用户从广告链接冷启动 App原生层在application:didFinishLaunching后立刻收到了深链马上调用UnitySendMessage但此时 Unity 场景还没加载完消息直接丢了。用户进来后没有绑邀请关系运营数据对不上排查了很久才发现是时序问题。解决办法就是我上面写的缓存兜底。原生层任何时候收到深链都先把 JSON 存单例再去尝试UnitySendMessage。C# 这边的DeepLinkBridge在Start里主动拉一次原生缓存。兜底路径是双保险即使UnitySendMessage因为时序失败C# 也能在场景起来后主动拿到。4.3 重复回调导致重复业务操作真实场景用户通过 Universal Links 唤起 App 时系统可能在启动后同一时间窗口内触发两次回调。第一次是冷启动系统自动恢复第二次是用户手滑多点了一下链接或者第三方归因 SDK 自己又解析了一次。如果业务层不去重就会出现邀请弹窗弹两次、归因上报发两次的现象。我的处理方案是给每次深链生成一个消费 ID。C# 侧在拿到DeepLinkData后用RawUrl 接收时间戳算一个 MD5 存本地列表一段时间内比如 30 秒内遇到相同 ID 直接丢弃。如果业务需要跨启动去重就改成持久化存储但一般 30 秒到几分钟的窗口就能覆盖 99% 的重复回调场景。4.4 中文参数乱码与编码问题有过一次线上反馈运营发的邀请链接里带了玩家昵称比如inviter张三结果客户端解析出来是乱码。深入排查后发现渠道方在拼接 URL 时对中文做了 URL 编码但部分老版本系统回调时返回的query是原始未编码的 UTF-8 字符串。处理上我给Parse方法加了一层容错。先看能不能直接用如果字符串里有%就尝试解码如果解码结果还是包含%E4%B8%AD这类序列就再解一次。另外在拼装上报参数时统一再做一次Uri.EscapeDataString保证写给服务端的数据是规范的。4.5 横竖屏切换导致回调“看起来丢失”还遇到过一个特别隐蔽的问题App 冷启动时强制横屏落地页是竖屏系统在启动瞬间发生了方向切换整个视图控制器重建。我放在原生单例里的深链数据没丢但投递时机被重建过程打乱C# 侧的Awake和Start执行顺序变得不可预期最后结果就是业务方收不到回调。解决方式是把 C# 侧主动拉取的动作从Start改到OnApplicationFocus首次触发之后并加一个小延时。这个处理虽然有点土但实测非常稳定。如果你们的 App 涉及复杂的方向切换建议把深链消费触发点放在用户真正进入主界面之后而不是最早期初始化阶段。4.6 微信、QQ 内置浏览器里的特例不少运营同学反馈从微信群里发出去的链接点了根本无法唤起 App。原因是微信、QQ 这类客户端会对 Universal Links 做拦截避免你不经允许就跳出到外部 App。这个行为现在没有合法的绕过方式唯一靠谱的做法是落地页做适配检测到微信内置浏览器时引导用户点击右上角“在浏览器中打开”再走 Universal Links 唤起的流程。也可以做“一键复制链接”按钮复制后切到 Safari 再打开。5. 综合兜底方案把深链做成一条标准流水线经历过上面的各种坑之后我最终在项目里沉淀了一套固定结构核心是“存储 - 拉取 - 消费”三个环节分离。原生层只负责接收和存储不主动决定投递时机C# 层负责在合适的时机拉取并做去重、解码、分发业务层只管注册监听和消费数据。这样每一环都独立出问题时定位也快。流程总结原生层handleURL:接收链接解析字典转 JSON 字符串缓存起来。尝试UnitySendMessage直接投递但投递失败不影响因为缓存还在。C# 层在Start和OnApplicationFocus时主动PullPendingDeepLink()把原生缓存的 JSON 拉到 Unity 侧。C# 层统一解析、去重、解码再分发到业务模块。业务模块注册监听处理各自的业务逻辑。这个结构我已经在两个中大型 Unity 手游项目里验证过覆盖了买量归因、邀请裂变、活动跳转、公会自动加入等常见需求。线上跑到 iOS 17AASA 更新、冷启动、热启动、横竖屏切换这些场景都没有再出现过深链丢失的情况。最后再分享一个小技巧开发阶段一定不要把深链调试依赖在真机日志的print输出上。直接用 Xcode 连接设备在continueUserActivity和openURL两个方法里打断点先确认系统回调到底有没有到原生层。这一步确认清楚了再回 C# 侧看参数投递排查效率能翻几倍。很多同学在 C# 侧打日志调试半天最后发现原生根本没回调方向就错了。把这套链路理顺深链才算真正做透了。