
做iOS手游的都知道拉新和召回离不开一条能直接戳进App深处的链接。这条链接背后就是Deep LinkiOS上最常打交道的是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?scenehero这样。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 SchemeURL 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不是ExtensionUnity导出时默认只有一个主target不用太担心。配置完Associated Domains在Xcode里跑一次到真机然后用Safari访问你域名的测试链接如果App唤起成功且没弹确认框说明系统关联成功。弹出确认框的话说明Universal Links没有完全生效大概率是AASA文件或者Team ID的问题按第6节的检查表逐项排。3.3 原生层接收链接并投递给 UnityiOS的链接接收有两种入口URL Scheme走application:openURL:options:Universal Links走application:continueUserActivity:restorationHandler:。Unity导出的是基于UnityAppController的工程常见做法是给UnityAppController写一个Category把两个方法都重写然后统一转成C#可接收的字符串。以Objective-C为例创建一个UnityAppControllerDeepLink.h/m文件#import UnityAppController.h interface UnityAppController (DeepLink) end#import UnityAppControllerDeepLink.h #import UnityInterface.h static NSString *cachedDeepLink nil; implementation UnityAppController (DeepLink) - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id *)options { NSString *urlString url.absoluteString; if (urlString.length 0) { [self sendDeepLink:urlString]; } return YES; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _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已就绪直接UnitySendMessageC#这边OnDeepLinkReceived会被调用。因为热启动时场景已经存在DeepLinkManager对象一定在不会丢。这里有一个容易忽略的细节OnDeepLinkReceived是被SendMessage动态调用的方法访问级别必须是public而且参数类型必须是string。写private或者参数不对不会有编译错误运行期也不会报错但就是调不到极其隐蔽。4.2 URL 参数解析的细节与坑拿到URL之后不能直接把整段字符串丢给业务去IndexOf(?)最好统一做一层解析转成结构稳定的模型再往游戏逻辑下发。目标URL大概是这两种URL Schemeyougame://open?scenedaily_giftuid10086frominviteUniversal Linkhttps://example.com/open?scenedaily_giftuid10086frominvite解析时第一步是提取Query部分然后拆键值对。C#的Uri类自带Query属性但它返回的字符串可能是带转义的还需要Uri.UnescapeDataString解一次。另外要小心多个同名参数和空值的情况。我项目里用的解析函数长这样public static Dictionarystring, string ParseQuery(string url) { var result new Dictionarystring, 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队列触发的如果你在原生层没有切回主线程就直接UnitySendMessageC#侧接到时不一定能安全调用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 冷启动先缓存后拉取冷启动的完整流程是用户点击链接系统唤醒AppiOS原生入口收到URL此时Unity引擎还在启动中C#对象不存在所以我们把URL缓存到原生静态变量。然后Unity启动、场景加载DeepLinkManager的Awake通过DllImport取到缓存再通过Start派发给业务。这个流程有几个关键时间点原生缓存必须在App启动早期就写入不能等到什么初始化完成才接收否则系统可能已经“丢失”了唤醒事件C#取缓存要保证场景已加载不要在BeforeSceneLoad阶段取取完后要清空原生缓存防止下一次冷启动又拿出来一次旧数据。在代码上我把“派发”和“拉取”分开了拉取放在Awake但只存到字段里派发放到Start并且用一个bool保证只派发一次。这样即使场景中有其他脚本在Awake时注册事件也来得及接收到。5.2 热启动监听即时事件热启动的相对简单App在运行系统把Universal Links或Scheme回调给原生层时原生直接UnitySendMessageC#的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 SchemeURL Scheme再弹确认框最后也能进游戏。另外尽量避免在Universal Links的目标链接里做重定向系统对最终响应的URL有严格校验重定向次数多了直接放弃关联。这里提一个通用原则所有由SDK、工具类生成的短链最后落地长度越短越好因为短链跳转往往伴随多次302Universal 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包要重新发布历史链接全部失效那代价就大了。希望这篇能让你少走几步弯路。