Deep Link(深度链接)在手游里是个绕不开的刚需,尤其是做买量发行、KOL 合作、活动拉新的时候——用户从 Safari、微信或者一个推广落地页点开链接,能不能直接从浏览器唤起 App,并且把携带的参数准确交到游戏内部逻辑手里,决定了一个投放渠道的 ROI 能不能算清楚。Unity 开发的 iOS 手游要做这件事,核心就是两条链路:URL Scheme 和 Universal Links,再加上一条从原生层到 C# 层的参数投递管道。这篇文章把我在真实项目里把这套流程整体跑通的细节完整写出来,包括 Unity 工程怎么配、iOS 工程怎么改、C# 在什么时机拿到参数才算稳,以及调试时会踩到的一堆坑,适合正在接 Deep Link、或者接完发现参数总是丢的 Unity 开发者参考。
1. 先看整条链路:Deep Link 到底在传什么
1.1 两条路线的能力边界
手游里的 Deep Link 基本分成两条技术路线。一条是 URL Scheme,注册一个自定义协议比如mygame://open?uid=10001,App 安装后系统会把这类链接交给对应的 App;另一条是 Universal Links,基于 HTTPS 域名关联,比如https://example.com/open?uid=10001,iOS 9 以后的官方推荐做法。两者不是替代关系,在很多实际场景里是互补关系。
URL Scheme 的优点是可以被网页里的<a href="mygame://open?uid=10001">直接触发,也会被部分二维码扫码工具识别。缺点也很明显:iOS 上从 Safari 点 scheme 链接会先弹一个确认框,体验比较打断;在微信、支付宝这类第三方内置浏览器里,scheme 常常被拦截,用户点下去毫无反应。Universal Links 则被苹果定义为标准能力,App 安装后点链接不会有确认框,直接从 Safari 无缝唤起 App,微信里如果配置了相关域名也能支持。但 Universal Links 的前提是域名必须走 HTTPS,而且需要一个验证文件放在服务器上,配置链路比 scheme 长不少。
所以我的建议很直接:两条都接。Universal Links 作为主链路,负责 Safari、邮件、推广页、微信生态里的大部分场景;URL Scheme 作为兜底,负责那些 Universal Links 覆盖不到或者无法验证的环境。项目里接一条链路的,往往会在后面某一天被某个渠道的异常反馈打断节奏。
1.2 一条参数链路上的三个环节
把 Deep Link 拆到底,无非是三个阶段:系统将链接分发到 App 原生层,原生层把链接转交给 Unity 引擎,Unity 的 C# 脚本拿到链接并解析、分发到业务模块。
第一个环节是 iOS 系统层面的事。URL Scheme 会走 AppDelegate 的application:openURL:options:回调,Universal Links 走application:continueUserActivity:restorationHandler:回调。这两个回调里拿到的都是 NSURL 对象,我们需要把它转成字符串并决定是否处理。
第二个环节是原生层向 Unity 层投递。Unity 官方提供了UnitySendMessage方法,可以从 Objective-C 直接调用 C# 脚本的公开方法。但这个调用有前提:Unity 引擎必须已经初始化完成,场景里必须存在目标 GameObject。很多刚接触这块的人在这里栽跟头,日志里看到原生回调触发了,但 C# 那边死活收不到,就是因为没有处理冷启动的时序问题。
第三个环节是 C# 层拿到参数后的解析与分发。链接里的 query 参数需要 URL 解码,参数要放进一个统一的事件系统里,让不同业务模块订阅,比如活动模块关心activityId,支付模块关心orderId,账号模块关心uid。如果这一步设计得粗糙,后面每个新业务接 Deep Link 都要改桥接代码,耦合会越来越严重。
2. 动手前的准备清单
2.1 需要哪些账号与资源
开始写代码之前,先确认手上的资源是否齐全。做 iOS 开发,Apple 开发者账号是必须的,Universal Links 还需要一个已经备案并且支持 HTTPS 的域名。这个域名不需要和游戏后端是同一个,但要求它的证书链完整、能在公网被正常访问,因为 iOS 设备首次启动 App 时会请求这个验证文件。
如果你在团队里是客户端侧开发,需要跟服务端同事提前约定一个静态文件路径:通常是域名根目录下的apple-app-site-association文件或者/.well-known/apple-app-site-association文件。另外还需要一个 Team ID 和 Bundle ID,因为验证文件里要用TEAMID.BUNDLEID这样的组合来标识 App。这些信息在 Apple Developer 后台都能查到,客户端和服务端配合的时候直接把这两个 ID 一次性给出,避免来回返工。
2.2 Unity 工程和 Xcode 工程的关系
Unity 手游做 iOS 包,流程上是 Unity 负责写游戏逻辑和出工程,但最终运行的是一个 Xcode 工程。Deep Link 的很多配置(比如 URL Types、Associated Domains entitlement)在 Unity 编辑器里没有可视化入口,最稳妥的做法是让 Unity 先导出 Xcode 工程,然后用 Xcode 打开做原生层配置。
这里要理解一个关键点:Unity 导出的 iOS 工程里,App 的原生入口是一个继承自UnityAppController的类。不同 Unity 版本下这个类的位置和名称稍有差异,但通常都会生成一个名为AppDelegate的类,或者你可以在Classes/UnityAppController.h找到基类。我们所有的 Deep Link 原生回调代码,最终都落在继承自UnityAppController的这个类上,而不是随手新建一个 NSObject 类。
版本方面,Unity 2019.4 以后的 LTS 版本我都实测过这套流程,没遇到兼容性问题。Xcode 建议用当前 App Store 上架要求的最低版本以上版本,太老的 Xcode 在签名和 Associated Domains 能力配置上会遇到一些不必要的阻力。
3. URL Scheme:最传统但依然必要的链路
3.1 在 Xcode 里注册 URL Types
URL Scheme 的注册位置在 Xcode 工程的 Target -> Info 面板下的 URL Types 区域。点加号新增一条记录,URL Schemes 里填你的协议名,比如mygame。这一步做完后,iOS 就知道mygame://开头的链接应该唤起你的 App。
填协议名有几个注意事项。第一,不要用太通用的单词,比如game、share这种,很容易和其他 App 冲突,某些知名 scheme 甚至会被系统判定为系统保留;第二,建议带上产品缩写,比如mygame、gameabc之类,既好记又降低冲突概率;第三,URL Scheme 大小写敏感,后面生成链接时要用完全一致的写法。
如果你希望自动化,不想每次手动改 Xcode,也可以直接在 Unity 工程里编写一个 PostProcessBuild 脚本,在构建完成后自动把信息写进Info.plist。核心是操作CFBundleURLTypes这个字段,用 UnityEditor.iOS.Xcode 的 PlistDocument 可以方便地读取和写入。自动化脚本的好处是换电脑、换人打包不会漏配置,缺点是一旦 Info.plist 里存在其他手动添加的条目,脚本处理不好可能覆盖掉已有配置,所以我个人建议第一版还是纯手动配置,跑通后再考虑自动化。
3.2 原生回调与 UnitySendMessage 桥接
注册好 URL Types 后,编译运行,用 Safari 访问一个包含mygame://open?uid=10001的网页,系统会回调 AppDelegate 的 openURL 方法。在这个方法里,我们要做的事情很明确:判断 URL 是不是自己关心的 scheme,如果是,就把完整链接转成字符串,然后投递给 Unity。
Objective-C 侧的核心代码大致是这样:
// AppDelegate.m - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options { NSString *param = url.absoluteString; if ([param hasPrefix:@"mygame://"]) { // 方案一:直接投递 UnitySendMessage("DeepLinkRouter", "OnNativeLink", [param UTF8String]); return YES; } return NO; }这段代码里最关键的是UnitySendMessage的三个参数:第一个是场景中 GameObject 的名字,第二个是 C# 脚本上的公开方法名,第三个是参数字符串。这里有一个非常容易踩的坑:UnitySendMessage只有在 Unity 引擎完成初始化、且场景中确实存在名为DeepLinkRouter的 GameObject 时才会可靠工作。如果你在 App 冷启动的早期调用,目标 GameObject 还没被加载,消息就会静默丢失。
因此更稳妥的做法不是拿到链接就立刻发消息,而是先把链接缓存下来,等 Unity 侧准备就绪后主动来拉取。这个设计我在后面的 C# 章节会详细展开。
3.3 Scheme 方案的边界与局限
URL Scheme 即便配置正确,也会遇到一些不可避免的体验问题。在 Safari 里点 scheme 链接,iOS 会弹一个系统确认框,用户需要手动点一下“打开”才能跳转,这一步会让转化率明显降低。更麻烦的是,在微信内置浏览器里打开 scheme 链接,通常直接被拦截,用户看到的是“已停止访问该网页”之类的提示。
微信场景下业界普遍的做法是走 Universal Links,或者通过微信开放平台的 OpenSDK 方式做跳转。所以 URL Scheme 可以保留作为兜底,但不要指望它覆盖所有渠道。我们在项目里的处理原则是:能走 Universal Links 的链接一律生成 HTTPS 链接,只有那些无法验证域名的特殊场景(比如某些扫码工具、特定 WebView 环境)才用 scheme 形式。
另外补充一个细节:openURL 回调不只是为了 Deep Link 服务。iOS 上的很多系统能力(比如某些第三方登录的跳回)也会走这个回调,所以判断 URL 前缀时一定要精确,避免把别人的链接也吞掉,然后返回 NO 让系统继续处理其他逻辑。
4. Universal Links:官方推荐的链路
4.1 服务端放置 apple-app-site-association 文件
Universal Links 的验证依赖一个名为apple-app-site-association的 JSON 文件(以下简称 AASA 文件),iOS 设备会在 App 首次安装后访问你的域名来拉取这个文件。文件内容长这样:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.yourcompany.mygame", "paths": ["/open/*"] } ] } }文件有两个要求必须同时满足:第一,文件不能带.json后缀,名字必须严格是apple-app-site-association;第二,文件必须放在 HTTPS 域名的根目录或/.well-known/目录下,且服务器响应头里的 Content-Type 必须是application/json。很多服务端同事第一次配置时会忽略 Content-Type,导致 iOS 端怎么验都不通过。
路径字段paths支持精确匹配和通配符,比如/open/*匹配所有以/open/开头的链接,*匹配全部路径。但要注意,这里只匹配路径,不匹配 query 参数,所以链接里的参数完全依靠后面的 query 传递,这正好符合我们“标识在路径、业务参数在 query”的约定。如果你的域名下同一时间有多个 App,details 数组里可以写多条记录,每个 App 一个 appID 和一组 paths。
4.2 开启 Associated Domains 能力
服务端文件放好后,客户的 App 侧要开启 Associated Domains 能力。在 Xcode 工程里选中 Target -> Signing & Capabilities,点击加号搜索 Associated Domains 并添加,然后在列表里填入一行:
applinks:example.com这里的example.com必须和 AASA 文件所在的域名完全一致,包括子域名。如果填了applinks:www.example.com,那么链接也必须使用https://www.example.com/...。
填完之后,Xcode 会生成并更新工程的 entitlements 文件。要特别注意的是,这个能力必须包含在 App 的描述文件(Provisioning Profile)里,否则签名报错或者装到真机上不生效。如果你在 Apple Developer 后台给 App ID 开启 Associated Domains 后忘记重新生成描述文件,那么即使 Xcode 里加了能力,真机验证依然会失败。这一条几乎是新手必踩的坑,检查顺序一定是后台 App ID -> 描述文件 -> Xcode 工程三处保持一致。
Unity 侧也有个补充入口:部分 Unity 版本在 Player Settings 的 Other Settings 里直接提供了 Associated Domains 的填写区域,但不同版本位置不统一。最通用的做法还是在 Unity 导出工程后用 Xcode 配置,这样不受 Unity 版本差异影响,也不容易出现设置项对不上的情况。
4.3 continueUserActivity 回调与投递
Universal Links 唤起 App 时,不走 openURL 方法,而是走application:continueUserActivity:restorationHandler:。代码写起来类似:
- (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.host isEqualToString:@"example.com"]) { NSString *link = url.absoluteString; // 这里同样建议先缓存,再由 Unity 侧拉取 return YES; } } return NO; }和 scheme 回调相比,这里多了一步判断activityType和域名 host。Universal Links 的回调并不仅限于你自己的域名,其他安装了 App 的域名内容也可能触发这个方法,所以必须做域名白名单校验,保证只有期望的域名才会进入业务处理逻辑。
另一个要注意的点是:如果 App 已安装且用户点击 Universal Link 后,系统唤起的是你的 App,那么 Safari 和 App 之间是平滑过渡的,不会出现 scheme 那样的确认弹窗。但在 App 未安装的情况下,系统会直接在 Safari 里打开这个 HTTPS 链接。所以落地页要提前设计好,当用户没装 App 时给一个引导下载的页面,别让人看到一个莫名奇妙的 JSON 或 404。
4.4 AASA 文件与证书的坑
AASA 文件配置完成后,调试期最大的问题不是配置本身,而是 iOS 的缓存。iOS 并不会每次都拉取最新的 AASA 文件,而是有较长一段时间的缓存策略,常见是几小时到一天的量级。遇到“明明文件改对了,但真机就是不进 App”的情况,不要反复刷新页面,直接删除 App 重新安装一次,再不行等一段时间再测。
验证 AASA 文件是否可访问,最好的方式是用命令行直接请求一次:
curl -v https://example.com/apple-app-site-association这样可以看到文件内容是否返回、Content-Type 是否为application/json。如果服务器是 Nginx,可以在对应 server 块里为这个路径单独指定:
location ~* ^/apple-app-site-association$ { default_type application/json; }关于证书,有一点容易忽略:AASA 文件所依赖的 HTTPS 证书必须是系统信任的有效证书,自签名证书或者某条证书链不完整的证书会导致验证失败。这个和 App 内请求 API 的证书策略是两回事,不能因为你 App 内置了自定义 CA 信任就以为 Universal Links 也能过,iOS 系统层面的校验不认这些。
5. C# 层的参数接收与投递设计
5.1 用主动拉取解决冷启动丢消息
前面我反复提到了一个核心痛点:冷启动时UnitySendMessage不可靠。为了把这个问题彻底解掉,我最终在项目里采用了一套“原生缓存 + C# 主动拉取 + 热更新通知”的组合方案。
原生侧收到任何 Deep Link 链接后,不急着调UnitySendMessage,而是先把链接保存到 AppDelegate 的字符串属性里:
// AppDelegate.h @interface AppDelegate : UnityAppController @property (nonatomic, strong) NSString *pendingDeepLink; @end然后提供一个 C# 可以调用的原生函数,让 C# 在场景加载完成后主动拿一次:
extern "C" const char *GetPendingDeepLink() { AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate; if (delegate && delegate.pendingDeepLink.length > 0) { const char *result = strdup([delegate.pendingDeepLink UTF8String]); delegate.pendingDeepLink = nil; return result; } return strdup(""); }C# 侧通过 DllImport 声明这个函数:
using System.Runtime.InteropServices; using UnityEngine; public class DeepLinkNativeBridge { #if UNITY_IOS && !UNITY_EDITOR [DllImport("__Internal")] private static extern string GetPendingDeepLink(); #endif public static string PollPendingLink() { #if UNITY_IOS && !UNITY_EDITOR return GetPendingDeepLink(); #else return null; #endif } }这套方案的思路是:冷启动阶段原生侧把参数安全保存起来,等 C# 场景一切就绪后再拉取,从根本上绕开了UnitySendMessage时序问题。热启动(App 已经在后台运行)时,原生层可以直接调用UnitySendMessage做到实时通知,因为此时引擎一定已经 ready。两条路径互不冲突,覆盖了所有启动形态。
5.2 参数解析与 URL 解码
不管参数是走 scheme 还是 Universal Links 进来的,最终 C# 拿到的都是完整链接字符串。接下来要做的是解析 query 参数。这里我建议使用System.Uri作为解析入口,不要手工去 Split&,因为参数值里一旦包含转义后的&或%26,手工切分就会出错。
using System; using System.Collections.Generic; public static class DeepLinkParser { public static 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; // 包含开头的 ? if (string.IsNullOrEmpty(query)) return result; query = query.TrimStart('?'); string[] pairs = query.Split('&', StringSplitOptions.RemoveEmptyEntries); foreach (string pair in pairs) { string[] parts = pair.Split('=', 2); if (parts.Length == 2) { string key = Uri.UnescapeDataString(parts[0]); string value = Uri.UnescapeDataString(parts[1]); result[key] = value; } } return result; } }这里有一个关于编码的重要提醒:参数值中如果有中文、空格、加号等特殊字符,生成链接的一端必须做 URL 编码。很多参数丢失问题的根源不在客户端解析,而在链接生成方漏了编码。例如用户昵称是“张三”,链接直接拼成?name=张三,那么客户端拿到的绝对是一个乱的编码字符串,除非双方约定编码规则,否则无法还原。正确做法是生成方对 value 做Uri.EscapeDataString这类编码,客户端再UnescapeDataString还原。
5.3 事件分发与跨场景传递
参数解析完成后,不要直接在某个 MonoBehaviour 里写死业务逻辑,而是设计一个全局的事件分发器。我项目里的做法是维护一个不销毁的DeepLinkRouter单例,挂在启动场景的一个常驻物体上,所有业务模块通过订阅事件来接收 Deep Link 参数。
public class DeepLinkRouter : MonoBehaviour { public static DeepLinkRouter Instance { get; private set; } public string LatestRawLink { get; private set; } public Dictionary<string, string> LatestParams { get; private set; } public event Action<Dictionary<string, string>> OnDeepLinkReceived; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } private void Start() { // 场景稳定后,主动拉取冷启动缓存链接 string pending = DeepLinkNativeBridge.PollPendingLink(); if (!string.IsNullOrEmpty(pending)) { HandleLink(pending); } } public void OnNativeLink(string link) { HandleLink(link); } private void HandleLink(string rawUrl) { LatestRawLink = rawUrl; LatestParams = DeepLinkParser.ParseQuery(rawUrl); OnDeepLinkReceived?.Invoke(LatestParams); // 日志与上报,见 6.3 Debug.Log($"[DeepLink] received: {rawUrl}"); } }原生侧热启动时的UnitySendMessage("DeepLinkRouter", "OnNativeLink", link)就会直接命中这个脚本。需要注意UnitySendMessage调用的方法名必须是公开的,最好加上using UnityEngine;,否则反射查找方法时会失败,日志里会出现找不到方法的警告。
事件分发的好处是解耦。活动模块、账号模块、支付模块分别订阅OnDeepLinkReceived,各取所需。后续新增一个 Deep Link 业务类型时,不需要再动桥接层和路由器,只需要在对应业务模块里监听事件即可。
5.4 复杂参数、多个链接与场景恢复
实际业务里,用户可能在游戏运行过程中多次点击不同链接进入 App,所以参数不能只处理一次。每次进入都触发OnDeepLinkReceived,业务模块要能够处理“参数更新覆盖”的情况。对于需要用户在多个场景间跳转后仍然能拿到参数的需求,我的建议是LatestParams保留最近一次即可,如果需要持久化,可以同时把参数同步一份到本地存档,以便用户下次冷启动后继续使用。
另外,有些 Deep Link 是带了#部分(fragment)的,比如https://example.com/open?uid=1#section。System.Uri.Query不会包含#之后的内容,这个逻辑是对的,但要注意 iOS 原生传过来的absoluteString里#之后的字符并不会被编码。所以链接协议层面最好明确约定:所有业务参数全部放在 query 中,禁止使用 fragment 传业务参数,否则不同客户端解析结果不一致,排查起来非常痛苦。
6. 常见问题速查与调试方法
6.1 高频问题排查表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 点击 Universal Link 直接打开 Safari 而非 App | AASA 文件无法访问或 Content-Type 不对 | 用 curl 验证文件返回内容与响应头 |
| 点击 Universal Link 提示无法连接 | 域名证书链不完整或非受信证书 | 更换有效公网证书 |
| 真机首次安装后 Universal Link 不生效 | iOS 缓存或描述文件未包含 Associated Domains | 删 App 重装、检查后台 App ID |
| scheme 链接无任何反应 | URL Types 未配置或 scheme 被第三方拦截 | Xcode 检查 URL Types,换一种跳转方式 |
| 冷启动后 C# 收不到参数 | 时序问题,Unity 引擎未 ready | 使用原生缓存 + C# 主动拉取方案 |
| 参数中文乱码 | 链接生成端未做 URL 编码 | 生成方统一对 value 做编码 |
| 参数解析时偶发丢字段 | 手工 Split 切割了参数值里的& | 改用System.Uri解析 query |
| 热启动链接参数覆盖了上一次的旧参数 | 业务设计如此,但未通知相关模块 | 事件机制中加入来源标识或时间戳 |
这份表格是从我实际排障中浓缩出来的,每个现象背后都对应至少一次线上反馈。如果你遇到第一行的问题,先别急着动客户端代码,把 AASA 文件请求一次,90% 的情况会立刻暴露原因。
6.2 调试与验证工具
调试 Universal Links,我常用的路线是这样:先用 Safari 直接访问你的 HTTPS 链接,观察地址栏是否短暂出现“在‘App名称’中打开”的提示。如果没有这个提示,大概率是 AASA 文件或 Associated Domains 有问题,而不是回调代码的问题。然后回到 Xcode 断点continueUserActivity,如果断点都不进,那就继续查配置;如果断点进了但没有跳 App,再查链接域名判断逻辑。
调试 scheme 相对简单,直接在真机上打开 Safari,输入带 scheme 的链接即可。iOS 真机调试需要在设备上信任开发者证书,如果你是新换的设备或者刚升完系统,通常会碰到“开发者模式未开启”的情况,在设置里找到开发者模式打开,再重新连 Xcode 安装。
另外我会在原生回调里加上一行 NSLog 日志,打印收到的完整 URL。很多参数问题靠这行日志就能判断是原生没收到,还是 C# 解析丢了。统一日志格式也更方便事后追溯:
NSLog(@"[DeepLink] native received: %@", url.absoluteString);6.3 建议的埋点与日志体系
Deep Link 链路比较长,从点链接到参数生效要经历系统回调、原生层、C# 解析层、业务模块四个环节。每个环节都是一个可能的断点,所以埋点很重要。我项目里的做法是在四个环节各自打点:
- 原生层收到链接时打一个点,记录完整 URL;
- C# 路由器
HandleLink时打一个点,记录解析后的参数字典; - 业务模块消费参数后打一个点,记录实际使用的字段;
- 如果用了主动拉取缓存,额外记录一次是“冷启动拉取”还是“热启动投递”。
这套埋点数据接入团队已有的分析平台后,可以清楚看到每个渠道链接的转化漏斗,也能在用户反馈“点了没反应”时快速定位是哪个环节的问题。没有埋点的 Deep Link 系统,排查一次问题就要靠人肉打断点,效率非常低。
最后分享一个我实际踩过的坑:参数里带了#号,解析的时候整个参数表都乱了。URL 里#是 fragment 标识,iOS 回调拿到的完整链接确实会包含它,但System.Uri.Query不会返回 fragment 部分,服务端生成链接时也可能把一个本应编码的#直接拼进 query。后来我们定了一条铁律:业务参数只走 query,所有特殊字符一律编码,fragment 一概不用。Deep Link 这套东西表面看就是几个回调加字符串解析,但每一环都影响最终转化率,踏踏实实把每一段链路都跑通、埋好日志,后面接任何新渠道都会顺畅很多。