做iOS手游的都知道,拉新和召回离不开一条能直接戳进App深处的链接。这条链接背后就是Deep Link,iOS上最常打交道的是URL Scheme和Universal Links这两套方案,而作为Unity开发者,还得把链接里的参数完整、可靠地送到C#层,才能让业务跑起来。今天这篇就把这条链路从头到尾讲透:从苹果后台配置、域名文件、Xcode工程,到原生层接收、C#侧解析和冷热启动时序,全程按实际项目落地的方式手把手拆,适合正在给Unity手游接iOS唤醒能力,或者已经接了但时不时丢参数的人参考。
1. 为什么非做 Deep Link 不可:手游拉新与召回的环境真相
1.1 三个躲不开的业务场景
游戏运营对Deep Link的依赖,远比你想象中更具体。第一个场景是买量投放,广告平台通常会生出一个落地页,如果设备上已经装了游戏,点落地页里的“立即打开”应该直接进App;如果没装,就跳App Store安装。这个动作如果做不好,用户明明手机里装了游戏,点了广告却跑到App Store,下载复装一次,次日留存和付费率的数据全乱。
第二个场景是玩家召回。运营在推送、短信、邮件或社群发一条链接,点进去要直接回游戏并自动进入活动页面,最好是连玩家账号、活动编号、来源渠道都能跟着链接带进来。没有Deep Link的话,用户被拉了回来,但还要自己找活动入口,流失率立刻上去。
第三个场景是玩法里做社交裂变,比如“分享给好友,双方都得资源”。好友点链接进游戏后,要给好友关系、邀请码、奖励参数。这套逻辑在iOS上尤其麻烦,因为从Web跳到App的通道并不只有一条,处理不好就变成“游戏启动了但啥也没带”。
这三个场景最终都落到一个核心能力上:iOS系统能识别某个链接或协议,把App唤醒,同时App能拿到唤醒时携带的目标地址和参数,并把它转换成游戏内可执行的动作。
1.2 URL Scheme 与 Universal Links,选哪个
URL Scheme是最古老的私有协议,像mygame://open?scene=hero这样。App注册好scheme后,系统里任何地方只要有人访问这个协议,就能唤起对应App。优点是配置简单,缺点是它是“私有协议”,系统不会把它当成常规链接,如果没装App,系统只会提示无法打开,无法跳转到App Store;而且现在iOS从第二层级弹窗里打开URL Scheme时,会多一次“是否允许打开”的确认,体验上比Universal Links多一道门槛。
Universal Links是iOS 9开始支持的统一链接。它把你的域名和App绑定,用户点击的是一个标准的https://链接,系统识别到域名关联了已安装App,会直接帮你唤起,不弹确认框;如果App没装,链接还能继续在Safari里正常展示落地页,不影响下载引导。安全性更高,别人没法随便冒充你的scheme。
实际项目里我的结论是:Universal Links做首选,URL Scheme做兜底。原因是Universal Links对域名、证书、后台配置的要求比较严格,有些内嵌浏览器、小程序容器对它的支持并不好,而URL Scheme虽然体验重一点,但通用性极强。两条路都保留,业务侧统一在C#层处理。
2. 配置之前的硬性准备:开发者账号、域名与 AASA 文件
2.1 苹果开发者后台要开的开关
先说账号层面。Universal Links依赖的是Associated Domains能力,所以你需要先把App ID对应的能力开出来。登录Apple Developer后台,找到Identifiers,选中你的App ID,在Capabilities列表里勾上Associated Domains,保存。这一步不做,后面Xcode里写applinks:你的域名也不会生效。
注意这里的Team ID。后面生成的apple-app-site-association文件里需要写App ID的组合格式,就是Team ID + bundle ID,两者都不能错。Team ID在开发者后台的Membership页面能看到,是一串10位字母数字;bundle ID就是你Unity工程里设置的Bundle Identifier,比如com.yougame.sample。组合出来的完整字符串类似ABCDE12345.com.yougame.sample。
如果你用的是新版Unity导出Xcode工程的流程,建议把Associated Domains能力通过Unity的PBXProject脚本自动加,避免每次导出后手动去Xcode里点,这个后面第3节会给出完整代码。
2.2 apple-app-site-association 文件的正确写法和上传姿势
Universal Links能不能生效,一半看这个文件。它叫apple-app-site-association,没有.json后缀,必须放在HTTPS可达的域名根目录或者.well-known目录下。常见写法如下:
{ "applinks": { "details": [ { "appIDs": [ "ABCDE12345.com.yougame.sample" ], "components": [ { "/": "/open/*" } ] } ] } }components里的/open/*表示你的域名下只要路径以/open/开头,就认为是匹配深度链接的地址。域名可以有几个,details数组里按顺序配。文件传上去之后,要确认两点:第一,服务器必须返回200,不能有重定向,也不能要求登录;第二,这个地址必须是HTTPS,且证书是系统信任的正式证书,自签名证书一定不行。
很多人在这里踩坑:文件内容是对的,但被CDN吞掉了后缀,或者返回了压缩格式,系统解析不了。建议把文件放在https://你的域名/.well-known/apple-app-site-association,同时再放一份到根目录https://你的域名/apple-app-site-association,两边都备一份,土办法但最稳。
3. Unity 工程里的接入实操:从 Info.plist 到原生回调
3.1 用 PostProcessBuild 自动写入 URL Scheme
URL Scheme在Xcode工程里对应的是Info.plist中的CFBundleURLTypes。手动加一次不难,但Unity项目经常会反复导出覆盖,所以一定要把它写进Unity的构建后处理脚本里。Unity自带的UnityEditor.iOS.Xcode命名空间里就有PlistDocument,可以直接修改Info.plist。
简单示例脚本,放在Editor目录下:
using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; using System.IO; public class DeepLinkBuildProcessor { [PostProcessBuild(1000)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target != BuildTarget.iOS) return; string plistPath = Path.Combine(path, "Info.plist"); var plist = new PlistDocument(); plist.ReadFromFile(plistPath); var urlTypes = plist.root.CreateArray("CFBundleURLTypes"); var dict = urlTypes.AddDict(); dict.SetString("CFBundleURLName", "com.yougame.sample"); var schemes = dict.CreateArray("CFBundleURLSchemes"); schemes.AddString("yougame"); plist.WriteToFile(plistPath); // 修改PBXProject,添加Associated Domains能力 string projectPath = PBXProject.GetPBXProjectPath(path); var pbx = new PBXProject(); pbx.ReadFromFile(projectPath); string targetGuid = pbx.GetUnityMainTargetGuid(); pbx.AddCapability(targetGuid, PBXCapabilityType.AssociatedDomains); pbx.WriteToFile(projectPath); } }scheme的命名要尽量规避冲突。iOS上URL Scheme不是全局唯一,但同一个设备上如果装了另一个App也注册了yougame,系统会随机唤起其中一个,体验很不可控。建议用“游戏名+公司缩写+别致组合”,越少见越好。目前苹果已经要求新应用必须声明scheme的使用场景,所以注册越少越安全。
3.2 在 Xcode 里配置 Associated Domains
如果你没用脚本,那就手动在Xcode里操作:Unity导出的工程里选主target,找到Signing & Capabilities,点加号,选Associated Domains,然后在Domains列表里加applinks:你的域名。注意格式必须是applinks:前缀,别把https://带进去。
这里有个小坑:很多人只加了一个applinks:example.com,但你的链接可能跨多个域名,比如正式环境一个域名,测试环境一个域名,那就把两个都加进去。另外,Associated Domains关联的是主target,不是Extension,Unity导出时默认只有一个主target,不用太担心。
配置完Associated Domains,在Xcode里跑一次到真机,然后用Safari访问你域名的测试链接,如果App唤起成功且没弹确认框,说明系统关联成功。弹出确认框的话,说明Universal Links没有完全生效,大概率是AASA文件或者Team ID的问题,按第6节的检查表逐项排。
3.3 原生层接收链接并投递给 Unity
iOS的链接接收有两种入口:URL Scheme走application:openURL:options:,Universal Links走application:continueUserActivity:restorationHandler:。Unity导出的是基于UnityAppController的工程,常见做法是给UnityAppController写一个Category,把两个方法都重写,然后统一转成C#可接收的字符串。
以Objective-C为例,创建一个UnityAppController+DeepLink.h/m文件:
#import "UnityAppController.h" @interface UnityAppController (DeepLink) @end#import "UnityAppController+DeepLink.h" #import "UnityInterface.h" static NSString *cachedDeepLink = nil; @implementation UnityAppController (DeepLink) - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options { NSString *urlString = url.absoluteString; if (urlString.length > 0) { [self sendDeepLink:urlString]; } return YES; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url = userActivity.webpageURL; if (url) { [self sendDeepLink:url.absoluteString]; } } return YES; } - (void)sendDeepLink:(NSString *)urlString { if (UnityAppControllerIsReady()) { UnitySendMessage("DeepLinkManager", "OnDeepLinkReceived", urlString.UTF8String); } else { cachedDeepLink = [urlString copy]; } } extern "C" const char *UnityDeepLinkGetPending() { if (cachedDeepLink != nil) { const char *result = strdup(cachedDeepLink.UTF8String); cachedDeepLink = nil; return result; } return NULL; } @end这段代码里最关键的是UnityAppControllerIsReady()判断加缓存逻辑。为什么需要这个?因为冷启动时,系统唤起App的时间点远早于Unity引擎初始化完成,如果这时候直接调UnitySendMessage,对应的C#对象还没创建,消息就丢了。缓存到原生静态变量,等C#主动来取,这是最稳的做法。
UnitySendMessage的第一个参数是场景里GameObject的名字,第二个参数是挂在它上面的某个组件里的public方法,第三个是字符串参数。要求GameObject必须叫DeepLinkManager,且挂载的脚本里有OnDeepLinkReceived(string url)方法,不然发过去也没回应。
4. C# 层接收与参数投递:别把字符串直接丢给业务
4.1 两种主流投递方式:主动拉取与即时回调
原生层处理完链接后,C#侧有两种接法:一种是从原生主动拉取缓存,也就是调用上文的UnityDeepLinkGetPending();另一种是原生通过UnitySendMessage实时push过来。
主动拉取的方式适合冷启动。在C#的DeepLinkManager里写一个[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]方法,或者直接在场景对象的Awake里通过DllImport取一次缓存:
using System; using System.Runtime.InteropServices; public class DeepLinkManager : MonoBehaviour { [DllImport("__Internal")] private static extern IntPtr UnityDeepLinkGetPending(); void Awake() { string pending = GetPendingLink(); if (!string.IsNullOrEmpty(pending)) { HandleDeepLink(pending); } } private string GetPendingLink() { IntPtr ptr = UnityDeepLinkGetPending(); if (ptr == IntPtr.Zero) return string.Empty; return Marshal.PtrToStringUTF8(ptr); } private void OnDeepLinkReceived(string url) { HandleDeepLink(url); } }注意Marshal.PtrToStringUTF8对应的是strdup的UTF8指针,取完后由原生缓存已经置空,所以下次再取不会重复。如果你用的是PtrToStringAnsi,遇到URL里的非ASCII字符会乱码,iOS的URL可能是国际化域名或带中文参数,这里最好统一UTF8。
即时回调则用于热启动,就是App已经在运行,用户从Safari或其他App跳转进来。原生层判断Unity已就绪,直接UnitySendMessage,C#这边OnDeepLinkReceived会被调用。因为热启动时场景已经存在,DeepLinkManager对象一定在,不会丢。
这里有一个容易忽略的细节:OnDeepLinkReceived是被SendMessage动态调用的,方法访问级别必须是public,而且参数类型必须是string。写private或者参数不对,不会有编译错误,运行期也不会报错,但就是调不到,极其隐蔽。
4.2 URL 参数解析的细节与坑
拿到URL之后不能直接把整段字符串丢给业务去IndexOf("?"),最好统一做一层解析,转成结构稳定的模型,再往游戏逻辑下发。目标URL大概是这两种:
- URL Scheme:
yougame://open?scene=daily_gift&uid=10086&from=invite - Universal Link:
https://example.com/open?scene=daily_gift&uid=10086&from=invite
解析时第一步是提取Query部分,然后拆键值对。C#的Uri类自带Query属性,但它返回的字符串可能是带转义的,还需要Uri.UnescapeDataString解一次。另外要小心多个同名参数和空值的情况。
我项目里用的解析函数长这样:
public static Dictionary<string, string> ParseQuery(string url) { var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrEmpty(url)) return result; var uri = new Uri(url); string query = uri.Query ?? ""; if (query.StartsWith("?")) query = query.Substring(1); string[] pairs = query.Split('&', StringSplitOptions.RemoveEmptyEntries); foreach (string pair in pairs) { int idx = pair.IndexOf('='); if (idx < 0) { string key = Uri.UnescapeDataString(pair); result[key] = string.Empty; continue; } string k = Uri.UnescapeDataString(pair.Substring(0, idx)); string v = Uri.UnescapeDataString(pair.Substring(idx + 1)); if (!result.ContainsKey(k)) { result[k] = v; } else { result[k] = $"{result[k]},{v}"; } } return result; }需要注意,Uri在解析带有+号时不会把它当成空格,这一点和传统HTML表单有点区别。如果你投放的链接在服务端生成了+,到了客户端还是原来的+,业务侧如果期望空格就要再替换一次。另外,参数里可能出现#锚点,Uri的Query属性会自动忽略锚点之后的部分,这个符合预期,不用额外处理。
4.3 主线程和时机问题
Unity的MonoBehaviour生命周期回调都在主线程,DllImport和UnitySendMessage的回调在iOS上也都发生在主线程,看上去没有线程切换的风险。但实际项目中,有些原生SDK的回调是在后台GCD队列触发的,如果你在原生层没有切回主线程就直接UnitySendMessage,C#侧接到时不一定能安全调用Unity API,严重时会出现“only main thread”这样的运行时错误。
保险的做法是在原生层统一保证回调发生在主线程。最简单的就是DispatchQueue.main.async包一层,或者用Unity的UnitySendMessage本身要求主线程,所以在原生接收时如果当前非主线程就先切一下。同时C#侧可以做一道防御,在OnDeepLinkReceived里用SynchronizationContext判断,必要时post到主线程。iOS的Universal Links唤起路径一般都在主线程,别依赖“肯定安全”。
还有一处时机坑在Awake。如果你在DeepLinkManager的Awake里拉取缓存,但场景中还有别的业务脚本在Awake里注册监听,顺序是不可控的,可能业务还没监听,DeepLink已经派发完了。所以派发动作要延后一帧,比如在Start里做,或者在Update里用一个pendingFlag,确保所有监听者先准备好。
5. 冷启动与热启动唤醒时序:丢失参数的元凶
5.1 冷启动:先缓存后拉取
冷启动的完整流程是:用户点击链接,系统唤醒App,iOS原生入口收到URL,此时Unity引擎还在启动中,C#对象不存在,所以我们把URL缓存到原生静态变量。然后Unity启动、场景加载,DeepLinkManager的Awake通过DllImport取到缓存,再通过Start派发给业务。
这个流程有几个关键时间点:原生缓存必须在App启动早期就写入,不能等到什么初始化完成才接收,否则系统可能已经“丢失”了唤醒事件;C#取缓存要保证场景已加载,不要在BeforeSceneLoad阶段取;取完后要清空原生缓存,防止下一次冷启动又拿出来一次旧数据。
在代码上,我把“派发”和“拉取”分开了:拉取放在Awake,但只存到字段里;派发放到Start,并且用一个bool保证只派发一次。这样即使场景中有其他脚本在Awake时注册事件,也来得及接收到。
5.2 热启动:监听即时事件
热启动的相对简单,App在运行,系统把Universal Links或Scheme回调给原生层时,原生直接UnitySendMessage,C#的OnDeepLinkReceived立刻被触发。这个场景下没有缓存问题,但要考虑业务正在某个页面,用户跳转回来后,是立刻执行跳转还是做二次确认。游戏内弹个确认框比较好,否则用户正在战斗,突然被一个链接拉去兑换界面,体验非常差。
热启动还有一个容易被忽略的点:用户可能通过Universal Links唤起App,这时候应用已经在前台,但Scene里如果刚好在做异度加载或资源解压,主线程卡顿严重,SendMessage会延迟触发。如果你在处理链接时依赖场景对象,要注意目标场景是否已经切换完成。
5.3 谁能吞掉你的链接:空白中间页与通用链接劫持
接入过程中你会遇到一些“玄学”现象,比如链接点了没反应,但在Safari里又能打开。最常见的原因有两个:一是很多App内置浏览器为了给自家App导流,会强制拦截Universal Links唤起,让你先加载他们的落地页,这时候需要用户手动点右上角跳Safari;二是某些第三方SDK可能注册了同个域名下的Universal Links验证逻辑,排第一项没有处理完就return NO,导致系统不再继续找别的App。
遇到这种问题不要跟系统较劲,业务设计上要留后路:如果Universal Links没唤起App,落地页上一定要有手动“打开App”按钮,按钮跳URL Scheme,URL Scheme再弹确认框,最后也能进游戏。另外,尽量避免在Universal Links的目标链接里做重定向,系统对最终响应的URL有严格校验,重定向次数多了直接放弃关联。
这里提一个通用原则:所有由SDK、工具类生成的短链,最后落地长度越短越好,因为短链跳转往往伴随多次302,Universal Links的关联判断可能在中间环节失效。如果必须用短链,建议让短链最终在服务端返回包含正确Universal Links链接的HTML自跳转页面,而不是HTTP重定向到AASA匹配路径。
6. 上线前必做的验证与问题速查
6.1 环境验证清单
接入完成后不要急着打包上线,先按这个清单过一遍:
- 真机安装App,注销后重新安装一次,确保Associated Domains能力生效。
- 用Safari直接访问你的Universal Links链接,确认可以唤起且不弹确认框。
- 用Safari访问你的URL Scheme链接,确认虽然有系统确认弹窗但能正常唤起。
- 在App未安装状态下访问Universal Links链接,确认落地页能正常显示,不影响下载流程。
- 在App运行状态下从备忘录、邮件、浏览器等不同来源分别点击链接,确认不同热启动场景都能收到参数。
- 杀掉App进程后,点击链接冷启动,确认C#能拿到缓存参数,且只拿到一次。
- 检查参数里的中文字符和特殊符号,确认不会乱码。
这些步骤最好写成一个自动化验收文档,每次版本提测前由测试跑一遍,因为iOS系统版本升级后,某些行为会有微调,尤其是iOS对Universal Links的处理策略。
6.2 高频问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| Universal Links点击无反应 | AASA文件未生效、证书无效、域名被重定向 | 用Safari访问AASA地址看返回内容,检查域名HTTPS状态 |
| URL Scheme点击提示“无法打开” | App未安装、scheme拼写不匹配 | 确认Info.plist中的scheme和链接前缀完全一致 |
| 冷启动后C#收不到参数 | UnitySendMessage时机太早、原生缓存没有包装 | 使用主动拉取方案,参考第3.3节的缓存实现 |
| 参数里中文乱码 | C#用了Ansi解析/原生没UTF8转 | 统一UTF8传参,使用PtrToStringUTF8 |
| 参数偶尔丢失 | 场景对象创建顺序问题、业务监听未注册 | 延后到Start派发,或使用事件总线延迟注册 |
| 唤起成功但落到首页而非指定页 | C#没有完整解析路径,只解析了query | 从URL里再取host和path,做路由分流 |
| 微信等应用内浏览器无法唤起 | 内置浏览器限制Universal Links | 落地页加引导,提供手动打开按钮 |
排查时最快的方法是看设备日志。在Xcode里运行App,点击链接后观察控制台有没有出现App Link相关的log,通常会写明AASA文件校验失败还是域名不匹配。此外,用iOS的“打开方式”选项生成一个当前链接的分享菜单,能快速看到系统是否识别到你的App。
线上环境里如果链接来自广告平台,还经常会附带归因参数,比如click_id、adset_id这些。这类参数往往由广告平台在跳转时动态拼接,时间戳和签名都很长,解析时不要限定参数个数,也不要因为某个参数不存在就放弃整个链接。能解析出多少算多少,把原始URL一起存到日志里,后面补归因时能查。
6.3 商业化链路里的最后一步
深度链接的价值远不止“能进游戏”,最终要落到渠道归因和活动运营上。常见做法是:在落地页拼接渠道来源参数,App收到后把这些参数上报给数据后台,和广告渠道平台做匹配,才能知道这个用户是从哪个campaign、哪条广告进来的。如果只做App唤起不传参,买量数据基本等于瞎猜。
我在商业项目里的习惯是:客户端只负责把完整原始URL和一个解析后的KV模型交给业务,业务再把数据原样上报给服务端,服务端统一归因。客户端不要自作聪明去截断、清洗、排序参数,因为后续配置不同的渠道可能产生新的字段,客户端一旦过滤掉就永远追补不回来。
最后,所有参数处理逻辑都必须带日志开关,并且保证无论参数是否解析成功,游戏主流程都不能被阻塞。有些坑只有在线上流量大了才暴露,比如某个渠道生成了超长URL,客户端解析时用了string.Split没判空,直接导致主线程卡顿。Deep Link是入口功能,性能和安全都要按最高标准来写。
结尾
根据我个人实际接入多个Unity手游项目的经验,最值得你多花时间的不是配置,而是冷启动参数不丢失的那套缓存机制。很多人卡在“明明配置都对,为什么冷启动拿不到参数”,其实就是忘了原生层要先缓存、C#层需要延后派发这两个节点。如果你不想把所有逻辑都重建一遍,至少要在原生层加统一的缓存入口,在C#层用主动拉取加即时回调双通道,再配一个全量日志,这套东西跑上线之后会帮你省掉大量追查“用户点了链接但没效果”的破事。另外,Universal Links域名最好提前定下来,别等买量上线了再换,换了域名之后App包要重新发布,历史链接全部失效,那代价就大了。希望这篇能让你少走几步弯路。