做投放和分享回流的时候,Deep Link 的坑真是一个接一个。之前上线的一款 Unity 手游,投放侧同事反馈说“广告点击之后有时候能直接进活动页,有时候就落在主城首页”,排查到最后发现 URL Scheme 和 Universal Links 走了两套链路,冷启动参数还经常因为投递太早直接丢了。这类问题在 Unity 项目里尤其突出,因为中间隔着一层 iOS 原生模板和 C# 场景,任何一环掉链子,玩家感知就是“打开了 App 但没反应”。
这篇文章把 Unity 手游 iOS Deep Link 的完整链路从头理一遍:URL Scheme 和 Universal Links 怎么选型、原生侧怎么配置、原生回调怎么把参数投递给 C# 层、冷启动和热启动的参数时序怎么处理,最后是真机验证和线上故障的排查方法。不管你是客户端开发还是技术负责人,只要产品正在做买量归因、分享回流、活动直达,这篇应该能帮你省下不少排查时间。
1. 先分清 URL Scheme 和 Universal Links:两种唤醒通道的差异与取舍
1.1 手游里最先被 Deep Link 触达的业务:归因与回流
很多团队第一次接触 Deep Link,不是因为自己要实现什么闭环,而是因为投放平台或归因平台逼着你接。渠道链接点击之后,设备上如果已经装了你的 App,系统会把链接直接甩给 App,链接里带着 campaign_id、click_id、渠道号之类的参数。归因 SDK 拿到这些参数之后,才能把“这次安装/这次启动”和“某一次广告点击”对上账,最终形成买量后台的激活、付费归因报表。
另外一条线是分享回流。游戏里做公会邀请、好友助力、活动分享,生成一个 H5 或 App 内链接,玩家 A 发给玩家 B,B 点开的一瞬间,如果 B 装了 App,就直接拉起来并跳转到对应的活动页面;没装就落到一个下载中间页。这个体验看着简单,实际背后就是 Deep Link 在做事。
所以 Deep Link 本身不是“一个新功能”,而是承载投放归因、增长回流、运营活动触达的底层通道。在 Unity 手游里这条链路尤其绕,因为没办法只在 C# 层搞定,必须在 iOS 原生层接收系统回调,再把参数递到 Unity 场景里的业务模块。
1.2 两种方案的第一性对比:体验、未安装场景与配置成本
iOS 上做 Deep Link 有两条主流通道:URL Scheme(自定义协议,比如mygame://open?...)和 Universal Links(Universal Links,标准的https://你的域名/...)。
URL Scheme 是老方案,iOS 2.0 就有了,配置成本最低,Xcode 里填一个 scheme 字符串即可。但它有几个天生短板:第一,如果 App 没有安装,系统无法帮你自动处理,你拿到的只会是一个“Safari 打不开该网页”的错误页;第二,跳转前 iOS 会弹一个确认框,大概意思是“是否打开 App”,多一次拦截,用户转化就低一截;第三,scheme 不是全局唯一,如果你的 scheme 和另一个 App 撞了,系统选谁完全是玄学。
Universal Links 是 Apple 在 iOS 9 推出的方案,本质上是让 App“认领”一段 HTTPS 域名下的 URL。用户点击https://yourgame.com/app/...,如果装了 App,无弹窗直接拉起;如果没装,Safari 正常打开这个 URL,你可以在这个 URL 对应的网页上做下载引导。它才真正解决了“已装唤醒,未装落地”的完整闭环。
我对这两者的对比常用一张表格:
| 对比项 | URL Scheme | Universal Links |
|---|---|---|
| 链接形式 | mygame://path | https://你的域名/path |
| 首次点击体验 | 弹系统确认框,多一步 | 无弹窗,直接拉起 |
| 未安装 App 时 | 系统报错,只能靠 H5 fallback | Safari 打开对应网页,可做下载跳转 |
| 是否会被其他 App 抢占 | 会,scheme 冲突无解 | 不会,AASA 文件校验域名所有权 |
| 配置成本 | Info.plist 加一项即可 | entitlements + 服务器 AASA 文件 |
| iOS 系统兼容 | 全版本 | iOS 9 及以上 |
1.3 手游场景下“主备双通道”是常规部署方式
实际业务里我很少只配一种。Universal Links 作为主通道,承担所有新用户分享链接、投放链接、活动链接的唤起;URL Scheme 作为兜底,主要面向那些老版本 App 没做过 Universal Links 适配、或者某些用户从第三方 App 内走系统逻辑时触发异常的兼容场景。
链接被人分享到 QQ、微信、钉钉里时,这些第三方 App 对 Universal Links 的拦截行为各不一样,有些版本会坚持用 WebView 先兜一下。这时候埋一层 URL Scheme 兜底,仍然可以尝试拉起。所以线上配置时,两条通道我会同时做,且都投递到同一个 C# 入口,由业务侧统一判断参数。
2. 原生侧配置全流程:Info.plist、AASA 文件与回调入口的配合
2.1 先配 URL Scheme:Xcode 里的几分钟,却常被忽略的细节
URL Scheme 的配置很简单:打开 Xcode,选中 TARGETS -> Info -> URL Types,点击加号,在 URL Schemes 里填mygame,Identifier 一般填反向域名,比如com.yourcompany.gametitle。
实际写入 Info.plist 的字段是这个结构:
<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleTypeRole</key> <string>Editor</string> <key>CFBundleURLName</key> <string>com.yourcompany.gametitle</string> <key>CFBundleURLSchemes</key> <array> <string>mygame</string> </array> </dict> </array>这里有两个容易踩的细节。一是 scheme 名称不要太短太通用,wx、alipay这种属于大厂专用,小团队如果取一个三个字母的通用 scheme,很可能和其他 App 撞车。我一般方案是“品牌缩写 + 业务缩写法”,比如xxgameopen,既好识别又降低冲突概率。二是一个工程里往往同时接微信登录、支付宝支付、分享 SDK,只要是从第三方集成拷贝过来的工程,注意保留它们的 scheme,不要删掉,否则登录支付会莫名失效。
URL Scheme 原生监听代码要放在 AppDelegate 或者 SceneDelegate,取决于工程是否启用了 UIScene 生命周期。这块在 2.3 里展开,先记住一个结论:哪个入口收,取决于工程模板。
2.2 Universal Links 的 AASA 文件与 Associated Domains 配置
Universal Links 的配置比 URL Scheme 多不少,但每一环都有对应的验证方法。先从 App 侧说起。
Xcode 里需要开启 Associated Domains 能力,添加applinks:yourgame.com。注意这里只能写域名,不要画蛇添足加路径,路径控制在服务器 AASA 文件里做。
服务器侧需要放一个 Apple 会去抓取的 JSON 文件,路径是:
https://yourgame.com/.well-known/apple-app-site-association也有团队放在域名根路径/apple-app-site-association但 Apple 文档优先推荐/.well-known/。文件内容长这样:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID1234.com.yourcompany.gametitle", "paths": ["/app/*"] } ] } }appID的格式必须是TeamID.BundleID拼接,TeamID 在 Apple Developer 后台的 Membership 页面能看到。paths是 App 要认领的 URL 路径,*代表全路径。线上建议别用["*"]把整个域名下所有页面都唤醒,而是限定在/app/*,这样网页站点的普通页面不会被 App 抢走。
AASA 文件有几个很关键的验证点:
- 必须走 HTTPS,不能用自签名证书;
- 服务器响应不要做 302 到登录页或 CDN 上内容不一致的地址;
- 文件不需要是
.json后缀,Apple 抓取的固定路径是上面那个; - 有些 CDN 默认会拦截 User-Agent 里带
com.apple的标识,导致 Apple 抓不到。
配置完可以用 curl 先自查一遍:
curl -k -v https://yourgame.com/.well-known/apple-app-site-association顺便用 Python 校验一下 JSON 格式:
curl -s https://yourgame.com/.well-known/apple-app-site-association | python3 -m json.tool如果输出的是年级生看不太懂的三段式报文,把小段 JSON 缩进打印出来,确认里面 appID 没写错。iOS 对 AASA 文件有相当长时间的缓存,测试时改了文件不一定立刻生效,这是正常现象,后面故障排查部分会细说。
2.3 回调入口究竟放 AppDelegate 还是 SceneDelegate
这是很多 Unity 工程"收不到回调"的第一大原因。iOS 13 之前,App 启动代理入口只有 AppDelegate,所有 openURL 和 continueUserActivity 都集中处理。iOS 13 之后如果工程启用了 SceneDelegate 生命周期,系统会把这类回调投递到UISceneDelegate而不是 AppDelegate。
Unity 默认生成的 iOS 工程比较传统,通常是 AppDelegate 处理。但如果你是从 Xcode 原生模板改过来的、或者手动勾选了 Scene lifecycle,那就必须到 SceneDelegate 里处理。一个工程里同时两个都收到回调的情况也存在,尤其是第三方 SDK 自己又模拟触发一次的时候。
我现在更推荐的做法是:写一个原生侧的桥接层,统一接收两端回调。AppDelegate 和 SceneDelegate 都实现回调方法,但内部都调用同一个分发函数,用 URL 字符串做一次去重。道理很简单——我见过多个项目因为两边都写了发送逻辑,导致 C# 层同一个 Deep Link 被处理两遍,玩家点一根链接跳两次活动页。
AppDelegate 里的完整处理代码,以 Objective-C 为例:
#import "UnityAppController.h" // 统一分发函数 static void DispatchDeepLink(NSString *urlString) { if (urlString.length == 0) return; UnitySendMessage("DeepLinkManager", "OnNativeDeepLinkReceived", urlString.UTF8String); } // 处理 URL Scheme - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options { if ([url.scheme isEqualToString:@"mygame"]) { NSString *raw = url.absoluteString; // 先暂存,冷启动时 C# 侧随时可以取 [[DeepLinkStore shared] savePendingUrl:raw]; DispatchDeepLink(raw); } return YES; } // 处理 Universal Links - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url = userActivity.webpageURL; if (url) { NSString *raw = url.absoluteString; [[DeepLinkStore shared] savePendingUrl:raw]; DispatchDeepLink(raw); return YES; } } return NO; }如果你确实要在 SceneDelegate 里处理,对应的 Swift 代码类似:
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { guard let url = URLContexts.first?.url else { return } UnitySendMessage("DeepLinkManager", "OnNativeDeepLinkReceived", url.absoluteString) } func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return } UnitySendMessage("DeepLinkManager", "OnNativeDeepLinkReceived", url.absoluteString) }Swift 调用UnitySendMessage有一个前置条件:需要通过 bridging header 把 Unity 的 C 接口暴露给 Swift,否则编译器认不出这个函数。Unity 模板工程默认全是 Objective-C,引入 Swift 文件时需要额外处理。纯新手不建议硬上 Swift,在既有 ObjC 模板里加一个 Category 是最稳的路线。
2.4 冷启动 vs 热启动:时机的本质差异
原生层拿到 URL 只是第一步,真正麻烦的是投递时机的选择。
热启动相对简单:App 进程活着,哪怕是退到后台,场景里的 GameObject 和 C# 组件都是已经加载好的。原生回调一触发,UnitySendMessage直接能打到一个活跃对象。
冷启动则完全相反:玩家点链接,系统直接拉起一个已经被杀掉进程的 App。原生回调触发时,Unity 场景可能还在加载、C# 脚本还没执行到Awake、你要接收消息的 GameObject 可能还不存在。这时候如果直接UnitySendMessage,目标对象找不到,消息直接丢弃,Play 端看起来就是“点链接进来了但什么都不发生”。
所以处理 Deep Link 必须把冷启动和热启动当成两套时序设计,而不是一个简单的“收到回调就跳转”。最优雅的兜底方案是原生侧先把 URL 暂存起来,等 C# 场景准备好之后主动去取,或者交给 Unity 官方的事件机制,它在内部已经处理了这个时序。
3. C# 层接参数:UnitySendMessage 的时机竞态与延迟投递方案
3.1 UnitySendMessage 的两个前提条件
UnitySendMessage是 iOS 原生层向 Unity 脚本层投递字符串消息的标准接口,但它有两个前提:第一,目标 GameObject 必须存在于当前场景,而且处于 active 状态;第二,目标方法必须是MonoBehaviour上的public void Xxx(string message)签名。
原生侧调用通常长这样:
UnitySendMessage("DeepLinkManager", "OnNativeDeepLinkReceived", urlString.UTF8String);C# 侧对应的脚本:
public class DeepLinkManager : MonoBehaviour { public void OnNativeDeepLinkReceived(string rawUrl) { Debug.Log($"[DDL] native received: {rawUrl}"); HandleDeepLink(rawUrl); } }方法名、类名拼错一个字,消息都会石沉大海。而且UnitySendMessage在冷启动时如果发现目标 GameObject 不存在,通常只会打一条错误日志,代码不会 crash,线上出问题时你根本看不到这条错误,所以排查起来特别讨厌。我的习惯是把接收脚本挂在常驻场景的 DontDestroyOnLoad 物体上。
3.2 原生侧投递代码:以 ObjC 为例的完整实现
我把 2.3 里的演示扩展成一份能直接抄的版本。包括一个简单原生缓存,供冷启动时 C# 主动拉取。
先定义一个单例用来暂存 URL:
// DeepLinkStore.h #import <Foundation/Foundation.h> @interface DeepLinkStore : NSObject + (instancetype)shared; - (void)savePendingUrl:(NSString *)url; - (NSString *)takePendingUrl; @end// DeepLinkStore.m #import "DeepLinkStore.h" @implementation DeepLinkStore { NSString *_pendingUrl; } + (instancetype)shared { static DeepLinkStore *store = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ store = [[DeepLinkStore alloc] init]; }); return store; } - (void)savePendingUrl:(NSString *)url { _pendingUrl = [url copy]; } - (NSString *)takePendingUrl { NSString *url = _pendingUrl; _pendingUrl = nil; return url; } @end然后 AppDelegate 的回调里调用这个单例保存,同时UnitySendMessage尝试直接投递。如果冷启动场景没准备好,至少 URL 还在原生层存着:
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options { if ([url.scheme isEqualToString:@"mygame"]) { NSString *rawUrl = url.absoluteString; [[DeepLinkStore shared] savePendingUrl:rawUrl]; UnitySendMessage("DeepLinkManager", "OnNativeDeepLinkReceived", rawUrl.UTF8String); return YES; } return NO; }C# 侧在初始化完成之后主动拉一次原生暂存:
[DllImport("__Internal")] private static extern string _deepLinkTakePendingUrl(); private void Awake() { // 场景初始化完成后,检查原生侧是否有冷启动暂存的 URL string pending = string.Empty; try { pending = _deepLinkTakePendingUrl(); } catch (Exception e) { Debug.LogWarning($"[DDL] native store not ready: {e.Message}"); } if (!string.IsNullOrEmpty(pending)) { HandleDeepLink(pending); } }这里要留一个说明:[DllImport("__Internal")]只会在真机 iOS 上生效,编辑器里运行会直接抛 EntryPointNotFoundException,加上 try/catch 是为了编辑器测试不炸。测试阶段可以用#if UNITY_IOS && !UNITY_EDITOR包一层,或者交给 Unity 官方事件通道来统一处理,下一节说。
3.3 冷启动竞态:官方 DeepLink API 与自建暂存方案选哪个
其实从 Unity 2019.3 开始,引擎已经内置了 Deep Link 接收能力,不需要我们跟原生层反复拉扯。官方提供了两个入口:
Application.absoluteURL:冷启动时,在脚本 Awake 阶段就能读到的启动 URL;Application.deepLinkActivated:热启动或后续每次被唤起时触发的事件。
推荐做法是直接在 C# 脚本 Awake 里注册事件,并同时读取absoluteURL:
private void Awake() { Application.deepLinkActivated += OnDeepLinkActivated; if (!string.IsNullOrEmpty(Application.absoluteURL)) { HandleDeepLink(Application.absoluteURL); } } private void OnDestroy() { Application.deepLinkActivated -= OnDeepLinkActivated; } private void OnDeepLinkActivated(string url) { HandleDeepLink(url); }为什么要吃这个方案?因为 Unity 已经把 AppDelegate/SceneDelegate 两侧的系统回调都收拢了,冷启动时序、场景加载时序都处理过一遍。注册事件时如果启动 URL 已经存在,它还会立刻补发一次。这比自己写原生暂存要省心得多。
那我为什么还写了自建暂存那一套?因为有两类场景绕不开原生层。一类是你接了 AppsFlyer、Adjust、GrowingIO 这类归因 SDK,它们也会自己监听openURL和continueUserActivity,你希望把原始 URL 先交给 SDK 解析归因参数,再拿到结果后做业务跳转;另一类是投放渠道要求你在原生层做一些签名校验、风控过滤,不允许原始 URL 原样直达 C#。这种情况下,原生层写暂存 + C# 侧主动拉取,仍然是最可控的兜底。
个人实测感受是:iOS 16 之后如果游戏用了 SceneDelegate 生命周期,自建暂存方案在极端冷启动条件下偶尔会丢参数,切到官方deepLinkActivated之后一直很稳。所以新项目我默认推荐官方 API 优先,原生代码只保留给归因 SDK 或风控环节处理。
3.4 参数解析与 URL 编码:中文、特殊字符、多字段怎么处理
无论从官方事件还是原生投递拿到 URL,下一步都是解析参数。一条典型链接长这样:
mygame://open?scene=activity&id=12345&from=shareUniversal Links 版本就是:
https://yourgame.com/app/open?scene=activity&id=12345&from=shareC# 侧解析 query 我通常用System.Uri配合Uri.UnescapeDataString,不要再用老旧的WWW.UnEscapeURL,后者在较新 Unity 版本里已经边缘化。完整解析代码:
using System; using System.Collections.Generic; private Dictionary<string, string> ParseQuery(string rawUrl) { var result = new Dictionary<string, string>(); if (string.IsNullOrEmpty(rawUrl)) return result; var uri = new Uri(rawUrl); string query = uri.Query; // 返回的 query 开头会带 '?' if (string.IsNullOrEmpty(query)) return result; query = query.TrimStart('?'); string[] pairs = query.Split('&'); foreach (string pair in pairs) { if (string.IsNullOrEmpty(pair)) continue; string[] kv = pair.Split('='); if (kv.Length == 2) { string key = Uri.UnescapeDataString(kv[0]); string value = Uri.UnescapeDataString(kv[1]); result[key] = value; } } return result; }注意几个细节。第一,投放平台生成的链接里,渠道参数值很可能已经做了一次 URL 编码,里面可能包含%2F、%3A这种编码结果,解析时用Uri.UnescapeDataString还原,不要手动拼接。第二,如果参数值里包含&,生成链接时必须先用UrlEncode处理整个 value,否则切分后字段会错位。第三,不要直接把完整 URL 打到日志里,尤其链接里带 token、user_id 时,线上日志很容易把敏感信息完整暴露,我一般只打 channel、scene、id 这几个业务字段。
4. 真机验证与线上排雷:Universal Links 失效、重复回调、归因 SDK 打架
4.1 一条完整的真机验证清单,覆盖冷热启动和不同入口
代码写完不是结束,验证才是大头。我在接入 Deep Link 时一定会跑一套固定清单,每一条都要在真机上过:
| 验证场景 | 操作方式 | 预期结果 | 检查点 |
|---|---|---|---|
| 冷启动 + Universal Links | 杀掉 App 进程,清后台,从备忘录/浏览器点链接 | 拉起后进入约定落地页 | C# 收到完整参数,无重复处理 |
| 热启动 + Universal Links | App 在前台/后台,点链接 | 无弹窗直接拉起,业务跳转生效 | 参数完整,不打断对局 |
| 冷启动 + URL Scheme | 杀掉进程,从微信/QQ 内点 scheme 链接 | 拉起进入约定落地页 | 系统可能弹确认框,属于正常 |
| 热启动 + URL Scheme | App 退后台,从 Safari 输入 scheme | 拉起并处理参数 | 观察确认框,用户点击才进入 |
| 未安装 App | 用一台未安装 App 的设备点 Universal Link | Safari 打开对应页面,不报错 | 中间页能引导到 App Store |
| 从第三方 App 内打开 | 在微信、抖音、QQ 内点分享链接 | 尽量唤起或落到 H5 | 各 App 拦截策略不同,观察 fallback |
这条清单建议每次发版前跑一遍,尤其是 iOS 大版本更新后,很多第三方 App 对 Universal Links 的拦截方式会变化。不要只在开发和测试自己手机上看一眼,那是最大的错觉来源。
4.2 Universal Links 不生效的排查链路
老规矩,先给排查链路而不是直接给答案。线上最常见的症状是“点了 Universal Links 链接,装在手机上的 App 就是不唤起,反而在 Safari 里打开了页面”。这个时候按顺序查四层:
第一层:检查 AASA 文件是否真的能抓到且内容正确。
curl -k -v https://yourgame.com/.well-known/apple-app-site-association重点看返回的 JSON 里appID是不是TeamID.BundleID,paths是不是覆盖了你实际点击的路径。如果 paths 写的是/app/*,点击的却是https://yourgame.com/share/xxx,那自然无法唤起。
第二层:检查 Associated Domains 是否写进签名。Xcode 的 Signing & Capabilities 里能看到 Associated Domains,对应 entitlements 文件里应该有这个 key:
<key>com.apple.developer.associated-domains</key> <array> <string>applinks:yourgame.com</string> </array>这个 domain 的域名必须和 AASA 文件所在域名完全一致,不要出现 entitlements 写applinks:yourgame.com,实际链接却是www.yourgame.com然后 AASA 只放在裸域的情况。
第三层:确认系统是否已经抓到新的 AASA。iOS 对 AASA 的缓存时间很长,开发者改完服务器配置后,同一台测试机可能半天内都用旧缓存。有些团队会用“重装 App 之后再试”,有效但不绝对。测试时想主动触发重新拉取,可以把 App 从后台彻底杀掉、开关飞行模式、重新点链接;还不行就换一台从没装过这个 App 的测试机,通常能拿到一次全新拉取。这一点在排查时很容易卡住,改成新设备后链路通了,基本就是缓存问题。
第四层:检查系统日志确认 Universal Link 是否匹配。真机连接 Mac,打开 Console.app 或终端跑:
log stream --predicate 'subsystem == "com.apple.Network" AND eventMessage CONTAINS "apple-app-site-association"'然后点击链接,观察系统是否真的请求了 AASA,以及有没有返回错误。如果日志完全没有请求记录,那问题大概率在 Associated Domains 签名或链接域名不匹配;如果有请求但返回 404,那就回到第一层查服务器。
常见故障与根因我做成了一张表:
| 症状 | 最可能的根因 |
|---|---|
| 链接在 Safari 打开,完全不进 App | AASA 404、paths 不匹配、entitlements 没配 |
| 进 App 了但收不到参数 | SceneDelegate 和 AppDelegate 入口选错、C# 脚本没挂对 |
| 参数中文乱码 | 生成链接时未对 value 做 URL 编码 |
| 同一链接处理了两遍 | AppDelegate 和 SceneDelegate 双份投递且没去重 |
| 冷启动丢参数 | UnitySendMessage在场景未就绪时执行,没做缓存/官方事件 |
4.3 归因 SDK 与自研 Deep Link 处理为什么容易打架
这是很多 Unity 团队线上翻车的高发区。AppsFlyer、Adjust 这些 SDK 在 iOS 层也会去监听openURL和continueUserActivity,它们需要拿到原始 URL 里的归因参数,然后报给后台。
如果你在自己的 AppDelegate 里把 URL 拦截了,或者先消费了,SDK 可能收不到完整数据,后果是后台归因面板出现大量 unmatched install,买量 ROI 算不出来。反过来说,如果 SDK 先消费了,你的业务 Deep Link 再拿到的时候,可能 URL 已经被改写得只剩业务参数,和广告渠道无关了。
我的统一处理原则是:原生层永远是第一消费者,把原始 URL 原样转发给归因 SDK 的接口;SDK 解析完归因之后,如果业务还需要,通过 SDK 的 deep link 回调或者延迟回调把业务链接交给 C# 层。不要在 AppDelegate 里自己先做一套if (url.scheme == "mygame")的完整处理,然后把 SDK 晾在一边。
尤其是接 AppsFlyer 的 Unity 工程,如果同时使用Application.deepLinkActivated和 AppsFlyer 的深度链接回调,你会发现一个链接可能触发两遍业务逻辑。这是因为 Unity 官方事件本身会把链路转发一次,SDK 内部又转发一次。解决办法是统一只信任一个源头:要么全部从归因 SDK 的回调拿链接,要么全部从Application.deepLinkActivated拿,另一个入口只做去重或直接关闭。
4.4 落地页跳转的时机:拿到了参数不代表可以立刻跳转
C# 层解析完参数后,很多团队直接SceneManager.LoadScene("ActivityScene"),然后线上就会多一撮闪退、黑屏报告。原因很粗暴:Deep Link 到达时,游戏可能还没有初始化完成,登录态还没建立,资源还没加载,活动场景数据也还没缓存,直接跳过去自然各种 null。
我的习惯是拆两段:
- 冷启动时,先只存参数,不立即跳转。等游戏初始化完成、玩家进入主城或登录态就绪后,再触发统一路由;
- 热启动时,如果正在战斗或者弹窗流程中,自动跳转会打断玩家,最稳妥的是弹一个确认框“是否前往活动页面”,玩家点了再跳。
落地路由可以做成一个独立的DeepLinkRouter,它不依赖具体场景,只是把一个pendingRoute存起来,等到业务层通知“初始完成”后消费。这样既不会抢在初始化前炸,也不会丢参数。
5. 上线前最后一轮自查清单与经验收尾
5.1 我每次发版前必跑的检查项
快到发版节点,我会把下面这张表再过一遍,任何一个检查项打不了勾就不发:
| 检查项 | 验证方式 |
|---|---|
| AASA 文件可访问 | curl -I -k https://yourgame.com/.well-known/apple-app-site-association |
| AASA 中 TeamID.BundleID 正确 | 打开 JSON 逐字符检查 |
| Associated Domains 已签名 | Xcode 看 entitlements |
| URL Scheme 未被 SDK 覆盖 | Info.plist 里对比所有 scheme |
| 冷/热启动两条链路都通 | 真机按 4.1 清单跑一遍 |
| 未装 App 设备有 fallback | 另一台无该 App 的设备点击链接 |
| 归因 SDK 后台有打开记录 | 打开归因后台看事件流 |
| C# 侧无重复回调日志 | 检查日志里有没有同一条 URL 被打印两次 |
| 业务参数能正确落地 | 用带参数链接进游戏,确认到达指定页面 |
如果团队有自动化测试环境,我把这几步也写进了自动化用例,跑一次至少能发现链路配置级别的回归,而不是等到买量同事投诉才发现。
5.2 一些可能让后续迭代更省力的扩展建议
接完基础 Deep Link 之后,我还会建议产品侧尽快考虑 deferred deep link,也就是“延迟深度链接”。玩家点开链接时没装 App,先落到下载中间页,系统记住这次点击和携带参数,等玩家装好 App 第一次启动时,归因 SDK 会把之前的参数补偿回来。Unity 官方 API 和 AppsFlyer、Adjust 都有现成支持,实现成本远低于自己写一套,但对拉新和投放下载转化率的帮助非常直接。
另一个比较实用的习惯是给 Deep Link 相关日志统一加[DDL]前缀,原生层和 C# 层都打。线上排查冷启动丢参数、重复回调时,用日志过滤[DDL]能看到完整的一跳链路,效率比翻一堆 UnityEngine 日志高太多。整个链路里最容易让人头大的就是时序问题,加了日志之后,哪些消息先到、哪些后到,一目了然,很多“偶现”问题其实几十秒就能定位到哪一环掉了。
最后再分享一个体会:Deep Link 这个事,配置层面看起来都是 Apple 的固定格式,但真正决定线上质量的是“谁先消费 URL、什么时候消费、消费完之后参数放哪里”这三个设计决策。别急着把代码堆上去,先把入口统一、时序理顺,后面在投放和回流上省下的心思,远比刚开始多写的那点原生代码值钱。