
1. 为什么手游团队绕不开 Deep Link 这件事做过手游投放或者运营活动的人大概率都碰过这样一个场景用户在微信里点开一条活动链接或者从浏览器里点了一个广告位结果要么是跳到一个空白页要么是打开 App Store 让用户重新下载要么是下载完之后打开游戏人却停在登录页活动奖励没领到渠道参数也丢了。用户骂骂咧咧地关掉投放的钱就这么打了水漂。这个问题的核心就是Deep Link深度链接。它要解决的事情说起来很简单让一个 URL 能够精准地把用户送进 App 内部的某个具体页面并且把 URL 上携带的参数比如活动 ID、渠道号、邀请码一路传递到游戏逻辑层。但真到 Unity 手游 iOS 这个组合上事情就变得琐碎起来了——iOS 侧有 URL Scheme 和 Universal Links 两套机制Unity 侧又有 AppDelegate 和 UnityAppController 的桥接问题参数从原生层传到 C# 层还得自己写桥。这篇内容我打算把整条链路从头到尾捋一遍从 iOS 端两种 Deep Link 方案的选型和配置到 Unity 原生插件层的接收再到 C# 层的参数投递和业务分发。中间会穿插我自己踩过的坑比如冷启动和热启动的参数获取时机差异、Universal Links 在微信里的表现、URL 编码导致参数被截断这些。适合正在做手游投放归因、活动唤端、邀请拉新的 Unity 开发者也适合刚接触 iOS 原生和 Unity 混合开发、想搞清楚这层桥怎么搭的同学。哪怕你之前没写过一行 Objective-C跟着走也能把流程跑通。2. Deep Link 两种方案到底怎么选2.1 URL Scheme 的工作机制和它的天花板URL Scheme 是最老牌也最简单的唤端方式。你在 iOS 工程的 Info.plist 里注册一个自定义协议比如mygame://那么任何地方只要打开这个开头的链接系统就会把请求路由给你的 App。它的原理是 iOS 的application:openURL:options:回调系统发现某个 URL 的 scheme 匹配到已注册的 App就把这个 URL 原封不动交给它处理。配置上其实不复杂在 Xcode 的 Info.plist 里加一段 URL TypesIdentifier 随便填个反向域名URL Schemes 填你的协议名。Unity 打包出来的 Xcode 工程这一步可以在 PostProcessBuild 脚本里自动注入省得每次出包手动改。我一般会写个OnPostprocessBuild用PlistDocument直接改 Info.plist把 scheme 写进去这样 CI 出包也不会漏。但 URL Scheme 有几个绕不过去的硬伤。第一它是全局唯一的如果两个 App 注册了同一个 scheme谁先装谁生效后装的那个反而抢不到这在多包体或者马甲包场景下很头疼。第二任何 App 都能唤起你的 scheme没有来源校验安全性差别人可以伪造链接。第三也是最要命的在微信、QQ、微博这些内置浏览器里非白名单的 scheme 基本被拦用户点了没反应或者弹一个是否打开的确认框转化率直接掉一半。所以纯靠 URL Scheme 做投放唤端在微信生态里是走不通的。2.2 Universal Links 凭什么成为投放首选Universal Links 是苹果在 iOS 9 之后推的方案本质是用标准的 HTTPS 链接来唤端。你有一个自己的域名比如game.example.com在域名根目录放一个apple-app-site-association简称 AASA文件声明哪些路径归哪个 App ID 处理。用户点开https://game.example.com/activity?id123这样的链接时如果设备上装了你的 App系统会直接打开 App 并把完整 URL 传进来如果没装就正常在浏览器里打开这个网页你可以在网页上引导下载。它相比 URL Scheme 的优势非常明显。首先链接是标准 HTTPS微信、Safari、各种浏览器都认不会被拦。其次域名是你自己的天然带来源校验别人伪造不了。第三没装 App 时能优雅降级到网页这对投放来说太重要了——用户不会看到一个死链接。第四苹果会校验域名归属安全性高。代价是配置麻烦一些。你需要一个支持 HTTPS 的域名能往根目录放文件还要在 Xcode 工程里开启 Associated Domains 能力填上applinks:game.example.com。AASA 文件的内容、Content-Type、签名这些细节都有讲究后面单独讲。2.3 我的选型结论两个都要但分工明确实际项目里我的做法是两套都配但用途分开。Universal Links 作为主链路负责所有对外投放、分享、活动唤端因为它能覆盖微信和浏览器降级体验也好。URL Scheme 作为兜底和内部跳转比如 App 内 H5 页面唤起原生、或者某些 Universal Links 覆盖不到的老旧场景。判断逻辑放在原生层先尝试从 Universal Links 回调拿 URL拿不到再看 URL Scheme 回调。C# 层其实不用关心是哪种方式进来的统一收一个 URL 字符串就行。这样业务层逻辑干净原生层负责适配差异。下面这张表是我总结的两种方案对比选型时可以直接参考。对比维度URL SchemeUniversal Links链接形式mygame://path?id1https://game.example.com/path?id1微信内可用基本被拦可用未安装降级无报错或空白打开网页可引导下载来源校验无域名归属校验配置复杂度低改 Info.plist中需域名 AASA 工程能力冷启动参数有有热启动参数有有适用场景内部跳转、兜底投放、分享、活动唤端3. iOS 原生侧配置把链接接进来3.1 URL Scheme 的注册与回调实现先讲 URL Scheme 的落地。Info.plist 里那段配置长这样CFBundleURLTypes 是个数组每个元素包含 CFBundleURLName反向域名标识和 CFBundleURLSchemes协议名数组。协议名建议用全小写加数字别用大写或者特殊字符有些系统版本对大小写敏感踩过坑。回调实现上iOS 13 之后要区分 SceneDelegate 和 AppDelegate。如果你的工程用了 Scene回调走scene:openURLContexts:没用 Scene 的老工程走application:openURL:options:。Unity 默认导出的工程在较新版本里是带 Scene 的这点要注意很多人照着老教程写 AppDelegate 回调结果死活不触发就是这个问题。Unity 的 Xcode 工程里UnityAppController是主控制器。我一般不改它而是写一个 Category 或者直接在UnityAppControllerDeepLink.mm里 hook 相关方法把 URL 存到一个静态变量里再通过UnitySendMessage通知 C# 层。这样升级 Unity 版本时不容易冲突。3.2 Universal Links 的 AASA 文件与工程能力Universal Links 的配置分三块。第一块是域名侧的 AASA 文件放在https://game.example.com/.well-known/apple-app-site-association注意没有后缀名Content-Type 必须是application/json。文件内容大致是applinks下面挂details每个 detail 里有appIDTeamID.BundleID和paths路径数组。paths 支持通配符比如/activity/*匹配活动页*匹配全部。这里有个大坑AASA 文件苹果的 CDN 会缓存改完之后不是立刻生效有时候要等几小时甚至一天。调试阶段我一般先用*全匹配确认链路通了再收窄路径。另外 iOS 会优先从 CDN 拉CDN 拉不到才回源所以源站的可用性也要保证。第二块是 Xcode 工程能力。在 Signing Capabilities 里加 Associated Domains填applinks:game.example.com。这一步 Unity 打包后需要手动加或者用 PostProcessBuild 脚本自动加。我推荐自动化因为手动加容易忘而且多环境测试、预发、正式域名不同脚本里按 build 配置切换更靠谱。第三块是回调。Universal Links 触发时走的是application:continueUserActivity:restorationHandler:或 Scene 版本scene:continueUserActivity:从userActivity.webpageURL里取 URL。注意这个方法在冷启动和热启动都会调用但冷启动时机的处理要小心后面细说。3.3 冷启动与热启动参数获取时机的分水岭这是整个流程里最容易出错的地方。冷启动指的是 App 没在运行用户点链接把它拉起来热启动是 App 已经在后台用户点链接把它切到前台。两者的回调时机完全不同。冷启动时didFinishLaunchingWithOptions会先执行然后才走 openURL 或 continueUserActivity 回调。问题在于 Unity 引擎的初始化也在didFinishLaunchingWithOptions里如果 C# 层在引擎初始化时就急着去读参数那时候原生回调可能还没执行读到的是空。我踩过这个坑活动参数在冷启动时永远拿不到热启动却正常。解决办法是原生层先把 URL 缓存到一个静态变量C# 层启动后主动来拉一次而不是等原生推。具体做法是 C# 层在第一个场景的 Awake 里调用一个原生方法GetLaunchURL原生方法返回缓存里的 URL如果有的话同时清空缓存避免重复消费。这样无论冷启动还是热启动C# 层都能拿到时机也由 C# 自己控制稳得多。热启动相对简单回调触发时引擎早就跑起来了直接UnitySendMessage推给 C# 就行。但要注意去重因为有些系统版本会重复回调业务层如果对同一个活动 ID 重复发奖就麻烦了。我一般会在原生层加个时间戳去重同一个 URL 在 1 秒内只投递一次。4. Unity 原生插件层把 URL 从 OC 送到 C#4.1 写一个 Objective-C 桥接类的完整思路Unity 和 iOS 原生通信标准做法是写一个.mm文件Objective-C里面用extern C导出 C 函数C# 层用DllImport调用。这个桥接类我一般叫DeepLinkBridge职责就三件事接收原生回调传来的 URL、缓存起来、提供给 C# 查询。核心方法有两个。一个是DeepLinkBridgeSetURL(const char* url)原生回调里调用它把 URL 存进一个NSString静态变量。另一个是DeepLinkBridgeGetURL()返回const char*C# 层来拉。返回的时候用strdup复制一份因为 C# 那边 marshal 完就释放了直接返回静态变量的指针会有生命周期问题。还有个细节是线程。原生回调都在主线程C# 的DllImport调用如果也在主线程就没问题。但如果你在子线程调GetURL就得加锁。我一般约定只在主线程调省得处理并发。4.2 UnitySendMessage 与主动拉取两种投递方式怎么配合UnitySendMessage是 Unity 提供的原生调 C# 的接口签名是UnitySendMessage(const char* objName, const char* methodName, const char* msg)。它只能调用场景里某个 GameObject 上的方法而且那个 GameObject 必须存在。冷启动时如果引擎还没初始化完或者目标 GameObject 还没创建调用就会失败消息直接丢了。所以我的策略是热启动用UnitySendMessage主动推因为这时候 GameObject 肯定在冷启动用 C# 主动拉避开时机问题。两者配合覆盖所有场景。C# 层收到推送或者拉到 URL 后统一走一个HandleDeepLink(string url)方法做解析和分发保证逻辑只有一份。这里有个小技巧UnitySendMessage的 msg 参数是const char*如果 URL 里有中文或者特殊字符记得在原生层做 URL decode否则 C# 收到的是百分号编码的乱码。我一般原生层 decode 一次C# 层再兜底 decode 一次双保险。4.3 C# 层的 P/Invoke 封装与平台判断C# 这边DllImport的写法要注意平台。iOS 上__Internal表示静态链接进主二进制写法是[DllImport(__Internal)]。但这个代码在 Editor 里跑会报错因为 Editor 没有这个符号。所以要用#if UNITY_IOS !UNITY_EDITOR包起来Editor 下走一个 mock 实现方便本地调试。我一般会封装一个DeepLinkNative静态类里面根据平台宏提供GetLaunchURL()和注册回调的方法。Editor 下GetLaunchURL返回空字符串或者返回一个测试 URL这样业务逻辑在 Editor 里也能跑通不用每次都出真机包。这个习惯能省大量调试时间。回调注册这块C# 侧要有一个常驻的 GameObject比如叫DeepLinkReceiver挂在启动场景里DontDestroyOnLoad保证不销毁。原生UnitySendMessage就发给它。这个 GameObject 上挂的脚本负责接收消息并转发给业务层的静态事件。5. 参数投递与业务分发的完整实现5.1 URL 解析把 query 拆成可用的键值对拿到 URL 之后第一步是解析。URL 结构是scheme://host/path?key1value1key2value2#fragment。我要的一般是 path 和 query 两部分。path 决定跳哪个页面query 里的参数决定带什么数据。解析我不用System.Uri因为它在某些 Unity 版本和 IL2CPP 下行为不一致而且对自定义 scheme 支持一般。我手写一个轻量解析器先按?切分前面是 path 部分后面是 queryquery 再按切分每段按第一个切分成 key 和 valuevalue 做一次Uri.UnescapeDataString解码。这个解析器不到 50 行可控性比库强。要注意的是参数值里可能包含或者所以切分 key 和 value 时只按第一个切value 里保留后续的。如果出现在 value 里正常应该被编码成%26如果没编码那就是对方的问题我一般会记录一条警告日志方便排查。5.2 参数投递的时机等业务系统就绪再分发解析出参数不代表能立刻用。冷启动场景下C# 层拉到 URL 的时候可能登录系统还没初始化、活动配置还没加载、UI 框架还没起来。这时候直接把参数丢给业务层大概率报空引用。我的做法是加一个待处理队列。C# 层拿到 URL 解析完先存进一个pendingDeepLink变量然后发一个事件通知有 Deep Link 待处理。业务层各个系统初始化完成后主动来问有没有待处理的 Deep Link有就消费掉。这样时机完全由业务层控制不会出现系统没准备好就硬塞参数的情况。具体实现上我会定义一个DeepLinkManager它维护pendingUrl和isReady两个状态。业务层调用MarkReady()之后如果pendingUrl不为空就触发OnDeepLinkReady事件。业务模块订阅这个事件在里面做跳转和发奖。5.3 一个完整的参数投递代码示例下面是我实际项目里精简出来的核心代码可以直接参考。原生桥接部分// DeepLinkBridge.mm #import Foundation/Foundation.h static NSString* _cachedURL nil; extern C { void DeepLinkBridgeSetURL(const char* url) { if (url NULL) return; NSString* str [NSString stringWithUTF8String:url]; if (str.length 0) return; _cachedURL [str copy]; } const char* DeepLinkBridgeGetURL() { if (_cachedURL nil) return strdup(); const char* result strdup([_cachedURL UTF8String]); _cachedURL nil; // 消费后清空避免重复 return result; } }C# 侧封装// DeepLinkNative.cs using System.Runtime.InteropServices; using UnityEngine; public static class DeepLinkNative { #if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern string DeepLinkBridgeGetURL(); #endif public static string GetLaunchURL() { #if UNITY_IOS !UNITY_EDITOR return DeepLinkBridgeGetURL(); #else return string.Empty; #endif } }业务层管理器// DeepLinkManager.cs using System; using UnityEngine; public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance; public static event Actionstring OnDeepLinkReady; private string _pendingUrl; private bool _isReady; void Awake() { if (Instance ! null) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } void Start() { // 冷启动主动拉取 string url DeepLinkNative.GetLaunchURL(); if (!string.IsNullOrEmpty(url)) SetPendingUrl(url); } // 热启动由原生 UnitySendMessage 调用 public void OnNativeDeepLink(string url) { if (!string.IsNullOrEmpty(url)) SetPendingUrl(url); } private void SetPendingUrl(string url) { _pendingUrl url; TryDispatch(); } public void MarkReady() { _isReady true; TryDispatch(); } private void TryDispatch() { if (!_isReady || string.IsNullOrEmpty(_pendingUrl)) return; string url _pendingUrl; _pendingUrl null; OnDeepLinkReady?.Invoke(url); } }这套代码的关键点在于MarkReady由业务层在合适时机调用TryDispatch保证参数只在系统就绪后投递一次。实测下来冷热启动都能稳定拿到参数。6. 常见问题与排查技巧实录6.1 参数丢失、乱码、被截断的排查顺序参数出问题是最常见的。我总结了一个排查顺序基本能覆盖九成情况。第一步看原生层有没有拿到完整 URL在DeepLinkBridgeSetURL里打日志如果这里就短了那是链接本身或者系统传参的问题。第二步看 URL decode 有没有做中文和特殊字符没 decode 会变乱码。第三步看 C# 解析器有没有把和处理对value 里带这些字符最容易出问题。第四步看业务层消费时机是不是系统没就绪就消费了。有个特别隐蔽的坑Universal Links 的 URL 在传递过程中如果路径里有中文苹果会做一次编码到原生层是%E4%B8%AD这种。如果你在原生层 decode 了一次C# 层又 decode 一次就会变成乱码。所以 decode 只做一次我一般统一在 C# 解析器里做原生层原样透传。6.2 Universal Links 不生效的六个检查点Universal Links 配好之后不生效按这六点依次查。第一AASA 文件能不能通过https://域名/.well-known/apple-app-site-association直接访问Content-Type 是不是application/json。第二appID 里的 TeamID 和 BundleID 对不对TeamID 是苹果开发者账号的十位字符BundleID 要和工程完全一致。第三Xcode 工程的 Associated Domains 有没有加格式是applinks:开头。第四测试时链接是不是从 Safari 地址栏直接输入的——注意从地址栏输入不触发 Universal Links必须从其他 App 或者网页里点。第五设备有没有装过这个 App 的旧版本旧版本可能缓存了旧的 AASA卸载重装试试。第六AASA 的 CDN 缓存改完等几小时。我遇到最多的是第四点很多人在 Safari 地址栏敲链接测试发现打开了网页而不是 App就以为配置错了其实是测试方法不对。正确的测试方式是在备忘录里写个链接点它或者用微信发给自己再点。6.3 微信内唤端的特殊处理微信内置浏览器对唤端限制很多。URL Scheme 基本被拦Universal Links 在微信里也有限制——微信会拦截一部分 Universal Links尤其是它认为有诱导分享嫌疑的。实测下来微信里 Universal Links 能不能唤起和域名信誉、链接路径、用户操作都有关不太稳定。我的应对策略是微信内优先走落地页 引导的模式。用户点链接先进 H5 落地页页面上放一个明显的按钮用户主动点击按钮时再尝试唤起。用户主动点击这个动作很关键微信对用户主动触发的唤端限制会松一些。如果唤起失败落地页上引导用户右上角在浏览器中打开或者直接跳 App Store。这套组合拳虽然转化率不如直接唤起但在微信生态里已经是最优解了。6.4 常见问题速查表现象可能原因排查方向冷启动拿不到参数C# 读参数早于原生回调改为 C# 主动拉取热启动参数重复系统重复回调原生层加时间戳去重参数乱码未 decode 或重复 decode统一在 C# 解析器 decode 一次Universal Links 打开网页AASA 未生效或测试方式错检查 AASA 可访问性从其他 App 点击测试微信内无反应scheme 被拦走落地页 用户主动点击参数被截断value 含未编码的 检查链接生成端是否编码回调不触发Scene/AppDelegate 用错确认工程是否启用 Scene7. 我踩过的坑和几条实操建议先说一个让我印象最深的坑。有次活动上线测试环境一切正常正式环境冷启动参数死活拿不到。查了半天发现是正式环境的 AASA 文件路径写错了测试环境用的是*全匹配正式环境收窄成了/activity/*而活动链接的路径是/act/不匹配系统就没把链接交给 App直接开了网页。所以路径匹配这块上线前一定要用真实链接验证别想当然。第二个坑是 URL 编码。我们的活动参数里有个邀请码用户昵称可能带 emoji生成链接的时候没编码结果 emoji 把后面的参数全截断了。后来在链接生成端统一做了encodeURIComponent问题解决。所以链接生成和解析两端要约定好编码规范最好写个单元测试覆盖特殊字符。第三个建议是关于日志。Deep Link 这条链路跨了原生和 C# 两层出问题很难定位。我在原生层和 C# 层都加了详细日志原生层用NSLogC# 层用Debug.Log关键节点都打上。真机调试时用 Xcode 的 Console 看原生日志Unity 的日志也能在里面看到。这套日志在排查线上问题时救过我好几次。最后分享一个小技巧做一个 Deep Link 测试面板。在游戏里加一个隐藏入口输入任意 URL 就能模拟触发整个流程不用每次都出包、发链接、点链接。这个面板在 Editor 下也能用开发阶段效率提升非常明显。面板里还可以显示当前解析出的参数、投递状态、消费结果一目了然。这套流程我在几个项目里都跑通过从投放唤端到活动拉新稳定性没问题。核心就三点原生层做好缓存和去重C# 层主动拉取加就绪判断业务层统一消费入口。把这三点落实了Deep Link 这块基本不会再出幺蛾子。