做手游的都知道,拉新靠买量,回流靠唤醒。而 iOS 端的 Deep Link,就是那条把用户从 Safari、广告页、活动 H5 重新拽回游戏 App 的绳子。很多人以为这不就是配个 URL Scheme 的事,但真做到 Unity 工程里就会发现,从链接点击到 C# 层拿到参数,中间隔着系统回调、原生桥接、消息时机三层坑,单是“热启动丢参数”就够人折腾半天的。
这篇东西不聊虚的,直接讲完整链路:URL Scheme 和 Universal Links 怎么选、原生侧怎么配、参数怎么从 Objective-C 一路安全送到 C# 层,以及在真机调试时最容易踩的坑。适合正在做游戏买量归因、老玩家召回、活动直达页面的 Unity 开发者,也适合刚接手手游 SDK 对接的客户端工程师。
1. 为什么手游需要 Deep Link,两条路线怎么选
1.1 URL Scheme 与 Universal Links 的核心差别
iOS 上的 Deep Link 技术路线,说穿了就两条。一是 URL Scheme,早期方案;二是 Universal Links(通用链接),iOS 9 之后苹果主推的方案。
URL Scheme 本质上是给 App 注册一个自定义协议头,比如gameact://。当 Safari 或其他 App 尝试打开gameact://open?item=1001这个地址时,系统会检测到有 App 注册了这个 scheme,然后拉起对应应用。
Universal Links 则是用标准的 HTTPS 链接来做。你在网页上点击https://www.game.com/promo?item=1001,系统会去校验这个域名下挂载的apple-app-site-association文件,校验通过后直接打开 App,而不是打开网页。
两者在体验上的差异很明显:
| 维度 | URL Scheme | Universal Links |
|---|---|---|
| 链接形式 | 自定义协议头(非 http/https) | 标准 HTTPS 链接 |
| 未安装时的表现 | 报错“打不开网页” | 正常打开对应网页 |
| 是否询问用户 | 会弹窗确认是否打开 | 不弹窗,直接打开 |
| 域名校验 | 无需校验 | 需要关联域名并用 AASA 文件校验 |
| 被系统封禁风险 | 其他 App 可随意注册同一 scheme | 不会冲突 |
| 归因准确性 | 一般 | 更高 |
手游项目里我通常建议两条都做,URL Scheme 保底,Universal Links 作为主路径。原因后文细说。
1.2 手游场景下哪种方案更适合主路径
如果你只是做个活动页跳转游戏,URL Scheme 绝对够用,配起来也快。但只要是涉及买量归因、老用户召回,就得把 Universal Links 扶正。
原因有三点。第一,Android 生态早就是 link 满天飞,投放素材里的链接都是 http/https,iOS 侧如果用 URL Scheme,很多落地页要单独维护一套custom://协议,渠道侧的配置工作量直接翻倍。第二,Universal Links 没装 App 时会优雅降级到网页,用户可以看到 H5 引导页而不是一个冰冷的“无法打开”。第三,归因平台(比如 AppsFlyer、Adjust)对 Universal Links 的归因成功率明显高于 URL Scheme,因为域名是你的,链路可信度高。
URL Scheme 也不是没用,它最大的价值是作为“兜底”。Universal Links 偶尔会有缓存失效、首次安装不识别之类的问题,这种时候 URL Scheme 反而能救场。像支付宝、微信这种体量的 App,至今依然保留 URL Scheme,道理一样,多一条路多一分唤醒成功率。
2. 原生侧配置:Info.plist 与 Associated Domains
2.1 URL Scheme 的注册信息配置
在 Unity 工程里,URL Scheme 的配置不在 C# 里写,而是在最终导出的 Xcode 工程里的 Info.plist 中。手动改一次容易,但每次出包都要手动改就太折磨了,所以我用 Unity 的OnPostProcessBuild自动注入。
先看手动配置长什么样:
<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLName</key> <string>com.yourgame.promo</string> <key>CFBundleURLSchemes</key> <array> <string>gameact</string> </array> </dict> </array>CFBundleURLName可以理解成这个 URL 类型的标识名,建议用反域名格式,比如com.company.product。CFBundleURLSchemes里填的就是你的协议头,这里要特别注意两点。
Scheme 千万不能用纯数字开头,系统会直接忽略掉。另外 Scheme 是全局唯一的隐患点,任何 App 都能注册相同的 scheme,如果你注册了gameact,别人也能注册gameact,iOS 在唤起时会优先找最近打开的 App,这个行为不可控。
2.2 Universal Links 的域名关联与 AASA 文件
Universal Links 的配置分两步。第一步,在 Apple Developer 后台把域名加进 App 的 Associated Domains 里,格式是applinks:www.game.com。第二步,在域名根目录挂一个apple-app-site-association文件,这个文件没有后缀名,内容是一个 JSON。
AASA 文件的标准结构如下:
{ "applinks": { "appids": [ "ABCDE12345.com.yourgame.promo" ], "components": [ { "#": "promo/*", "exclude": false, "comment": "匹配 promo 路径下的所有链接" } ] } }appids里填的是 Team ID 加 Bundle ID 的组合。很多人卡在这一步,AASA 文件上传了就是不生效,结果一看,appids里只写了 Bundle ID,忘了前缀的 Team ID。
组件的匹配规则也是重点。#键对应 URL 的 path 部分,*支持通配。exclude为 true 表示不匹配,常用于排除某些路径。还有一点,AASA 文件必须通过 HTTPS 访问,不能用 HTTP,且证书要有效。
2.3 Unity 工程自动注入配置脚本
手动配置只适合第一次验证,真正出包必须让 Unity 自动处理。我通常在建一个PostProcessBuild.cs,挂在Editor文件夹下:
using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; public class PostProcessBuild { [PostProcessBuild(1)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target != BuildTarget.iOS) { return; } string projectPath = path + "/Unity-iPhone.xcodeproj/project.pbxproj"; PBXProject project = new PBXProject(); project.ReadFromFile(projectPath); #if UNITY_2019_3_OR_NEWER string targetGuid = project.GetUnityMainTargetGuid(); #else string targetGuid = project.TargetGuidByName("Unity-iPhone"); #endif project.AddCapability(targetGuid, PBXCapabilityType.AssociatedDomains); project.WriteToFile(projectPath); string plistPath = path + "/Info.plist"; PlistDocument plist = new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementArray urlTypes = plist.root.CreateArray("CFBundleURLTypes"); PlistElementDict dict = urlTypes.AddDict(); dict.SetString("CFBundleURLName", "com.yourgame.promo"); PlistElementArray schemes = dict.CreateArray("CFBundleURLSchemes"); schemes.AddString("gameact"); plist.WriteToFile(plistPath); } }AddCapability会自动往 Entitlements 文件里写入 Associated Domains,但关联域名本身不会自动添加。你还需要在 Assets 下建一个 Entitlements 文件,或者继续在脚本里操作。我习惯直接把 entitlements 文件放到Assets/Entitlements下,手动填好applinks:www.game.com,然后在 Xcode 里勾选。
导出后务必手动检查一次:Build Settings 里的 Associated Domains 是否勾选,Entitlements 文件是否生效,Info.plist 里是否有 URL Types。这个检查花不了两分钟,但能省掉后面一上午的排查。
3. 原生到 C# 层的参数透传桥接(最关键的一步)
3.1 为什么参数不能直接用网页跳转方式处理
链接被系统识别后,iOS 会把它交给AppDelegate的某个方法。在这个环节之前,一切都还是 iOS 原生层面的事。而 Unity 游戏逻辑跑在 Mono 或 IL2CPP 里,两边像两个国家,需要一座桥。
这座桥就是 UnitySendMessage。Objective-C 通过这个方法可以调用 C# 层 GameObject 上挂的脚本方法,完成参数投递。
在动手之前,得先统一参数格式。iOS 回调给你的 URL 是原始的,可能长这样:
gameact://open?item=1001&type=gift&ts=1712345678或者 Universal Links 传递过来的:
https://www.game.com/promo?item=1001&type=gift&ts=1712345678我不会直接把完整 URL 丢给 C#,而是统一解析成 key-value 然后以 JSON 字符串传递。原因很简单,C# 层直接再去解析 URL 虽然可行,但 Unity 的Uri类在解析某些编码后的参数时会出幺蛾子,比如中文参数被不断重复转码。原生侧解析一次,JSON 化,C# 侧收到就是一串干净的字符串。
3.2 冷启动与热启动调用时机的差异
这里是我踩坑最多的地方。
所谓冷启动,是 App 完全被杀掉后,通过 Deep Link 拉起来。此时 Unity 引擎还在初始化过程中,UnitySendMessage要求目标 GameObject 已经存在,否则消息发过去会直接丢失。
热启动则是 App 在后台活着,通过 Deep Link 唤起。这时引擎完全就绪,UnitySendMessage可以立即送达。
Cold Start 场景下,最简单的做法是延迟发送。我在 OC 侧会做个延迟重试,每 0.5 秒检查 Unity 是否就绪,最多重试 5 次。
3.3 OC 侧解析与消息投递实现
我要在AppDelegate里接收两种链接。旧版本写法是分开的,iOS 13 之后需要同时处理 SceneDelegate 的情况,但 Unity 导出工程默认仍以 AppDelegate 为主,这里以 AppDelegate 为例:
#import <UnityFramework/UnityFramework-Swift.h> - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options { return [self handleDeepLink:url]; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSUserActivity * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { return [self handleDeepLink:userActivity.webpageURL]; } return NO; } - (BOOL)handleDeepLink:(NSURL *)url { NSString *parameterString = [self convertURLToJSONString:url]; [self sendMessageToUnity:parameterString]; return YES; }convertURLToJSONString这个方法建议把 query 展开成可变字典,再序列化成 JSON。核心代码:
- (NSString *)convertURLToJSONString:(NSURL *)url { NSMutableDictionary *params = [NSMutableDictionary dictionary]; if (url.scheme) { params[@"scheme"] = url.scheme; } if (url.host) { params[@"host"] = url.host; } if (url.path) { params[@"path"] = url.path; } NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; for (NSURLQueryItem *item in components.queryItems) { params[item.name] = item.value; } NSData *data = [NSJSONSerialization dataWithJSONObject:params options:0 error:nil]; return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; }然后sendMessageToUnity就是那个带延迟重试的函数:
- (void)sendMessageToUnity:(NSString *)message { [self trySendMessage:message retryCount:0]; } - (void)trySendMessage:(NSString *)message retryCount:(int)retryCount { if (retryCount >= 5) { return; } if ([UnityFrameworkGetInstance() appController] == nil || ![[UnityFrameworkGetInstance() appController] unityIsReady]) { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self trySendMessage:message retryCount:retryCount + 1]; }); return; } UnitySendMessage("DeepLinkManager", "OnDeepLinkReceived", [message UTF8String]); }先说明一下,Unity 导出 Xcode 工程后,UnityFrameworkGetInstance和unityIsReady是从UnityFramework-Swift.h里暴露的。老版本 Unity(2019 之前)直接用UnityGetAppController也行,但新版本强烈建议走 UnityFramework 接口,否则冷启动时序会判断不准。
3.4 C# 侧接收与分发处理
C# 侧的工作分两块。第一块是接收原生消息,丢到主线程执行;第二块是解析 JSON,触发业务逻辑。
挂载对象名必须和方法名完全匹配,UnitySendMessage的第一个参数填的是 GameObject 名称,不是脚本名。我在场景中建了一个常驻的DeepLinkManager节点,挂上DeepLinkManager.cs:
using System; using System.Collections.Generic; using UnityEngine; public class DeepLinkManager : MonoBehaviour { public static event Action<Dictionary<string, string>> OnDeepLinkArrived; private static bool _pendingEventsPending = false; private static readonly Queue<string> PendingMessages = new Queue<string>(); private readonly object _lock = new object(); public void OnDeepLinkReceived(string json) { lock (_lock) { if (!_pendingEventsPending) { _pendingEventsPending = true; ExecuteOnMainThread(json); } else { PendingMessages.Enqueue(json); } } } private void ExecuteOnMainThread(string json) { DispatchQueue(); } private void DispatchQueue() { // 这里放在 Update 里逐帧出队 } private void Update() { lock (_lock) { while (PendingMessages.Count > 0) { string json = PendingMessages.Dequeue(); ParseAndDispatch(json); } } } private void ParseAndDispatch(string json) { try { var dict = MiniJSON.Json.Deserialize(json) as Dictionary<string, object>; var result = new Dictionary<string, string>(); foreach (var kv in dict) { result[kv.Key] = kv.Value == null ? "" : kv.Value.ToString(); } OnDeepLinkArrived?.Invoke(result); } catch (Exception ex) { Debug.LogError("[DeepLink] 参数解析失败: " + ex.Message); } } }上面的代码是个简化骨架,重点是搞清楚两点。
第一,不是所有场景都适合用Update轮询出队,但它是 Unity 主线程执行的最稳妥方式。原生回调本身就是主线程,理论上可以直接执行,但为了保险,我仍然建议入队后延迟到Update里处理,避免在引擎未完全初始化的极小时间窗内出问题。
第二,MiniJSON是 Unity 的简单 JSON 库,可以直接用。如果你项目已经引入了 Newtonsoft.Json,用哪个都无所谓,核心是把参数还原成 C# 可用的键值字典。
业务侧接收消息就是订阅事件:
void OnEnable() { DeepLinkManager.OnDeepLinkArrived += OnDealWithDeepLink; } void OnDisable() { DeepLinkManager.OnDeepLinkArrived -= OnDealWithDeepLink; } private void OnDealWithDeepLink(Dictionary<string, string> payload) { if (payload.TryGetValue("item", out string itemId)) { // 跳转活动页、发放礼包、进入指定关卡等 } }4. 常见问题排查实录
4.1 Universal Links 不生效的常见原因
Universal Links 不生效是排查频率最高的问题,我列一份自己常用的排查顺序:
| 排查点 | 操作 | 验证结果 |
|---|---|---|
| AASA 文件是否可访问 | Safari 打开https://www.game.com/apple-app-site-association | 能看到 JSON 内容 |
| AASA 文件是否合法 | 用 JSON 校验工具检查格式 | 无语法错误 |
| Team ID 是否写全 | 检查appids是否TeamID.BundleID | 与开发者后台一致 |
| Associated Domains 是否配置 | Xcode 里检查 entitlements 文件 | 包含applinks:www.game.com |
| iOS 是否缓存旧配置 | 每次改动后在系统设置里重装 App | 重新验证 |
这里有个系统行为,iOS 对 AASA 文件的缓存策略比较保守,首次拉取之后可能在长时间内不再重新请求。测试链接不生效,不要马上断言配置错了,先在真实设备上删掉 App、重启、再安装,很多缓存问题在这一步就消失了。
还有一种情况,你在 Safari 地址栏直接输入链接,它会先尝试打开网页而不是 App。这正常,Universal Links 的要求是点击链接触发,纯手动输入地址不算标准场景。
4.2 参数在冷启动时丢失或过早回调
这是我被玩家反馈最多次的问题。
症状是玩家点了链接,游戏打开了,但礼包没发。查日志发现,OnDeepLinkReceived在 C# 层压根没被调用过,或者调用太早,业务侧监听事件的脚本还没注册上。
第一个坑,GameObject 不存在。UnitySendMessage 的 target 必须在场景里,而且名字要一字不差。注意大小写,deepLinkManager和DeepLinkManager是两个名字。最简单的验证方法是在OnDeepLinkReceived第一行加Debug.Log,如果 Unity 控制台完全没有输出,问题就在消息没送达或者名字错了。
第二个坑,回调早于业务监听。游戏启动后,场景加载、热更下载、SDK 初始化是一大串逻辑,Deep Link 参数到了,但你的礼包模块还没初始化。解决方法是把参数先暂存起来,等到业务模块就绪后再取。我在DeepLinkManager里加了一个静态属性LastPayload,业务侧随时可以拉取。
第三个坑,IL2CPP 下字符串编码。原生传来的是 UTF8 C 字符串,C# 侧作为string接收正常,但如果中间经过了 Objective-C 的NSString转char*且没指定编码,中文参数会出现乱码。稳妥做法是原生侧用[message UTF8String],C# 侧默认按 UTF-8 处理,别用Default编码。
4.3 真机调试的实用技巧
Xcode 的 Console 是调试 Deep Link 的第一阵地。链接点击后,AppDelegate里是否收到回调、UnitySendMessage是否执行,这些日志一目了然。
如果你想知道 iOS 系统到底有没有识别链接,有一个快速验证方法:在备忘录里输入链接,长按,如果弹出“在 xxx 中打开”选项,说明这个链接已经被系统登记并能唤起 App。这是设备级的链路验证,绕过了你的代码,可以快速区分是“系统没识别”还是“代码没处理”。
命令行调试也有一个小技巧,在 Mac 终端执行:
xcrun simctl openurl booted "gameact://open?item=1001"这个命令可以在模拟器上直接唤起 App,测试冷启动热启动都很方便。不过模拟器对 Universal Links 的支持不完整,最终验证还是要上真机。
5. 工程化落地的几点经验
5.1 参数标准化是设计的第一步
Deep Link 参数天生不稳定。链接从广告平台出来,被各种落地页包裹、跳转,某些渠道还会在回调 URL 里加追踪参数。如果 C# 层依赖固定参数名,渠道侧一多就容易打架。
我落地项目的习惯是定义一份文档,把 item、type、ts 这类核心字段固定下来,渠道侧只允许追加参数,不允许改动语义。C# 侧接收到参数后,统一做类型校验和默认值兜底,宁可缺失不报异常。
5.2 日志是排查 Deep Link 问题的唯一依靠
Deep Link 链路长,从系统到原生再到 Unity,任何一段出问题都可能导致静默失败。工程里必留三段日志:原生收到链接原文、UnitySendMessage 发出的 JSON、C# 层解析后的 key-value 列表。这三处日志完整的情况下,用户反馈“点链接没反应”,我们能在五分钟内定位到是哪一环出了问题。
5.3 多场景兼容,别只盯着 iOS
现在很多游戏项目是 Unity 双端一起出,iOS 走了 Universal Links,Android 也大概率要接 App Links。两端在参数设计上如果能统一,后台上传链接的同事就省心很多。这里不展开 Android 的具体实现,但参数 JSON 化的思路是共通的。
最后分享一个日常被忽视的小细节。每次改了 AASA 文件或者 entitlements 配置,别急着在开发者后台里反复看,先用 Safari 直接访问 AASA 的 URL 确认文件内容正确、可访问,再上真机验证。很多“改了不生效”的问题,其实是从第一步就把文件放错了位置或者格式不对。这行干久了你会明白,Deep Link 调试的大多数时间都花在排除各种低级错误上,链路本身并不神秘,系统回调、协议匹配、消息投递,每一步都有章可循。把日志埋好,把方案选对,剩下的事情就是按照流程逐步验证。