有些坑,只在真机上等你
先说个真实场景:你辛辛苦苦做了一款 Unity 手游,上线到 iOS 平台之后,运营跑过来跟你说“我们要跟别的 App 互相导量,你接一下 Deep Link”。然后又补了一句“微信里打开链接也能直接跳进游戏,最好把活动页参数带上,这样我们好做归因”。
这就是这篇东西要解决的问题:从 iOS 端的 URL Scheme / Universal Links 一步步接到 Unity C# 层,把参数安全投递到业务脚本里。听起来不复杂,但如果你没踩过里面那些坑,真的会在这个看似“加个回调就行”的需求上耗掉一整天。
我会从原生侧讲起,一直讲到 Unity 侧的代码组织。不是抄文档那种讲解,是我实际在项目里跑通过的做法。看完之后哪怕你之前完全没接触过 iOS 原生开发,也能按着这套思路去落代码。
1. 先搞清楚 iOS 端的两套唤醒体系
1.1 URL Scheme:老牌的协议唤醒方案
URL Scheme 说白了就是给 App 注册一个自定义协议,比如mygame://。当 iOS 系统发现某个链接的 scheme 是mygame://时,就会找到注册了这个 scheme 的 App 并把它唤起。
实现方式很直接。在 Unity 导出的 Xcode 工程里,找到 Info.plist,加一段 CFBundleURLTypes:
<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLName</key> <string>com.yourcompany.yourgame</string> <key>CFBundleURLSchemes</key> <array> <string>mygame</string> </array> </dict> </array>然后在 AppDelegate 里处理回调:
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options { if ([url.scheme isEqualToString:@"mygame"]) { NSString *deepLink = [url absoluteString]; // 投递给 Unity const char *params = [deepLink UTF8String]; UnitySendMessage("DeepLinkHandler", "OnDeepLinkReceived", params); } return YES; }就这么简单。但简单是有代价的。从 iOS 9 开始,苹果就在推 Universal Links 来替代 URL Scheme,因为 URL Scheme 有俩硬伤。
第一个硬伤是:你没法确认链接背后对应的域名。任何人只要知道你注册的 scheme,就能构造一个mygame://foo把你 App 唤起来,这就给恶意调用留下了空间。就算你后面做了一堆参数校验,用户被不明链接唤醒的体验也谈不上好。
第二个硬伤更恶心:从 iOS 10.2 开始(具体版本记不太清了,大概这个阶段),如果用户设备上装了多个注册了相同 scheme 的 App,系统会弹一个选择框问“用哪个 App 打开”,这个问题在 iOS 13/14 上已经变成每次跳转都可能弹窗了,对用户体验伤害非常大。你要是做过国内 App 间互导量就知道了,弹窗一出来,转化率肉眼可见地掉。
1.2 Universal Links:苹果钦定的正规军
Universal Links 是苹果从 iOS 9 开始推的标准方案。核心思想是:明明你有一个 HTTPS 域名,那就用域名来关联 App。用户点击https://yourdomain.com/game/event/123,系统检查这个域名是否关联到某个 App,如果是,直接唤起,否则就在 Safari 里正常打开网页。
想在 App 里启用 Universal Links,需要做三件事:
第一,在开发者后台配置 Associated Domains。你需要在 Apple Developer 的 App 能力配置里把这个能力打开,然后下载一个新的 provisioning profile,里面会带上这个 entitlement。注意这里有个坑就是要重新下载描述文件,不是说你代码里写一下就行的。
第二,在 Xcode 工程的 Signing & Capabilities 里把 Associated Domains 加上,domain 填applinks:yourdomain.com。
第三,把你那个 HTTPS 域名的根目录放一个 JSON 文件,路径固定是https://yourdomain.com/.well-known/apple-app-site-association(新版本也兼容不带.well-known的路径),内容长这样:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.yourcompany.yourgame", "paths": [ "*" ] } ] } }这里的appID是你的 Team ID 加 Bundle ID,paths可以精确到某个路径,也可以用通配符。注意 iOS 会因为 CDN 或者 AASA 文件更新延迟导致配置不生效,调试的时候这个问题特别头疼。
1.3 两条路怎么选
我的习惯是:全都接。因为需求往往不是“二选一”,而是“都要”。
做渠道归因的时候,大多数第三方归因 SDK 本身用的是 URL Scheme 那套,因为它的跳转链更短——直接一个协议就能拉起。而微信朋友圈、Safari 里点击链接拉起 App,苹果更推荐 Universal Links。到了某些聚合广告平台那里,还坚持用 Scheme 来唤醒。
所以实际工程里,两条路都要走。URL Scheme 负责响应各种自定义协议,Universal Links 负责响应标准网页链接。关键在于,两条路线进到原生层之后,统一整理成一个规范格式,再投给 Unity。
这就是我们常说的“统一入口设计”。不要搞两套处理方法,否则后面 Unity 侧的逻辑会炸裂。
2. Unity 工程的桥接层设计与参数协议
2.1 原生层要做什么
一旦决定“两条路都接”,原生层的职责就很明确了:
- 监听 URL Scheme 和 Universal Links 的回调。
- 把回调的原始来源(Scheme 还是 Universal Link)和完整 URL 包装成一个统一结构体。
- 判断当前 Unity 是否已经就绪。
- 把包装好的内容投递给 Unity 侧。
理清职责之后,你会发现这活儿的核心其实不是“调起”,而是“投递的时机管理”。因为 Unity 的运行时代和原生层是独立的,你不能保证 App 被唤起的时候 Unity 引擎已经跑起来了。尤其是冷启动场景,App 是被 Deep Link 直接拉起来的,此时 Unity 还在初始化,你要是直接调用 UnitySendMessage,消息会发不出去。
2.2 从原生注入 Unity 的三条常规路径
Unity 暴露给原生层做通信的接口非常有限,常规有三条:
第一条是UnitySendMessage。这是 Unity 官方提供的最简单方式,接收方场景里必须存在指定的 GameObject 和挂在它上面的脚本方法。缺点是:如果那个 GameObject 在场景加载完成后才被创建,或者脚本被禁用,消息照样无效。高版本 Unity 开启 IL2CPP 代码裁剪后还会出现方法被裁掉的情况。
第二条是丢PlayerPrefs。原生层把 Deep Link 字符串写到PlayerPrefs里,Unity 侧在启动流程里主动去读。这法子笨,但非常可靠,因为PlayerPrefs本质是写 plist 文件,不存在“引擎没起来”的情况。缺点是要处理写和读之间的同步问题,而且只适合传递简单字符串。
第三条是走 SDK 初始化回调。如果你接了某种渠道 SDK,SDK 的初始化流程里有一个“等待唤起参数”的原生接口,原生层先把参数传给 SDK,等 Unity 初始化完成后从 SDK 那边主动拉取。这种是最稳的,但前提是你确实用了这种带队列机制的 SDK。
这三种方式,我在项目里不是只选一个,而是组合使用:UnitySendMessage负责“热启动时的实时投递”,PlayerPrefs负责“冷启动时的兜底存储”。这样两套互补,不会因为单一方案失效导致参数彻底丢失。
2.3 参数协议设计:一次把话说清
这个环节是最容易被忽略但最重要的。Deep Link 本身是 URL 格式,比如:
mygame://open?page=activity&id=12345&from=wechat https://yourdomain.com/open?page=activity&id=12345&from=wechat到了 Unity 侧,业务层想知道的是:打开的是哪个页面、页面 ID 是什么、从哪里跳过来的。你完全可以让业务层自己去 parse 这个 URL,但那样做的结果就是每个用 Deep Link 的模块都要写一遍解析代码,这就是麻烦的开始。
我推荐的做法是:在原生层就完成解析,把结果转成一个固定格式的 JSON 字符串投给 Unity。规则是:解析出源类型(urlscheme还是universal)、完整路径、查询参数。举个例子:
{ "source": "universal", "originalUrl": "https://yourdomain.com/open?page=activity&id=12345&from=wechat", "path": "/open", "page": "activity", "id": "12345", "from": "wechat" }原生层解析好,Unity 侧拿到 JSON 直接左键鼠标一把梭。这样做的好处有俩:第一,业务代码不用关心 URL 编码、特殊字符、参数截断这些细节;第二,如果以后要接 Android,Android 那边也套同样的 JSON 协议,Unity 层完全不用改。
URL 里的参数一定要做 URLDecode。我就遇到过%E5%95%86%E5%93%81这种中文没解码,直接传到 UI 层变成一坨乱码的尴尬情况。
3. 冷启动与热启动:状态机才是核心
3.1 为什么必须区分冷启动和热启动
冷启动指 App 已经完全退出,被 Deep Link 直接拉起;热启动指 App 已经在后台或者前台运行,被 Deep Link 从后台唤回前台。
两者的区别决定了投递方式。热启动时 Unity 引擎已经是活的,你可以立刻把参数传过去;冷启动时 Unity 引擎可能还在黑屏阶段,收到参数的脚本还没加载,你传了也白传。
另一个隐藏问题是时序。iOS 的回调发生在 App 进入主循环前还是后、Unity 的Awake和OnSceneLoaded什么时候触发,这些顺序完全是散的。你要是把“等收到 Deep Link 参数再初始化业务模块”的逻辑写反了,就会出现业务模块已经初始化完成但参数还没到、或者参数到了但 UI 还没创建这类问题。
3.2 缓冲区的实现与生命周期
既然存在“参数先到,业务后到”的情况,那就得在 Unity 侧塞一个缓冲区。我习惯在项目里加一个DeepLinkManager单例,启动时自建一个 pending 队列:
public class DeepLinkManager { private static Queue<string> _pendingLinks = new Queue<string>(); private static bool _initialized = false; public static void OnDeepLinkReceived(string jsonPayload) { if (!_initialized) { _pendingLinks.Enqueue(jsonPayload); } else { ProcessLink(jsonPayload); } } public static void LateInit() { _initialized = true; while (_pendingLinks.Count > 0) { ProcessLink(_pendingLinks.Dequeue()); } } }这里面的LateInit要在什么时机调用?我的做法是:在游戏主入口流程里,比如LoginScene加载完成并初始化完基础 UI 框架之后调用。因为这个阶段业务模块已经具备处理参数的能力了,把缓冲的参数交出去是安全的。
请特别注意:这个缓冲队列千万不要清空得太早。某些业务逻辑可能只是暂存了参数,并没有立刻使用,如果你在一次循环里 pad 完了还把它清掉,后面其他模块再读就没了。
3.3 一次唤醒的完整链路
我直接把一次完整链路写出来让大家感受下:
玩家在微信里点了一个链接https://yourdomain.com/open?page=event&id=88。iOS 系统拦截到这个链接,检查 Associated Domains 配置,确认命中你的 App,于是唤起你的 App。你的 App 冷启动,原生 AppDelegate 的didFinishLaunchingWithOptions里带了一个launchOptions[UIApplicationLaunchOptionsURLKey],这表示用户是从 Universal Link 冷启动进来的。
此时 Unity 还没起来,原生层把这个 URL 解析成 JSON,然后做两件事:先用PlayerPrefs保存一份,再尝试调UnitySendMessage投递。因为你工程里 Unity 接收方的 GameObject 还没创建,所以UnitySendMessage大概率失败,但没关系,JSON 已经备份到PlayerPrefs了,不丢数据。
Unity 场景加载完,游戏主入口跑起来,DeepLinkManager.LateInit被调用。它会先读PlayerPrefs里备份的 JSON,再接着检查缓冲队列,最终把参数交给业务模块。业务模块拿到参数之后,弹出“你要进入活动页 88 吗”的弹窗或者直接自动跳转。
而热启动的情况就简单得多:Unity 一直活着,原生层调用UnitySendMessage,直接进DeepLinkManager.OnDeepLinkReceived,_initialized为 true,参数当场被消费掉。
4. 常见问题与排障实录
4.1 UnitySendMessage 为什么偶尔投递失败
这是我最常被问到的问题,没有之一。UnitySendMessage有它的天然限制:接收方必须是场景中实际存在的 GameObject,方法必须是重载 MonoBehaviour 实例的方法,而且方法名要和方法保持一致。
高版本 Unity 在 IL2CPP 开 Managed Stripping 时,会把一些没被引用或者被判定为“未被 C# 调用”的方法给裁掉,而这恰恰是原生层调用的方法——它是原生调用,不是 C# 调用,因此编译器不知道这个方法是入口点。
解法是在被调用的方法上增加[UnityEngine.Scripting.Preserve]特性,或者在 Strip 设置里排除这个类。
using UnityEngine.Scripting; public class DeepLinkHandler : MonoBehaviour { [Preserve] public void OnDeepLinkReceived(string jsonPayload) { DeepLinkManager.OnDeepLinkReceived(jsonPayload); } }如果你在调试的时候发现UnitySendMessage一直调不通,另一个原因是 GameObject 的名称对不上。场景里那个挂脚本的对象可能不叫DeepLinkHandler,名字在 Awake 里被改名了,原生层那边还是用的旧名字。这坑太隐蔽了,我上次排查这个问题的经验是:先在 C# 侧挂一个 Debug 日志输出,再在原生层调通后会看到 Unity 日志打出来,由此反向确认名称匹配。
4.2 Universal Links 为什么在调试时死活不生效
Universal Links 配置不生效的原因非常多,我在实际项目里踩过的就有:开发者后台那个Associated Domains能力没开、provisioning profile 没重新下载、AASA 文件 JSON 格式不对、HTTPS 证书用了不受信任的私有 CA、域名被 CDN 缓存了旧 AASA。调试时排查起来相当恼人。
我自己总结了一套排查顺序。先看这个 AASA 文件在 Safari 里能不能直接访问到,确认路径和 JSON 都对;再用一个手势验证:从备忘录里输入这个链接,长按,如果弹出了“在“你的 App”中打开”,说明系统已经关联上了;之后在 Xcode 的 Device 面板里查看 console 日志,能看到系统关于 Universal Link 匹配的原始日志,这才是最靠谱的调试信息。
注意:在同一个设备上,iOS 只信任 app 安装时或者 App 启动时加载过的 AASA。如果你改了 AASA 文件,即使服务器上已经更新,设备上也得重装 App 或者重启才能重新拉取。调试时一定要有耐心。
4.3 参数里的中文字符变成乱码
URL 里头带中文或者特殊字符,极容易出现乱码。比如用户在活动页的标题是“签到有礼”,运营把链接拼成https://yourdomain.com/open?title=签到有礼,到 Unity 侧一解析,变成了%E7%AD%BE%E5%88%B0%E6%9C%89%E7%A4%BC,有的接口没做 decode 就展示到 UI 上,直接乱成一团。
正确的姿势是:在原生层做完整 URL 解析时,对每个查询参数都调用removingPercentEncoding或者stringByRemovingPercentEncoding。如果拼接方自行做了二次编码,你还需要先处理一下。如果参数里本身带&或者=,这种直接 “字符串拼接 URL” 的做法本来就是错的,应该用 URLComponents 去拼,让系统帮你做好编码。
4.4 参数没有在第一次启动时完整传给业务
这种“时有时无”的 Bug 最致命,因为它不是必现的,需要多次冷启才能复现。通常的根因有三种。
第一种是当时用按内存时序投递,但 Unity 启动时异步加载某个场景还没完成,参数处理模块跑在场景加载之前。这时候你用LateInit兜底就能解决,也就是我上面说到的队列缓冲。
第二种是重登录场景。比如游戏登录结束后,场景被重新加载,而DeepLinkManager还在旧的单例里,新场景没法访问到它。这种情况我会建议把DeepLinkManager放到一个持久化的对象身上,比如挂在启动场景里并设置DontDestroyOnLoad。
第三种是参数被“消费”了就清理,但下游业务模块是多点监听,A 模块消费完清空了,B 模块后面又去读,结果读了个空。这种情况要在协议层面区分——有些参数是一次性消费,有些参数是全局共享,全局共享的要存到单独的位置,不要跟一次性队列混在一起。
5. 实战调试工具与方法论
5.1 三类实用的调试发起点
开发调试时最烦的就是没法方便地模拟 Deep Link。我的做法是准备三个入口:
第一个是 Safari 模拟。直接用 Safari 打开你的 Universal Link 链接,系统会跳转。注意 iOS 13 以后如果用户没开启 Universal Link 对应的 App 开关,系统会直接在 Safari 里打开网页,这个开关在设置里面可以找到,检查一下。
第二个是备忘录模拟。把链接粘在备忘录里,长按,点击“在“你的 App”中打开”。这个很适合验证 AASA 是否被 App 当前安装版本信任。
第三个是 Xcode 的 URL Scheme 触发。在真机或者模拟器上,用xcrun simctl openurl booted "mygame://open?page=test"这个命令直接唤起模拟器的 Scheme 注册。真机上可以在 Safari 地址栏输入你的 scheme,或者用网页里的<a href="mygame://...">来触发。
实际开发中,我还会在原生层加一个手动触发的测试按钮,直接在 App 内伪造一条 Deep Link 消息投递给 Unity,这样连系统解析都跳过,专注验证 Unity 侧的处理逻辑。这个入口对单元测试特别有用。
5.2 如何打点日志来定位时序问题
Deep Link 的时序问题排查极其依赖日志。我在原生层和 Unity 层的出入点都打了带时间戳的日志,格式约定为[DL][原生][进入回调]、[DL][原生][发出投递]、[DL][Unity][收到消息]这样的统一前缀。在 Unity 侧用Application.consoleLogTags(不同版本 API 可能不同,一般直接 Debug.Log)打时间戳。
通过比对两边的日志,能瞬间看到“原生投递时 Unity 侧有没有活着”。要是发现 Unity 侧一次性收到好几条排队的参数,说明缓冲队列生效了,流程没问题也不用管。
另外一个调试技巧:在编辑器里也能测 Deep Link。Unity 编辑器运行期间,我通常会在 Inspector 上搞一个调试面板,手动粘贴 URL 触发DeepLinkManager.OnDeepLinkReceived。这样无需真机就能验证业务模块对参数的处理逻辑,但话要说在前头,这种验证阻止不了原生回调那些坑,只适合业务层联调。
5.3 上线前的配置核对清单
这部分是我个人长期形成的核对表,每次提审前都过一遍,避免上线后出问题找不到原因:
- 开发者后台 Associated Domains 能力是否已经开通
- provisioning profile 是否已经更新并且包含新的 entitlement
- Xcode 工程 Associated Domains 里 Domain 拼写是否正确
- 服务器上 AASA 文件路径是否可公开访问,JSON 是否合法
- AASA 里的 appID 是否正确(Team ID 加 Bundle ID)
- App 的 URL Scheme 是否在 Info.plist 里注册,冲突情况是否排查过
- 冷启动路径下 PlayerPrefs 兜底是否正常写入
- Unity 侧
[Preserve]是否加在接收方法上 - 特殊字符(中文、&、=、%)在模拟链路中是否显示正确
这张清单我打印过好几次,每次排查问题时都会先对着清单逐项排除。
AASA 文件的“路径匹配优先级”也是坑点之一。假设你的详情配置里既有精确路径又有通配符,iOS 会按照 AASA 文件里给出的顺序匹配,一旦前面的匹配规则挡了后面的,就会导致某些路径跳不了。反正我见过不少项目被这个问题折腾,后来干脆只保留一套精确规则。
6. 一点经验总结
API 调用谁都会,Google 一下五分钟能懂,但这些方案真正用起来,坑全在时序、状态管理和配置细节里。尤其是冷启动状态下的参数投递,如果不做缓冲区,别的实现了也是纸糊的。
我做这个功能的时候前前后后改了三版:第一版只接了 URL Scheme,发现运营在微信里发不了链接;第二版接了 Universal Links 但没做冷启动缓冲区,结果活动页参数经常丢;第三版完善了统一 JSON 协议和字节存储兜底之后才稳定下来。最后把 Android 那边的 Deep Link 也套了同一套协议,Unity 业务层几乎零改动地接上了。
如果你当前正被 Deep Link 唤醒问题卡住,我建议先别急着查代码,先把“冷启动、热启动”这张状态图在纸上画出来,理清“谁先执行、谁等谁”,再动手写代码。这个思路能帮你在接入之前就避免掉大部分时序雷区。
最后再分享一个小技巧:调试 Universal Links 时可以把https://yourdomain.com/.well-known/apple-app-site-association的访问记录和服务端日志打开,这样你能确认是“设备没来拉”还是“服务器发错了”。这比你在 Xcode 里反复猜要高效得多。