ARTICLE DETAIL

资讯详情

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

Unity手游iOS Deep Link实战:URL Scheme与Universal Links参数传递全解析

Unity手游iOS Deep Link实战:URL Scheme与Universal Links参数传递全解析 1. 为什么手游团队绕不开 Deep Link 这件事做过手游投放或者运营活动的人大概率都遇到过这样一个尴尬场景用户在朋友圈看到一条活动链接点进去之后跳到了 App Store 的下载页装完 App 打开结果首页干干净净什么活动入口都没有用户一脸懵运营那边数据也对不上。这个问题的根源就是从浏览器到 App 之间的那一段接力棒没有传好而 Deep Link 就是负责传这根棒子的机制。在 Unity 手游的 iOS 端Deep Link 的落地其实分成两条完全不同的技术路线一条是传统的URL Scheme另一条是苹果后来主推的Universal Links。这两条路线在系统层面的行为差异非常大而真正让 Unity 开发者头疼的往往不是怎么唤起 App而是唤起之后那个 URL 里的参数怎么一路传到 C# 层并且在对的时机被业务代码消费掉。我见过太多项目唤起是能唤起的但参数要么丢了要么在冷启动时被吞了要么热启动时重复触发。这篇文章就把整条链路拆开讲从 iOS 工程侧的配置到 Unity 的 AppDelegate 桥接再到 C# 层的参数投递与消费时机控制。适合正在做手游投放、活动拉新、跨端跳转的 Unity 开发者也适合被参数丢失折磨过的客户端同学。看完之后你应该能自己搭出一套稳定、可复现、冷热启动都不掉链子的 Deep Link 方案。2. URL Scheme 与 Universal Links 的底层差异决定了你的方案选型2.1 URL Scheme 的本质一个全局注册的字符串协议URL Scheme 的原理非常朴素就是在Info.plist里注册一个自定义协议头比如mygame://。系统在收到这个协议头的 URL 时会去查所有注册过这个 scheme 的 App然后把它拉起来。它的优点是配置简单、兼容性极好从很老的 iOS 版本就支持缺点也很明显任何 App 都能注册同一个 scheme所以存在被劫持的风险而且如果用户没装 App点击链接会直接报错体验很差。在 Unity 项目里URL Scheme 的唤起入口是AppDelegate的application:openURL:options:方法。这个方法在冷启动和热启动时都会被调用但调用时机不同这正是参数容易丢的第一个坑点。2.2 Universal Links 的本质用 HTTPS 链接绑定 App 与域名Universal Links 是苹果在 iOS 9 之后推出的方案它用一条普通的https://链接通过服务器上的apple-app-site-association文件简称 AASA来声明这个域名下的哪些路径归哪个 App 处理。用户点击链接时如果装了 App 就直接进 App没装就正常打开网页体验非常顺滑而且因为绑定了域名安全性比 URL Scheme 高得多。但 Universal Links 的坑在于它的唤起入口和 URL Scheme 不是同一个方法。它走的是application:continueUserActivity:restorationHandler:而且冷启动时这个方法的调用时机比didFinishLaunchingWithOptions还要早或者交错如果你在didFinishLaunching里就把参数读走了很可能读到的是空的。2.3 两条路线的对比与选型建议维度URL SchemeUniversal Links配置位置Info.plist服务器 AASA 文件 工程 Associated Domains未安装 App 时报错或跳 Safari正常打开网页安全性低可被劫持高域名绑定唤起回调openURLcontinueUserActivity冷启动参数时机didFinishLaunching 之后可能早于 didFinishLaunching微信内打开常被拦截也常被拦截需中间页我的建议是两条都配但业务逻辑统一收敛到一个入口。因为微信、QQ 这类内置浏览器对两者的拦截策略不一样多一条路就多一次唤起成功的机会。但无论从哪条路进来最终都要把参数归一化成同一个结构交给同一套 C# 逻辑处理否则代码会变成一团乱麻。3. iOS 工程侧的配置那些文档不会告诉你的细节3.1 URL Scheme 配置里最容易忽略的大小写问题在Info.plist里加 URL Scheme 是在CFBundleURLTypes数组里配置结构大概是这样keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.mygame/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array这里有个细节scheme 在系统层面是不区分大小写的但很多服务端生成的链接会带大写。比如你注册的是mygame但运营给的链接是MyGame://xxx系统能识别但你在代码里做字符串匹配时如果用了区分大小写的比较就会匹配失败。我一般会在解析前统一ToLower()处理避免这种低级问题。另外CFBundleURLName建议用反域名格式虽然它不参与匹配但在多 scheme 场景下能帮你区分来源调试时很有用。3.2 Universal Links 的 AASA 文件到底该怎么写AASA 文件必须放在域名的根目录路径是https://yourdomain.com/.well-known/apple-app-site-association注意没有后缀名而且必须用Content-Type: application/json返回不能是text/plain。这一点很多人踩坑服务器默认给.well-known下的无后缀文件返回了错误的 MIME 类型导致苹果的 CDN 拉取失败。文件内容大致如下{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.mygame, paths: [/share/*, /activity/*] } ] } }appID是TeamID.BundleID的格式paths里可以用通配符。这里有个经验不要用*匹配所有路径苹果在审核和实际拉取时对过于宽泛的配置会有额外校验而且容易和别的 App 冲突。精确到业务路径既安全又好维护。配置完之后在 Xcode 的 Signing Capabilities 里加上 Associated Domains填applinks:yourdomain.com。注意这里不要带https://只写applinks:加域名。3.3 验证 AASA 是否生效的土办法苹果的 AASA 是通过 CDN 缓存的改完之后不会立刻生效。我常用的验证方式是把 App 删掉重装然后在备忘录里输入你的 Universal Link长按看是否弹出在 App 中打开。如果没弹大概率是 AASA 没被正确拉取。这时候可以用curl -I检查响应头确认Content-Type和状态码都对。还有一个更直接的办法在真机上用 Safari 打开链接如果顶部出现一个带 App 图标的小横幅说明绑定成功了。4. Unity 与原生桥接AppDelegate 里的参数接力4.1 冷启动与热启动的参数入口完全不同这是整个链路里最关键、也最容易出错的地方。iOS 唤起 App 分两种情况冷启动App 进程不在系统先启动进程再投递 URL。此时didFinishLaunchingWithOptions会先执行URL 信息在launchOptions里然后才调用openURL或continueUserActivity。热启动App 在后台系统直接把 URL 投递给openURL或continueUserActivity不会走didFinishLaunching。很多项目的 bug 就出在只在openURL里处理参数结果冷启动时openURL虽然也会被调用但如果你在didFinishLaunching里做了某些初始化把状态清空了参数就丢了。正确的做法是两个入口都处理并且用一个标志位防止重复消费。4.2 一个可靠的 AppDelegate 桥接写法在 Unity 导出的 Xcode 工程里UnityAppController是默认的 AppDelegate 子类。我一般会新建一个分类或者直接改UnityAppController.mm把两个回调都接管- (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]; }handleDeepLink里做的事情很简单把 URL 字符串缓存到一个静态变量里然后通过UnitySendMessage发给 Unity 侧的一个常驻 GameObject。注意UnitySendMessage是异步的而且如果 Unity 还没初始化完消息会丢。所以缓存这一步是必须的。4.3 UnitySendMessage 的时机陷阱UnitySendMessage要求目标 GameObject 必须已经存在且挂载了对应脚本。冷启动时didFinishLaunching阶段 Unity 引擎还没起来这时候调用UnitySendMessage是无效的。我的做法是原生侧只负责把 URL 存到一个全局可访问的地方比如一个单例的DeepLinkManagerUnity 侧在第一个场景的Awake里主动去拉这个参数而不是等原生推。这种拉模式比推模式稳定得多因为它把时机控制权交给了 Unity 自己。原生侧只需要提供一个方法给 C# 调用比如GetPendingDeepLink返回缓存的 URL 字符串取完之后清空避免重复消费。5. C# 层的参数投递与消费时机控制5.1 用 DllImport 打通 C# 与 Objective-CUnity 的 iOS 平台可以通过[DllImport(__Internal)]直接调用原生 C 函数。所以我在原生侧暴露两个 C 函数extern C { const char* _GetPendingDeepLink() { NSString *url [[DeepLinkManager sharedInstance] consumePendingURL]; if (url nil) return NULL; return strdup([url UTF8String]); } }C# 侧对应#if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern string _GetPendingDeepLink(); #endif public static string GetPendingDeepLink() { #if UNITY_IOS !UNITY_EDITOR return _GetPendingDeepLink(); #else return null; #endif }这里有个内存管理的坑strdup分配的内存需要手动释放否则每次调用都会泄漏。但 Unity 的 marshaling 在接收string返回值时会自动拷贝一份所以原生侧返回的这块内存其实没人释放。更稳妥的做法是返回const char*指向一个静态缓冲区或者提供一个_FreeDeepLink函数让 C# 侧显式释放。小项目里泄漏几次无所谓但如果是频繁触发的场景还是规规矩矩处理。5.2 参数解析从 URL 到业务字典拿到 URL 字符串之后下一步是解析。URL Scheme 的格式是mygame://path?key1value1key2value2Universal Links 是https://yourdomain.com/path?key1value1。两者的解析逻辑可以统一public static Dictionarystring, string ParseDeepLink(string url) { var result new Dictionarystring, string(); if (string.IsNullOrEmpty(url)) return result; var uri new Uri(url); // 解析 query var query uri.Query.TrimStart(?); foreach (var pair in query.Split()) { if (string.IsNullOrEmpty(pair)) continue; var kv pair.Split(); if (kv.Length 2) { result[Uri.UnescapeDataString(kv[0])] Uri.UnescapeDataString(kv[1]); } } // path 也存进去业务可能要用 result[_path] uri.AbsolutePath; result[_host] uri.Host; return result; }注意Uri类对自定义 scheme 的解析在部分 .NET 版本上会有兼容问题如果遇到异常可以退化成纯字符串切割。另外参数一定要做 URL Decode否则中文和特殊字符会乱码这是活动链接里最常见的 bug。5.3 消费时机为什么不能在 Awake 里直接跳转很多新手会在第一个场景的Awake里拿到参数就直接SceneManager.LoadScene跳到活动页。这在冷启动时往往出问题因为此时 SDK 初始化、资源加载、登录态都还没准备好跳过去也是白屏或者报错。我的经验是分三步走缓存在最早的Awake里把参数拉过来存到一个静态类DeepLinkParams里不做任何业务跳转。等待等游戏的主流程初始化完成比如登录成功、主城加载完毕这时候再检查DeepLinkParams是否有待处理的数据。消费在主流程的某个确定节点比如主界面Start之后读取并清空参数执行跳转。这样做的核心逻辑是Deep Link 的参数是意图不是指令。意图需要等系统准备好之后再执行而不是一拿到就冲。6. 冷热启动、重复触发与参数丢失的排查实录6.1 一个典型的参数丢失案例之前有个项目测试反馈说从浏览器点链接进游戏十次有三次参数丢了。我排查的过程是这样的第一步在原生handleDeepLink里加日志确认 URL 有没有进来。结果发现 URL 是进来了的说明唤起没问题。第二步在 C# 的GetPendingDeepLink里加日志发现有时候返回空。这就说明原生缓存和 C# 拉取之间有时间差——C# 拉取的时候原生还没收到 URL。第三步追时机。原来冷启动时didFinishLaunching里launchOptions带的 URL 是在application:openURL:之前处理的但我们的代码只在openURL里缓存而 Unity 的第一个场景Awake执行得比openURL还早所以第一次拉取是空的。修复方案在didFinishLaunchingWithOptions里也检查launchOptions[UIApplicationLaunchOptionsURLKey]和UIApplicationLaunchOptionsUserActivityDictionaryKey提前把 URL 缓存好。这样无论 Unity 多早来拉都能拿到。6.2 热启动重复触发的处理热启动时如果用户连续点两次链接openURL会被调用两次参数也会被消费两次可能导致活动页被 push 两次。解决办法是在 C# 侧加一个去重窗口记录上一次处理的 URL 和时间戳如果 1 秒内收到相同的 URL直接忽略。private static string _lastUrl; private static float _lastTime; public static bool ShouldProcess(string url) { if (url _lastUrl Time.realtimeSinceStartup - _lastTime 1f) { return false; } _lastUrl url; _lastTime Time.realtimeSinceStartup; return true; }这个去重逻辑看起来简单但能挡掉大量因为用户手抖或者系统重试导致的重复跳转。6.3 排查 Deep Link 问题的通用检查清单现象可能原因排查手段完全唤不起scheme 没注册 / AASA 没生效检查 Info.plist、用备忘录测试唤起但无参数回调方法没接管在 openURL/continueUserActivity 打断点冷启动丢参数拉取时机早于缓存在 didFinishLaunching 里提前缓存参数乱码没做 URL Decode检查解析代码重复跳转没去重加时间窗口去重微信内唤不起被内置浏览器拦截引导用外部浏览器打开7. 上线前必须验证的几个边界场景7.1 未安装 App 时的降级体验Universal Links 在未安装时会打开网页这个网页一定要做好引导比如展示活动内容加一个下载游戏的按钮。URL Scheme 在未安装时会直接失败所以如果只用 scheme务必在落地页做设备检测iOS 用户优先走 Universal Links。7.2 App 在后台被系统回收后的表现iOS 在内存紧张时会回收后台 App这时候用户点链接系统会走冷启动流程。如果你的参数缓存只存在内存里回收后就没了。所以关键参数建议在原生侧做一次持久化比如写入NSUserDefaultsUnity 拉取后再清除。这样即使进程被杀参数也不会丢。7.3 多场景下的参数隔离如果游戏有多个入口场景比如登录场景、主城场景要确保 Deep Link 参数只在正确的场景被消费。我的做法是给参数加一个consumed标志并且消费时校验当前场景是否是预期的场景不是就继续等待避免在错误的时机跳转。8. 我个人在实际项目里沉淀下来的几条经验第一原生侧永远只做缓存不做业务判断。原生代码越薄越好所有解析、去重、跳转逻辑都放在 C# 层这样调试方便也方便热更。第二参数结构要预留扩展字段。我一般会在 URL 里带一个type字段标识链接类型活动、分享、邀请等再加一个payload字段放业务数据。这样以后加新类型不用改解析代码。第三测试一定要覆盖冷启动、热启动、后台回收三种状态而且要在真机上测模拟器对 Universal Links 的支持不完整测出来的结果不可信。第四AASA 文件的更新有延迟上线前至少提前一天配置好并且用苹果的验证工具或者真机实测确认生效别等到投放开始才发现链接打不开。这套方案我在几个上线项目里跑过冷热启动的参数到达率基本能做到 99% 以上剩下的 1% 大多是微信内置浏览器的拦截导致的这种只能靠引导用户用外部浏览器打开来规避。Deep Link 这件事技术本身不复杂难的是把时机和边界情况都考虑周全希望这篇拆解能帮你少走几个弯路。
返回列表