拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity iOS Deep Link全流程实践:Scheme与Universal Links参数投递

Unity iOS Deep Link全流程实践:Scheme与Universal Links参数投递

做手游的,特别是做 iOS 发行、带买量投放和活动运营的,对 Deep Link 这东西肯定不陌生。但真要说能把整个链路理清楚,从 iOS 系统的 URL Scheme 到 Universal Links,再一路把参数干干净净地递进 C# 层,让 Unity 侧业务丝滑拿到邀请码、渠道号、活动参数,这中间的文章其实很深。我不止一次看到有人把 Universal Links 配置好了、AASA 文件也放了,结果一进 App 参数空上天,冷启动拿不到、热启动事件不触发,最后只能靠埋点硬凑。

这篇文章就把我从头到尾踩过的坑、落地的完整流程写一遍。内容涵盖方案选型、Xcode 工程配置、AASA 文件部署、C# 层代码实现、冷热启动时序处理,以及“App 没装”这种最坑的场景下怎么把参数补回来。不管你是刚接触 Deep Link,还是已经接了一半卡住了,这篇应该都能帮上忙。

1. 为什么 iOS 的 Deep Link 一直是 Unity 开发者的心病

1.1 一次运营活动引出的完整链路问题

先说个很典型的场景。运营要做一个邀请回归活动:老玩家分享一个链接给新用户,新用户点开链接,如果装了游戏就直接唤起并自动绑定邀请关系;如果没装,跳去 App Store,下载完打开游戏首次启动时,还得能识别出来“这个人是因为谁邀请才下载的”。

听起来好像不复杂——一个链接的事。但真正在 iOS 上动手你会发现,这个“链接”从被点击,到操作系统把数据交付给 App,再到 Unity 引擎真正拿到参数,每一环都有各自的规矩和脾气。URL Scheme 给得干脆但功能单薄,Universal Links 功能强大但配置繁琐,服务器那边还得放一个叫apple-app-site-association的文件。对 Unity 项目来说,还得面对原生层和 C# 层之间的那座桥。

更要命的是时序问题。用户点链接那一刻,你的 App 可能是死的、是活的、是刚从后台回来的,也可能是压根不存在的。每一种状态下,系统投递参数的路径都不一样。参数明明传了,但你家 Unity 代码拿到的时机不对,等于白传。

1.2 两个体系的本质区别:URL Scheme 与 Universal Links

理解这两个东西的区别,是解决整个问题的地基。

URL Scheme 有点像“手机号直拨”。你在 Xcode 里给自己注册一个协议头,比如mygame://,别的 App 或者网页拿到这个地址,直接呼叫系统“帮我打开 mygame 这个 App”。系统一看,确实装了,就唤起它,然后把完整的 URL 字符串交给 AppDelegate。这个方案的好处是简单、直接、稳定,几乎不会失败。但缺点也很明显:没有安装 App 的时候,这个链接直接失效,浏览器会提示“打不开网页”;另外 iOS 系统对这类唤起会有限制,重复操作时弹确认框,体验不太好。

Universal Links 走的是另一套逻辑,用的是 HTTPS 域名。你在自己的网站上放一个 JSON 配置文件,告诉苹果“我这个域名以下这些路径,统统领到某某 App”。用户点击链接时,系统会先去验证这个域名是否与已安装的 App 绑定。验证通过,就直接在你 App 里打开,而不是在浏览器里。因为地址本身是标准 HTTPS,没装 App 时也能正常落到网页上,由网页自己决定后续行为——跳 App Store,或者引导下载。这才是原生 iOS 推荐的方向。

一句话总结:Scheme 是“备用钥匙”,Universal Links 是“自动门禁”。现代 iOS 产品的做法通常是两个都配,以 Universal Links 为主、Scheme 兜底。后面我会详细说怎么配。

2. 方案选型:什么时候用 Scheme,什么时候用 Universal Links

2.1 URL Scheme 的配置与使用边界

URL Scheme 的配置在 Xcode 里非常轻量。选中 Target,切到 Info 页签,往下拉找到 URL Types,加一条,填上 URL Schemes。比如说我填mygame,那么mygame://invite?uid=123就能唤起 App。

但这个简单有个前提:你必须在 AppDelegate 里实现对应的方法去接住这条链接。iOS 9 之前用handleOpenURL,iOS 9 之后系统改成了这个:

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options { return YES; }

对于 Unity 项目来说,这个方法大概率是写在iOS 原生插件里,或者更常见的做法是直接用UnityAppController分类(Category)来重写生命周期,拿到 url 之后再UnitySendMessage丢给某个 C# 挂点。

方案配置上有一个容易被忽略的地方:URL Scheme 的字符串会被系统当作 App 的唯一标识之一,跟别的 App 撞了会很麻烦。大的服务商和游戏公司都有占位习惯,但小团队经常随便填一个,撞上了就会出现“我在浏览器打开,唤起的是别人家 App”的灵异事件。这一点一定要提前警惕,Scheme 写一个自己产品独有的、带品牌标识的完整单词,别用game、app这种俗名。

2.2 Universal Links 的配置与 AASA 文件细节

Universal Links 玩的就是apple-app-site-association文件,下文简称 AASA。你先要有一个支持 HTTPS 的域名,然后在域名的根目录或/.well-known/目录下放这个文件,系统会自动去取。

文件内容大概长这样:

{ "applinks": { "apps": [], "details": [ { "appID": "ABCDE12345.com.example.mygame", "paths": [ "/invite/*", "/game/*" ] } ] } }

然后回到 Xcode,在工程里打开 Background Modes 和 Associated Domains 能力,加一条applinks:yourdomain.com。

整套配置里坑最多的就是 AASA 文件本身。第一,路径写错了,系统直接不认。/invite/*这个写法匹配/invite/下面任意子路径,但不会匹配/invite本身,也不匹配/inviteabc。第二,查询参数不会参与路径匹配,也就是说https://yourdomain.com/invite?uid=123走的是https://yourdomain.com/invite这个路径是否被 profiles 覆盖。如果你 AASA 里只写了/invite/*,那么上面的链接在路径匹配阶段就会失败。推荐做法是,AASA 的 path 写宽一点,比如/*,然后在业务层自己判断路径前缀;或者干脆把所有带参数的活动链接统一指向同一个不带参数的路径,参数全部放 query string 里。实际上正式环境里 AASA 配置得越简单越不容易出问题,权限控制在你自己服务器上做就好了。

第三,AASA 文件不能有重定向。iOS 请求这个文件时,如果服务器返回一个 302 跳转,系统大概率直接判定失败。还有 HTTPS 证书必须有效,不能用自签名证书。很多团队把文件放在 CDN 上,CDN 又带了重定向策略,这种情况是最难排查的。验证方法很简单,用电脑 curl 看一下,curl -v https://yourdomain.com/apple-app-site-association,盯着 response headers 有没有 3xx,Content-Type 是否正常,以及最底下的 JSON 是不是完整可解析。

2.3 双通道并行加降级策略,才是生产环境该有的姿态

有人会纠结,到底是接 Scheme 还是接 Universal Links?我的建议是别纠结,两个都接。Scheme 作为兜底,Universal Links 作为主通道。为什么?

Universal Links 虽然体验好,但它有个很现实的问题:不是所有点击场景都能成功唤起。比如在微信内置浏览器里,Universal Links 经常不稳定,很多情况下链接会直接停在网页里,不会唤起 App;此时你可以打开一个带 Scheme 的跳转地址,让浏览器把控制权交还给系统,通过系统机制唤起 App。这其实就是业内常见的“H5 检测 + 双通道唤起 + 失败落商店”方案。

反过来,Universal Links 成功唤起时,体验远比 Scheme 好,系统不会出现多余的弹窗确认,且链接本身是普通 HTTPS,分享出去也不那么“像广告”,用户信任度更高。

所以落地时的判断逻辑大致如此:H5 页面先监听document visibilitychange,记录 App 是否被切走;如果 3 秒内页面还活着,说明 Universal Links 没起来,那就改走 Scheme;Scheme 要是也失败了(比如没安装),就等着页面 JS 把用户送到 App Store 去。

判断“App 有没有被唤起”这个逻辑,在移动端 H5 里属于常规操作了。核心就是页面切到后台的瞬间不一定会立刻触发,所以业内普遍用“定时检测 + visibilitychange”的组合。这个方案虽然糙,但管用。

3. C# 层参数投递:打通原生与 Unity 的最后一步

3.1 老方案:iOS 原生插件 + UnitySendMessage

很多 Unity 项目的 Deep Link 接入,第一步都是照着远古时代流传下来的模板,写一个 Objective-C 的插件,挂到 AppController 生命周期里。重写刚才说的openURL方法,在拿到 url 之后调用:

UnitySendMessage("DeepLinkHandler", "OnReceiveURL", [url.absoluteString UTF8String]);

C# 侧挂一个名为 DeepLinkHandler 的 GameObject,挂载脚本实现OnReceiveURL(string url),这就完成了一次参数投递。

这个方案本身没有任何问题,我直到今天还在一些老项目里用。它最大的优点是可控性强——原生层能百分百拿到 URL 字符串,你想在原生层做参数预处理、存本地、或者延迟投递,都完全由你说了算。但缺点是麻烦,Unity 版本升级后 AppController 的名称和行为可能变化,分类写的稍有不慎,Xcode 编译直接报错;调试也不方便,C# 侧的报错信息基本对不上原生层的问题。

3.2 新方案:Application.deepLinkActivated 与 absoluteURL

如果项目用的是 Unity 2019.4 LTS 以上的版本,我强烈建议直接用 Unity 自带的能力,省去原生插件这一大坨。

Unity 在 iOS 平台上封装了两个关键入口:

  • Application.absoluteURL:App 启动完成后,用于获取本次启动时系统传进来的 Deep Link。特性是只在冷启动链路有效。
  • Application.deepLinkActivated:C# 层的事件,App 已经处于运行状态时,突然被一个 Deep Link 重新拉起来,这个事件就会触发。特性是热启动专用。

很多新手容易把这两个搞混。我给你们一个简化的理解:

App 是死的,被链接唤醒,用absoluteURL去查; App 是活的,被链接顶起来,用deepLinkActivated去监听。

所以标准的 C# 层接入模板是这样:

public class DeepLinkHandler : MonoBehaviour { private void OnEnable() { Application.deepLinkActivated += OnDeepLinkActivated; } private void OnDisable() { Application.deepLinkActivated -= OnDeepLinkActivated; } private void Start() { // 冷启动,可能是通过DeepLink拉起,查一次绝对地址 string coldLink = Application.absoluteURL; if (!string.IsNullOrEmpty(coldLink)) { ProcessDeepLink(coldLink); } } private void OnDeepLinkActivated(string url) { // 热启动,事件回调里拿到的就是完整URL ProcessDeepLink(url); } }

你看,代码量比原生插件方案小了一个量级,而且因为这个链路是 Unity 官方封装的,性能和行为稳定性都有保障。但我还是要多提醒一句:不要完全放弃原生插件。Unity 封装毕竟是黑盒,出了问题你无从下手。等后面排查问题的时候你就会明白,一个能把原始 URL 打日志打到 Xcode 控制台的原生插件,在 Debug 时有多值钱。

3.3 参数编码、短链与去重的坑

C# 层拿到 URL 字符串之后,第一件事不是屁颠屁颠去解析参数,而是先做三件事:解码、去重、判有效性。

解码这方面,URL 里经常会带中文、带特殊符号。你从Application.absoluteURL拿到的字符串大概率是编码后的状态,比如%E9%82%80%E8%AF%B7。所以解析参数时,先整体把url做一次Uri.UnescapeDataString,再拆 query。注意不要拆完单独解码 key 和 value,顺序错了也会出乱码。

static Dictionary<string, string> ParseQueryString(string url) { var result = new Dictionary<string, string>(); var uri = new Uri(url); var query = uri.Query.TrimStart('?'); if (string.IsNullOrEmpty(query)) return result; foreach (var pair in query.Split('&')) { var idx = pair.IndexOf('='); if (idx < 0) continue; var key = Uri.UnescapeDataString(pair.Substring(0, idx)); var val = Uri.UnescapeDataString(pair.Substring(idx + 1)); result[key] = val; } return result; }

去重这件事,很多团队会忽略。用户从同一个唤起的链接进入 App,理论上只会触发一次事件;但在 Universal Links 和 Scheme 双通道并存的情况下,存在极小的概率两条链路都投递成功,C# 层会收到两次一模一样的 URL。如果你不对参数做去重,“绑定邀请关系”这个逻辑就会执行两次,轻则前端界面闪一下,重则后端多出一条绑定失败记录。去重的思路很简单,用一个字典缓存最近处理过的 URL 的哈希值,相同的在短时间内忽略掉。

短链是另一个容易踩坑的地方。活动链接参数一多,URL 就很容易长得离谱。iOS 对 URL 长度虽然没有硬性限制,但 URL 越长,分享传播的可靠性就越差,部分 IM 工具还会截断链接。所以运营侧的常规做法是走短链服务器:把完整参数存在服务端,回传一个短码,比如https://yourdomain.com/invite/abc123。然后 App 端拿到这段短链接之后,再请求一次服务器接口,把完整参数换回来。这个方案能规避绝大多数 URL 长度问题,代价是客户端需要多做一次网络请求,且要考虑换参接口失败时的重试逻辑。

4. 实操:完整落地一个 Deep Link 全流程

4.1 第一步:域名与 AASA 文件部署

AASA 文件的部署是整个链路里第一个核心动作,部署错了后面全白搭。

文件位置有两种,系统会优先查https://yourdomain.com/.well-known/apple-app-site-association,你也可以放在https://yourdomain.com/apple-app-site-association。为了保险,我两个位置都放。文件内容刚才给过一个示例,这里再补充一个生产环境的推荐写法:

{ "applinks": { "apps": [], "details": [ { "appID": "ABCDE12345.com.example.mygame", "paths": [ "/invite/*", "/game/*" ] } ] } }

部署完以后用curl自检:

curl -v https://yourdomain.com/apple-app-site-association

检查重点有三个:响应是不是 200,有没有 301/302 跳转;响应体是不是纯 JSON,有没有被包在 HTML 里;appID是不是TeamID.BundleID的完整拼接。

很多团队会漏掉请求头这层。确保服务端返回的Content-Type是application/json或者application/pkcs7-mime,虽然系统对 Content-Type 的容忍度比网上流传的要高,但正确设置能省掉很多莫名奇妙的兼容问题。

4.2 第二步:Xcode 工程配置

AASA 文件是服务器端的事,接下来看客户端。

在 Xcode 打开工程,Target -> Signing & Capabilities,点加号添加 Associated Domains。注意你的开发者账号必须是付费的,免费的个人开发账号在真机上使用 Universal Links 会有问题。添加域名时前缀必须写applinks:,比如applinks:yourdomain.com。

同一个页面里,如果你想让 App 支持极高的定制化,可以加一段 App Clips 之类的配置,但那是另一个话题了,这里不展开。

另外在 Info 页签的 URL Types 里,把兜底的 URL Scheme 也配上。填法就是前面说过的,在 URL Schemes 里填一个独特的字符串。确保 App 的 Bundle Identifier、Team ID、AASA 文件里的 appID 完全对得上,这是配置层最容易忽略、也最容易让 Universal Links 永久不可用的一点。

4.3 第三步:Unity C# 侧代码实现

把 C# 侧的代码做完整一些。除了前面演示的监听逻辑,一个能上生产的 DeepLinkHandler 应该还要承担这些职责:解析参数、参数投递给业务层、处理冷启动时序。

冷启动时序是最大的坑之一。Start方法里读Application.absoluteURL,如果在Start执行的那一帧,URL 还没有被系统投递进来,你读到的就是空字符串。为什么?因为 Unity 引擎自身初始化需要时间,iOS 系统把启动参数交给 Unity 和 Unity 执行到你的业务脚本之间存在一段间隙。放心,这种情况是小概率,但环境切换时偶尔会出现。

应对办法是做一个简单的轮询或延迟补偿:

private IEnumerator CheckColdLinkWithDelay() { yield return new WaitForSeconds(0.5f); string url = Application.absoluteURL; if (!string.IsNullOrEmpty(url)) { ProcessDeepLink(url); } }

0.5 秒不会影响玩家的启动体验,却能显著提升冷启动场景的 Deep Link 捕获率。如果你不想用协程,也可以用一个简单的计时器在 Update 里做两次检查。个人实测,延迟检查的效果在 iOS 模拟器和旧机型上尤为明显。线上环境我见过冷启动丢参数率 3% 左右的项目,加了延迟补偿之后降到接近 0。

完整的处理器里,业务层投递我用的是一个 C# 事件:

public static event Action<Dictionary<string, string>> OnDeepLinkParsed; private void ProcessDeepLink(string url) { Debug.Log($"[DeepLink] raw url: {url}"); if (_recentLinks.Contains(url)) return; _recentLinks.Add(url); var parsed = ParseQueryString(url); OnDeepLinkParsed?.Invoke(parsed); }

业务层谁关心这个事件谁去订阅。例如邀请活动的模块在初始化时挂一个监听,收到参数就拉起绑定流程。这样 DeepLinkHandler 就不用去耦合业务逻辑了,往后新增活动也方便。

4.4 冷启动、热启动、Deferred Deep Link 三种场景的处理

现在统一梳理一下三种常见场景下系统行为和代码逻辑的对应关系。

冷启动(App 未运行):用户在 Safari、微信或扫码工具里点击链接,系统唤起 App,Unity 引擎完整走一遍启动流程。拿参数的路径是Application.absoluteURL。需要注意,部分情况下absoluteURL不是马上非空,需要那个 0.5 秒延迟补偿。

热启动(App 已运行):游戏正挂在后台或者正在前台,用户点击链接后 App 被顶起来,系统走deepLinkActivated事件。Unity 的官方封装做得不错,这个事件基本稳定可靠。但要注意,如果你同时在OnEnable和Start里都做了处理逻辑,要防止热启动时Start里的冷启动查询把同一个链接处理两次。我的做法是把冷启动和热启动的两个入口都汇合到ProcessDeepLink,让去重逻辑统一兜底。

Deferred Deep Link(App 未安装):这是最麻烦的一种。用户点链接时手机根本没装游戏,Universal Links 会自动把网页加载出来,网页运营文案完了以后跳 App Store 下载游戏。这时候链接里的邀请参数,App 本身是拿不到的——安装是一个全新启动,系统不会再告诉你“用户是因为哪个链接装的”。这种场景必须引入归因能力,最常见的就是接 Adjust、AppsFlyer、Branch 这类平台,它们有 SDK 级的能力把安装前的点击与安装后事件关联起来。但假如你只是做活动运营,不想接一堆 SDK,也可以自己做一个轻量方案:用户在 H5 页面时,把参数写进剪切板,App 首次启动时去读剪切板内容,识别出是邀请链接再上报后端。这个方案体验稍差(需要用户授权剪切板权限),但在一些轻量场景下足够用了。

5. 常见问题与排错实录

5.1 Universal Links 打不开 App,在浏览器里原地停留

这在测试阶段出现频率极高。我的排查顺序是这样的:先用系统自带的 Safari 打开链接,看能不能正常唤起。如果 Safari 能唤起,微信里不能唤起,那八成是微信的 Universal Links 限制问题,走双通道降级方案解决。如果 Safari 也不行,先检查 AASA 文件是否可访问、appID 是否正确。然后检查 Associated Domains 有没有带上applinks:前缀,域名有没有写错。如果确认都没问题,再等个十几秒重试——iOS 对 AASA 的缓存相当顽固,它不会立刻刷新,有时候你把文件改对了,系统还要过一阵子才生效。

调试系统是否真的拉到了 AASA 文件,可以用 Xcode 的 Console 配合系统日志查看,也可以直接跑真机用断点观察 AppDelegate 有没有回调。Universal Links 用模拟器测试不靠谱,最好直接上真机,这句话我记不清说了多少遍了。

5.2 参数中文乱码或特殊字符丢失

链接里带中文参数是非常普遍的事,比如邀请人昵称。URL 里的中文必须编码后再拼接,不要在 H5 端生成链接时直接用原始中文。后端生成链接时统一用encodeURIComponent对参数值编码,客户端解析时用Uri.UnescapeDataString解码。

特殊字符里最坑的是#。URL 结构里#之后的部分是 fragment,根本不会发给服务器,很多链接在拼接时把参数值里的#漏编码了,导致参数被截断。这种问题排查起来非常隐蔽,因为你光看 URL 不容易察觉。

5.3 冷启动拿不到参数,热启动却正常

这种情况我见得太多了。热启动正常说明 Universal Links 链路本身是通的,系统投递逻辑没问题,就是冷启动时 C# 层没接住。最常见的两个原因:一是Application.absoluteURL读取时机太早,按前面的 0.5 秒延迟补偿方案处理;二是你自定义了TEXT或者 AppDelegate 生命周期,破坏了 Unity 的默认转发链路。如果项目里集成了其他 iOS 原生插件,极有可能互相覆盖了 AppController 的分类方法区段,导致 Unity 引擎在启动阶段没拿到 url。

排查这种问题,我的习惯是在原生层加日志,拿 unity 的 AppController 分类,在里面把openURL、continueUserActivity全部 hook 一遍,打日志到控制台,对比 C# 层收到的事件是否一致。如果原生层收到了但 C# 层没收到,问题出在投递;如果原生层自己都没收到,问题出在 AASA 或域名配置。

5.4 Debug 构建一切正常,Release 构建就失灵

这是一个容易被忽略的打包差异。Release 构建如果开了 Bitcode,或者做了依赖裁剪,某些系统框架行为会不一样。Universal Links 本身不因此受影响,但如果你在 Release 里去掉了某些插件,或者 PlayerSettings 里关闭了 Deep Link 相关的回调,就有可能出现“Debug 好好的,Release 拉垮了”的诡异问题。

更常见的情况是:你的 Release 构建包和 Debug 构建包的 Bundle Identifier 不一样。很多团队开发包和生产包的包名不同,而 AASA 文件里的 appID 只对应生产包名。这时候 Debug 包自然是唤不起 Universal Links 的。检查方式很简单,把两个包的 Bundle ID 和 AASA 里的配置逐一比对。

6. 调试链路的心得与建议

这一节不聊配置,聊点真正干活时候的小习惯。

第一,务必养成日志打点意识。Deep Link 链路长、跨端多,任何一个环节断了都不容易看出来。我的做法是在 H5 端、原生层、C# 层三层都打上唯一标识的日志,比如链接里带一个traceid参数,每一层都把这个 ID 打出来,排错时用同一个 ID 串起整条链路,哪里断了立刻就能看出来。这个习惯帮我省了大量猜测的时间。

第二,iOS 缓存是个友善的坑。AASA 文件的缓存周期不稳定,即使你把服务端文件改对了,客户端本地缓存可能还在用它第一次拉到的旧文件。所以改完配置后,测试时要“杀”掉游戏进程,切换飞行模式再关掉重新连网,或者干脆重启手机。网上很多人说要等 24 小时,我没验证过这么极端的周期,但至少做上述操作后重测,在我接触的项目里基本都能正常。

第三,千万不要在模拟器上排查 Universal Links 问题。模拟器对 Universal Links 的支持有历史上限和版本差异,你在模拟器里测出来的行为基本不具备参考价值。老老实实上真机,关掉 Debug 日志构建,模拟真实用户操作流程,比任何模拟器配置都管用。

第四,建立主动降级的思维。不要以为配置完美了就万事大吉。iOS 版本迭代很快,每年大版本都可能对 Deep Link 行为做调整——比如 iOS 18 之后 Safari 唤起 App 时的动作就有些变化,微信等内置浏览器对 Universal Links 的支持也在不停变动。一个可靠的生产方案必须包含“Universal Links 失效之后自动走 Scheme,Scheme 失效之后落到网页再由网页引导”的完整降级链。这套链路的兜底才是真正的保险。

我自己的体会是,Deep Link 接通不难,真正难的是把事情做扎实。链路里每一环都想当然,最终就会在某个不起眼的角落摔倒一次。希望这篇内容能帮你少踩几个我踩过的坑,也欢迎你带着项目里的实际问题来讨论。

返回列表