做手游发行这几年,Deep Link 这玩意儿平时不起眼,但一到买量投放、老玩家召回、活动页拉新的时候,它就是最关键的命根子。用户从广告位点进来,能不能一键唤起你的 App,直接决定次留、转化、付费这些核心指标。我在 Unity 手游 iOS 端反复折腾过这套流程,从 URL Scheme 到 Universal Links,再走到 C# 层把参数安全投递给业务逻辑,中间踩了不少坑,也沉淀出一套适合游戏项目的完整方案。这篇文章就把这套全流程掰开揉碎讲清楚,适合正在做 Unity iOS 游戏、并且需要接入 Deep Link 拉新召回能力的开发同学参考。
1. 方案选型:为什么 Universal Links 是主力,URL Scheme 却必须保留
1.1 URL Scheme 的底层机制与致命短板
URL Scheme 是最老牌的唤起方式,原理简单粗暴:你在 Info.plist 里注册一个自定义协议,比如mygame://,系统在收到这类协议的打开请求时,会去找注册了这个协议的 App 并唤起它。
它的最大优势是配置极其简单,不用服务器、不用证书,五分钟就能跑通。很多团队早期只靠它做 App 互相跳转,比如从社区 App 跳游戏,或者从客服系统跳回游戏充值页。但在实际投放场景里,URL Scheme 有几个天生缺陷:
第一,无法判断用户是否安装了 App。用户没装 App 时点击链接,系统只会弹一个"打不开网页,因为地址无效"的错误页,你连降级引导到 App Store 的机会都没有。iOS 9 之后canOpenURL还被限制,只能在 Info.plist 里预先声明可以查询的 Scheme,否则返回 false,这导致你没法在 H5 端提前检测唤起成功率。
第二,唤起时会有系统弹窗确认。用户点击链接后,iOS 会弹一个"在'我的游戏'中打开吗?"的确认框,多一步操作就多一层转化损耗,买量场景里这 5% 的流失都让人肉疼。
第三,在 WebView 环境里越来越不稳定。微信、抖音这类超级 App 的内置浏览器对 URL Scheme 的拦截越来越严格,经常出现点了没反应的情况。
1.2 Universal Links 的原理与优势
Universal Links 是苹果官方力推的替代方案,它的工作方式是:你的 App 声明自己要接管某个域名下的特定路径,系统验证你对该域名拥有控制权后,当用户在 Safari 或系统内点击该域名下的链接,且 App 已安装时,就会直接唤起 App,没有任何弹窗确认。如果用户没装 App,Safari 会直接打开对应网页,你可以在这个网页上放 App Store 下载引导,实现平滑降级。
这里面的关键机制有三层:Associated Domains 能力(在 Xcode 里声明 applinks 域名)、apple-app-site-association 文件(放在你服务器上的 JSON 验证文件,证明你拥有这个域名)、以及NSUserActivity 回调(系统通过continueUserActivity把网页 URL 交给 App)。
对比下来,Universal Links 在体验和安全层面全面优于 URL Scheme:无弹窗、可降级、域名鉴权防冒用。但它的配置成本也高得多,涉及服务器、HTTPS 证书、开发者后台、Xcode 签名等多处联动,任何一个环节出错,链接就"死"在 Safari 里。
1.3 游戏项目里的最终选型结论
我经手过的项目最终都采用了双链路并存的架构:
- Universal Links 作为主力唤起链路,用于广告投放、H5 活动页拉起、邮件营销等所有外部流量入口。
- URL Scheme 作为兜底链路,用于 App 间跳转、部分 WebView 场景(有些 WebView 对 Universal Links 支持不佳,但 URL Scheme 反而能触发一次系统级跳转)以及老版本 iOS 的兼容。
这种"双保险"不是画蛇添足,而是实际投放中你会发现,Universal Links 在某些内嵌 WebView 里依然会被吞掉,此时 URL Scheme 能救命。两条链路在 C# 层最终汇聚到同一个参数分发入口,业务侧完全不需要关心是从哪条链路进来的。
2. iOS 侧配置实操:从 Xcode 到服务器,每一步的坑都写在这里
2.1 URL Scheme 的注册与验证
URL Scheme 的配置位置在 Xcode 的 Info 面板里,但 Unity 项目每次重新导出 Xcode 工程都会覆盖手动改动,所以建议你在 Unity 的 Player Settings 里直接配置,保证每次导出后配置还在。
打开 Unity 的Player Settings > iOS > Other Settings > Configuration,找到URL Scheme一栏,填入你的自定义协议名,例如mygame。等价于在 Info.plist 里生成这样的结构:
<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLName</key> <string>com.yourcompany.yourgame</string> <key>CFBundleURLSchemes</key> <array> <string>mygame</string> </array> </dict> </array>注意:Scheme 名不要用太通用的单词,比如
game、share这类,很容易和其他 App 冲突。一旦冲突,iOS 的唤起行为会变得不可预测。建议用"产品名缩写 + 业务标识",例如mygamepay、mygameact。
配置完成后,在 Safari 地址栏输入mygame://test?room=10086,系统弹窗确认后你的 App 被唤起,说明 Scheme 链路通了。
2.2 Universal Links 的三步配置,一步都不能少
Universal Links 的配置分三块:开发者后台、Xcode 工程、服务器文件。
第一步,在 Apple Developer 后台开启 Associated Domains 能力。进入 Certificates, Identifiers & Profiles,找到你的 App ID,勾选 Associated Domains。注意这里改完要重新生成 Provisioning Profile,否则 Xcode 里会报签名错误。
第二步,在 Xcode 里添加 Associated Domains 域名。选中工程 Target,在Signing & Capabilities里点击 + 号添加Associated Domains,然后填入applinks:yourdomain.com。这个域名必须是 HTTPS 可达的。
这里有个 Unity 特有的坑:Xcode 工程每次由 Unity 重新导出,你在 Xcode 里手动加的 Capability 会被清掉。解决方案有两个:
- 使用 Unity 的iOS 原生插件,通过
PBXProject的 API 在导出时自动注入 applinks 能力; - 或者用Xcode 的 xcconfig 文件,在 Unity 导出后通过脚本自动修改工程文件。
我自己写了一个 Unity Editor 脚本,在OnPostProcessBuild回调里用PBXProject添加com.apple.developer.associated-domains这个 entitlement 键值,这样 Xcode 工程导出后 Universal Links 能力自动带上,省掉了每次都手动加一遍的重复劳动。
第三步,在服务器上部署 AASA 文件。文件名必须精确为apple-app-site-association,不能有 .json 后缀,放在你域名的根目录或/.well-known/下。文件内容如下:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.yourcompany.yourgame", "paths": ["/open/*"] } ] } }这里最关键的两个字段:appID是Team ID + Bundle ID的组合,中间是点号;paths是你允许唤起 App 的路径规则,支持通配符。比如/open/*代表所有形如https://yourdomain.com/open/...的链接都会唤起 App。
AASA 文件部署完成后,务必用https://yourdomain.com/apple-app-site-association在浏览器里访问一次,确认能返回 JSON 内容且响应头里Content-Type包含application/json。
额外提醒:AASA 文件的更新存在最长 24 小时的缓存周期,苹果的 CDN 会缓存这个文件。改完文件后不要立刻以为生效了,拿测试机多试几次,或者过期后强制联网刷新。
2.3 双链路的参数协议设计
既然两条链路最终指向同一个业务,参数格式必须统一。我推荐的统一格式是:
https://yourdomain.com/open/path/to/page?key1=value1&key2=value2其中path/to/page是业务页面标识,query参数是具体业务数据。URL Scheme 链路则复用同样参数,只是前缀换成mygame://open/path/to/page?key1=value1。
业务层只认一个统一的数据结构,类似:
public class DeepLinkData { public string path; // 页面标识 public Dictionary<string, string> query; // 参数表 }这样无论从哪条链路进来,C# 层解析出的模型都是一样的,思路清晰,代码也好维护。参数命名统一采用下划线风格,不要混用驼峰和下划线,避免解析时还要做兼容映射。
3. Unity C# 层参数投递:冷启动、热启动、时序问题全解
3.1 直接硬编码 vs 原生桥接:怎么选
Unity 从某个版本开始提供了Application.deepLinkReceived事件和Application.absoluteURL属性,很多开发者以为直接用这个就行。但实际上这个事件在 iOS 端的表现依赖 Unity 版本和原生层的桥接能力,尤其在冷启动场景下,事件触发时机可能早于你业务代码的初始化,导致你收到回调时业务模块还没准备好,参数丢了。
我的建议是:核心链路自己写原生桥接,Unity 的事件作为旁路辅助。理由很简单:
- 原生桥接可以在
didFinishLaunchingWithOptions里第一时间拿到冷启动 URL 并缓存,等 Unity 引擎完全就绪后再投递,时序可控; - 原生层可以同时处理 URL Scheme 和 Universal Links 两套回调,统一逻辑;
- 你可以精确控制投递时机,比如等待主场景加载完成后再丢给业务层,避免业务层因为场景未就绪而空转。
3.2 原生侧桥接代码实现
Unity iOS 的原生桥接本质是继承UnityAppController并重写生命周期方法。新建一个 Objective-C 文件,代码如下:
#import "UnityAppController.h" @interface MyAppDelegate : UnityAppController @property (nonatomic, strong) NSString *pendingDeepLink; @end @implementation MyAppDelegate // 冷启动:App 被 URL Scheme 或 Universal Links 唤起时 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [super application:application didFinishLaunchingWithOptions:launchOptions]; // URL Scheme 冷启动 NSURL *url = launchOptions[UIApplicationLaunchOptionsURLKey]; if (url) { self.pendingDeepLink = url.absoluteString; } return YES; } // 热启动:App 已在后台运行,通过 URL Scheme 唤起 - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options { [super application:app openURL:url options:options]; self.pendingDeepLink = url.absoluteString; [self sendPendingDeepLink]; return YES; } // 热启动:通过 Universal Links 唤起 - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler { [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { self.pendingDeepLink = userActivity.webpageURL.absoluteString; [self sendPendingDeepLink]; } return YES; } // 如果 Unity 引擎已经就绪,立刻把 URL 投递给 C# 层;否则等 C# 层主动拉取 - (void)sendPendingDeepLink { if (!self.pendingDeepLink) return; // 判断 Unity 是否已准备好 if (UnityIsInitialized) { UnitySendMessage("DeepLinkManager", "OnDeepLinkReceived", [self.pendingDeepLink cStringUsingEncoding:NSUTF8StringEncoding]); self.pendingDeepLink = nil; } } // 供 C# 层在初始化后主动拉取缓存的 URL - (const char *)getPendingDeepLink { if (self.pendingDeepLink) { const char *cString = [self.pendingDeepLink cStringUsingEncoding:NSUTF8StringEncoding]; self.pendingDeepLink = nil; return cString; } return NULL; } @end IMPL_APP_CONTROLLER_SUBCLASS(MyAppDelegate)这里面UnityIsInitialized是否真实存在,取决于你用的 Unity 版本。如果不好判断,最保险的策略是:统一缓存到本地,C# 层启动后主动通过外部函数拉取,逻辑更可靠。上面代码里getPendingDeepLink就是干这个用的。
老版本 Unity 的用户可能需要在main.mm里手动改入口,新版本用IMPL_APP_CONTROLLER_SUBCLASS(MyAppDelegate)这个宏就能自动替换默认的 AppController,不用动 main.mm,推荐这种方式。
3.3 C# 层接收与分发,重点是时序
C# 侧的核心是建立一个名为DeepLinkManager的单例,挂在启动场景的空物体上,提供两个入口:一个是供 UnitySendMessage 回调的OnDeepLinkReceived(string url),一个是初始化时主动向原生层拉取冷启动缓存的 URL。
public class DeepLinkManager : MonoBehaviour { public static event System.Action<string> OnDeepLink; [System.Runtime.InteropServices.DllImport("__Internal")] private static extern string getPendingDeepLink(); private void Awake() { DontDestroyOnLoad(gameObject); } private void Start() { // 冷启动时,先尝试从原生层拿缓存的 URL #if UNITY_IOS && !UNITY_EDITOR string cachedLink = getPendingDeepLink(); if (!string.IsNullOrEmpty(cachedLink)) { HandleDeepLink(cachedLink); } #endif } // 供 UnitySendMessage 调用的入口 public void OnDeepLinkReceived(string url) { HandleDeepLink(url); } private void HandleDeepLink(string url) { StartCoroutine(DispatchWhenReady(url)); } private System.Collections.IEnumerator DispatchWhenReady(string url) { // 等主场景加载完成后再投递 while (SceneManager.GetActiveScene().name != "Main") { yield return null; } // 等一帧,确保场景内所有模块完成初始化 yield return null; Debug.Log($"[DeepLink] 收到参数: {url}"); OnDeepLink?.Invoke(url); } }这个DispatchWhenReady是整个投递机制里最容易踩坑的地方。如果一个 Universal Links 在 App 启动后 3 秒才被触发,此时如果你的主场景加载很慢,协程会卡在while循环里空转,这没问题。但如果你没有做这个等待,直接把参数丢给一个还没初始化的业务模块,可能拿到空对象或者干脆崩溃。
参数解析我建议统一走一个工具类,把 URL 拆成 path 和 query:
public static DeepLinkData Parse(string url) { var data = new DeepLinkData(); data.query = new Dictionary<string, string>(); var uri = new System.Uri(url); data.path = uri.AbsolutePath.TrimStart('/'); string query = uri.Query.TrimStart('?'); if (string.IsNullOrEmpty(query)) return data; foreach (var pair in query.Split('&')) { var kv = pair.Split('='); if (kv.Length == 2) { data.query[kv[0]] = System.Uri.UnescapeDataString(kv[1]); } } return data; }注意不要自己写
replace("+", " ")之类的逻辑,直接用Uri.UnescapeDataString做 URL 解码,能正确处理大多数编码边界情况。如果发现某些参数里有中文或特殊符号,先检查生成链接的那一侧是否做了Uri.EscapeDataString编码,两边对称才不会乱。
4. 联调测试与参数安全:本地怎么测、线上怎么验
4.1 本地方案:两个链路都要有独立的测试入口
联调阶段最怕的就是"不知道当前到底走通了哪个环节"。我习惯的做法是,把两条链路的测试入口做成独立的、可区分的地址。
测试 URL Scheme,直接在 iOS 的 Safari 地址栏输入mygame://open/test?debug=1,观察 App 是否弹窗唤起。建议在原生桥接里加一行 NSLog 日志,打印每次收到的 URL,通过 Xcode 控制台直接确认原生层有没有拿到参数。
测试 Universal Links,在 iOS 的备忘录里输入完整链接https://yourdomain.com/open/test?debug=1,长按链接选择"在Safari中打开"。如果 Universal Links 生效,Safari 顶部会出现一条横幅:
在"我的游戏"中打开?
点击横幅,App 唤起。如果没有横幅,说明 AASA 文件、Associated Domains 配置或签名可能有问题,按第 5 节的排查表逐项查。
还有个更贴近线上环境的测试方法:用xcrun simctl对模拟器发送 Universal Link。命令长这样:
xcrun simctl openurl booted "https://yourdomain.com/open/test?debug=1"但这只能验证模拟器行为,Attention:模拟器对 Associated Domains 的支持有历史问题,建议重点在真机上测。
4.2 参数设计与安全红线
Deep Link 链接会出现在广告后台、日志里、用户分享的社媒内容中,属于半公开信息。所以参数设计上必须有红线意识:
第一,不要在链接里传敏感数据,比如用户手机号、身份证号、支付 token。链接一旦被转发或爬取,这些信息就泄露了。需要身份信息的场景,只传一个短期有效的 ticket 或 code,让客户端拿它去服务器换真实数据。
第二,服务端必须校验来源。所谓校验,不是说 H5 页面校验,而是 App 内发起后续网络请求时,携带的 Deep Link 参数必须由服务端二次确认,防止有人构造恶意链接诱导 App 发起异常请求。
第三,客户端不要 "信任" 参数里的操作指令。比如某个参数叫action=login,你不能直接执行登录,你要把它当成"用户意图",最终还是要走正常的鉴权流程。
4.3 与归因 SDK 的共存问题
大多数买量项目集成了 AppsFlyer、Adjust 这类归因 SDK,它们也依赖 Deep Link 机制。需要注意的坑是:归因 SDK 和你的业务代码不要同时消费同一个 Universal Link,否则可能出现重复处理或者互相覆盖。
我处理的方法是:原生层拦截到 Universal Link 后,先分发给归因 SDK 处理,再投递给 Unity 的DeepLinkManager。流程上做成"先归因、后业务",因为归因 SDK 拿到参数后有可能在本地写入数据,业务侧的落地页参数不能抢先消费掉链接里的归因字段。
具体实现也不复杂:在continueUserActivity里,先调用[[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:restorationHandler]这类方法,再把自己的pendingDeepLink存下来。注意如果你的归因 SDK 冷启动时也监听了didFinishLaunchingWithOptions,两边在冷启动场景下都要调,而且顺序不能反。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| Universal Links 点击后直接打开网页,不唤起 App | AASA 文件未生效或配置错误 | 浏览器访问 AASA 地址看返回内容;检查 appID 是否填错 |
| 唤起 App 成功,但 C# 层收不到参数 | UnitySendMessage 已投递但目标对象没监听 | 确认 DeepLinkManager 已挂载且命名一致;查看原生层日志 |
| 冷启动丢失 URL Scheme 参数 | didFinishLaunchingWithOptions里没正确处理 | 确认拿到launchOptions[UIApplicationLaunchOptionsURLKey] |
| URL Scheme 唤起时系统弹窗过于频繁 | 本身 Scheme 机制限制 | 尽量改用 Universal Links 作为主链路 |
| 微信等 App 内点击链接无法唤起 | WebView 拦截了 Universal Links / Scheme | 引导用户用 Safari 打开,或做提示页 |
| 配好 AASA 后 24 小时仍不生效 | 苹果 CDN 缓存 | 更换文件路径版本号,加时间戳路径 |
| Xcode 导出后 Associated Domains 消失 | Unity 覆盖了 Capabilities | 用脚本自动注入或导出后手动检查 |
5.2 三个最容易让人抓狂的细节
第一个是AASA 文件的 appID 写错成 Bundle ID。很多人只填了 Bundle ID 没填 Team ID,导致苹果无法把域名和 App 关联起来,Universal Links 永远不触发。打开 Apple Developer 后台翻一下你的 Team ID,拼成TEAMID.com.company.game的完整格式放进去。
第二个是Universal Links 在 iOS 15+ 的 Safari 上表现不同。新版本 Safari 默认在首次访问时不会直接唤起 App,而是先展示横幅。如果你的测试人员以为"必须直接唤起才算成功",可能误判为失败。联调前先明确行为预期。
第三个是热启动和三小时规则。苹果规定,如果用户在一段时间内(约三小时)初次进入 App,之后点击 Universal Links 可能会直接走网页而不是唤起 App,这是系统层面的防骚扰机制。线上投放量大的游戏,你可能需要在 H5 落地页上给用户明确的指引,比如"点击右上角按钮,在 App 中打开"。
5.3 复盘:一个真实的线上事故
有次我们上线了一个拉新活动页,投放量很大,结果第二天数据反馈唤起率比预期低了 20%。排查发现不是 Universal Links 配置问题,而是活动页里 H5 跳转用的按钮绑定的域名写错了子域。AASA 配置里只声明了www.yourdomain.com,但活动页用的是campaign.yourdomain.com,导致 Universal Links 匹配失败,全部走了网页降级。
那次事故之后,我把团队的 Universal Links 配置加了一条规范:线上所有投放域名的子域都要在 AASA 的 paths 里显式声明,或者在路径规则里用通配符统一接住。大促活动临时新增子域的情况很常见,提前规划好域名规则能省掉一大笔流量损失。
写在最后
如果你问我在这个项目里最大的体会是什么,我会说 Deep Link 这套东西,表面上是技术配置,实际上是把"用户从哪儿来"和"用户要去哪儿"重新用链接串起来的过程。方案选型别贪新,Universal Links 确实好用,但 URL Scheme 的兜底价值在 WebView 场景里依然不可替代;参数投递别图省事,冷启动和热启动的时序差异,不上真机踩一次坑永远想象不到。每次新游戏上线,我都会把第 4 节的联调 checklist 完整跑一遍,确认两条链路、三个生命周期入口、C# 层的场景等待逻辑全都没问题,才敢把投放链接交出去。希望这套流程能让你少走几个月的弯路。