分享回流、广告归因、活动页跳转、客服工单——只要你的项目里挂了其中任何一个需求,就绕不开 Deep Link。它听起来是个"配一下就行"的活儿:iOS 上注册个 scheme,或者丢一个 json 文件到域名根目录,然后等回调进来就完事。
现实是,Unity 手游的 Deep Link 通常以"半个好"的状态上线:冷启动能进,热启动没反应;自定义 scheme 能跳,https 链接跳不进来;开发机全对,测试机全挂。而这些现象的根因往往不在你自己的代码里,而在 UIKit 生命周期、Unity 的 UIScene 适配、以及 Apple CDN 的抓取策略上。
这篇文章按"配置 → 时序 → 落点 → 投递 → 排错"的顺序,把这条链路拆开讲清楚,代码可以直接抄。
一、两条唤醒路径:配置差异与安全边界
URL Scheme:零校验的"先到先得"
自定义 URL Scheme 通过 Info.plist 的 CFBundleURLTypes(具体是 CFBundleURLSchemes 数组)声明。Unity 侧更省事:Player Settings > iOS > Other > Configuration > Supported URL schemes,在 Element 0 填进去即可。
问题在于它的归属模型。只要不是 Apple 保留的 scheme(http、mailto、tel 等),任何 App 都能注册同一个,且没有任何校验机制。Apple 官方文档的表述被多方引用:"如果多个第三方 App 注册了同一个 URL scheme,目前没有流程可以决定哪个 App 会被授予该 scheme。" 也就是说行为未定义,实际取决于安装顺序,恶意 App 可以抢先接管,甚至反过来导致正版 App 装不上。
OWASP MASWE-0029 把这个风险归成三类:Deep Link 里携带的 token / 个人信息被截获、认证授权被绕过(截获登录或改密链接导致账号接管)、未净化参数流入解释器或 WebView 导致任意代码执行。最容易出事的恰恰是"改密""邮箱验证""邀请""magic link"这类流程——它们比完整的 OAuth 更脆弱,因为 App 往往直接把"链接本身"当成身份证明。典型脆弱写法是只校验 scheme 不校验 host/path,例如:
myapp://oauth/callback?token=xxx
任何注册了 myapp 的 App 都能收到这条消息。缓解方向很明确:改用 Universal Links(https + 域名归属 + AASA,别的 App 抢不走),对其余 Deep Link 的入参做白名单校验,并且不要用链接传递长效密钥。
Universal Links:真正的门槛在服务端
Universal Links 需要在 Xcode 里给 Target 打开 Associated Domains 能力,加入 applinks:yourdomain.com,然后由服务端提供 AASA 文件。因为依赖外部网站,Unity 无法在 Editor 内配置,必须走 Xcode 工程或 PostProcessBuild 脚本注入。
AASA 的硬性要求(来自 Apple TN3155):
- 文件名必须是无
.json后缀的apple-app-site-association,放在https://<domain>/.well-known/apple-app-site-association,域名根目录是兜底位置 - 仅 HTTPS,不能有任何重定向
Content-Type必须是application/json- 文件体积必须小于 128 KB
- iOS 14 起设备不再自己拉 AASA,改由 Apple CDN 抓取并缓存:App 安装时立即从 CDN 下载一份,之后大约每周检查一次更新,且没有任何直接失效 CDN 缓存的手段——想让测试机拿到新 AASA 只能重装 App
排查时不要看自己服务器的文件,直接看 Apple 实际给你的副本:
https://app-site-association.cdn-apple.com/a/v1/<yourdomain.com>
Xcode 直连调试有个 developer 模式,在 Associated Domains 里给域名加 ?mode=developer,可以绕过 CDN 直拉源站,但只对 development 签名的包生效,TestFlight / Ad Hoc / App Store 包无效。
一个值得记住的真实故障:Apple 开发者论坛 thread 815853 里记录过一例,源站 AASA 返回 HTTP 200 的合法 JSON,Content-Type 正确、无重定向、SSL 有效,但 Apple CDN 返回 {"cause":"invalid character '<' looking for beginning of value"}。< 说明 CDN 拿到的是 HTML 而不是 JSON。诊断结论是服务器 / WAF 拦截了 Apple 爬虫的 User-Agent(AASA-Bot/1.0)和 / 或 Apple 的 IP 段,返回了 403 或验证挑战页。修复方式是在防火墙里为该路径放行这个 UA 与 IP 段,改完之后还要等数小时才生效。
一句话写进排查表:一个连浏览器都能正常打开的 AASA 文件,只要服务端屏蔽了 Apple CDN 的 User-Agent,Universal Link 依然会断。
AASA 匹配规则:顺序决定成败
iOS 13 引入的现代格式是 appIDs 数组 + components 数组,旧格式是单数 appID + paths 数组。两套格式不能混用,混用行为未定义。iOS 13+ 只要出现 components,paths 键就被整个忽略(要兼容 iOS 12 及以下才保留 paths 兜底)。
现代格式长这样:
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.game"],
"components": [
{ "/": "/products/preview/*", "exclude": true },
{ "/": "/shop/*", "?": { "chan": "?" }, "comment": "带来源参数的分享回流" },
{ "/": "/products/*" },
{ "/": "/share/*", "caseSensitive": false }
]
}
]
}
}
三个易错点:
components会覆盖paths,别指望两套并存。- 排除用
exclude: true,等价于旧格式的NOT关键字,但NOT在components格式下已不再支持。 - 顺序决定成败:iOS 取第一条命中的规则,所以排除规则必须排在通配规则上面。上例中
/products/preview/*的exclude如果写在/products/*之后,就永远不生效。
components 每一项可以同时约束 path(/)、query(?)、fragment(#),query 支持字典值做精确匹配,例如 {"articleNumber": "????", "articleName": "?*"}。WWDC20 Session 10098 解释了为什么要换这套格式:老式 * / ? 路径规则下,两种语言码 × 两种地区码 × 四个产品就膨胀出约 180 万个 pattern、文件超过 27 MB,而 AASA 硬上限只有 128 KB。
二、两条时序:冷启动与热启动的落点完全不同
这是绝大多数 Deep Link bug 的来源,值得单独拎出来。
冷启动:App 没在运行,进程从零开始。系统把 URL 塞进 application:didFinishLaunchingWithOptions: 的 launchOptions(key 是 UIApplicationLaunchOptionsURLKey),或者在 scene 场景下塞进 connectionOptions.URLContexts / connectionOptions.userActivities。这个回调发生在 Unity 运行时初始化之前,此时首个场景还没加载,C# 侧连 MonoBehaviour 都还没跑起来。
热启动:App 在后台,进程是活的。回调走 application:openURL:options: 或 application:continueUserActivity:restorationHandler:(scene 场景下是 scene:openURLContexts: / scene:continueUserActivity:),进的是一个已经初始化完成、可能还停留在某个 UI 状态的进程。
Unity 官方对这个划分是明确的:Application.absoluteURL 保存的是"应用未运行、被 deep link 拉起时"的 URL(冷启动);Application.deepLinkActivated 是"应用已在运行时"收到的事件(热启动),事件参数带 URL,同时 absoluteURL 也会刷新。官方 ProcessDeepLinkMngr 示例的骨架就是在 Awake() 里订阅事件,紧接着立刻判断 if (!string.IsNullOrEmpty(Application.absoluteURL)) 手动调一次回调——这正是冷启动那条路径的补丁。
三、iOS 13+ SceneDelegate 引入后的历史坑
Unity 从 2022.3.72f1 / 6000.0.68f1 起接入 UIScene 生命周期,用新的 UnityScene.mm 充当 SceneDelegate,并在 Info.plist 里默认写入 UIApplicationSceneManifest。UIKit 生命周期改由 UIScene 接管之后,AppDelegate 上的 application:openURL:options: 和 continueUserActivity 就不再是唯一入口了。
这里有一个代码级根因值得记录:UnityScene.mm 实现了 scene:openURLContexts: 并处理了 connectionOptions.URLContexts(所以自定义 scheme 是好的),但完全没有实现 scene:continueUserActivity:,也没有处理 connectionOptions.userActivities。结果是 https 的 Universal Link 既不触发 Application.deepLinkActivated,Application.absoluteURL 也不被设置。
典型现象:App 未运行时冷启动能进(走 launchOptions 那条老路),App 已在后台时点击链接毫无反应。 Unity 工程师在讨论帖里承认过:"universal deep links require quite a set up... it was missed during UI Scene implementation, and when fixing deep links only custom url schemes were fixed."
官方分了两个 issue 修:
- UUM-135497(修自定义 URL scheme):落地于 2022.3.75f1、6000.0.71f1、6000.3.12f1、6000.4.0b12、6000.5.0a9、6000.6.0a1
- UUM-140746(修 Universal Link,并把
AbsoluteURL的赋值时机提前到Awake()可读):含 UIScene + Universal Link 完整支持的推荐版本为 Unity 6.6 = 6000.6.0a5、6.5 = 6000.5.0f1、6.4 = 6000.4.7f1、6.3 LTS = 6000.3.16f1、6.0 LTS = 6000.0.75f1、2022 xLTS = 2022.3.76f1
| 版本线 | 自定义 scheme 修复 | Universal Link + UIScene 完整支持 |
|---|---|---|
| 2022.3 LTS | 2022.3.75f1 | 2022.3.76f1 |
| Unity 6.0 LTS | 6000.0.71f1 | 6000.0.75f1 |
| Unity 6.3 LTS | 6000.3.12f1 | 6000.3.16f1 |
| Unity 6.4 | 6000.4.0b12 | 6000.4.7f1 |
| Unity 6.5 | 6000.5.0a9 | 6000.5.0f1 |
| Unity 6.6 | 6000.6.0a1 | 6000.6.0a5 |
低于这些版本有两个 workaround:一是删掉 Info.plist 里的 UIApplicationSceneManifest,退回老 AppDelegate 生命周期——这是治标,UIScene 后续会被 Apple 强制;二是手工给 UnityScene.mm 打补丁。
如果你的项目需要自己接管回调,接法有两种。一是子类化 UnityAppController,用 IMPL_APP_CONTROLLER_SUBCLASS 宏注册:
// MyAppController.mm
#import "UnityAppController.h"
@interface MyAppController : UnityAppController @end
IMPL_APP_CONTROLLER_SUBCLASS(MyAppController)
@implementation MyAppController
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url
options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options {
[MyDeepLinkBridge stash:url.absoluteString];
return [super application:app openURL:url options:options];
}
@end
二是完全自定义 AppDelegate,成本更高,一般只在需要接管 UIScene 配置时用。注意:UIScene 接入后,上面这些 AppDelegate 方法可能根本不被调用,此时要改成实现 scene:openURLContexts: 与 scene:continueUserActivity::
// UnityScene 接入后需要补的方法
- (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts {
UIOpenURLContext *ctx = URLContexts.anyObject;
if (ctx) [MyDeepLinkBridge stash:ctx.URL.absoluteString];
}
- (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity {
if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
[MyDeepLinkBridge stash:userActivity.webpageURL.absoluteString];
}
}
四、参数投递:为什么"静态缓存 + 就绪轮询"比 UnitySendMessage 可靠
Unity 手册对 iOS 原生回调的限定很硬:
UnitySendMessage三个参数是目标 GameObject 名、脚本方法名、消息字符串- 原生侧只能调用签名形如
void MethodName(string message)的方法,没有别的重载 - 调用是异步的,有约一帧延迟
- 多个 GameObject 同名时会造成冲突
再加上 Unity 主线程与原生线程不同步,绝大多数 Unity API 只能在主线程调用,原生回调必须 marshal 回主线程;常见失败模式就是 GameObject 名或方法名写错导致消息静默丢失(没有报错)。
把这几条串起来看冷启动:回调发生在 Unity 运行时和首个场景就绪之前,这时 UnitySendMessage 找不到目标 GameObject,消息直接丢弃。这就是为什么"原生侧直接投递"在冷启动路径上不可靠。
正确做法是原生侧先把 URL 静态缓存下来,C# 首个场景起来之后再主动取走,并配去重与超时。
原生侧(DeepLinkBridge.mm):
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
static NSString *g_pendingURL = nil;
static BOOL g_consumed = YES;
@implementation MyDeepLinkBridge
+ (void)stash:(NSString *)url {
if (url.length == 0) return;
g_pendingURL = [url copy];
g_consumed = NO; // 新的一条,重新置为未消费
}
@end
extern "C" {
// 返回的指针在下次 stash 前保持有效,C# 侧立即 Marshal 成 string 即可
const char *_DeepLink_Peek(void) {
if (g_consumed || g_pendingURL.length == 0) return NULL;
return [g_pendingURL UTF8String];
}
void _DeepLink_Consume(void) { g_consumed = YES; }
}
C# 侧(DeepLinkBootstrap.cs):
using System;
using System.Collections;
using System.Runtime.InteropServices;
using UnityEngine;
public class DeepLinkBootstrap : MonoBehaviour
{
#if UNITY_IOS && !UNITY_EDITOR
[DllImport("__Internal")] static extern IntPtr _DeepLink_Peek();
[DllImport("__Internal")] static extern void _DeepLink_Consume();
#endif
const float TimeoutSeconds = 10f;
static string _lastHandled;
static DeepLinkBootstrap _instance;
void Awake()
{
if (_instance != null) { Destroy(gameObject); return; }
_instance = this;
DontDestroyOnLoad(gameObject);
// 热启动路径:进程已就绪,事件直接进来
Application.deepLinkActivated += OnDeepLink;
}
void OnDestroy()
{
Application.deepLinkActivated -= OnDeepLink;
if (_instance == this) _instance = null;
}
IEnumerator Start()
{
// 冷启动路径:轮询原生缓存,直到拿到或超时
float elapsed = 0f;
while (elapsed < TimeoutSeconds)
{
if (TryConsumePending()) yield break;
elapsed += Time.unscaledDeltaTime;
yield return null;
}
Debug.LogWarning("[DeepLink] 等待原生缓存的 URL 超时,本次不投递");
}
bool TryConsumePending()
{
#if UNITY_IOS && !UNITY_EDITOR
IntPtr p = _DeepLink_Peek();
if (p == IntPtr.Zero) return false;
string url = Marshal.PtrToStringAnsi(p);
if (string.IsNullOrEmpty(url)) return false;
_DeepLink_Consume();
OnDeepLink(url);
return true;
#else
return false;
#endif
}
void OnDeepLink(string url)
{
// 去重:热启动事件与冷启动轮询可能拿到同一条
if (url == _lastHandled) return;
_lastHandled = url;
Debug.Log($"[DeepLink] 收到跳转:{url}");
var uri = new Uri(url);
// TODO: 按 uri.Host / uri.AbsolutePath / Query 路由到对应界面
}
}
几个设计点值得说明。超时是必须的:原生侧没收到任何东西时,轮询不能无限跑下去。去重也是必须的:热启动事件和冷启动轮询可能拿到同一条 URL,尤其在 App 被杀死又立刻被拉起的情况下。用 unscaledDeltaTime:首场景加载时可能有 loading 动画把 timeScale 调成 0,用缩放时间会永远等不到超时。
五、不同来源的实测差异
微信内置浏览器是最麻烦的一条,属于"双失效"。微信会主动拦截 scheme:// 跳转,未进白名单的开发者域名无法在微信内用 H5 拉起 App,而这份白名单并未对外公开。iOS 侧更严:微信内嵌 WebView 在 iOS 14+ 使用 SFSafariViewController,该系统容器主动屏蔽所有 URL Scheme 跳转,不支持 location.href 触发式唤端,iOS 17+ 连 iframe 注入 scheme 也会被静默拦截。同时 Universal Links 在微信内也常被屏蔽或配置失效。
官方合规方案是微信开放标签 wx-open-launch-app,但有明确门槛:必须是已认证服务号的 JS 接口安全域名、必须真机才能渲染、文字链无法拉起、微信版本需 ≥ 8.0.36、必须正确调用 wx.config 并在 wx.ready() 回调内初始化组件(否则组件降级成普通 div),且通过"复制网页地址"形成的蓝色外链打开的页面不能使用外跳 App 功能。还有一条实测差异:有用户反馈扫码、收藏、分享卡片进入可以跳转,但直接点链接进入的 H5 不能跳,Android 尤其明显。
兜底策略是引导用户点右上角"在浏览器中打开",或在开放标签失败时降级到 intent:// 再兜底下载页。注意跳转必须由真实用户点击触发,不能靠 setTimeout 自动跳。
QQ 内置浏览器的拦截策略与微信类似但白名单尺度不同;Safari 是最"守规矩"的,Universal Links 与自定义 scheme 都能按预期工作,是排查时最干净的对照组;短信 / iMessage 里的链接通常直接进默认浏览器,Universal Link 表现正常。
六、真机验证手段与排查表
命令行触发(模拟器):
xcrun simctl openurl booted "https://example.com/product/123"
命中则打开 App,未命中则回落 Safari。同一个命令也可以用来在模拟器 Safari 里打开 AASA 地址,验证可达性。
核心验证点在 swcd 进程(负责 associated-domain 注册)。连真机、开 Console.app、删除并重装 App,过滤 swcd,正常应看到:
swcd(CoreUtils) <Notice>: Added service 'applinks', appID 'xxx', domain 'xxx'
出现 Domain not associated with app 或 No app for URL 就是配置问题;如果 swcd 日志里什么都没有,最可能的原因是 Associated Domains entitlement 根本没配上。还能看到 AASA 下载动作日志,例如 Beginning data task AASA-... { domain: example.com, bytes: 0, route: cdn }。
不装 Console.app 时可以用命令行流式抓日志:
xcrun simctl spawn booted log stream --level debug --predicate 'process == "MyApp"'
两个坑要提前知道:SWCErrorDomain Code=1701 "Failed to get associated domain data from ManagedConfiguration framework" 是非企业受管设备上的正常噪声,别当故障排查;模拟器上 Universal Link 历史上多次出现"真机正常、模拟器不正常",可尝试清模拟器数据、切换 Wi-Fi、重启或换模拟器版本,Apple 工程师更推荐看真机 sysdiagnose 里的 swcutil.txt(见 TN3155)。
| 现象 | 优先怀疑 | 验证方式 |
|---|---|---|
| 冷启动进 App,热启动无反应 | Unity 版本低于 UUM-140746 修复线 | 查上文版本表,升级或打补丁 |
| 自定义 scheme 跳不动 | CFBundleURLSchemes 未写或写错 | 检查 Info.plist / Player Settings |
| Universal Link 完全无反应 | Associated Domains entitlement 缺失 | Console.app 过滤 swcd,无日志即未配置 |
| 开发机正常,TestFlight 不正常 | ?mode=developer 只对 development 签名生效 | 换正式签名验证 + 查 CDN 副本 |
| AASA 源站正常但仍失败 | WAF 拦截了 AASA-Bot/1.0 | 直接访问 app-site-association.cdn-apple.com/a/v1/<domain> |
| 改完 AASA 不生效 | Apple CDN 缓存不可主动失效 | 卸载重装 App |
| 微信内点击无反应 | 未使用 wx-open-launch-app 或被 SFSafariViewController 屏蔽 | 引导"在浏览器中打开" |
七、结论与实施顺序
如果只能记三件事:
- 先升版本再写代码。Unity 2022.3.76f1 / 6000.0.75f1 及以上的完整支持版本,能省掉一整套
UnityScene.mm打补丁的工作。UIScene 是 Apple 在推的方向,靠删UIApplicationSceneManifest退回老生命周期只是缓兵之计。 - 冷启动那条路径必须走"静态缓存 + 就绪轮询",不要指望
UnitySendMessage在 Unity 运行时起来之前把消息送进去。去重和超时是配套的必需品,不是可选项。 - 服务端与客户端要一起排。Universal Links 的故障大概率不在你的 C# 代码里,而在 AASA 的重定向、Content-Type、128KB 体积、CDN 缓存、以及 WAF 对
AASA-Bot的态度上。app-site-association.cdn-apple.com那条 URL 应该加进你的书签栏。
至于安全,URL Scheme 只要还挂在项目里,就要默认它是可被劫持的:不放长效 token、不把"能打开链接"当作身份证明、所有入参做白名单校验。真要承载敏感流程,用 Universal Links,并且把 AASA 的匹配规则写严——用 exclude: true 把不该进 App 的路径挡在外面,比在客户端里判断要可靠得多。
参考链接
- Apple Developer:Allowing apps and websites to link to your content developer.apple.com/documentati…
- Apple Developer:TN3155 Debugging Universal Links developer.apple.com/documentati…
- Apple Developer Forums:AASA 源站合规但 CDN 返回 invalid character 的 WAF 拦截案例 developer.apple.com/forums/thre…
- Apple Developer Forums:swcd 日志与 Universal Link 真机排查 developer.apple.com/forums/thre…
- Apple Developer:WWDC20 Session 10098 关于 AASA components 格式与文件膨胀的动机 developer.apple.com/videos/play…
- Unity Discussions:iOS Universal Links 在 UnityScene 中不被处理(UUM-135497 / UUM-140746) discussions.unity.com/t/bug-ios-u…
- Unity Manual:Deep linking(absoluteURL 与 deepLinkActivated 的划分) docs.unity3d.com/6000.2/Docu…
- Unity Manual:iOS native plugin call back(UnitySendMessage 的签名与异步限制) docs.unity3d.com/6000.1/Docu…
- OWASP MASWE-0029:URL Scheme 劫持的三类危害与缓解方向 mas.owasp.org/MASWE/MASVS…
- 微信开放社区:微信内 H5 唤起 App 的限制与 wx-open-launch-app 使用条件 fuwu.weixin.qq.com/community/d…