Unity手游iOS Deep Link实战:Universal Links与URL Scheme桥接
1. 为什么手游团队绕不开 Deep Link 这件事做过 Unity 手游投放的同学大概都有过这种体验买量素材里明明写了“点击直接进游戏领奖励”结果用户点完先跳 App Store下载完打开游戏人已经忘了自己从哪来的奖励没领到客服工单倒是先来了。这个转化漏斗里丢掉的用户很多时候不是产品问题而是Deep Link 没打通。Deep Link 说白了就是一条能“穿透”浏览器、短信、社交 App直接把你自己的 App 拉起来并带上参数的链接。在 iOS 上它有两个主力方案URL Scheme和Universal Links。前者是老牌方案兼容性好但容易被拦截、有弹窗确认后者是苹果主推的方案体验顺滑但配置门槛高还牵扯到域名和签名。而 Unity 项目又比原生项目多一层麻烦——链接唤起来了参数怎么从 iOS 原生层一路传到 C# 的 MonoBehaviour 里中间隔着 Objective-C/Swift 和 C# 的桥接。这篇内容就是把这整条链路拆开讲清楚从 iOS 侧的配置、Unity 侧的桥接代码到参数投递的时机控制再到真机联调时那些文档里不会写的坑。适合正在做 iOS 手游买量归因、活动唤端、分享回流功能的 Unity 开发者也适合刚接手 SDK 接入、对原生桥接不太熟的同学。下面所有代码和配置都是我在实际项目里跑通过的思路你可以直接照着改。2. 两种唤端方案到底怎么选2.1 URL Scheme 的适用边界URL Scheme 的本质是给 App 注册一个自定义协议头比如mygame://。系统收到这个协议的链接时会去查哪个 App 注册了它然后拉起来。配置方式是在 Xcode 的 Info.plist 里加CFBundleURLTypes或者在 Unity 导出的 Xcode 工程里用 PostProcessBuild 脚本自动注入。它的优点是实现简单、兼容性极好从很老的 iOS 版本就支持而且不依赖域名和服务器。缺点也很明显iOS 会弹一个“是否打开某某 App”的确认框用户点取消就断了而且任何 App 都能注册同名 scheme存在被劫持的风险。所以它更适合站内唤端、自家 App 之间跳转、或者对体验要求不极致的场景。2.2 Universal Links 为什么是首选Universal Links 走的是标准 HTTPS 链接比如https://game.example.com/open?uid123。它的原理是你在 App 里声明自己“认领”了这个域名苹果服务器会去校验域名根目录下的apple-app-site-association简称 AASA文件校验通过后用户点这个链接如果装了 App 就直接进 App没装就进网页。它没有确认弹窗体验最顺而且链接本身是标准 URL在社交平台、短信里都不会被当成奇怪的东西拦截。代价是配置链路长需要HTTPS 域名、AASA 文件、Team ID、Bundle ID 三者对齐任何一环错了都会静默失败——注意是静默苹果不会告诉你哪里错了链接就是老老实实打开网页。2.3 我的选型建议实际项目里我一般两个都配用 Universal Links 做主路径URL Scheme 做兜底。因为有些场景比如从某些 App 内部 WebView 跳转Universal Links 会被拦这时候用 scheme 兜一下。判断逻辑放在网页侧先尝试跳 Universal Link超时没反应再跳 scheme。对比项URL SchemeUniversal Links配置复杂度低改 Info.plist高需域名AASA签名用户体验有确认弹窗无弹窗直达安全性低可被劫持高苹果校验失败表现弹窗被取消静默打开网页适用场景站内唤端、兜底买量归因、分享回流提示如果你的项目只做国内安卓iOS 双端安卓侧通常用 scheme 或 App LinksiOS 侧建议优先把 Universal Links 跑通因为买量平台回传归因基本都依赖它。3. iOS 原生侧配置的完整落地3.1 URL Scheme 的注入方式在 Unity 里最省事的做法是写一个PostProcessBuild脚本在 Xcode 工程生成后自动往 Info.plist 里塞 scheme。这样每次出包都不用手动改避免漏配。#if UNITY_IOS using UnityEditor; using UnityEditor.Callbacks; using System.IO; using UnityEditor.iOS.Xcode; public class iOSPostProcess { [PostProcessBuild(999)] public static void OnPostProcessBuild(BuildTarget target, string path) { string plistPath Path.Combine(path, Info.plist); PlistDocument plist new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementArray urlTypes; if (plist.root.values.ContainsKey(CFBundleURLTypes)) urlTypes plist.root[CFBundleURLTypes].AsArray(); else urlTypes plist.root.CreateArray(CFBundleURLTypes); PlistElementDict dict urlTypes.AddDict(); dict.SetString(CFBundleURLName, com.example.mygame); PlistElementArray schemes dict.CreateArray(CFBundleURLSchemes); schemes.AddString(mygame); plist.WriteToFile(plistPath); } } #endif这段代码的关键点是PostProcessBuild(999)里的优先级数字设大一点保证在其他插件改完 plist 之后再执行否则可能被覆盖。CFBundleURLName建议用反域名格式虽然它不参与匹配但多个 scheme 时便于区分。3.2 Universal Links 的三方对齐Universal Links 最容易翻车的地方就是“三方对齐”AASA 文件里的 Team ID、Bundle ID必须和 App 签名时用的完全一致。Team ID 是苹果开发者账号里的 10 位字符串Bundle ID 就是你的包名。AASA 文件长这样放在https://game.example.com/.well-known/apple-app-site-association注意没有后缀名Content-Type 要是application/json{ applinks: { apps: [], details: [ { appID: ABCDE12345.com.example.mygame, paths: [/open/*, /share/*] } ] } }appID就是TeamID.BundleID拼起来的。paths里写你允许唤端的路径*是通配。这里有个坑AASA 文件更新后苹果有缓存改完不是立刻生效测试时最好用带随机参数的链接或者等一段时间。Xcode 侧还要开 Associated Domains 能力在 entitlements 里加applinks:game.example.com这一步同样可以用 PostProcessBuild 自动加避免手动开。Unity 的ProjectCapabilityManager可以帮你写 entitlementsvar capManager new ProjectCapabilityManager( projPath, Unity-iPhone.entitlements, Unity-iPhone); capManager.AddAssociatedDomains(new string[] { applinks:game.example.com }); capManager.WriteToFile();3.3 一个常被忽略的细节域名不能带路径很多人第一次配 Universal Links 会踩这个坑Associated Domains 里写的是applinks:game.example.com/open这是错的。这里只能写域名不能带路径路径的匹配规则全部由 AASA 文件里的paths决定。写错了不会报错就是永远唤不起来。4. Unity 与原生层的桥接实现4.1 iOS 侧接收唤端回调App 被 URL Scheme 或 Universal Links 唤起时iOS 会回调AppDelegate里的方法。URL Scheme 走的是application:openURL:options:Universal Links 走的是application:continueUserActivity:restorationHandler:。我们要做的就是把这两个入口拿到的 URL 存下来再传给 Unity。在 Unity 导出的 Xcode 工程里UnityAppController.mm是主控制器。我一般不改它而是写一个分类或者直接在 PostProcess 里注入代码。更干净的做法是写一个原生插件暴露一个方法给 C# 调用。// DeepLinkManager.mm #import Foundation/Foundation.h static NSString *pendingUrl nil; extern C { void _SetDeepLinkUrl(const char* url) { pendingUrl [NSString stringWithUTF8String:url]; } const char* _GetDeepLinkUrl() { return pendingUrl ? strdup([pendingUrl UTF8String]) : strdup(); } void _ClearDeepLinkUrl() { pendingUrl nil; } }然后在UnityAppController的回调里调用_SetDeepLinkUrl。这里有个关键点App 冷启动时唤端回调可能比 Unity 引擎初始化还早所以不能回调里直接调 C#必须先把 URL 缓存到原生静态变量等 Unity 侧准备好了再来取。4.2 C# 层的参数拉取时机C# 侧用DllImport调原生方法但拉取时机非常讲究。太早原生还没收到回调太晚业务逻辑已经跑完了。我的做法是在一个常驻的 MonoBehaviour 里在Start之后延迟几帧轮询或者干脆让原生在收到 URL 后主动通知。using System.Runtime.InteropServices; using UnityEngine; public class DeepLinkReceiver : MonoBehaviour { [DllImport(__Internal)] private static extern string _GetDeepLinkUrl(); [DllImport(__Internal)] private static extern void _ClearDeepLinkUrl(); private bool consumed false; void Update() { if (consumed) return; string url _GetDeepLinkUrl(); if (!string.IsNullOrEmpty(url)) { consumed true; _ClearDeepLinkUrl(); HandleDeepLink(url); } } void HandleDeepLink(string url) { // 解析参数并分发 var uri new System.Uri(url); var query System.Web.HttpUtility.ParseQueryString(uri.Query); string uid query[uid]; string activity query[activity]; Debug.Log($DeepLink 收到: uid{uid}, activity{activity}); // 这里再分发给具体业务模块 } }注意DllImport的库名在 iOS 上要写__Internal因为原生代码是静态链接进主二进制的。另外_GetDeepLinkUrl返回的是strdup出来的 C 字符串Unity 的 marshaling 会自动转成 C# string但原生侧那块内存严格来说需要释放实际项目里因为只取一次影响不大追求严谨可以加个释放接口。4.3 热启动与冷启动的差异处理冷启动App 没在后台和热启动App 在后台被唤起走的是不同回调但对我们来说处理逻辑可以统一都是把 URL 存起来C# 侧轮询取。区别在于冷启动时 Unity 还没起来热启动时 Unity 已经在跑。热启动的情况下如果 C# 侧已经消费过一次 URLconsumed标志要重置否则第二次唤端会被忽略。我的处理是原生侧每次收到新 URL 都覆盖pendingUrlC# 侧不要用consumed永久锁死而是记录上一次处理的 URL发现 URL 变了就重新处理。这样热启动多次唤端也不会丢。5. 参数投递与业务分发的实战设计5.1 参数该带什么、不该带什么Deep Link 的 query 参数设计有几个原则。第一不要带敏感信息因为 URL 会经过浏览器、剪贴板、日志明文传 token 是找死。第二参数要短有些平台对 URL 长度有限制。第三要有幂等标识比如一个trace_id防止同一次唤端被处理两次。我一般会带这几类uid用户标识用于归因、activity活动标识决定跳哪个界面、channel渠道标识、ts时间戳用于判断链接是否过期。像订单号、支付信息这种只传一个 ID具体数据让客户端拿 ID 去服务器换。5.2 参数解析的健壮性System.Web.HttpUtility在 Unity 的 IL2CPP 下有时候会有兼容问题我一般自己写一个轻量解析避免依赖public static Dictionarystring, string ParseQuery(string url) { var result new Dictionarystring, string(); int idx url.IndexOf(?); if (idx 0) return result; string query url.Substring(idx 1); foreach (var pair in query.Split()) { if (string.IsNullOrEmpty(pair)) continue; var kv pair.Split(); if (kv.Length ! 2) continue; string key System.Uri.UnescapeDataString(kv[0]); string val System.Uri.UnescapeDataString(kv[1]); result[key] val; } return result; }这里一定要做UnescapeDataString因为中文、特殊字符在 URL 里是编码过的不还原会拿到一堆%E4%B8%AD之类的东西。另外Split()只取两段如果 value 里本身有会被截断更严谨的做法是用IndexOf()切分。5.3 分发到业务模块的时机参数拿到之后不能立刻跳界面因为这时候 UI 可能还没初始化完。我的做法是先把参数存到一个全局的PendingDeepLinkData里等主界面加载完成、登录态确认之后再消费。如果用户还没登录就先存着登录成功后再跳否则会出现“跳到一个需要登录的界面然后被踢回登录页”的尴尬。注意如果唤端参数里带了活动 ID而活动已经下线客户端要能优雅降级到首页而不是白屏或者报错。这个兜底逻辑一定要写。6. 真机联调与常见问题排查6.1 联调前的自检清单真机测试前先把这几项过一遍能省掉大量瞎折腾的时间AASA 文件能否通过https://game.example.com/.well-known/apple-app-site-association直接访问返回 JSON 且无重定向Team ID 和 Bundle ID 是否和签名一致用security find-identity或 Xcode 里核对Associated Domains 是否在 entitlements 里且和 AASA 域名一致URL Scheme 是否在 Info.plist 里大小写是否一致scheme 匹配是大小写敏感的测试链接是否用了真实域名而不是 localhost6.2 常见问题速查表现象可能原因排查方向点链接只开网页不进 AppAASA 未生效或路径不匹配检查 AASA 可访问性、paths 配置弹窗确认后没反应scheme 未注册或拼写错误核对 Info.plist 的 CFBundleURLSchemes冷启动收不到参数回调早于 Unity 初始化确认原生侧有缓存 URL热启动第二次无效C# 侧 consumed 锁死改为对比 URL 是否变化参数中文乱码未做 URL 解码加 UnescapeDataString部分 App 内跳转失败该 App 拦截了 Universal Links用 scheme 兜底6.3 几个我踩过的坑第一个坑AASA 的缓存。苹果的 AASA 是通过 CDN 分发的更新后可能要几小时才生效。测试时如果改了 AASA 没反应别急着怀疑代码先等或者换个域名测试。有个技巧是用?加随机参数访问链接能绕过一部分缓存。第二个坑Universal Links 在 Safari 地址栏直接输入不生效。这是苹果的设计地址栏输入被认为是用户主动访问网页不会触发唤端。必须从其他 App 或者网页里的链接点击才会触发。所以测试时别在 Safari 地址栏敲链接用备忘录、短信或者一个测试网页来点。第三个坑Unity 的 IL2CPP 裁剪。如果你用了DllImport但原生方法没被引用到IL2CPP 可能会把它裁掉导致运行时找不到方法。解决办法是在link.xml里保留或者确保有代码路径引用到。第四个坑多场景下的 URL 冲突。如果项目里同时接了多个 SDK每个 SDK 都想处理openURL容易互相覆盖。建议做一个统一的路由层所有回调先汇总到一个地方再按 scheme 或域名分发给对应模块。7. 上线前的收尾与灰度验证功能跑通不代表能上线。上线前我一般会做几件事第一用真实买量链接做端到端测试从点击到进游戏到参数上报全链路走一遍确认归因数据能回传。第二做异常链接测试比如参数缺失、活动已下线、域名拼错看客户端是否优雅降级。第三灰度发布先放一小部分流量观察唤端成功率和崩溃率没问题再全量。还有一点Deep Link 的参数最好在客户端做一次校验再上报防止被恶意构造的链接刷数据。比如ts时间戳超过一定时长就丢弃uid格式不对就忽略。这些防御性代码平时看不出价值出事的时候能救命。我个人在实际项目里的体会是Deep Link 这东西“配通”和“配稳”是两回事。配通可能半天就搞定了但真正在各种机型、各种跳转来源下都稳定需要反复真机验证。尤其是 Universal Links它的失败是静默的没有日志没有报错只能靠一个个场景去试。所以我的建议是把测试用例列全每个入口都点一遍别嫌麻烦。最后再分享一个小技巧在原生侧收到 URL 时打一条NSLogC# 侧收到时打一条Debug.Log两端日志对一下时间戳就能快速定位是原生没收到还是 C# 没取到比盲猜高效得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →