ARTICLE DETAIL

资讯详情

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

Unity iOS Deep Link全链路接入指南:从URL Scheme到参数投递

Unity iOS Deep Link全链路接入指南:从URL Scheme到参数投递 前几天有个做投放的朋友来找我说他们在iOS端接Deep Link唤醒的时候遇到一个很头疼的问题从广告渠道点开链接App能打开但用户在落地页里选好的角色、携带的渠道归因参数全都丢了Unity游戏里的活动页面怎么也跳不过去。排查了半天最后发现问题出在原生层把参数传给C#的时候时机不对——Unity的接收脚本还没初始化完成参数已经先到了被当成无效消息丢掉了。这个场景估计很多Unity手游团队都撞上过。Deep Link这个东西说起来原理并不复杂无非是把链接里面的参数提取出来交给游戏里对应的模块去处理。但真要在Unity手游里跑通全流程从URL Scheme到Universal Links再到C#层参数投递中间埋着不少隐藏的坑。这篇文章就把我实际接过的完整链路拆开讲一遍从XR换季补货到渠道归因、老玩家召回你可以直接照着抄。1. 唤醒两种技术路线的核心差异先选对再动手在动手写代码之前先把URL Scheme和Universal Links这两兄弟搞清楚。很多刚接触Deep Link的开发者容易陷入一个误区既然Universal Links更先进那就只接Universal Links。这话在纯iOS应用里有一定道理但在手游场景下只接一个其实是给自己埋雷。1.1 URL Scheme的触发方式与体验瓶颈URL Scheme本质上就是系统层的一个协议注册。你在Info.plist里声明了你的App能处理什么协议比如mygame://那么当Safari或者其他App试图打开这个协议的时候iOS就会弹出系统弹窗询问用户是否允许打开你的App。这个方案最大的优点就是接起来简单不需要服务器和域名客户端自己就能搞定。但缺点也很明显系统会弹一次打开XX吗的确认框多了一步用户操作转化率必然会掉一些如果你的App没安装URL Scheme会直接报错——Safari显示无法打开网页而不是跳转到App Store链接不携带任何域名信息从安全角度看容易被其他App恶意调用手游场景下URL Scheme目前主要的用途是老用户召回和站内跳转比如玩家在社区里看到一条攻略帖点击打开游戏按钮通过URL Scheme直接进到对应活动页面。1.2 Universal Links的能力边界与前置条件Universal Links是iOS 9之后推出的它要求开发者具备一个HTTPS的域名并且在这个域名的根路径下放置一个apple-app-site-association文件下文统一叫AASA文件。当用户在Safari里输入或点击一个标准HTTPS链接比如https://www.mygame.com/event/xxxiOS会先去检查域名下有没有AASA文件如果验证通过就直接拉起你的App不弹窗。它的体验显然比URL Scheme好一个档次无感知唤醒用户体验丝滑链接本身是标准的HTTPS链接用户没安装App时点击仍然可以打开网页端做兜底而不是报错域名验证机制从系统层面避免了其他App伪装你的协议但是Universal Links也有让团队头疼的边界条件必须有一个可通过HTTPS访问的域名不能只有IPAASA文件的放置、更新、CDN缓存问题会在上线后给你来几次已经配置了就是唤醒不了的玄学体验iOS 9.2之前还有大小限制后来放宽到了128KB基本没影响部分第三方App内部的WebView对Universal Links支持并不好Safari里完美但嵌在某个超级App里可能就失效了1.3 手游场景下的选型结论我个人的建议是两个都接但分工不同。对比维度URL SchemeUniversal Links触发场景站内跳转、老用户召回外部广告投放、网页分享、邮件用户确认弹窗有无未安装App时的表现报错网页兜底接入成本低纯客户端高需域名、服务器配置链接伪装风险较高低系统级验证参数传递能力通过URL部分传递通过URL部分传递广告投放和用户增长场景主用Universal Links老玩家召回和App内部跳转URL Scheme反而更快更稳。两个方案共用同一套参数解析逻辑原生层拿到URL之后都走同一条路往Unity传。还有一个原因是国内的一些渠道SDK并不支持Universal Links的拉起方式但URL Scheme兼容性几乎百分百。2. Unity工程的特殊性iOS原生代码改在哪消息怎么和时间赛跑Unity项目的iOS端原生代码跟你用Xcode单独建一个App工程的时候差别很大。很多Unity转iOS的开发者第一次打开生成出来的Xcode工程会懵怎么没有AppDelegate就算有改完怎么一重新出包就没了这里先把底层的运行机制讲透。2.1 Unity生成的Xcode工程结构拆解Unity导出iOS工程之后你会看到Classes/目录下有一套Unity自带的原生文件其中最重要的就是UnityAppController。它就是Unity替我们实现的AppDelegate整个App生命周期都是在它里面管理的。如果你直接在AppDelegate.mm里写代码生成出来的工程里可能根本没有这个文件不同Unity版本结构有差异。我看过的Unity 2020/2021/2022版本默认都没有传统的AppDelegate取而代之的是一个叫UnityAppController的类App启动流程全部在application:didFinishLaunchingWithOptions:里。所以正确做法是写一个UnityAppController的子类或者直接改UnityAppController.mm。这里我不建议直接改原生文件原因是每次从Unity编辑器重新出包Xcode工程都会被覆盖你改的所有原生代码全没了。正确做法是放到Assets/Plugins/iOS/目录下让Unity在出包时自动把源码文件拷到Xcode工程里。2.2 冷启动还是热启动参数到达时机完全不同这是整个流程里最关键的变量。Deep Link的唤醒场景可以分成两大类冷启动App进程不在用户点链接系统拉起App从application:didFinishLaunchingWithOptions:开始走热启动App进程已经在后台用户点链接走application:openURL:options:或continueUserActivity:回调冷启动的时候千万不能马上往Unity发消息。为什么因为Unity引擎都还没起来你的C#脚本对象压根不存在。你发消息等于对着空气喊话UnitySendMessage打到一个不存在的GameObject上直接报错或者静默丢弃。热启动相对简单Unity已经在运行了收到回调之后直接转发就行。2.3 原生的参数暂存机制为了解决冷启动时序问题原生层必须有一个暂存区。我习惯在UnityAppController的子类里维护一个静态字典把收到的URL参数先存下来等Unity那边准备就绪之后主动来取。传给Unity的消息时机我见过不少人写死在applicationDidBecomeActive里这样不太合适。因为didBecomeActive在App每次从后台回到前台时都会触发如果这时候恰好没有暂存数据也只是空跑一次但如果你在冷启动后第一次didBecomeActive就发消息Unity可能还在加载首场景GameObject可能还不存在一样会丢。比较稳的做法是双保险原生层在UnitySendMessage之前延时一小段时间同时C#层也做轮询拉取两边配合确保参数必达。具体怎么配合后面专门讲。3. 原生捕获与向Unity投递参数这层代码决定成败好到了真正写代码的部分。这一节我给出一套可以直接用的Objective-C实现覆盖URL Scheme和Universal Links两种回调同时处理暂存、延时和参数格式。3.1 URL Scheme专用回调// Assets/Plugins/iOS/DeepLinkHelper.mm #import UIKit/UIKit.h #import UnityAppController.h interface DeepLinkHelper : UnityAppController end static NSMutableDictionary *pendingLinkData nil; implementation DeepLinkHelper // 冷启动App未被系统运行时从链接拉起 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [super application:application didFinishLaunchingWithOptions:launchOptions]; if (pendingLinkData nil) { pendingLinkData [NSMutableDictionary dictionary]; } // URL Scheme冷启动时参数在launchOptions里的UIApplicationLaunchOptionsURLKey NSURL *url launchOptions[UIApplicationLaunchOptionsURLKey]; if (url) { [self handleDeepLinkURL:url]; } return YES; } // 热启动App在后台通过URL Scheme被拉起 - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id *)options { [self handleDeepLinkURL:url]; return YES; } // Universal Links冷启动和热启动共用 - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayid *restorationObjects))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; [self handleDeepLinkURL:url]; } return YES; } - (void)handleDeepLinkURL:(NSURL *)url { if (url nil) return; NSMutableDictionary *params [NSMutableDictionary dictionary]; params[absoluteString] url.absoluteString ?: ; params[scheme] url.scheme ?: ; params[host] url.host ?: ; params[path] url.path ?: ; // 解析query参数例如 ?sceneshopgoods_id10086 NSURLComponents *components [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; for (NSURLQueryItem *item in components.queryItems) { if (item.name item.value) { params[item.name] item.value; } } // 存入暂存区 if (pendingLinkData nil) { pendingLinkData [NSMutableDictionary dictionary]; } [pendingLinkData removeAllObjects]; [pendingLinkData setDictionary:params]; // 如果Unity已经就绪发送消息 // 如果还没就绪等C#侧主动来拉 [self trySendToUnity]; } - (void)trySendToUnity { if (pendingLinkData.count 0) return; // 需要确认unityReady标记这个标记由C#侧在Start/Enable时置为1 BOOL isUnityReady [self isUnityReadyFlag]; if (!isUnityReady) { // 延时重试避免Unity在启动过程中的早期阶段收不到消息 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self trySendToUnity]; }); return; } // 序列化成JSON字符串一次性丢给C# NSError *error nil; NSData *jsonData [NSJSONSerialization dataWithJSONObject:pendingLinkData options:0 error:error]; if (!jsonData) return; NSString *jsonString [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; [[NSUserDefaults standardUserDefaults] setObject:jsonString forKey:deep_link_pending_json]; [[NSUserDefaults standardUserDefaults] synchronize]; const char *objName DeepLinkBridge; const char *methodName OnNativeMessage; const char *param jsonString.UTF8String; // C#侧签名public void OnNativeMessage(string json) UnitySendMessage(objName, methodName, param); }这段代码的思路很明确不管哪种渠道唤醒都把原始URL和解析好的query参数放进一个字典先暂存再判断Unity是否就绪就绪就发没就绪就0.5秒重试一次。3.2 Unity侧C#接收与主动拉取原生层发消息只是一个方向我更推荐C#侧同时做启动时主动拉取双保险。// Assets/Scripts/DeepLinkBridge.cs using System.Collections; using System.Collections.Generic; using UnityEngine; public class DeepLinkBridge : MonoBehaviour { public static DeepLinkBridge Instance { get; private set; } private bool _messageHandled false; void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } void Start() { // 场景初始化完成后从原生层拉取暂存数据 StartCoroutine(PullPendingLinkFromNative()); // 同时订阅热启动事件 Application.deepLinkActivated OnDeepLinkActivated; } void OnDestroy() { Application.deepLinkActivated - OnDeepLinkActivated; } private IEnumerator PullPendingLinkFromNative() { yield return new WaitForSeconds(0.3f); #if UNITY_IOS !UNITY_EDITOR string pending PullPendingJsonFromNative(); #else string pending null; #endif if (!string.IsNullOrEmpty(pending)) { OnNativeMessage(pending); } } // 原生层通过UnitySendMessage调到这里 public void OnNativeMessage(string jsonString) { if (string.IsNullOrEmpty(jsonString)) { return; } // 重复消息保护同一个链接处理过一次就不再处理 if (_messageHandled) { Debug.Log([DeepLink] 消息已处理忽略重复回调); return; } JsonData data JsonMapper.ToObjectJsonData(jsonString); string absoluteUrl (string)data[absoluteString]; Debug.Log([DeepLink] 收到唤醒链接: absoluteUrl); // 这里根据业务场景分发 ProcessDeepLink(data); _messageHandled true; } private void OnDeepLinkActivated(string url) { // 这是Unity引擎自带的热启动回调适用于Universal Links部分场景 Debug.Log([DeepLink] Unity内置回调: url); ProcessUrlString(url); } [System.Runtime.InteropServices.DllImport(__Internal)] private static extern string PullPendingJsonFromNative(); }这里要注意Application.deepLinkActivated这个API是Unity 2019.3之后才有的可以在C#侧直接收到部分场景的Deep Link回调。但实测下来并不完全可靠冷启动时它偶尔会错过所以我坚持原生层暂存 C#侧主动拉取的方案作为主路径Unity内置回调当辅助。4. Universal Links配置实操从域名到AASA文件一次说透Universal Links的配置明显比URL Scheme啰嗦得多但也是归因和广告投放最常用的路径。配置失败的症状几乎都长一个样链接在Safari里打开顶上出现一个打开App的横幅点一下也能跳但就是不自动拉起体验差了一大截。4.1 域名关联与AASA文件细节第一步在Xcode的Signing Capabilities里添加Associated Domains填上applinks:你的域名比如applinks:cb95f.example.com。注意不要带https://前缀。第二步把AASA文件放到域名的根目录或者/.well-known/目录下两者选一个就行。文件格式要求iOS 9之后必须是JSON不带签名。下面是我实际用的一份{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.yourgame, paths: [*] } ] } }TEAMID替换成你在开发者后台看到的Team ID一串10位的字母数字com.yourcompany.yourgame替换成你的Bundle Identifier。有一个坑很多人踩过paths字段建议用带具体路径的写法比如[/event/*, /invite/*]不要一上来就*。原因有两个。第一是安全考量反正你也不希望随便一个域名下的链接都能拉起你的App吧第二个原因是AASA文件的匹配规则不支持URL编码之后的模糊匹配如果你把*改成精确路径后面排查问题反而容易很多。4.2 AASA文件可验证性与缓存问题配置完AASA文件之后不要急着在Safari里测试。先用https://你的域名/apple-app-site-association或者https://你的域名/.well-known/apple-app-site-association直接访问看返回的JSON内容是否和上面一致。最关键的验证方式是让iOS系统刷新对AASA文件的信任。系统默认缓存时效是不定的有时候改了文件要等很久才能生效。测试前可以采用这个土办法关掉Safari的全部标签页再清掉Safari缓存然后进设置里开飞行模式再关掉实在不行就重启手机。多试几次确保你的测试设备和理论上的缓存周期错开。另外提醒一句如果你的域名用了CDN加速要确认CDN节点正确地返回了AASA文件而且响应头里不要有奇怪的拦截规则。我见过一个项目AASA文件返回了200但Content-Type是text/htmliOS直接判定格式非法花了两天才查出来。4.3 ATS与容错回退Universal Links走的是标准的HTTPS请求如果你的App确实要允许HTTP请求在Info.plist里配置NSAppTransportSecurity的NSAllowsArbitraryLoads为YES。不过这里我强烈建议不要为了省事就全开而是用NSExceptionDomains精确控制。否则审核可能被拒别问我怎么知道的。还有一层兜底逻辑别漏Universal Links按系统规则应该直接拉起App但如果用户从某些WebView内点击或者因为各种奇怪的系统原因没有拉起你应该让链接对应的H5落地页去执行一次URL Scheme跳转作为回退。很多手游是这样设计的落地页检测到App没有通过Universal Links被拉起就生成一个mygame://的URL Scheme链接给用户点点了系统弹窗确认后再进游戏。这套双保险能兜住Universal Links各种意料之外的失效情况。5. 参数规范设计不要让C#层变成万国语言翻译器把链接参数往C#层丢完之后真正的业务灾难才刚开始。很多团队的原生层写得没问题但C#层解析和分发一团乱最后游戏里活动跳转全是Bug。5.1 参数命名和序列化规范先约法三章所有参数名统一小驼峰比如goodsId、sceneType不要出现goods_id和goodsId混用参数解析统一在DeepLinkBridge里处理别的脚本不允许直接解析原生JSON所有链接参数的值一律视为字符串数值类型转换在业务层做原生层不做类型推断我习惯定义一套统一的分发协议举个例子{ absoluteString: mygame://invite?inviterUID_12345serverId3001, scheme: mygame, host: invite, path: , inviter: UID_12345, serverId: 3001 }C#层拿到这个JSON之后看host或者path就知道用户被唤醒是为了什么。mygame://invite表示邀请裂变https://www.mygame.com/shop/goods?goodsId10086表示商品跳转。后续所有新增场景都往这个结构里填解析逻辑不用大改。5.2 中文和特殊字符的编码陷阱这里必须单独拎出来说因为我在这个上面翻过车。URL里的query参数如果包含中文、空格、等保留字链接在生成那一刻就应该做一次URL编码。原生层拿到的URL是已经被编码过的比如中文签到会被编成%E7%AD%BE%E5%88%B0。如果你的C#层直接拿absoluteString做字符串匹配一定会踩坑。做一个统一的解码处理放在原生层解析时顺手做掉。用上面3.1代码的思路NSURLComponents.queryItems本身就自动解码了所以拿到params[inviter]的时候已经是正常的中文文本了。前提是你不要再用absoluteString去做字符串匹配要在params字典里取值。还有URL编码的经典误区链接里如果带的是%本身比如业务参数里的字符串带有百分号你直接URLDecode两次会把%25这种合法内容也解掉出来的跟你预期的不一样所以解码只做一次。这个鬼问题排查起来非常隐蔽。5.3 安全校验不能省Deep Link被调用的安全性问题在手游里尤其突出。你的链接协议一旦泄漏别人可以伪造一条mygame://recharge?count99999丢到玩家群里如果你的C#层不校验合法性直接处理后患无穷。我一般在原生层和C#层各做一道校验原生层校验URL的host和path是否在白名单内非法直接丢弃C#层校验关键参数比如服务器ID、玩家ID格式对不上就进入通用落地页而不是具体活动页虽然对纯客户端来说逆向破解终归防不住但该做的拦截一定要做别把安全底线放在一个小孩子都能改的URL参数上。6. 全链路真机测试每种唤醒路径都要手动过一遍代码写完了配置好了最痛苦的是验证。Xcode模拟器对Universal Links的支持还行但真机上才能暴露ATS、缓存、系统弹窗这些真问题。这里给出一套亲测有效的测试流程。6.1 测试用例拆解按照两个维度做矩阵唤醒方式是URL Scheme还是Universal LinksApp状态是冷启动、热启动还是后台挂起。我建议每项都测不要偷懒。因为冷启动 URL Scheme测的是launchOptions分支热启动 URL Scheme测的是openURL分支冷启动 Universal Links测的是didFinishLaunchingWithOptions continueUserActivity分支热启动 Universal Links测的是continueUserActivity分支App在前台时点击链接iOS可能只走continueUserActivity热启动和前台不一致也要分开测6.2 快速构造测试链接的方法真机测试不需要写一个完整的H5页面。测试URL Scheme的时候直接在Safari地址栏输入mygame://invite?inviterUID_12345serverId3001回车系统弹窗点允许就能拉起App。测试Universal Links的时候Safari地址栏输入完整的HTTPS链接比如https://www.mygame.com/invite?inviterUID_12345看是否自动拉起App。如果只是顶部出现横条说明AASA文件识别有问题优先检查域名关联和文件格式。我建议再准备一个iCloud备忘录把测试链接都存下来真机测试时打开备忘录点链接比在Safari里反复输入方便得多。而且备忘录内点链接的环境跟很多社交App内点击链接很像更能暴露Universal Links在非Safari环境下的兼容性问题。6.3 日志验证法C#层和原生层日志对照调试Deep Link光靠肉眼看现象不够你得确认参数到底走到哪一步丢了。我在原生层的关键节点都打了NSLog在C#层也打Debug.Log然后用Xcode连真机直接看完整日志。对照原则是这样的原生层打印了URLC#层没有打印说明是UnitySendMessage时目标接收器没就绪或者消息在发送前被吞了原生层没打印说明系统压根没回调到你的方法优先检查Capabilities配置、AASA文件、回调方法签名C#层收到了但业务层没反应说明参数分发逻辑有问题另外提醒一句continueUserActivity这个方法必须在restorationHandler这个参数存在时调用super方法否则在某些场景下会导致Universal Links回调失效。这个坑在Unity改写过原生AppController的时候很容易翻车。7. 上线后仍然会遇到的边界问题几个真实踩坑记录最后这部分聊聊我经历过的几个线上才会触发的隐性坑。这些情况在测试期往往发现不了但上线后玩家一多各种机型、各种系统版本一混问题就全冒出来了。7.1 用户点击链接但App已安装未升级应用商店的版本更新不是所有人都会及时点的。如果你的App老版本不支持新的Deep Link参数或者老版本压根没接Deep Link那么Universal Links点击后iOS会走网页兜底页面应该自己去判断当前App版本是否过低然后提示用户升级而不是干巴巴地等一个永远不会发生的唤醒。这个判断怎么做在舱底页里用JavaScript读取navigator.userAgent里的App版本号跟当前活动链接的最低版本需求做比对。版本号对不上就弹升级引导而不是尝试唤醒。7.2 冷启动时的C#脚本执行顺序两个场景都在调DontDestroyOnLoad的接收脚本而且都依赖第一个场景初始化完毕。但Unity在冷启动时Awake和Start的执行时机取决于你的场景加载顺序和脚本执行顺序。如果你的接收脚本被挂在第一个场景的某个UI节点上而第一个场景是多场景叠加的接收脚本可能晚于原生层第一次发消息。所以前面的双保险设计才那么重要。原生层延时重试C#层主动拉取两边互相覆盖等第一个场景加载完成之后参数就不会丢了。不要试图省掉这条主动拉取逻辑我吃过这个亏某个版本改动后原生层发消息过早C#侧又没拉结果线上开服活动玩家点了链接进来全落在冷冰冰的主界面上活动面板压根没弹。7.3 iOS系统更新后的行为漂移iOS版本升级有时候会悄悄改变某些API的行为。Deep Link相关的API算稳定但有一年iOS的系统更新对Universal Links的缓存刷新策略做了调整导致很多玩家反映更新系统之后点链接不拉起App了。这不是你的代码出了问题是系统缓存策略变了。处置思路是遇到这类问题优先怀疑系统缓存引导玩家从Safari地址栏手动输入一次链接相当于强刷一次关联验证往往就能恢复。别一上来就改代码、改AASA文件先让玩家试这个操作。7.4 参数里嵌套业务场景的黏性问题最后说个偏产品层面的事。Deep Link不只是打开App这一步真正的价值在于把用户直接送到他想去的页面。你收到一条sceneshopgoodsId10086的链接游戏里就应该真的跳到那个商品详情页而不是只回主界面。很多团队在这里会做等首场景Idle后再处理的操作为了确保场景、UI框架、数据层都准备好了。这个思路对但要注意处理超时和失败回退。我等了3秒场景没好就直接进主界面别再傻傻等下去。否则玩家会觉得自己点的链接石沉大海。提示建议把Deep Link的处理入口收敛到一个独立的C#脚本里不要在多个场景各自实现一套解析。维护成本低排查问题也方便得多。8. 写在最后的经验沉淀把Unity手游的iOS Deep Link全链路走通之后最大的体会是这个功能90%的坑不在原理而在时序和配置细节。原生层的参数转发、Unity侧的接收时机、AASA文件的服务端配置这三件事只要有一件没衔接好线上就会有一批玩家点链接无声无息。我的建议很简单如果项目还在早期就把Deep Link接入量化为一个正式任务来做不要塞到上线前最后一周才匆忙接。上线前务必把测试用例矩阵跑一遍把冷启动、热启动、两种链接方式、版本回退全部过一遍日志逐条对照。如果你现在正在被唤醒链路折磨照着这篇文章的流程重头梳理一遍先确认选型再验证原生层是否真的收到URL再看C#层接收时机最后检查AASA文件。技术本身不算深但每个环节都有它的小脾气摸顺了就好。
返回列表