ARTICLE DETAIL

资讯详情

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

Unity手游iOS深度链接实战:URL Scheme与Universal Links全链路解析

Unity手游iOS深度链接实战:URL Scheme与Universal Links全链路解析 1. 为什么手游团队绕不开 Deep Link 这件事做过手游运营的同行大概都有过这种体验投放素材里挂了一个链接用户点进来结果要么跳浏览器、要么跳 App Store 首页就是进不了游戏里那个活动页面。用户流失在最后一步投放的钱等于打了水漂。这个问题的核心就是Deep Link深度链接没有打通。在 Unity 手游的 iOS 侧Deep Link 的落地其实分两条完全不同的技术路线一条是传统的URL Scheme另一条是苹果主推的Universal Links。两者在系统层面的行为差异很大最终都要把参数从原生层投递到 C# 层才能被游戏逻辑消费。这篇文章我就把整条链路拆开讲清楚——从 Xcode 工程配置、Info.plist 与 entitlements 的写法到 UnityAppController 的桥接、C# 侧的参数解析再到真机联调时那些文档里不会写的坑。适合谁看如果你正在做 Unity 手游的 iOS 版本需要接活动跳转、分享回流、广告归因、渠道分包这类需求或者你已经被点了链接没反应冷启动丢参数热启动参数不刷新这几个问题折磨过那这篇基本能覆盖你 90% 的场景。我会尽量把每一步的为什么这么做讲透而不是只丢一段配置让你抄。先给一个整体认知Deep Link 的本质是让系统知道某个链接该交给哪个 App 处理并把链接里的参数原封不动传进去。URL Scheme 靠的是 App 自己注册一个私有协议头系统做字符串匹配Universal Links 靠的是域名归属验证走的是标准 HTTPS 链接。前者配置简单但容易被拦截和伪造后者体验好、可信度高但需要服务端配合放一个apple-app-site-association文件。理解了这一点后面所有的配置细节就都顺了。2. 两条技术路线的选型与底层差异2.1 URL Scheme 的工作机制与适用边界URL Scheme 的思路非常直白你在 App 的Info.plist里声明我认识mygame://这个协议头系统在收到这个开头的链接时就会把 App 拉起来并把完整 URL 通过回调交给 App。整个过程不涉及网络请求纯本地字符串匹配所以响应极快。它的优势是配置成本极低不需要服务端、不需要域名、不需要证书改个 plist 就能跑。对于内部测试、渠道包区分、App 之间的互相唤起Scheme 依然是最省事的选择。但它有几个绕不过去的硬伤第一如果用户没装 App点击链接会直接报错或者毫无反应体验断裂第二任何 App 都能注册同名 Scheme存在被劫持的风险第三从 Safari 地址栏直接输入 Scheme 链接很多情况下会被浏览器拦截必须由页面里的 JS 触发才行。所以我的经验是Scheme 适合做确定已安装场景下的跳转比如 App 内 H5 活动页跳回原生、合作方 App 之间的互跳。而面向公网投放、需要处理未安装兜底的场景必须上 Universal Links。2.2 Universal Links 的验证链路与优势Universal Links 用的是标准https://链接系统在安装 App 时会去你配置的域名下拉取一个apple-app-site-association简称 AASA文件校验这个域名确实属于这个 App。校验通过后用户点击该域名下的链接系统会直接唤起 App 并把 URL 传进来如果没装 App就正常在 Safari 打开网页天然完成了未安装兜底。这条链路的关键在于AASA 文件的正确性。它必须满足几个条件放在域名根目录或者.well-known目录下、必须是application/json类型、不能有重定向、必须走 HTTPS 且证书有效。任何一条不满足系统就静默失败——注意是静默你不会收到任何报错只是链接乖乖地在浏览器打开了。这也是新手最容易卡住的地方。另外要强调一个版本差异iOS 9 到 iOS 12 时代AASA 文件里用paths数组来声明哪些路径归 App 处理从 iOS 13 开始苹果推荐改用components字段支持更精细的匹配规则。虽然paths目前仍然兼容但新项目建议直接上components避免以后踩坑。2.3 两条路线的对比与组合策略维度URL SchemeUniversal Links配置位置Info.plistentitlements 服务端 AASA未安装兜底无需自行处理自动回落网页安全性低可被抢注高域名验证触发方式页面 JS / 其他 App系统级任意位置点击参数传递完整 URL完整 URL调试难度低中高依赖缓存实际项目里我一般两条都配。Universal Links 作为主链路承接投放和分享Scheme 作为兜底和内部跳转。判断逻辑放在原生层先尝试 Universal Links 回调如果走的是 Scheme 就说明是内部触发参数格式统一处理即可。这样既拿到了 Universal Links 的体验又保留了 Scheme 的灵活性。3. iOS 原生侧的配置实操3.1 Info.plist 中注册 URL Scheme打开 Unity 导出的 Xcode 工程找到Info.plist添加CFBundleURLTypes数组。结构是这样的数组里每个字典代表一组 SchemeCFBundleURLName是这组 Scheme 的唯一标识一般用反写域名CFBundleURLSchemes是具体的协议头数组。keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.mygame/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array这里有个实操细节Scheme 命名尽量加前缀避免冲突。像game://、app://这种通用词极大概率已经被别的 App 注册了系统行为不可预期。用mygame、yourbrand-game这类带品牌标识的更稳妥。另外 Scheme 是大小写不敏感的MyGame和mygame会被系统视为同一个别指望靠大小写区分。注意Unity 每次重新导出 Xcode 工程都会覆盖 Info.plist所以这些配置一定要写进 Unity 的 PostProcessBuild 脚本里自动注入否则每次打包都要手动改一遍迟早出错。3.2 配置 Universal Links 的 entitlementsUniversal Links 需要在 Xcode 的 Signing Capabilities 里添加 Associated Domains格式是applinks:yourdomain.com。对应到工程文件里就是.entitlements文件中的com.apple.developer.associated-domains数组。keycom.apple.developer.associated-domains/key array stringapplinks:link.yourgame.com/string /array几个容易翻车的点第一域名不要带https://前缀也不要带路径就写纯域名第二如果你要支持子域名得单独加一条applinks:yourgame.com不会自动覆盖link.yourgame.com第三这个 entitlements 必须和你的 Provisioning Profile 匹配如果 Profile 里没开 Associated Domains 权限签名会失败。3.3 服务端 AASA 文件的正确写法AASA 文件是 Universal Links 能否生效的命门。它必须放在https://link.yourgame.com/.well-known/apple-app-site-association注意没有.json后缀Content-Type 必须是application/json。{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.mygame, components: [ { /: /activity/*, comment: 活动页跳转 }, { /: /share/*, comment: 分享回流 } ] } ] } }appID的格式是TeamID.BundleIDTeamID 在开发者后台的 Membership 页面能看到BundleID 必须和 App 完全一致一个字符都不能差。components里的/字段是路径匹配规则支持*通配和?单字符。我一般会把不同业务路径分开写方便后续按路径做归因统计。提示AASA 文件修改后系统不会立即刷新。苹果的 CDN 会缓存这个文件真机上可能要等几小时甚至一天才生效。调试阶段可以在设备上删除 App 重装来强制重新拉取这是最快的验证方式。4. Unity 原生桥接层的实现4.1 UnityAppController 的回调入口Unity 导出的 iOS 工程里UnityAppController.mm是 App 生命周期的核心。Deep Link 的两个关键回调都在这里application:openURL:options:处理 URL Schemeapplication:continueUserActivity:restorationHandler:处理 Universal Links。默认情况下 Unity 已经实现了这些方法但只是简单转发给内部逻辑不会把 URL 暴露给 C#。我们需要做的是在回调里把 URL 截获存到一个全局变量或者直接调用 C# 的导出函数。- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id *)options { [self handleDeepLink:url.absoluteString]; return [super application:app openURL:url options:options]; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [self handleDeepLink:userActivity.webpageURL.absoluteString]; } return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; }这里有个关键判断冷启动和热启动的回调时机完全不同。冷启动时openURL可能在 Unity 引擎初始化完成之前就被调用这时候直接调 C# 函数会因为引擎还没起来而失败。所以稳妥的做法是先把 URL 存到一个静态变量里等 Unity 初始化完成后再投递。4.2 冷启动与热启动的参数缓存策略我的做法是在UnityAppController里维护一个pendingDeepLink字符串。回调触发时先判断引擎是否就绪没就绪就存起来就绪了就直接投递。static NSString *pendingDeepLink nil; static BOOL unityReady false; - (void)handleDeepLink:(NSString *)urlStr { if (unityReady) { UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [urlStr UTF8String]); } else { pendingDeepLink urlStr; } } // 在 Unity 初始化完成的回调里 - (void)unityDidFinishLaunching { unityReady true; if (pendingDeepLink) { UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [pendingDeepLink UTF8String]); pendingDeepLink nil; } }UnitySendMessage是 Unity 提供的原生到 C# 的通信接口第一个参数是场景里挂载的 GameObject 名字第二个是方法名第三个是字符串参数。注意它只能传字符串复杂结构得自己序列化成 JSON。还有一点UnitySendMessage是异步的调用后不会立即执行 C# 方法而是在下一帧的主线程里处理所以别指望它同步返回结果。4.3 用 PostProcessBuild 自动注入配置前面说过手动改 Xcode 工程迟早会忘。正确姿势是写一个IPostprocessBuildWithReport的实现在 Unity 打包完成后自动修改 Info.plist 和 entitlements。using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; using System.IO; public class iOSDeepLinkPostProcess { [PostProcessBuild(100)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target ! BuildTarget.iOS) return; string plistPath Path.Combine(path, Info.plist); PlistDocument plist new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementArray urlTypes plist.root.CreateArray(CFBundleURLTypes); PlistElementDict dict urlTypes.AddDict(); dict.SetString(CFBundleURLName, com.yourcompany.mygame); dict.CreateArray(CFBundleURLSchemes).AddString(mygame); plist.WriteToFile(plistPath); // entitlements 处理 string projPath PBXProject.GetPBXProjectPath(path); PBXProject proj new PBXProject(); proj.ReadFromFile(projPath); string targetGuid proj.GetUnityMainTargetGuid(); proj.AddCapability(targetGuid, PBXCapabilityType.AssociatedDomains, applinks:link.yourgame.com); proj.WriteToFile(projPath); } }PostProcessBuild的参数 100 是执行优先级数字越大越晚执行。放在 100 是为了确保在其他处理脚本之后运行避免被覆盖。这套脚本跑通之后每次打包出来的工程就自带 Deep Link 配置省心很多。5. C# 层的参数接收与业务分发5.1 DeepLinkManager 的挂载与解析C# 侧需要一个常驻的 GameObject 来接收原生消息我一般叫它DeepLinkManager在游戏启动的第一个场景里挂载并且DontDestroyOnLoad保证跨场景不销毁。using UnityEngine; using System; public class DeepLinkManager : MonoBehaviour { public static event ActionDeepLinkData OnDeepLink; void Awake() { DontDestroyOnLoad(gameObject); } public void OnDeepLinkReceived(string url) { Debug.Log($[DeepLink] 收到链接: {url}); var data DeepLinkParser.Parse(url); if (data ! null) { OnDeepLink?.Invoke(data); } } }注意方法名OnDeepLinkReceived必须和原生UnitySendMessage里的字符串完全一致包括大小写。这个错误非常隐蔽原生调了但 C# 没反应八成就是名字对不上。5.2 URL 参数的健壮解析URL 解析看着简单实际坑不少。mygame://activity?id123fromad这种格式Uri类能处理但 Scheme 后面的部分会被当成 Host容易解析错。我一般用字符串手动切分更可控。public static DeepLinkData Parse(string url) { if (string.IsNullOrEmpty(url)) return null; var data new DeepLinkData(); int queryIndex url.IndexOf(?); if (queryIndex 0) return data; string query url.Substring(queryIndex 1); foreach (var pair in query.Split()) { var kv pair.Split(); if (kv.Length ! 2) continue; string key Uri.UnescapeDataString(kv[0]); string value Uri.UnescapeDataString(kv[1]); data.Parameters[key] value; } return data; }一定要做UnescapeDataString因为参数值里如果有中文、空格、特殊符号原生传过来是 URL 编码的。不做解码你拿到的就是一堆%E6%B4%BB%E5%8A%A8这样的乱码。另外Split()只取前两段如果 value 里本身含会被截断更严谨的写法是用IndexOf()定位第一个等号再切分。5.3 参数投递到业务层的时机控制参数解析出来之后什么时候消费是个关键问题。如果 Deep Link 是冷启动进来的OnDeepLinkReceived可能在游戏还没登录、UI 还没初始化的时候就被调用这时候直接跳活动页会崩。我的处理方式是事件 队列DeepLinkManager收到参数后先入队业务层在合适的时机比如登录完成、主界面加载完主动来取。private static readonly QueueDeepLinkData pendingLinks new QueueDeepLinkData(); public static DeepLinkData ConsumePending() { return pendingLinks.Count 0 ? pendingLinks.Dequeue() : null; }这样冷启动和热启动就统一了热启动时业务层已经就绪收到事件立即处理冷启动时先入队等主界面加载完再消费。避免了时序问题导致的崩溃和丢参数。6. 真机联调与常见问题排查6.1 分场景的测试方法Deep Link 的测试必须分场景做因为冷热启动走的是完全不同的代码路径。我一般列这么一张测试清单场景操作方式预期结果Scheme 冷启动App 未运行Safari 输入mygame://activity?id1拉起 App 并跳活动页Scheme 热启动App 后台运行触发 Scheme切回 App 并刷新参数UL 冷启动未装 App 点链接打开网页装后点链接拉起 AppUL 热启动App 后台点链接切回 App 并传参未安装兜底卸载后点 UL 链接正常打开网页Scheme 的测试可以在 Safari 地址栏直接输入但注意从地址栏输入有时会被拦截更可靠的方式是写一个简单的 HTML 页面用window.location.href mygame://...触发。6.2 高频问题速查表现象可能原因排查方向点链接完全没反应Scheme 未注册 / AASA 未生效检查 Info.plist、重装 App链接在浏览器打开AASA 校验失败检查文件路径、Content-Type、证书冷启动丢参数引擎未就绪就投递加 pending 缓存机制热启动参数不刷新事件未重新触发检查回调是否被覆盖参数乱码未做 URL 解码加 UnescapeDataString部分机型失效系统版本差异检查 components 兼容性6.3 几个文档里不会写的坑第一个坑AASA 文件的缓存问题。苹果的 CDN 缓存时间不固定有时候改完文件几小时都不生效。最快的验证方式是删 App 重装重装时系统会重新拉取。如果重装还不行那就是文件本身有问题别怀疑缓存。第二个坑Universal Links 和 Scheme 同时配置时的优先级。当用户点击一个 Universal Link如果 App 已安装系统会直接唤起 App不会走 Scheme。但如果这个链接同时也能被 Scheme 匹配比如页面里手动触发了 Scheme行为就取决于触发方式。我的建议是两条链路的参数格式保持完全一致这样无论走哪条C# 层的解析逻辑都不用改。第三个坑Unity 的UnitySendMessage在 App 从后台恢复时的时序。热启动时openURL回调触发时 Unity 可能正处于暂停状态UnitySendMessage会排队到恢复后才执行。如果你的业务逻辑依赖立即响应需要在原生层做额外处理比如先存起来等applicationDidBecomeActive之后再投递。第四个坑测试环境的域名和正式环境混用。开发阶段用测试域名配 AASA上线前忘了换成正式域名结果线上 Universal Links 全部失效。这个错误我见过不止一次建议把域名做成配置项打包时根据环境自动切换。7. 我踩过的几个真实教训说几个我自己项目里真实翻车的例子。有一次投放链接全部走 Universal Links测试环境一切正常上线后用户反馈点了没反应。排查了半天发现是 AASA 文件里的appID用了测试证书的 TeamID正式包签名对不上系统校验直接失败。这个问题的隐蔽性在于它不会报任何错链接就是安静地在浏览器打开你从日志里什么都看不到。还有一次是冷启动丢参数。用户从广告点进来App 冷启动结果活动页没弹出来。查下来是openURL在 Unity 引擎初始化之前就触发了UnitySendMessage调用时 GameObject 还不存在消息直接丢了。后来加了 pending 缓存才解决。这个坑的教训是永远不要假设原生回调的时机和引擎生命周期是对齐的。最后一个关于参数编码。有个活动的参数值里带了用户昵称中文的测试时用英文 ID 没发现问题上线后中文昵称全部乱码。原因是原生传过来的是 URL 编码C# 侧没解码。加上Uri.UnescapeDataString之后正常。所以我现在写解析逻辑第一件事就是先解码再处理不管看起来有没有必要。这套流程跑通之后Deep Link 这块基本就稳了。核心就三件事原生配置别手改、参数缓存别偷懒、解析逻辑要健壮。剩下的就是按业务需求扩展参数和路由规则了。
返回列表