☰
Unity iOS Deep Link 全链路实战:URL Scheme 与 Universal Links 配置到 C# 参数投递
2026/9/30 9:22:56 网站建设 项目流程

每次运营群里发来“分享链接打开 App 后没落地页”的消息,我都知道九成问题出在 Deep Link 链路上。很多 Unity 团队把 URL Scheme、Universal Links 两个词挂在嘴边,但真正动手时才发现,从 Xcode 原生配置、AppDelegate 回调,到 C# 层参数接收,每一步都有能坑到你的细节。这篇内容把我完整做过一版 Unity iOS Deep Link 唤醒流程的路径捋清楚,覆盖 URL Scheme 和 Universal Links 两套方案,从原生回调一直写到 C# 参数投递与业务消费,适合做 Unity 客户端、原生桥接、渠道投放回流拉新的同学直接参考。

1. 先理清思路:Deep Link 到底解决什么问题

1.1 两套方案面对的不同战场

URL Scheme 可以理解为给 App 注册一个自定义协议,类似mygame://,系统收到这种协议时会用名字找到对应 App 并唤起。从 iOS 早期版本就存在,历史悠久,第三方分享、网页跳转、扫码唤起大量使用这种形式。

Universal Links 是 iOS 9 引入的,思路完全换了个方向:不是你注册一个“电话号”,而是让系统去验证一个域名和 App 之间的绑定关系。你在工程里声明applinks:yourdomain.com,并且在服务器上放一个签名文件,系统确认这个域名确实是你的 App 所有后,用户在浏览器点击这个域名下的链接,就能直接唤起 App。

一个最直观的差异是:URL Scheme 唤起会弹一个系统确认框,写“是否在‘XX’中打开链接”,Universal Links 是不会弹的,因为用户本身就在访问网页内容,系统在后台完成了校验。另一个差异是:点击一个 Universal Link,但用户没装 App,Safari 会正常打开网页,你可以在这个网页上展示“下载 App”的按钮;而点击自定义 scheme,系统只能弹一个“无法打开”的提示,你连兜底页面都控制不了。

1.2 为什么多数项目两套并修

很多老项目只做了 URL Scheme,因为当年就没有 Universal Links。但只靠 Scheme 的问题很明显:无法可靠判断用户是否安装了 App,无法在浏览器里做落地页分流,唤起时还会被系统弹窗打断体验。

我的建议是两套同时维护,形成互补。Universal Links 作为主入口,承担日常链接、分享卡片、短信落地、活动页唤起的职责;URL Scheme 作为历史兼容和兜底入口,保留给旧版本客户端、老活动链接和部分第三方平台的内嵌场景。两个入口最终都回收到同一个 C# 方法里,业务层不用关心是哪种方式进来的,只需要拿到最终的 URL 参数。

1.3 选型对照一张表

对比维度URL SchemeUniversal Links
工作机制自定义协议,系统按协议名匹配HTTPS 域名 + 签名文件验证,系统按域名匹配
是否弹确认框弹不弹
是否能判断未安装不能,直接报“无法打开”可以,未安装时打开网页兜底
需要配置的原生项Info.plist 注册 schemeAssociated Domains + AASA 文件
需要服务器资源不需要需要 HTTPS 域名,且要放配置文件
覆盖版本全版本iOS 9 及以上(低版本需 scheme 兜底)
被第三方平台拦截程度高,尤其国内 App 内浏览器相对低,但仍有限制

一句话:Universal Links 是正规军,URL Scheme 是老兵,实战里先把正规军配置好,再让老兵兜底,最后全链路都收敛到 C# 层统一处理。

2. 原生层配置:Xcode 里的一锤子买卖

2.1 Associated Domains 与 applinks 配置

Universal Links 的原生配置并不多,但一步错就会整体失效。首先登录苹果开发者后台,找到 App ID 里对应的 Bundle ID,在 Capabilities 里打开 Associated Domains 选项,顺手把推送能力也检查一遍,避免签名出问题时找错方向。

然后回到 Xcode,选择 target 的 Signing & Capabilities,添加 Associated Domains capability。下面的 Domains 列表里填applinks:cb95f.example.com。这里注意格式,是applinks:前缀加你自己的域名,域名不要带https://。这个域名会出现在回传的 AASA 文件名校验中,前后必须严格一致。

接着要配置服务器上的 apple-app-site-association 文件,路径是https://你的域名/.well-known/apple-app-site-association。文件内容是 JSON,大致结构是:

{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.xxx.game", "paths": ["*"] } ] } }

TEAMID是你开发者账号的 Team ID,可以在开发者后台 Membership 里找到。com.xxx.game是 Bundle ID。这个文件必须用 HTTPS 访问,不能重定向,不能放在 CDN 后面做乱七八糟的跳转。我建议测试阶段就用 curl 抓一次这个 URL,确认能拿到 200 和完整 JSON。如果 TEAMID 和 Bundle ID 对不上,系统不会报错,但你等十年也不会唤起。

2.2 Info.plist 的 URL Types 定义

URL Scheme 的配置在 Info.plist 里,也比较简单。在 Xcode 的 Info 面板里添加 URL Types,URL Schemes 填你的协议名,比如mygame,URL Identifier 填你的 Bundle ID,比如com.xxx.game.url。

这里有个容易被忽略的地方:如果你注册了mygame这个 scheme,那么所有能访问这个 scheme 的 App 都能往你的 App 里传内容。别人也可以注册同样的mygame并手动打开你的 App,所以不要依赖 scheme 做安全校验,参数必须做服务端签名或有效期校验。

配置完后,模拟器上可以直接用命令行测:

xcrun simctl openurl booted "mygame://activity?scene_id=1002"

这个命令会把 scheme 链接推给模拟器里的 App。热启动时原生回调立刻触发,冷启动时也能验证系统是否把它交给 AppDelegate 处理。

2.3 系统回调的两个入口:AppDelegate 与新场景

iOS 12 及以下版本,Universal Links 的回调入口是 AppDelegate 的continueUserActivity,Scheme 的入口是openURL。

// 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; UnitySendMessage("DeepLinkManager", "OnLinkReceived", [url.absoluteString cStringUsingEncoding:NSUTF8StringEncoding]); } return YES; }

iOS 13 开始,App 生命周期从 AppDelegate 迁到了 UIScene。打开链接的回调不再走application:openURL:options:,而是走 SceneDelegate:

// SceneDelegate 版本,适配 iOS 13+ - (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts { NSURL *url = URLContexts.allObjects.firstObject.URL; UnitySendMessage("DeepLinkManager", "OnLinkReceived", [url.absoluteString cStringUsingEncoding:NSUTF8StringEncoding]); }

不要觉得工程里没建 SceneDelegate 就万事大吉,如果你的项目是 iOS 13 之后新建的,苹果默认就走场景生命周期。我碰到过有人在 AppDelegate 里写了回调,但 UIScene 接管后一直收不到链接的情况。最稳的办法是同时处理 AppDelegate 和 SceneDelegate 两个入口,双写回调,避免版本差异。

3. 原生回调到 Unity:消息桥接的完整链路

3.1 UnitySendMessage 在 ObjC 里的正确姿势

原生拿到 URL 之后,交给 Unity 最直接的方式是 UnitySendMessage。这个函数签名有三个参与要素:目标 GameObject 名字、方法名、message 字符串。

UnitySendMessage("DeepLinkManager", "OnLinkReceived", urlString);

C# 那边对应的脚本必须挂在名为 DeepLinkManager 的 GameObject 上,脚本里必须写一个 public 方法:

public void OnLinkReceived(string message) { // 处理原始 URL 字符串 }

这个方法必须由 MonoBehaviour 提供,方法返回值必须是 void,参数必须是 string,名字和原生端严格一致,大小写都要对。UnitySendMessage 找 GameObject 时按场景层级查找,场景里没有这个物体,或者该物体没有挂目标脚本,原生调用会报错甚至导致崩溃。

ObjC 端构造字符串时还需注意:如果 URL 里包含中文、特殊符号、换行符,直接转成 C 字符串可能会把深链数据搞脏。建议原生端先对 URL 做一次编码提取,或者把整个参数字典拼成 JSON 串,让 C# 端一次解析到位。

3.2 冷启动 vs 热启动的消息时序

热启动场景很好处理:用户点链接唤起时 App 已经在后台,Unity 引擎已经跑起来了,Scene 里的 DeepLinkManager 一直活着,UnitySendMessage 一调,C# 立刻收到。

冷启动场景就麻烦得多。点击链接唤起 App 时,引擎还没启动,场景还没加载,DeepLinkManager 根本不存在。你不能在原生回调里直接 UnitySendMessage,因为 Unity 还没准备好。

我常用的方案是在原生端做一次缓存。冷启动时先把 URL 存进原生变量或者 NSUserDefaults,然后在 Unity 场景加载完成后,用一个“补偿”机制再次投递到 C# 层。具体做法是:在 AppDelegate 里监听applicationDidBecomeActive,判断缓存里有没有未投递的链接,再调 UnitySendMessage。C# 端在 Awake 里设置一个 Ready 标记,表示“我已经准备好接收”,原生端和 C# 端使用同一套协调逻辑。

原生端大致逻辑:

- (void)cacheLinkIfNeeded:(NSURL *)url { if (![UnityAppController isUnityReady]) { [[NSUserDefaults standardUserDefaults] setObject:url.absoluteString forKey:@"PendingDeepLink"]; } }

C# 端 Ready 之后再主动告诉原生端“可以补投”。这个方案的最常见 bug 是:原生缓存了,C# 端 Ready 之后原生却已经过了投递时机。所以明确一条规则:原生端每次获取到新链接时都先判断 Unity 是否 ready,不 ready 就存缓存;Unity 每次从场景启动到 Awake 完成都要主动拉一次原生缓存。这不是什么黑科技,就是两个端都做幂等处理,保证同一链接只会被消费一次。

3.3 为什么我建议统一走 JSON

UnitySendMessage 只能传一个字符串。如果链接里只有scene_id,传 URL 原样就行;但业务方一天要传落地页路由、来源渠道、活动参数、timestamps、签名,甚至还会加嵌套 JSON 字段,这时候用一个裸链接字符串就不够用了。

我的建议是原生端收到 URL 后,解析出完整参数,拼成一个标准 JSON 对象,再通过 UnitySendMessage 传给 C#。比如:

{ "url": "mygame://activity?scene_id=1002&from=share", "host": "activity", "query": { "scene_id": "1002", "from": "share" }, "launchType": "cold" }

C# 端用 JsonUtility 或者第三方的 JSON 库解析。用 JSON 的好处在于:链路清晰、参数结构不依赖字符串拼接、多字段扩展不用改桥接方法签名。原生端不解析查询串也能传递全部信息,但建议至少把host拆出来,方便 C# 路由分发时提高性能。

4. C# 层参数接收与业务投递

4.1 建立单例 DeepLinkManager 与 Ready 标记

C# 端我建议做一个常驻单例,挂在场景启动时的第一个 GameObject 上,并保证整个生命周期不销毁。

public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } public event Action<DeepLinkData> OnDeepLinkReceived; private bool isReady = false; private void Awake() { Instance = this; DontDestroyOnLoad(gameObject); isReady = true; NativeBridge.FetchPendingLink(); } public void NotifyReadyToNative() { // C# 就绪后通知原生可补投 } public void OnLinkReceived(string rawJson) { var data = ParseJson(rawJson); if (data != null) { OnDeepLinkReceived?.Invoke(data); } } }

关键点是isReady标志和事件系统的解耦。业务页面不需要知道 DeepLinkManager 内部怎么解析、怎么缓存,只需要订阅事件,在界面初始化完成后等待跳转。我在实际项目里遇到一个问题:业务页面跳转时,UI 界面还没初始化完,Route 收到深链参数就急着切场景,结果场景里的组件状态丢失。所以建议先消费事件,把参数暂存在一个路由表里,界面初始化完成后再触发跳转。

4.2 URL 解析工具函数:从 query 到参数表

拿到原始 URL 后必须做解析,iOS 原生传过来的字符串很可能已经做过一次编码。C# 最简单的做法是用Uri类拆 host 和 path,再手写一个 query 解析函数,而不是指望Uri直接给干净字典。

public static Dictionary<string, string> ParseQueryString(string query) { var dict = new Dictionary<string, string>(); if (string.IsNullOrEmpty(query)) return dict; foreach (var pair in query.Split('&')) { if (pair.Length == 0) continue; var index = pair.IndexOf('='); if (index < 0) { dict[Uri.UnescapeDataString(pair)] = ""; continue; } var key = Uri.UnescapeDataString(pair.Substring(0, index)); var value = Uri.UnescapeDataString(pair.Substring(index + 1)); // 部分系统会把 + 号编码为空格,这里主动替换 value = value.Replace("+", " "); dict[key] = value; } return dict; }

这个函数是所有深链参数投递的地基,建议放在公共工具类里。注意绝对不能直接Split('&')后扔给业务层,因为 URL 编码后的内容里可能有嵌套编码问题。遇到%2F代表/、%3F代表?,Uri.UnescapeDataString可以处理,但如果你发现参数在浏览器里能正常解析、进 App 后却是乱码,多半是原生产生了双重编码,要追到源头解决,而不是在 C# 里反复 Unescape。

4.3 消费深链:延迟到 UI 初始完成后触发

多数游戏在启动时会经过启动动画、登录态校验、资源更新、主界面生成这几个阶段。深链最好在登录态校验通过之后进入路由。如果深链要求跳转到“活动页”,而登录态还没就绪,直接跳转会导致活动页拿不到账号数据。

我做这种路由时通常会给 DeepLinkManager 加一个是“待处理”的缓存:OnDeepLinkReceived收到事件后,不立刻跳转,而是放进一个PendingRoute字段,等 GameFlow 触发OnLoginFinished之后再读取并消费。跳转本身通过事件发布,避免不同模块产生强依赖。

4.4 冷启动参数持久化的必要性

如果用户点击深链拉起 App 后,先出现的是日志页、隐私弹窗、登录页,此时业务层还没注册跳转事件,深链参数就会丢失。这个场景经常被忽略,尤其是国内应用需要用户同意隐私协议后才允许业务初始化。

我建议 C# 端在收到深链后立刻把参数存到 PlayerPrefs。下一次业务流程走到路由阶段时,先读本地配置恢复待消费的深链,完成跳转后再清除这个配置。这样即使用户杀掉了 App 重新进入,也能在“刚才那条分享链接让我进 App”的场景下重新路由一次。需要注意:深链一旦被消费过,要记得清除本地记录,避免重复弹窗。

5. 高频坑位逐个说清(排查实录)

5.1 Universal Links 一直不生效

最经典的失败场景是:在 Safari 里输入域名,页面能打开,但就是不唤起 App。排查顺序我按经验排序:

一是 AASA 文件问题。系统会把 AASA 缓存下来,更新不生效很正常。测试时去设置里关闭 Wi-Fi、重开蜂窝网络可以强制刷新,但最干净的验证办法是把 AASA 文件放到https://你的域名/apple-app-site-association并且不做重定向,然后自己用浏览器访问一次确认内容。

二是证书路径问题。AASA 里的appID必须写成TEAMID.BundleID格式,如果 Team ID 写错,系统永远不会把链接派发给你的 App,也不会报错。写完最好用 Apple 官方提供的验证方式,在 Safari 中长按链接查看是否显示“在 App 中打开”。

三是 Associated Domains 列表配置缺失或格式错误。applinks:前缀必须正确,域名不要带协议头。四是模拟器表现不一致,Universal Links 在模拟器里的可靠性不如真机,尽量用真机测试。五是 App 是否处于冷启动,如果 App 被强杀,首次唤起可能不回调,要结合冷启动缓存方案一起考虑。

5.2 URL Scheme 唤起后被应用商店拦截

Scheme 最大的坑是未安装场景。iOS 10 之后,系统点击未注册的 scheme 会直接弹无法打开提示,你想跳 App Store 都做不到。所以现在所有渠道链接都要求 Universal Links 优先。

有些第三方 App 的内置浏览器会对 scheme 做限制,直接在网页里点击 scheme 链接没有任何反应。处理方式是给前端一个 JS Bridge 接口,让前端判断当前环境后选择跳转方式,或者走等待下载页。分享卡片和短信里的通用落地页,必须带 Universal Links,而不是写死mygame://,否则用户没装就彻底流失。

我还遇到过 scheme 冲突:多个 App 注册同一个 scheme,iOS 会选择先装的那个,导致链路指向错误。所以注册 scheme 时尽量带上前缀特征,比如mygamexxx://,不要用game://这种通用名。

5.3 Unity 层收不到消息的经典翻车

原生调试明明调用了 UnitySendMessage,C# 层就是不触发。最常见的原因:GameObject 不在当前场景里、场景目录放在隐藏文件夹、或者脚本没挂上去。还有一个坑是方法名不是你写的哪个,UnitySendMessage 找方法时区分大小写,方法名和参数类型必须完全匹配。

另外 UnitySendMessage 必须由 MonoBehaviour 的 GameObject 方法接收,如果你是挂在一个接口组合类上,方法被定义为非 public 方法是不行的。原生线程方面,UnitySendMessage 在原生主线程调用没问题,但如果回调发生在子线程,需要先 dispatch 到主线程再调用。否则会出现内存不可预知问题。

我建议在 C# 端第一行加 Debug.Log,原生端第一行也加日志,先确认两端有没有进球,再查业务逻辑。日志里把收到的 URL 原样输出,这是最快的定位手段。

5.4 参数在传输中变脏

场景:https://yourdomain.com/activity?scene_id=1002&title=新春%20好礼,经过 Objective-C 原生处理后,C# 端 Decode 出来“新春 好礼”变成“新春+好礼”。这个多数是前端或 H5 侧把参数又做了一次encodeURIComponent,导致二次编码。

规范处理方式是:所有链接生成统一由后端提供标准模板,前端只负责替换参数占位符,客户端统一解码一次。如果发现某条字段实测是乱码,就要去源头 JSON 或 URL 构造代码里找,而不是在 C# 层写一堆兼容代码。线上链路一旦跑起来,参数格式没统一,后续每次排查都是灾难。

6. 最后再分享一点实战心得

我只建议在这条深度链路里保留“一条主路、一条兜底路、一个统一收口”的结构。主路是 Universal Links,兜底是 URL Scheme,统一收口是 C# 里的 DeepLinkManager 事件。不要每个业务模块都自己监听原生回调,久了会乱成蜘蛛网。

我自己的项目里有三个验证链路的小工具:模拟器上xcrun simctl openurl测 scheme;真机上用备忘录创建https://你的域名/test?scene=1001链接,点一下测试 Universal Links;再加一个内测调试面板显示最近 5 次收到的深链数据字段。这三件事基本能覆盖日常 80% 的深链问题排查。

最后再提醒一句:深链参数里的from、source这类渠道字段,最好在服务端做一次签名再下发,客户端不要单独信任一个scene_id就跳内部奖励页面。别问我为什么提这茬,线上被刷过之后就懂了。

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

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

立即咨询