☰
Unity手游iOS深链:URL Scheme与Universal Links实践
2026/10/1 9:57:35 网站建设 项目流程

做手游买量和社交裂变的朋友,应该都绕不开 Deep Link 这个词:用户手机里明明装着你家的 App,但此刻他在浏览器、在聊天框、在广告落地页,你丢过去一个链接,点一下能不能把游戏唤起,还能把邀请码、渠道号一起带上。落到 Unity 手游加 iOS 这套技术栈上,实际链路是这样:iOS 系统把链接事件交给原生层,原生层解析 URL,再通过 UnitySendMessage 投递给 C#,由 C# 做统一分发。看起来就三步,但配置、时序、参数格式、生命周期,每一步都有能让你调试到怀疑人生的坑。

这篇文章完整拆解 URL Scheme 和 Universal Links 的配置流程、原生层到 C# 层的参数投递机制,以及我在真实项目里踩过的几个高频问题。适合正在做 Unity SDK 集成、iOS 原生桥接或买量归因的客户端同学参考。

1. 唤醒链路全景:从点击链接到 C# 收到参数

1.1 URL Scheme 与 Universal Links 的核心差异

URL Scheme 是 iOS 很早就支持的机制。你在 Info.plist 里声明一个自定义协议,比如mygame://,当系统检测到类似mygame://invite?inviter=123的链接时,就唤起你的 App,并把完整 URL 交给application:openURL:options:处理。优势是原理简单、支持面广,从 iOS 3 到 iOS 17 全都能用。缺点是体验有点打断感:用户在 Safari 或第三方浏览器里点这个链接时,系统会弹一个确认框,问“是否要在 App 中打开此页面”,这一步就会带来一部分用户流失;另外 URL Scheme 不唯一,同一个协议名谁先抢着注册就是谁的,存在被抢注的隐患,劫持风险也更高。

Universal Links 是 iOS 9 之后苹果主推的方案,逻辑完全反过来:你的 App 通过 Associated Domains 能力和一个 HTTPS 域名绑定,并在域名根目录放一个apple-app-site-association(简称 AASA)文件作为“电子凭证”。用户点击的是带https://的普通链接,如果设备上装了 App,系统会在不弹窗的情况下直接拉起 App,Safari 顶部会出现一条“在 App 中打开”的返回条;如果没装 App,链接照常打开网页,可以继续做下载引导。这种“有条件唤起”的特性在买量模型里特别关键。

1.2 手游场景下怎么选型

买量和裂变场景,Universal Links 是绝对主力。原因很简单:广告从点击到唤起,中间每多一个弹窗、每多一次跳转,转化率就掉一截。Universal Links 无感唤起,用户几乎意识不到 App 是自己被拉起来的,体验顺滑得多。而且它可以优雅处理“未安装”的分支——没装 App 就继续在落地页展示引导下载,整个过程不需要客户端参与。

但我的建议是:两个都配上。Universal Links 在微信、QQ 这类内置浏览器里有拦截行为,部分老系统对 AASA 的缓存刷新也确实慢,这种时候 URL Scheme 就是兜底方案。尤其是国内安卓转 iOS 的玩家,习惯上更接受 URL Scheme 的拉起方式。两者不是互斥关系,配置上各管各的,C# 层统一收敛解析即可。

1.3 深链在手游里的几种真实用途

深链在这些场景里会经常用到:

  • 买量归因:广告投放回传的链接带有campaign_id、adset_id等参数,客户端收到后把这些字段上报给归因平台,判断用户来自哪个渠道。
  • 邀请裂变:老玩家发链接给新玩家,链接带inviter_id,新用户启动后读取参数,在注册或绑定界面自动填上邀请人关系。
  • 活动跳转:运营在社群里发带活动码的链接,玩家唤起游戏后直接打开对应活动页,而不是停留在主界面。
  • 加好友/进公会:从公会群分享的链接点进来,自动拉起公会界面并展示入会确认弹窗。

这些场景里,“参数”就是命根子。链接里带了什么参数、怎么安全地传给 App、什么时候送达 C#、业务层怎么消费,整条链路都有讲究。

2. Xcode 原生配置:绕不开的基础功

2.1 在 Info.plist 中注册 URL Scheme

Unity 导出 Xcode 工程后,用 Xcode 打开,找到 Info.plist,添加 URL types。这是最原始也最直观的方式:

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

CFBundleURLName一般填 bundle identifier 或一个固定字符串,系统不校验,主要是内部标识用。CFBundleURLSchemes里填你要注册的协议名。注意:

  • 协议名建议小写字母开头,全部小写。你注册MyGame和用户点击mygame://,系统匹配时大小写敏感,容易翻车。
  • 协议名不要带特殊符号比如://、空格,它只是 scheme 那一串。
  • 一个 App 可以注册多个 scheme,但每多一个就多一份被抢注风险,没必要不要乱加。

2.2 配置 Universal Links 与 Associated Domains

接下来是 Universal Links,步骤固定:

  1. 在 Xcode 的 Signing & Capabilities 标签页添加Associated Domains能力。
  2. 添加域名,格式是applinks:yourdomain.com。注意不要写https://,也不要写路径。
  3. 确保你的域名是 HTTPS,证书有效,服务器能正常响应对 AASA 文件的请求。
  4. 把 AASA 文件放到https://yourdomain.com/apple-app-site-association或https://yourdomain.com/.well-known/apple-app-site-association这个位置。

AASA 文件内容长这样:

{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.yourcompany.yourgame", "paths": ["/game/*", "/invite/*"] } ] } }

appID是Team ID 加 Bundle Identifier拼接,中间没有空格。paths数组用来控制哪些路径允许唤起 App。通配符支持两种:*匹配任意多个字符,?匹配单个字符。注意路径是大小写敏感的,你在服务器上用/Invite/跳转,AASA 里写/invite/*,是不会命中的。

这里有个容易踩坑的点:AASA 不能用重定向。很多同学把文件放在某个对象存储的临时链接上再重定向到正式域名,苹果拉取时会失败。AASA 请求必须直接返回 200 和 JSON 内容,最好不要经过任何重定向、登录鉴权、WAF 拦截。

还有一件事:苹果对 AASA 文件大小有建议,越精简越好。如果你的 details 数组里堆了大量 App,文件膨胀到几百 KB,虽然大多数情况下系统也能拉取,但缓存和更新都可能变慢,排查起来也麻烦。

2.3 在 Unity 构建流程里自动化注入配置

如果每个版本都从 Unity 重新导出 Xcode 工程,那么上面所有手工操作都会在导出时被覆盖,这是开发团队最容易吃暗亏的地方。所以强烈建议写一个 PostProcessBuild 脚本,在每次构建 iOS 工程后自动注入配置。

using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; public class iOSDeepLinkPostProcess { [PostProcessBuild(1000)] public static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject) { if (target != BuildTarget.iOS) return; string plistPath = pathToBuiltProject + "/Info.plist"; PlistDocument plist = new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementArray urlTypes = plist.root.CreateArray("CFBundleURLTypes"); PlistElementDict typeDict = urlTypes.AddDict(); typeDict.SetString("CFBundleURLName", "com.yourcompany.yourgame"); PlistElementArray schemes = typeDict.CreateArray("CFBundleURLSchemes"); schemes.AddString("mygame"); plist.WriteToFile(plistPath); } }

UnityEditor.iOS.Xcode在 Unity 里已经内置,不用额外引包。但要注意:PlistDocument.root.CreateArray如果 key 已经存在,会报错或覆盖,所以脚本里最好先判断 key 是否已存在。

Associated Domains 的自动化稍微麻烦一点,因为你实际上操作的是 entitlements 文件。Unity 导出的工程里,开启 Associated Domains 后通常会生成一个.entitlements文件。构建脚本可以这样处理:

string entitlementsPath = pathToBuiltProject + "/Unity-iPhone/Unity-iPhone.entitlements"; PlistDocument entitlements = new PlistDocument(); entitlements.ReadFromFile(entitlementsPath); PlistElementArray domains = entitlements.root.CreateArray("com.apple.developer.associated-domains"); domains.AddString("applinks:yourdomain.com"); entitlements.WriteToFile(entitlementsPath);

不同 Unity 版本导出工程后的文件命名可能有差异,建议在第一个脚本里先打印Directory.GetFiles查看真实结构,再写死路径。这个自动化脚本写好后,配合 CI/CD 打包,每次出包都不用人工打开 Xcode 点来点去,省心很多。

3. 原生回调接力:把参数安全送进 C# 层

3.1 理解 iOS 生命周期变化

iOS 12 及以前,AppDelegate的application:openURL:options:是所有 Deep Link 回调的唯一入口。iOS 13 之后 App 引入了 Scene 生命周期,如果工程使用UIScene,系统会走scene:openURLContexts:而不是 AppDelegate 的回调。

多数 Unity 导出工程默认不启用 Scene 生命周期,回调只会落在 AppDelegate。但如果你在工程里集成了一些第三方 SDK,它们可能会把生命周期切到 Scene 模式,或者你自己在原生层面加了 SwiftUI 兼容代码,那么回调入口就变了。我见过一个项目,Deep Link 在 iOS 15 上一直正常,升到 iOS 17 后突然不触发了,排查三天最后发现是第三方统计 SDK 把 Scene 生命周期接管了。

稳妥的做法是双入口都写。AppDelegate 收到回调后统一转发到一个DeepLinkManager单例;如果检测到 Scene 代理回调,也转发到同一个单例。这样不管系统走哪条路,原生层的处理逻辑只有一个出口。

3.2 原生层接收链接并投递的完整代码

先看一段典型的 Objective-C 处理代码。Unity 导出的工程里,UnityAppController是AppDelegate的子类,你可以直接继承它,或者写一个 Category 扩展它。我习惯新建一个专门的.mm文件,在里面用一个单例管理所有深链逻辑:

#import "UnityAppController.h" #import <UIKit/UIKit.h> @interface DeepLinkManager : NSObject + (instancetype)shared; - (void)handleURL:(NSURL *)url; - (NSString *)takeCachedLink; // C# 侧主动拉取 @property(nonatomic, copy) NSString *cachedLinkJSON; @end @implementation DeepLinkManager + (instancetype)shared { static DeepLinkManager *instance; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [[DeepLinkManager alloc] init]; }); return instance; } - (void)handleURL:(NSURL *)url { NSMutableDictionary *parsed = [NSMutableDictionary dictionary]; parsed[@"fullUrl"] = url.absoluteString ?: @""; parsed[@"scheme"] = url.scheme ?: @""; parsed[@"host"] = url.host ?: @""; parsed[@"path"] = url.path ?: @""; parsed[@"query"] = url.query ?: @""; NSData *data = [NSJSONSerialization dataWithJSONObject:parsed options:0 error:nil]; NSString *json = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; self.cachedLinkJSON = json; // 如果 Unity 已经初始化完,直接投递;否则等 C# 侧来拉取 UnitySendMessage("DeepLinkBridge", "OnNativeDeepLinkReceived", [json UTF8String]); } - (NSString *)takeCachedLink { NSString *link = self.cachedLinkJSON; self.cachedLinkJSON = nil; return link; } @end

然后在 UnityAppController 的扩展或者子类里补上系统回调:

@implementation UnityAppController (DeepLink) - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options { [[DeepLinkManager shared] handleURL:url]; return YES; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> *))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url = userActivity.webpageURL; [[DeepLinkManager shared] handleURL:url]; return YES; } return NO; } @end

UnitySendMessage有三个参数:第一个是 GameObject 名称,第二个是挂在它身上的组件方法名,第三个是参数字符串。这个方法只能从主线程调用,并且Unity 侧必须已经完成场景加载。如果 App 刚冷启动,Unity 引擎还在初始化,这时候调用UnitySendMessage会直接静默失败。所以上面的代码里不管 Unity 是否 ready,都把 JSON 缓存了一份,这就是兜底。

3.3 C# 层接收与业务分发

C# 侧创建一个常驻不销毁的 GameObject,挂一个DeepLinkBridge脚本。这个脚本不需要每个场景都摆一个,用单例初始化即可:

using System; using System.Collections.Generic; using UnityEngine; public class DeepLinkBridge : MonoBehaviour { private static DeepLinkBridge instance; private string pendingLinkJson; public static DeepLinkBridge Instance { get { if (instance == null) { var go = new GameObject("DeepLinkBridge"); DontDestroyOnLoad(go); instance = go.AddComponent<DeepLinkBridge>(); } return instance; } } private void Start() { // 主动拉取原生缓存,防止 UnitySendMessage 调用过早丢失 PullPendingDeepLink(); } public void OnNativeDeepLinkReceived(string json) { if (string.IsNullOrEmpty(json)) return; var data = DeepLinkData.Parse(json); DeepLinkDispatcher.Instance.Dispatch(data); } public void PullPendingDeepLink() { if (Application.platform != RuntimePlatform.IPhonePlayer) return; // 调用原生方法拉取缓存 // 这里通过 extern 方法绑定到原生 DeepLinkManager 的 takeCachedLink string cached = DeepLinkNativeBridge.TakeCachedLink(); if (!string.IsNullOrEmpty(cached)) { OnNativeDeepLinkReceived(cached); } } }

业务层推荐做一个全局事件分发器:

public class DeepLinkDispatcher { public static DeepLinkDispatcher Instance { get; } = new DeepLinkDispatcher(); public event Action<DeepLinkData> OnDeepLinkReceived; private readonly List<DeepLinkData> pendingQueue = new List<DeepLinkData>(); public void Dispatch(DeepLinkData data) { // 如果还没有任何业务注册监听,先把数据缓存下来 if (OnDeepLinkReceived == null) { pendingQueue.Add(data); return; } OnDeepLinkReceived?.Invoke(data); } public void Register(Action<DeepLinkData> handler) { OnDeepLinkReceived += handler; // 注册后立即回放缓存的深链 foreach (var data in pendingQueue) { handler(data); } pendingQueue.Clear(); } }

这样邀请模块、买量归因模块、活动模块各注册各的监听,互不干扰。比如邀请模块在初始化时调用Register,收到数据后就弹邀请确认 UI;归因模块收到数据后拼装上报参数。所有逻辑不集中在同一个巨型回调里,后续排查也方便。

3.4 参数解析的几个关键细节

URL 参数两种形态都要兼容:

  • URL Scheme 形式:mygame://invite?inviter=123&channel=news
  • Universal Links 形式:https://yourdomain.com/invite?inviter=123&channel=news

C# 解析推荐直接使用System.Uri,不用自己造轮子:

public class DeepLinkData { public string RawUrl; public string Scheme; public string Host; public string Path; public string Query; public Dictionary<string, string> Parameters = new Dictionary<string, string>(); public static DeepLinkData Parse(string url) { var data = new DeepLinkData { RawUrl = url }; var uri = new Uri(url); data.Scheme = uri.Scheme; data.Host = uri.Host; data.Path = uri.AbsolutePath; data.Query = uri.Query; if (!string.IsNullOrEmpty(uri.Query)) { string trimmed = uri.Query.TrimStart('?'); foreach (string pair in trimmed.Split('&')) { if (string.IsNullOrEmpty(pair)) continue; int idx = pair.IndexOf('='); if (idx < 0) { data.Parameters[pair] = ""; } else { string key = pair.Substring(0, idx); string value = pair.Substring(idx + 1); data.Parameters[key] = System.Uri.UnescapeDataString(value); } } } return data; } }

注意Uri.UnescapeDataString会把%E4%B8%AD%E6%96%87解码成中文。如果值里本身还带了%字符,解码逻辑要再包一层容错。我的习惯是解码失败时保留原串,不能让一个异常参数把整条深链搞崩。

关于frame里用什么 key 对接业务,团队最好在项目里定一个规范。比如统一用inviter、channel、campaign_id,而不是一半人写userId一半人写user_id。这个规范不在代码里强制,但真等出问题的时候,统一命名能少吵不少架。

4. 实战中的高频坑与排查方法

4.1 Universal Links 打不开的排查清单

开发阶段最常遇到的问题就是:点了链接,Safari 老老实实打开了网页,就是不唤起 App。排查顺序很有讲究,按这个列表来基本能覆盖 90% 的情况:

  1. 确认 AASA 文件可访问性:手机直接访问https://yourdomain.com/apple-app-site-association,看看返回的 JSON 里appID是否和工程里的 Team ID + Bundle ID 完全一致。
  2. 确认 Associated Domains 格式:Xcode 里写的必须是applinks:yourdomain.com,有人会手滑写成https://yourdomain.com,直接失效。
  3. 确认路径匹配:AASA 里写的是/invite/*,你测试链接却是/game?foo=bar,肯定不唤起。路径不区分参数,?后面的部分不影响匹配。
  4. 确认系统缓存:iOS 对 AASA 有缓存机制,改完文件后短则几分钟,长则一两天才会重新拉取。开发阶段可以重启手机,一般重启后缓存会刷新。
  5. 确认域名不是 IP:iOS 16 之后,AASA 里的域名不能是裸 IP,必须有真实域名。
  6. 确认没有经过重定向:AASA 请求一旦被服务端重定向,苹果拉取就会失败。

4.2 冷启动时序问题:Unity 还没准备好,参数就丢了

这个坑我真实踩过,而且是在线上环境踩的。用户从广告链接冷启动 App,原生层在application:didFinishLaunching后立刻收到了深链,马上调用UnitySendMessage,但此时 Unity 场景还没加载完,消息直接丢了。用户进来后没有绑邀请关系,运营数据对不上,排查了很久才发现是时序问题。

解决办法就是我上面写的缓存兜底。原生层任何时候收到深链,都先把 JSON 存单例,再去尝试UnitySendMessage。C# 这边的DeepLinkBridge在Start里主动拉一次原生缓存。兜底路径是双保险,即使UnitySendMessage因为时序失败,C# 也能在场景起来后主动拿到。

4.3 重复回调导致重复业务操作

真实场景:用户通过 Universal Links 唤起 App 时,系统可能在启动后同一时间窗口内触发两次回调。第一次是冷启动系统自动恢复,第二次是用户手滑多点了一下链接,或者第三方归因 SDK 自己又解析了一次。如果业务层不去重,就会出现邀请弹窗弹两次、归因上报发两次的现象。

我的处理方案是给每次深链生成一个消费 ID。C# 侧在拿到DeepLinkData后,用RawUrl + 接收时间戳算一个 MD5 存本地列表,一段时间内比如 30 秒内遇到相同 ID 直接丢弃。如果业务需要跨启动去重,就改成持久化存储,但一般 30 秒到几分钟的窗口就能覆盖 99% 的重复回调场景。

4.4 中文参数乱码与编码问题

有过一次线上反馈,运营发的邀请链接里带了玩家昵称,比如inviter=张三,结果客户端解析出来是乱码。深入排查后发现,渠道方在拼接 URL 时对中文做了 URL 编码,但部分老版本系统回调时返回的query是原始未编码的 UTF-8 字符串。

处理上我给Parse方法加了一层容错。先看能不能直接用,如果字符串里有%就尝试解码,如果解码结果还是包含%E4%B8%AD这类序列就再解一次。另外,在拼装上报参数时统一再做一次Uri.EscapeDataString,保证写给服务端的数据是规范的。

4.5 横竖屏切换导致回调“看起来丢失”

还遇到过一个特别隐蔽的问题:App 冷启动时强制横屏,落地页是竖屏,系统在启动瞬间发生了方向切换,整个视图控制器重建。我放在原生单例里的深链数据没丢,但投递时机被重建过程打乱,C# 侧的Awake和Start执行顺序变得不可预期,最后结果就是业务方收不到回调。

解决方式是把 C# 侧主动拉取的动作从Start改到OnApplicationFocus首次触发之后,并加一个小延时。这个处理虽然有点土,但实测非常稳定。如果你们的 App 涉及复杂的方向切换,建议把深链消费触发点放在用户真正进入主界面之后,而不是最早期初始化阶段。

4.6 微信、QQ 内置浏览器里的特例

不少运营同学反馈,从微信群里发出去的链接,点了根本无法唤起 App。原因是微信、QQ 这类客户端会对 Universal Links 做拦截,避免你不经允许就跳出到外部 App。这个行为现在没有合法的绕过方式,唯一靠谱的做法是落地页做适配:检测到微信内置浏览器时,引导用户点击右上角“在浏览器中打开”,再走 Universal Links 唤起的流程。也可以做“一键复制链接”按钮,复制后切到 Safari 再打开。

5. 综合兜底方案:把深链做成一条标准流水线

经历过上面的各种坑之后,我最终在项目里沉淀了一套固定结构,核心是“存储 - 拉取 - 消费”三个环节分离。原生层只负责接收和存储,不主动决定投递时机;C# 层负责在合适的时机拉取,并做去重、解码、分发;业务层只管注册监听和消费数据。这样每一环都独立,出问题时定位也快。

流程总结:

  • 原生层handleURL:接收链接,解析字典,转 JSON 字符串,缓存起来。
  • 尝试UnitySendMessage直接投递,但投递失败不影响,因为缓存还在。
  • C# 层在Start和OnApplicationFocus时主动PullPendingDeepLink(),把原生缓存的 JSON 拉到 Unity 侧。
  • C# 层统一解析、去重、解码,再分发到业务模块。
  • 业务模块注册监听,处理各自的业务逻辑。

这个结构我已经在两个中大型 Unity 手游项目里验证过,覆盖了买量归因、邀请裂变、活动跳转、公会自动加入等常见需求。线上跑到 iOS 17,AASA 更新、冷启动、热启动、横竖屏切换这些场景都没有再出现过深链丢失的情况。

最后再分享一个小技巧:开发阶段一定不要把深链调试依赖在真机日志的print输出上。直接用 Xcode 连接设备,在continueUserActivity和openURL两个方法里打断点,先确认系统回调到底有没有到原生层。这一步确认清楚了,再回 C# 侧看参数投递,排查效率能翻几倍。很多同学在 C# 侧打日志调试半天,最后发现原生根本没回调,方向就错了。把这套链路理顺,深链才算真正做透了。

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

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

立即咨询