☰
Unity手游iOS Deep Link唤醒全链路:冷启动参数不丢的方案
2026/10/1 13:07:23 网站建设 项目流程

你应该也遇到过这样的情况:买量落地页做得挺漂亮,用户填了手机号、领了礼包,结果跳到App Store下载安装完,打开游戏一切从头开始——弹窗没了、礼包没了、连刚才填过信息的痕迹都没有。不是说好了点击-激活-归因吗?怎么链路说断就断。

问题出在大部分团队只做了“能下载App”这一步,没做“能让打开后的App认识到自己是被谁带进来的”这一步。这一步在iOS上就是Deep Link。说白了,Deep Link是iOS系统把启动App前后的来源信息交给App的一种机制,在Unity手游里,我们要做的是接住它,然后把它投递给C#层,让游戏逻辑决定接下来是弹回归礼包、直接进房间,还是默默记一个归因渠道。

这篇文章不绕弯子,直接把Unity手游iOS Deep Link唤醒完整链路讲透,从URL Scheme和Universal Links的选型,到原生侧关联域名和验证文件配置、回调拦截、参数暂存,再到C#层用UnitySendMessage和主动拉取两种方式接收参数,最后把真机调试和几个高频坑一起点掉。适合Unity客户端开发、客户端主程,以及负责买量归因和渠道接入的同学参考。

1. 为什么手游离不开Deep Link:三个真实到不能再真实的场景

1.1 投放归因:点击、下载、打开这条链路必须能串起来

做买量的人都知道一句话:没有归因,投放就是往水里扔钱。iOS这边,从广告点击到App Store下载,再到用户打开App,中间隔着好几次系统跳转。系统本身并不会告诉你“这个用户是看了哪条广告素材、点了哪个按钮才装上你的App的”,谁告诉你?链接参数告诉你。

当用户在落地页点击“立即下载”时,那个下载按钮的跳转链接往往带着一串参数,比如渠道ID、广告组ID、素材ID,或者一个归因token。用户下载安装完,第一次打开App,就需要去请求激活归因接口,把这串token传回去验证激活来源。而这个token从哪来?就是落地点到App内部的那个链接参数。

如果App没有Deep Link能力,或者链接参数没解析成功,激活请求里就带不上来源信息,归因平台只能按“自然量”算,投放团队ROI瞬间说不清,优化师能跟你急到半夜。

1.2 用户召回:一条短信或推送把流失用户拉回游戏

游戏上线一段时间后,活跃用户下滑是常态。运营同学最常见的召回手段是给流失用户发短信、推送、邮件,里面放一条链接,用户一点,希望直接回到游戏领召回礼包。这里如果只是打开游戏首页,用户还得自己去选服、找角色、找礼包入口,很多人多点两下就不玩了。

召回链接里带上玩家ID、老角色ID、礼包码,用户点击后直接拉起游戏,游戏内弹“欢迎回来,这是你的专属回归礼包”,转化率能高不少。我见过有项目做好Deep Link和没做好之间的唤醒后转化率能差两倍还多。

1.3 社交裂变:邀请码跟链接绑在一起

另外一个高频场景是玩家邀请好友。玩家把邀请链接发到微信、QQ或朋友圈,好友点击后如果是新用户,下载安装完打开游戏,直接进注册起名阶段,然后自动填上邀请人ID,双方领奖励;如果是老用户,则直接跳回主城并提示邀请成功。没有Deep Link,这个体验就得靠玩家手动输入邀请码,这一步流失非常感人。

这三个场景的共同点不是简单“打开App”,而是“打开App的同时,把URL上承载的业务参数精准投递到游戏逻辑层”。

1.4 认清一个现实:Deep Link只是传输通道,不解决业务转化

期望值得摆正。Deep Link是iOS系统给App的一条“附带参数的启动通道”,它能保证的是“参数从链接到App进程,再到Unity C#这一路不丢、不串、不乱”。至于拿到参数后怎么设计落地页、怎么发礼包、怎么引导用户,那是运营和策划的事。技术侧的职责,就是把这条通道建得又稳又通用。

2. URL Scheme 与 Universal Links 选型:别等上线才发现白做

2.1 URL Scheme:最容易接入却也最脆弱的旧路

URL Scheme是iOS早期就支持的机制,格式类似mygame://open?scene=1&room=2。只要App的Info.plist里注册了mygame这个scheme,系统就会在检测到该scheme被打开时回调App。

优点很直白:

  • 配置简单,只改plist,不依赖服务器。
  • 模拟器和真机都支持,开发调试方便。
  • 老版本iOS也都支持。

缺点同样明显:scheme名字如果撞了别的App,尤其是weixin、alipay这种大众化的,系统只能把链接交给最匹配的一个App,同名冲突时还有“在XX中打开吗”的确认弹窗,体验割裂;而且URL Scheme在浏览器拉起时会先弹系统确认框,多一步确认就意味着投放落地页的跳转折损;更难受的是Scheme在App没装的时候直接报“无法打开网页”,没法引导去App Store下载,这对投放场景几乎是致命的。

实际上现在很多投放平台已经不给足量Scheme流量了,尤其是iOS 9之后Universal Links推出,苹果官方一直在引导开发者把深度链接能力迁过去。

2.2 Universal Links:苹果主推的“正统”方案

Universal Links的思路是:把域名和App绑定。你有一个备案好的HTTPS域名,配置好关联域名(Associated Domains),并且把apple-app-site-association验证文件放到域名根目录,iOS系统就承认“这个域名下的链接,可以在App已安装时直接唤醒App”;没安装时仍然打开网页,或者落到App Store,行为不割裂。

优点是取消了不少弹窗,唤起顺滑;同一个链接天然覆盖“未安装跳网页、已安装拉App”两种场景;域名机制也方便和投放归因平台配合,很多平台直接把归因链接做成短链。

缺点也很实在:必须真机测试,模拟器不支持;配置链路长,涉及开发者后台、描述文件、Xcode Capability、服务器文件上传;受服务器可达性、HTTPS证书、CDN缓存影响,排查麻烦;微信、QQ内置浏览器的兼容性说不清,业内普遍用“域名白名单、优先进落地页兜底”的思路来规避。

实际投放中还有一个容易忽略的点:Universal Links对重定向有要求。投放短链点击后通常会302跳到最终落地页,如果这个跳转链路上有一段域名没配置过关联关系,唤醒就有概率失败。所以接入投放平台时,最好让平台方把归因域名提前加到关联域名配置里,或者使用平台方提供的白名单域名。

2.3 我的选型结论:两条腿走路

我的建议是URL Scheme和Universal Links都做,不要二选一。

  • 游戏对外展示的链接、投放归因链接优先用Universal Links,体验好,装没装App都不会被系统“拦路”。
  • URL Scheme留着做三方App直接拉起的兼容通道,比如渠道SDK、客服系统、老版本客户端的外呼链接,以及模拟器和内部测试时方便调试。
  • C#层接收时,两种来源统一转成一种内部格式(自定义DeepLinkData结构),避免业务层去区分今天是从哪条路进来的。

3. iOS原生侧配置:plist、Associated Domains 与验证文件

3.1 URL Scheme 的 plist 配置

先给基础配置。在Xcode的Info.plist里添加CFBundleURLTypes数组,里面按App配置一个字典:

<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLName</key> <string>com.yourcompany.mygame</string> <key>CFBundleURLSchemes</key> <array> <string>mygame</string> </array> </dict> </array>

注意几个点:CFBundleURLName只是标识,不影响实际唤起;CFBundleURLSchemes里可以写多个scheme,比如同时兼容mygame和mg;改了Info.plist后需要重新签名安装。如果是在Unity打包生成Xcode工程后手动改的,记得每次出包后重新贴一遍,团队里最好是写一个打包后处理脚本自动patch,否则漏一次就是事故一次。

3.2 Universal Links 的系统侧配置

Universal Links配置分三步,任何一步漏了整体就不生效。

第一步,在Apple Developer后台找到App的Bundle ID,打开Associated Domains能力开关,确认后重新生成或更新Provisioning Profile。

第二步,在Xcode工程里,Signing & Capabilities里点+ Capability,添加Associated Domains,然后在Domain列表里填入:

applinks:yourgame.com

注意格式是applinks:前缀加你的域名,不要加https://。

第三步,在域名服务器根目录或/.well-known路径上传验证文件。苹果会先后尝试根目录和/.well-known两种位置,业界稳定做法是放在https://yourgame.com/.well-known/apple-app-site-association,并确保HTTPS证书有效。

文件内容示例:

{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.yourcompany.mygame", "paths": [ "*" ] } ] } }
  • appID是Team ID加Bundle ID拼接,Team ID在开发者后台Membership里查看。
  • paths支持通配符,*表示整个域名下所有URL都能唤醒,也可以精确到/invite/*。建议初期先用*,后面需要收紧再限制。
  • 文件不能带BOM头,文件名大小写要严格一致,不要放在会被代理或CDN篡改的路径。

3.3 配置是否生效的快速自检方法

改完验证文件后,先命令行确认文件能否拉取:

curl -I https://yourgame.com/.well-known/apple-app-site-association

主要看返回状态是不是200,Content-Type最好设置成application/json,有些服务器即使返回text/plain也能认,但保险起见统一用application/json。

这里必须说一下被很多人忽略的缓存现象:Universal Links的验证文件会被苹果CDN缓存一段时间,不是改完立刻生效。开发测试时改了paths,把链接粘到备忘录里等几秒再试;实在不行开一下Safari无痕模式,或者把App删掉重装,让系统重新拉取验证。很多“配置了但不生效”的案例,最后都是缓存惹的祸。

4. 原生代码拦截与参数存储:C# 还没醒来时的临时停车位

4.1 回调入口全梳理:AppDelegate 和 SceneDelegate 一个都不能漏

iOS 13之后苹果引入Scene生命周期,如果你的游戏工程用了UISceneDelegate,URL Scheme和Universal Links的回调有可能会走到SceneDelegate而不是AppDelegate。所以回调要写全,或者至少先搞清楚工程实际走的是哪一套生命周期。

AppDelegate侧写法:

- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable restorableObjects))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url = userActivity.webpageURL; [self storeAndHandleDeeplink:url]; } return YES; } - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options { [self storeAndHandleDeeplink:url]; return YES; }

SceneDelegate侧写法:

- (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url = userActivity.webpageURL; [self storeAndHandleDeeplink:url]; } } - (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts { for (UIOpenURLContext *context in URLContexts) { [self storeAndHandleDeeplink:context.URL]; } }

更省心的做法是让这些回调统一走一个内部方法,比如storeAndHandleDeeplink:,把AppDelegate和SceneDelegate的差异提前消化掉。

4.2 参数解析:别用字符串截取硬拼

拿到URL之后第一步是解析出真正有用的query参数,这里直接推荐NSURLComponents,它会自动把URL的query部分按percent-encoding解码:

NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; NSString *scene = nil; for (NSURLQueryItem *item in components.queryItems) { if ([item.name isEqualToString:@"scene"]) { scene = item.value; } }

这里有个容易踩的坑:URLScheme的query里可能还带#锚点,如果用absoluteString自己截取,非常容易处理错片段。交给NSURLComponents是最省心的选择。

另外再强调一点:参数解析后,建议保存一份标准化后的参数字典,比如转成JSON字符串,而不是保存原始URL。因为原始URL在C#端再解析一次,容易出现双重解码或编码错乱;JSON可以规避这个问题,还能让C#端直接拿到与业务强相关的字段。

4.3 冷启动与热启动:参数要先放在“临时停车位”

这里解释一下整个链路里最容易翻车的冷启动问题。

热启动:App已经跑着,在后台或前台被链接唤起,Unity引擎早就加载完了,原生代码拿到URL后可以直接用UnitySendMessage通知C#层,参数实时到达。

冷启动:App进程被杀掉,用户点击链接后系统拉起App进程,Unity引擎才开始初始化。这时候你调用UnitySendMessage,GameObject的脚本可能还没注册、C#接收方法还没挂上,消息极大概率发不出去,参数直接丢掉。

所以标准做法是原生层不着急“推送”,而是先把参数存到一个临时存储点,等Unity C#层初始化完成之后主动过来“拉取”。最简单的存取点就是NSUserDefaults:

- (void)storeAndHandleDeeplink:(NSURL *)url { NSDictionary *params = [self parseDeeplinkToDictionary:url]; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:params options:0 error:nil]; NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; [defaults setObject:jsonString forKey:@"pending_deeplink"]; [defaults synchronize]; if ([[self unityReadyFlag] boolValue]) { const char *msg = [jsonString cStringUsingEncoding:NSUTF8StringEncoding]; UnitySendMessage("DeepLinkManager", "OnReceiveLink", msg); } }

unityReadyFlag可以在Unity调用过原生方法后置位,也可以简单地让C#在Ready后主动拉一次,两条路相互兜底。

5. Unity C# 层接收:UnitySendMessage、主动拉取与事件分发

5.1 C# 侧声明原生拉取接口

在Unity的iOS平台上要从C#调用Objective-C函数,固定写法是[DllImport("__Internal")]:

#if UNITY_IOS && !UNITY_EDITOR [DllImport("__Internal")] private static extern string _GetPendingDeepLink(); #endif

两个硬性条件必须记住:只能在iOS真机包上调用,Editor里跑会崩,所以必须加#if UNITY_IOS && !UNITY_EDITOR宏隔离;方法名要和Objective-C实现完全一致。

对应的Objective-C实现:

char* _GetPendingDeepLink(void) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; NSString *jsonString = [defaults stringForKey:@"pending_deeplink"]; if (jsonString == nil || jsonString.length == 0) { return strdup(""); } [defaults removeObjectForKey:@"pending_deeplink"]; return strdup([jsonString UTF8String]); }

strdup分配的内存由Unity侧负责释放;这个实现在原生拉取成功后顺手清掉存储位,避免下次启动误取旧链接。

5.2 DeepLinkManager:集中处理和分发

C#侧接收组件建议做成一个独立的常驻单例,挂在DontDestroyOnLoad的空物体上,名字固定为DeepLinkManager,类名和GameObject名保持一致,减少UnitySendMessage查找出错的可能。

using System; using UnityEngine; public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } public event Action<DeepLinkData> OnDeepLinkReceived; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } private void Start() { #if UNITY_IOS && !UNITY_EDITOR string pending = _GetPendingDeepLink(); if (!string.IsNullOrEmpty(pending)) { HandleJson(pending); } #endif } // 由UnitySendMessage回调进入 public void OnReceiveLink(string json) { if (!string.IsNullOrEmpty(json)) { HandleJson(json); } } private void HandleJson(string json) { try { var data = JsonUtility.FromJson<DeepLinkData>(json); if (data == null) return; OnDeepLinkReceived?.Invoke(data); } catch (Exception e) { Debug.LogError($"[DeepLink] 解析失败: {e.Message} json={json}"); } } }

DeepLinkData是业务无关的数据结构:

[Serializable] public class DeepLinkData { public string source; // 来源标识:scheme / universal public string scene; public string roomId; public string inviteCode; public string clickId; public string campaignId; public string rawQuery; // 预留字段 }

JsonUtility的坑要讲清楚:它只认[Serializable]类型,字段名要和JSON key完全一致;如果JSON里包含数组嵌套、复杂对象,就很容易解析失败。这种场景下建议直接换Newtonsoft.Json或System.Text.Json。游戏里已经有第三方JSON库的话,没必要为了贴JsonUtility做额外序列化适配。

5.3 热启动实时推送:战斗中被拉起的处理

热启动时,原生层会直接调UnitySendMessage("DeepLinkManager", "OnReceiveLink", jsonString),这个调用是同步跑到主线程的,所以C#里的事件回调也在主线程执行,理论上没有线程安全问题。

但业务层要做到“打开App立刻进房间”或者“战斗中弹出召回礼包”,还是需要做状态判断,最好在业务侧的GameSystems组件里监听事件:

private void OnEnable() { if (Instance != null) { Instance.OnDeepLinkReceived += HandleDeeplinkEvent; } } private void OnDisable() { if (Instance != null) { Instance.OnDeepLinkReceived -= HandleDeeplinkEvent; } }

另外建议在DeepLinkManager上暴露一个PendingLink属性,业务方可以在自己的初始化流程里自行判断,不一定只依赖事件。这样可以避免“业务系统还没注册完事件,链接已经先到”的时序问题。

6. 真机调试与常见坑:链路全通才算真正接完

6.1 Universal Links 不生效:按顺序排查

如果配置完,真机上点击链接却没有唤起App,下面这个顺序基本能在20分钟内定位问题:

  1. 用curl -I验证验证文件是否可达、是否200。
  2. 检查验证文件里的appID是不是Team ID加Bundle ID,很多人把Team ID和App ID前10位搞混。
  3. 确认Xcode的Associated Domains里域名格式没有带https://,带了就是错。
  4. 删掉App重装,让系统重新拉取验证文件;部分场景可以关Wi-Fi用蜂窝网络试,避免本地HTTP代理干扰。
  5. 在备忘录里输入完整链接,长按链接选择“在Safari中打开”,观察系统反应。
  6. 确认测试手机系统版本在iOS 9以上,以及系统设置里是否给App开启了关联域名权限。

排查过程中,投放平台短链重定向是最容易栽的地方。一个短链点击后大概率会302跳转一次或多次,只有最终落地页域名也在关联域名列表里,Universal Links才认得。遇到投放链接不稳定,就找平台方要域名白名单配置项,或者在投放侧压缩跳转层级。

6.2 参数编码:中文、&号与双重解码

从落地页拼URL时,运营或前端同学很容易漏掉Encode,常见症状是中文参数直接放到URL里导致整个query解析错位;参数值里天然带&导致两个参数混在一起;第三方平台返回的click_id已经做过一次URLEncode,到我们代码里又解了一次,最后变成%252525这种套娃。

建议立几条规矩:所有下游使用URL的时候不要手动拼接query,用NSURLComponents的queryItems构造;C#侧解析时只解一次码,拿到原生层处理好的JSON字符串,不要既解析JSON又对字段调用UnescapeDataString;如果链路上有第三方SDK再改写参数,一定要保存一份原始URL和最终解析参数的对照日志,方便排查是谁在中间动了手脚。

这里额外提一个容易出现双层编码的场景:有些投放归因平台生成的链接本身已经带https://跳转,最终落地页还会有平台自己的参数拼接。我们调通后发现,平台拼出来的query里有的字段是单层编码、有的已经是双层编码。统一解决思路是原生层只保留NSString原值,解析后同样放进JSON;C#侧拿到什么用什么,绝不再二次解码,除非业务方明确知道某字段做了两次Encode。

6.3 校验来源App与防滥用

URL Scheme很容易被别的App直接拼接调用,比如恶意App可以通过mygame://open?inviteCode=xxx强行打开你的游戏并带假参数。Universal Links虽然对域名做了校验,但只要你的关联域名验证文件允许*通配路径,其实任何能构造出该域名下合法URL的人都能拉起游戏。

所以业务上需要做两个校验:

  • 在原生层处理openURL时,通过options字典里的UIApplicationOpenURLOptionsSourceApplicationKey拿到发起方Bundle ID,判断是不是自己信任的App或系统浏览器。
  • 在C#层拿到邀请码、活动ID这类关键参数后,回调服务端接口做二次校验,结合登录态或设备指纹确认参数是否有效。这个对防止邀请裂变被刷尤其重要。

只靠客户端白名单不够,因为After重签和改Bundle ID等手段能绕过去;但只要服务端校验过一遍,至少能挡住大部分脚本刷量。

6.4 升级Unity版本后原生桥接代码失效

有一类坑不发生在第一次接入,而发生在升级Unity版本后。Unity每次升级iOS工程模板,生成的Xcode工程里AppDelegate或UnityAppController的基类方法都会变,如果你当初改的是模板生成的AppDelegate,升级后这些修改就会被覆盖,Deep Link突然就失效了。

所以再次强调:所有原生桥接代码,包括回调方法、参数解析、暂存逻辑,都放在Assets/Plugins/iOS/DeepLink/目录下的独立文件中,用分类(Category)扩展UnityAppController,或者把自己写的类挂进Unity的宿主工程。这样升级Unity后只需要重新确定模板里回调方法的签名是否变化,而不用重写整段逻辑。

6.5 团队协作的配置管理建议

接Deep Link这件事往往横跨iOS原生、Unity客户端、服务端、投放运营四拨人。最容易乱的点是:iOS改的原生代码升级Unity版本后被覆盖、服务端改了验证文件但没人同步、运营侧生成链接用的域名和技术侧配置的域名不一致。

我现在习惯这样做:

  • 把所有原生桥接代码集中放在一个目录里,例如Assets/Plugins/iOS/DeepLink/,不散落在Unity模板生成的AppDelegate里改。
  • 把验证文件的内容和上传路径写进服务端部署脚本,同时维护一份README,列出所有关联域名、Team ID、Bundle ID。
  • 每次发版前,让客户端在真机上用同一批测试链接跑一遍唤醒冒烟测试,链接集合固化成清单,谁改配置谁更新清单。

这些看着是杂事,但做过一次就会明白:真正让项目头疼的往往不是C#怎么接收参数,而是链路中某个环节被改坏了没人发现。

7. 踩坑记忆最深的一次:冷启动参数在投放归因里静默丢失

最后讲一次我实际遇到的最隐蔽问题。当时项目接好了Universal Links,测试链接在真机上各种姿势都能唤起,C#也能收到参数,大家都以为链路通了。结果买量一跑,归因平台后台显示激活回传参数率只有不到三成,大量激活请求都落在“自然量”里。

查了半天,定位到一个冷启动时序问题。用户从广告落地页点击链接时,App还没安装或进程早被系统杀掉,iOS拉起App进入冷启动流程,此时Unity引擎尚未完成初始化。原生层的UnitySendMessage如果在这个时间点执行,消息会丢得干干净净。我们的代码当时只在原生热启动回调里推送,冷启动路径完全没做暂存,导致激活参数在静默中流失。

后来改成“原生层任何时刻都先把参数存到NSUserDefaults,C#的Start里主动拉一次,拉完后清除本地存储”,这个激活回传率才从不到三成恢复到九成以上。这个双保险机制已经沿用到现在,几乎没再丢过参数。

所以说,Deep Link接入的核心不是“能不能唤起App”,而是“冷启动时参数能不能一丝不差地送进C#层”。把原生暂存、C#主动拉取、事件分发这三件事做扎实,剩下的大多数问题都能在真机调试阶段解决。如果你正在接或者准备接,我建议把这条规则直接写进开发规范:原生收到链接必须存一份,C#启动必须主动拉一次。就这一条,能帮你少熬两个通宵。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询