☰
Unity手游iOS Deep Link全链路实践:从URL Scheme到Universal Links参数投递
2026/10/2 4:51:05 网站建设 项目流程

先交代背景:今年接了好几个 Unity 手游的 iOS 发行需求,每次都绕不开 Deep Link 这条链路,从市场投放的落地页、邀请好友的短链,到玩家点开链接后能不能直接进游戏大厅、自动带上邀请码,这背后就是一套完整的 URL Scheme + Universal Links 处理流。我在这条链路上踩过的坑,包括冷启动时序、UnitySendMessage 传参、Universal Links 不唤起、参数乱码,都整理在下面这份流程里。

这套方案同时覆盖了 iOS 原生层和 Unity C# 层:原生层负责收链接,C# 层负责解析参数、投递给业务模块。对正在接归因、拉新奖励、邀请返利的 Unity 团队来说,可以直接照着做。

1. 从一条邀请链接说起:Deep Link 到底解决了什么问题

1.1 业务场景拆解:投放、邀请、落地页

过去很多手游团队对 Deep Link 的理解是“能打开 App 就行了”,实际上真正的需求远不止这一步。以最常见的邀请裂变场景为例:玩家 A 把邀请链接发给玩家 B,B 点击链接后,如果手机里已经装了游戏,那就直接唤起 App,并且自动跳到注册页、填上 A 的邀请码;如果没有安装,浏览器先打开落地页,下载完装好后,首次启动时依然要把 A 的邀请码带给游戏服务端。这一整条链路里,难点不只是“唤起”,而是“唤起之后参数怎么跟着一起进到游戏内”。

投放场景也类似:广告平台回传的点击 URL 通常带着 campaign、adgroup、creative 这些参数,如果只做到“打开 App”而没把参数传给客户端,那归因平台就识别不了这次激活来自哪条广告。很多团队在项目上线后才发现归因数据对不上,回头查基本都是 Deep Link 参数投递这层没做扎实。所以这篇文章我先把业务目的放在前面:Deep Link 的交付物不是一次点击唤起,而是把一段完整的链接原封不动送到 C# 业务层。

1.2 URL Scheme 和 Universal Links 的能力边界

URL Scheme 是 iOS 上最早支持的唤起方式。在 Info.plist 里注册一个自定义协议,比如mygame://,然后别的 App 或者网页通过mygame://open?invite=abc123就能拉起游戏。它最大的优势是“快、简单、可控”,缺点也明显:从 iOS 9 开始,自定义 Scheme 唤起前系统会弹窗询问用户“是否打开”,多了一次确认;而且 Safari 地址栏里直接输入mygame://xxx并不一定会触发唤起,通常需要走a标签或者location.href跳转。

Universal Links 是苹果后来主推的方案。它本质上是让 App 和一个 HTTPS 域名绑定:当用户在 Safari 里打开https://api.mygame.com/open?invite=abc123,如果 App 已安装,系统直接唤起;如果没安装,页面正常在浏览器里打开。关键优势是没有确认弹窗,体验顺滑,而且链接本身就是普通 HTTPS,分享出去不吓人。

两种方案的参数承载方式也不同。URL Scheme 可以用 path、query、fragment 任意传;Universal Links 虽然也能带 query,但最终拿到的是一整个https://链接,需要服务端配合路径规则。做技术选型时不能只看“能不能唤起”,还要想清楚你的业务参数是放在域名路径里,还是 query 里。

1.3 双通道并存的选型思路

成熟项目的标准做法是两条通道都保留,不要搞二选一。URL Scheme 作为保底方案,兼容老系统、第三方 WebView 里通过自定义协议跳转的场景;Universal Links 作为主方案,覆盖 Safari、邮件、短信、以及大多数系统级入口。两个通道最终在原生层汇合,统一转成一个字符串交给 C#。

我见过一些团队只配置了 Universal Links,结果被渠道方的 WebView 环境坑过:部分第三方的内置浏览器对 Universal Links 支持不完整,点击后直接打开了网页而不是唤起 App。这时候如果留有 URL Scheme,还能用location.href = "mygame://..."的方式补救。反过来,只配 Scheme 的项目,在 iOS 上每次唤起都弹窗,用户流失非常明显。双通道的成本实际上很低,原生层代码是同一套,只是多监听一个回调而已。

2. 配置侧的前置工作:苹果后台、关联文件与 Unity 工程

2.1 开通 Associated Domains 能力

Universal Links 要生效,第一步是在苹果开发者后台给 App ID 开启 Associated Domains 能力。登录 developer.apple.com,找到 Certificates, Identifiers & Profiles,进入 App IDs,选择你的 Bundle ID,勾选 Associated Domains 后保存。这一步做完之后,Xcode 工程里的 Capabilities 页签也要同步打开 Associated Domains,并填入你要关联的域名。

域名格式是applinks:你的域名,比如applinks:api.mygame.com。一个 App 可以配置多个域名,但注意 Team ID 和 Bundle ID 一旦换了,关联文件和设备上的状态都要重新校验。这块经常被忽略的一个点是:团队如果在多个开发者账号之间迁移,旧的 apple-app-site-association 里写的还是旧 Team ID,新包就永远无法唤起。

2.2 编写并托管 apple-app-site-association 文件

关联文件必须在 HTTPS 域名根路径下,路径有两种:https://你的域名/apple-app-site-association或https://你的域名/.well-known/apple-app-site-association。苹果会优先尝试根路径,建议两个位置都放一份,避免某些缓存环境只认其中一个。文件内容是一个不带后缀的 JSON:

{ "applinks": { "details": [ { "appIDs": [ "TEAMID.com.yourcompany.yourgame" ], "components": [ { "/": "/game/open/*", "comment": "允许所有 /game/open/ 开头的路径唤起" } ] } ] } }

appIDs里的TEAMID是你的团队 ID,后面跟 Bundle ID,必须和开发者后台完全一致。components是匹配规则,支持通配符*。上面例子表示:当玩家点击https://api.mygame.com/game/open/invite?code=xxx时,系统会尝试唤起 App,并且把这个完整 URL 交给 App。注意components里的匹配只决定“是否唤起”,完整的链接内容苹果不会帮你解析,参数依然通过原生回调取到。

托管这个文件时,服务器必须支持 HTTPS,不能有重定向跳转,Content-Type 建议保持application/json或application/pkcs7-mime。我排查过不少关联文件问题,十次里有八次是服务器给文件加了重定向或者返回了 404 页面。

2.3 Unity 工程里注入 URL Scheme 配置

Unity 工程里注册 URL Scheme,最直接的方式是修改Assets/Plugins/iOS/Info.plist文件,让它在每次构建时自动合并进 Xcode 工程。配置片段如下:

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

CFBundleURLSchemes数组里的字符串就是你的自定义协议名。只要 Info.plist 里有这一段,App 安装后就会被注册为mygame://协议的接收方。我建议至少注册主协议和备用协议两个,比如正式包用mygame,测试包用mygametest,避免测试环境覆盖正式环境。

如果你是在 Unity 的 Player Settings 里勾选了Custom Info.plist,那就手动把上面的键加到自定义 plist 里;如果用默认的 plist 生成流程,则放到Assets/Plugins/iOS/Info.plist下,构建时会自动生效。

2.4 Xcode 工程导出的三个检查点

每次构建完 Xcode 工程,我习惯先打开工程检查三个地方:

第一,Capabilities 页签里 Associated Domains 是否真的勾选上了,并且 domains 列表里有applinks:api.mygame.com。很多时候 Unity 构建脚本没有自动打开 capability,需要手工加。更稳妥的做法是在构建脚本里处理,不依赖人工。

第二,Info.plist 里的CFBundleURLTypes是否被 Xcode 正确识别。检查方式:在 Xcode 里打开 Info 页签,看URL Types里有没有你的协议名,Bundle Identifier 是否填写了主 Bundle ID。

第三,Build Settings 里的 Code Signing 证书、Team 是否和开发者后台匹配。签名不对的话,设备上安装后 Universal Links 和 URL Scheme 都会失灵,而且这种问题隐藏得很深,看起来像配置没生效,其实是签名不一致。

3. 原生层拦截与转发:把链接安全地交给 C#

3.1 用 AppDelegate 子类接管生命周期回调

Unity 生成的 iOS 工程默认继承自UnityAppController,我们不要直接改它的源码,而是新写一个 Objective-C 子类,放到Assets/Plugins/iOS/目录下,Unity 构建时会自动打进工程。子类要重写的回调有:

  • application:openURL:options:负责 URL Scheme。
  • application:continueUserActivity:restorationHandler:负责 Universal Links。
  • application:didFinishLaunchingWithOptions:负责冷启动时直接通过链接拉起的情况。

代码骨架如下:

#import "UnityAppController.h" @interface DeepLinkAppController : UnityAppController @end @implementation DeepLinkAppController - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options { [self handleDeepLink:url.absoluteString]; return YES; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [self handleDeepLink:userActivity.webpageURL.absoluteString]; } return YES; } - (void)handleDeepLink:(NSString *)urlString { if (urlString && urlString.length > 0) { const char *msg = [urlString UTF8String]; UnitySendMessage("DeepLinkHandler", "OnReceiveDeepLink", msg); } } @end IMPL_APP_CONTROLLER_SUBCLASS(DeepLinkAppController)

IMPL_APP_CONTROLLER_SUBCLASS是 Unity 提供给UnityAppController子类的注册宏,必须在类实现的末尾调用。这个文件必须用.mm后缀,因为涉及 Objective-C++ 的运行时特性。

3.2 冷启动时序问题:为什么不建议直接 UnitySendMessage

上面这段代码在“热启动”场景下没问题,也就是 App 已经跑起来、玩家从后台切去 Safari 再点链接,UnitySendMessage会立刻把消息发给 C#。但冷启动场景会出大问题:App 被杀掉后,玩家点击链接启动游戏,系统会在didFinishLaunchingWithOptions阶段就把链接给你,而这时 Unity 引擎可能还没完全初始化。UnitySendMessage底层依赖 Unity 的 native 消息系统,引擎没 ready 时调用,轻则消息丢失,重则直接崩。

很多团队上线后反馈“第一次点击链接没反应,第二次就好了”,多半就是这个时序问题。实际现象是:App 冷启动后 Unity 场景已经加载完,但那条链接早就丢了,因为原生层在启动早期调用的UnitySendMessage没有被接收方注册。

3.3 原生缓存 + C# 主动拉取的桥接设计

我的方案是:原生层收到链接后,先存到一个静态变量里,然后通过 C# 启动时主动拉取。这样不依赖调用时机,任何时刻收到链接都能保证不丢。

先在原生层加缓存和两个导出函数:

static NSString *gCachedDeepLink = nil; void UnitySendDeepLinkToManaged(NSString *urlString) { if (urlString.length == 0) return; if (UnityAppReady()) { UnitySendMessage("DeepLinkHandler", "OnReceiveDeepLink", [urlString UTF8String]); } else { gCachedDeepLink = [urlString copy]; } } char* GetCachedDeepLink() { if (gCachedDeepLink == nil) return NULL; const char *utf8 = gCachedDeepLink.UTF8String; char *result = (char *)malloc(strlen(utf8) + 1); strcpy(result, utf8); return result; } void ClearCachedDeepLink() { gCachedDeepLink = nil; }

UnityAppReady()是 Unity 提供的接口,判断引擎是否已经初始化完毕。GetCachedDeepLink返回的是调用malloc分配的内存,C# 侧拿到字符串后要记得释放,或者干脆在 C# 侧用Marshal.PtrToStringAnsi复制出来,避免内存泄漏。

C# 侧启动时主动查询一次,代码里就是[DllImport("__Internal")] extern static string GetCachedDeepLink(),这个细节放到下一章展开。

4. C# 层参数投递与业务消费

4.1 常驻接收者与待处理队列

C# 层需要一个永远挂载的DeepLinkHandler,它承担两件事:接收原生层消息、向业务层分发。在场景加载之前,它得先被创建出来,用RuntimeInitializeOnLoadMethod可以保证在进入第一个场景前注册好回调:

public class DeepLinkHandler : MonoBehaviour { private static readonly Queue<string> PendingLinks = new Queue<string>(); public static event System.Action<string> OnDeepLinkReceived; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void Bootstrap() { var go = new GameObject("[DeepLinkHandler]"); DontDestroyOnLoad(go); go.AddComponent<DeepLinkHandler>(); } private void Start() { #if UNITY_IOS && !UNITY_EDITOR string cached = GetCachedDeepLink(); if (!string.IsNullOrEmpty(cached)) { PendingLinks.Enqueue(cached); ClearCachedDeepLink(); } #endif } public void OnReceiveDeepLink(string rawUrl) { PendingLinks.Enqueue(rawUrl); OnDeepLinkReceived?.Invoke(rawUrl); } }

队列是个很重要的设计。玩家连续点击多条邀请链接,或者投放平台回传了多个跳转,如果只是用一个字段覆盖保存,后到的链接会把前面的挤掉。用队列按顺序消费,每条链接都不会丢。

4.2 解析链接参数:Query、Path 与 URL 解码

拿到完整链接之后,接下来要解析出业务参数。这里的坑在于:URL Scheme 和 Universal Links 拿到的链接格式不一样,不能假设只有一种结构。我会先根据http前缀判断类型,再用System.Uri处理:

public static Dictionary<string, string> ParseDeepLink(string rawUrl) { var map = new Dictionary<string, string>(); if (string.IsNullOrEmpty(rawUrl)) return map; // scheme://host/path?query 或 https://domain/path?query var uri = new Uri(rawUrl); string path = uri.AbsolutePath; string query = uri.Query.TrimStart('?'); if (!string.IsNullOrEmpty(path)) { map["path"] = path.Trim('/'); } if (!string.IsNullOrEmpty(query)) { foreach (string pair in query.Split('&', System.StringSplitOptions.RemoveEmptyEntries)) { var kv = pair.Split(new[] { '=' }, 2); if (kv.Length == 2) { string key = System.Uri.UnescapeDataString(kv[0]); string value = System.Uri.UnescapeDataString(kv[1]); map[key] = value; } } } return map; }

Uri.UnescapeDataString用来解 URL 编码,别用"a+b".Replace("+", " ")这种手写逻辑,处理特殊字符容易出问题。中文参数和带=的 value,在编码之后都能被UnescapeDataString正确还原。

解析完参数不要直接散落到各个 GameObject 上,而是先封装成一个结构体,里面放RawUrl、Path、Query。后续要接渠道归因、邀请码、房间号,都需要基于这套解析结果扩展。我个人习惯建一个DeepLinkPayload类,所有业务模块统一消费它,而不是消费原始字符串。

4.3 场景就绪后再投递:消息的有效性保护

参数解析完成还只是第一步。最大的坑是:业务场景还没加载完,参数已经到 C# 层了。比如玩家点击链接唤起 App,冷启动后第一个场景往往是 Logo、加载页,你这时候想把邀请码塞给大厅 UI,大厅节点根本不存在。

处理方式不是“延迟几秒再发”,而是监听“业务就绪”信号。我会在游戏主入口处注册一个静态事件GameEntryReady,当大厅、登录模块初始化完毕后置为true。DeepLinkHandler里维护一个待处理列表,业务就绪前只入队,就绪后逐条消费并触发业务回调:

private IEnumerator DispatchWhenReady() { yield return new WaitUntil(() => GameEntry.Instance != null && GameEntry.Instance.Ready); while (PendingLinks.Count > 0) { string url = PendingLinks.Dequeue(); var payload = ParseDeepLink(url); OnDeepLinkReceived?.Invoke(url); // 业务侧再订阅 OnDeepLinkReceived 处理邀请码、房间号等 } }

这里我用了WaitUntil,比写死yield return new WaitForSeconds(2f)靠谱。因为真机性能不均,弱机 2 秒内场景可能还没起来,强机可能已经过了最佳投递时机。等一个明确的事件信号,逻辑才可控。

4.4 去重、时效与多页签判定

线上环境里,同一链接可能会被多次投递。比如 Universal Links 触发后,iOS 在某些系统界面还会补发一条openURL,同一个邀请链接重复进队列,业务侧会有重复弹窗、重复请求。我会给每条链接生成一个指纹,根据“scheme + host + path + query + 时间戳”去重,窗口期设为 5 秒,同一指纹只消费一次。

时效性也要考虑:Deep Link 通常是一次性的,投递给业务层之前,如果玩家已经登录且在大厅待了很久,再突然弹一个“进入邀请房间”的提示,体验很突兀。我会在投递时带上“App 启动后经过了多少秒”这个字段,由业务侧判断是走强提示还是弱提示(比如只在小红点里加个标记)。

多页签问题主要发生在 Universal Links 的网页端:玩家用 Safari 开了好几个标签页,每一个都指向同一个链接,系统会尝试多次唤起。我碰到的实际表现是:App 收到三次相同的链接,玩家根本没点击第二次。这种情况靠原生层过滤不掉,因为 URL 一模一样,只能在 C# 层做指纹去重。

5. 真机调试和线上问题排查实录

5.1 三类测试入口:Safari、短信、备忘录

真机调试的时候,很多人直接在 Safari 地址栏敲mygame://xxx,发现没反应就以为方案没生效。其实 iOS 对地址栏输入自定义 Scheme 的处理并不稳定,更规范的测试方式是准备一个本地 HTML 页面,里面放几个测试链接:

<a href="mygame://open/invite?code=abc123">Scheme 唤起</a> <a href="https://api.mygame.com/game/open/invite?code=abc123">Universal Link 唤起</a>

把这个 HTML 放到本地 HTTP 服务或者直接拖到备忘录里,点击超链接测试。备忘录链接点击对 Universal Links 支持比较接近真实短信场景,非常适合模拟用户收到邀请短信后点击的效果。如果从短信点击后能唤起、从 Safari 地址栏不能,这不一定是问题,要看你自己定义的“成功标准”是什么。

测 Universal Links 还可以用系统自带的“快捷指令”或者 Pages,本质都是生成一个可点击的 HTTPS 链接。记住:永远不要在地址栏测试 Universal Links,地址栏点击会优先走网页打开,不一定会走 App 关联逻辑。

5.2 Universal Links 不唤起的高频原因

我把这些年排查到的 Universal Links 不唤起原因按频率排个序:

现象最常见原因
点击链接永远打开网页关联文件返回了非 200 状态码,或者被 CDN 重定向
只有第一次点击有效苹果 CDN 缓存了旧的关联文件,等缓存刷新再测
部分设备有效部分无效设备系统版本太旧,iOS 8 以下不支持 Universal Links
换证书后失效Team ID 或 Bundle ID 变了,关联文件里没更新
点击后提示无法打开appIDs与当前包签名不一致,或 associated domains 没勾选

排查时可以先在 Safari 里输入https://你的域名/apple-app-site-association看看 JSON 是否正常体格式;再用终端curl -I看响应头,重点确认有没有Content-Type,有没有跳转。关联文件变更后,苹果服务器不会实时刷新,通常等几分钟,我在测试时试过最长等过十几分钟。

如果所有配置都对,但设备上依然无效,可以尝试卸载重装 App。因为系统会把“这个域名是否关联这个 App”的信息记录下来,卸载会清掉关联状态,重装后系统重新拉取关联文件。这是很多团队配置改动后“为什么还不起作用”的盲区。

5.3 参数丢失、乱码和重复发起的处理

参数丢失最常见的原因是 C# 层消费太早。冷启动时链接已经存在原生缓存里,但我见过一些团队直接在Awake里解析并调用业务逻辑,结果大厅还没创建,日志显示“收到链接”但业务侧没有任何反应。解决方法是回到第 4.3 节的场景就绪机制,消息到了不代表能消费。

乱码一般发生在链接参数里带中文或特殊字符时。链接从点击到传给 App,中间经过两层:系统层和原生层。如果原生层直接取url.absoluteString,系统已经在编码状态;C# 侧用System.Uri.UnescapeDataString解码一次就够了。不要再额外做WWW.UnEscapeURL二次解码,会适得其反。

重复发起要分两种看:一种是系统层面的重复回调,iOS 某些版本在冷启动时会对同一个 Universal Link 触发continueUserActivity又触发openURL;另一种是业务层自己重复触发,比如原生层代码里两个回调都调了handleDeepLink。我的排查顺序是先看原生层日志,确认回调次数;再看 C# 层队列长度;最后才是业务弹窗次数。一级一级排查,不要上来就改 C# 逻辑。

5.4 iOS 15 之后剪切板“假唤醒”问题

iOS 15 之后系统加强了剪切板感知:如果某个 App 的 Universal Links 和系统检测到的剪贴板链接匹配,系统可能自动唤起 App,并把这个链接当作 Deep Link 投递。这个机制本意是方便用户体验,但对我们做邀请链接的团队来说是个麻烦。

玩家可能只是复制了一段别人发来的邀请文本,并没有主动点击,App 被唤起后却弹出了邀请提示,体验很怪。处理方式是在 C# 层校验“来源”:只有明确从openURL或continueUserActivity这些用户点击路径进来的链接,才走强提示;从didFinishLaunchingWithOptions冷启动并且能判断为剪贴板来源的,降级成弱提示,或者在用户未主动操作时不弹窗。这块没有统一 API,需要结合自身产品形态做策略,我只能提醒大家别忽略了这条“意外唤起”路径。

6. 我踩完这轮坑之后的固定自检习惯

最后说一个我自己形成的固定习惯:每次配置完 Deep Link,我都要在真机上跑五条用例,分别是 URL Scheme 热启动、URL Scheme 冷启动、Universal Links 热启动、Universal Links 冷启动、短信点击唤起。每一条用例都带不同的参数,唤起后看 C# 层日志里的RawUrl和解析后的Query,和发送端逐一比对。

这套自检看起来基础,但能拦住绝大多数低级错误。实际项目里很多时候并不是“不会配”,而是配置改过之后没有完整回归:证书换了、域名换了、路径规则换了,只要其中一项漏掉,线上就会有一批用户点链接唤不起 App。把这些用例固定下来,每次发包前跑一遍,能省下后面大量追查客服反馈的时间。

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

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

立即咨询