拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity iOS深度链接方案:URL Scheme与Universal Links

Unity iOS深度链接方案:URL Scheme与Universal Links

有些坑,只在真机上等你

先说个真实场景:你辛辛苦苦做了一款 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 原生层要做什么

一旦决定“两条路都接”,原生层的职责就很明确了:

  1. 监听 URL Scheme 和 Universal Links 的回调。
  2. 把回调的原始来源(Scheme 还是 Universal Link)和完整 URL 包装成一个统一结构体。
  3. 判断当前 Unity 是否已经就绪。
  4. 把包装好的内容投递给 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 里反复猜要高效得多。

返回列表