ARTICLE DETAIL

资讯详情

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

Unity手游iOS Deep Link接入:URL Scheme与Universal Links参数解析全指南

Unity手游iOS Deep Link接入:URL Scheme与Universal Links参数解析全指南 1. 项目背景与整体链路设计做Unity手游客户端的朋友应该都有这个经历市场投放、短信营销、邮件推送里带着一条链接用户点开之后手机上已经装了游戏就直接进游戏没装就跳去App Store下载。这条链接背后的技术就是Deep Link。在iOS上目前主流的实现有两种URL Scheme和Universal Links。而作为一个Unity开发者最头疼的不是原生端怎么配而是链接里的参数怎么安全、快速地从系统层传到C#层再在游戏里完成落地页跳转、礼包发放、房间邀请这些业务逻辑。这篇文章适合几类人看Unity客户端开发、负责渠道SDK对接的工程师、做买量归因和游戏运营投放的伙伴。看完你可以直接把整套处理逻辑搬进自己的工程不用再去苹果文档和Unity文档之间来回翻。Deep Link说白了就是一条外部链接链接不仅要把App唤起还得携带参数。比如房间号、渠道标识、邀请人ID、活动码客户端拿到这些参数之后才能知道要跳转到哪里、给谁发奖励。但这里面的链路比大部分人想的长得多链接点下去系统先把消息交给原生层原生层再传给UnityUnity侧还要处理冷启动和热启动的差异最后才能把参数投递到业务模块。任何一个环节出了纰漏结果都是用户明明点击了链接进了游戏却什么都没发生。整体链路我拆成五段用户点击链接系统解析URL Scheme或Universal Links系统唤起App把消息交到AppDelegate或SceneDelegate原生层把URL暂存并尝试发送给UnityUnity侧通过事件或原生桥接收到URL解析参数游戏逻辑根据参数跳转页面、弹礼包、加入房间。这五步看着简单实际工程里每一步都有坑。尤其是Unity引擎天生的启动时序问题导致参数投递在冷启动场景下特别容易丢失。下面按顺序把每一层配置和代码都摊开说。1.1 Deep Link 在手游里的常见应用场景在游戏项目里Deep Link最常见的用法就三个买量归因、邀请裂变、跨应用跳转。买量归因场景通常投放平台给一条带渠道标识和点击ID的链接用户点击后应用打开客户端读取参数回传给广告平台平台确认这个激活是从哪条广告来的。这个场景最看重参数完整性和归因准确性链接里一般会有gid、clickid、campaignid这类字段任何一个丢了都会直接导致单子对不上。邀请裂变场景更典型老玩家分享一个链接给好友链接里带了邀请人的玩家ID好友装好游戏后第一次打开客户端识别出是邀请激活给双方发奖励。这类游戏内非常常见而且对“首次启动也能正确取到参数”的要求特别高因为一旦漏了邀请人ID奖励就发错了玩家之间的纠纷还会找上客服。跨应用跳转场景一般游戏会和玩家社群、工会社区、赛事系统联动用户在看网页时点一个“进入游戏”按钮App被唤起后直接跳转到指定页面。这种场景对“唤起后跳转位置”的要求更重参数里通常是page、tab、nav这类路由字段。三种场景的共同点都是链路要能唤起App唤起后要能拿到完整参数、能按参数执行对应逻辑。所以后面所有方案都围绕这两个核心目标展开。1.2 方案选型背后的取舍逻辑我最早做的时候偷懒只接了URL Scheme。原因很简单配置快不用域名不用HTTPSiOS和Unity都直接支持。但后来渠道反馈越来越多在部分App内嵌浏览器里URL Scheme唤起经常弹“无法打开页面”体验很糟糕而且URL Scheme能被任意外部App调用安全校验基本靠裸奔。Universal Links就好很多它是苹果基于HTTPS域名做校验的。只要域名下面放一个apple-app-site-association文件并且在开发者后台开启Associated Domains系统就会在用户点击链接时自动匹配并唤起App。如果没装应用系统直接打开网页体验平滑很多。但Universal Links也不是万能的配置流程复杂调试难度高域名证书过期、文件没部署、CDN缓存错误都会让唤起直接失效。我的结论是两种都要接按场景区分使用。渠道SDK强制要求URL Scheme的保留官方买量、官网落地页走Universal Links。这套混合方案在实测里覆盖率最高。2. 两种唤醒方式的原理与差异在动手配置之前先把两种方式的原理搞清楚否则后面排查问题的时候都不知道该去哪儿看日志。2.1 URL Scheme一套简单的协议注册URL Scheme本质上是给App注册一个自定义协议类似mygame://。用户在浏览器地址栏输入mygame://room/123或者网页里通过location.href跳转到这个协议系统就会去寻找能处理这个协议的App找到后拉起。这个协议注册在Info.plist里通过声明CFBundleURLTypes数组来定义。系统拉起App后会调用AppDelegate的openURL方法把完整URL传过来这就是原生侧接收参数的入口。URL Scheme最大的问题是没法判断App是否已安装。用户没装App点了URL Scheme链接浏览器只会提示“无法打开”没法像Universal Links那样自动分流到App Store。所以纯用URL Scheme做推广链接通常需要一个中间页H5去判断、引导下载。给一个经验值URL Scheme只适合在确认目标App存在、或者App之间相互跳转的场景用。比如支付跳转、WebView打开App内页。用在买量落地页上用户体验很容易翻车。2.2 Universal Links基于 HTTPS 域名的可信唤起Universal Links的思路是你的域名通过HTTPS提供服务域名根目录下放一个apple-app-site-association文件文件里写明哪些App能处理这个域名下的哪些路径。系统在用户点击URL时会先检查这个文件如果匹配到了App且已安装就直接唤起App没装就继续用Safari打开网页。配置Universal Links需要在Apple开发者后台的Enabled Capabilities里添加Associated DomainsXcode工程里也要添加对应的applinks:域名。苹果的校验逻辑要求域名必须支持HTTPS文件必须能被外网直接访问并且建议文件Content-Type为application/json。实际调试时很多人会卡在配置文件缓存上。苹果会抓取这个文件而且CDN、代理、请求头都会影响文件能否被正确读到。有些开发者改了配置很久不生效就是苹果那边还缓存着旧文件。上线前我一般会用在线验证工具或者真机Safari打开链接来测试至少做一轮覆盖。2.3 选型对比什么时候用哪个我建议按这张表来判断对比项URL SchemeUniversal Links链接格式mygame://room/123https://yourdomain.com/game/room/123是否需要HTTPS域名不需要需要未安装App时的行为提示无可打开App体验差自动打开网页配置复杂度低仅Info.plist高需域名、证书、关联文件安全校验弱任意外部App可调强需apple-app-site-association在部分内嵌浏览器中唤起不稳定视系统版本而定适用的业务场景渠道SDK、App间跳转官网、买量、品牌推广还有个细节Universal Links的path匹配规则支持通配符。比如paths可以配置[]表示整个域名都可以唤起也可以配置[/game/]把唤起范围限制在某个路径下。我的建议是路径细化不要一上来全放开不然以后想收都收不回来日志里全是无关的URL记录。3. iOS 原生侧配置实操原生侧是整个链路的源头配置正确与否直接决定后面Unity能不能收到参数。这一节按两种方式分开写。3.1 URL Scheme 的 Info.plist 配置第一步在Xcode里找到Info.plist添加CFBundleURLTypes。一个典型的URL Scheme配置如下keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.mygame/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /arrayCFBundleURLName是业务标识可以是Bundle ID或自定义字符串不参与URL匹配。真正决定了链接协议的是CFBundleURLSchemes数组里的mygame。配置完这个Safari输入mygame://任意路径系统就会尝试唤起App。多个Scheme可以写在同一个数组里比如同时支持mygame和mg。但我不建议随便加每多一个Scheme就多一份被恶意调用的面能少则少。配置完成后原生侧需要在AppDelegate里实现回调- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { if (url nil) { return NO; } // 先把参数存起来Unity就绪之后再取 [[NSUserDefaults standardUserDefaults] setObject:url.absoluteString forKey:DeepLinkPendingURL]; [[NSUserDefaults standardUserDefaults] synchronize]; // 如果Unity引擎已经Ready直接发过去 UnitySendMessage(DeepLinkManager, ReceiveFromNative, url.absoluteString.UTF8String); return YES; }记住两个动作先存档再发送。存档是为了兜底冷启动场景发送是为了热启动时能够即时响应缺一不可。3.2 Universal Links 的域名关联与回调实现Universal Links需要三个环节配合缺一个都不行。第一个环节在Apple开发者后台找到App ID把Associated Domains capability打开保存后重新生成Provisioning Profile并下载到Xcode。这一步很多老项目容易漏因为开发者后台的capability开关独立于工程文件只改工程不后台排查起来很费时间。第二个环节在Xcode的Signing Capabilities里添加Associated Domains值写applinks:你的域名。注意不需要加https://只需要applinks:example.com这种格式。第三个环节把apple-app-site-association文件部署到域名根目录或.well-known目录下内容大致如下{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.mygame, paths: [ /game/*, /activity/* ] } ] } }appID格式是Team ID加Bundle ID一定要写对大小写敏感。paths的路径匹配支持通配符也支持“NOT”排除规则。部署好后用浏览器访问https://你的域名/apple-app-site-association能直接看到JSON内容才算第一步通过。原生侧的接收代码在AppDelegate里写continueUserActivity- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring *restorationHandler))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; if (url) { [[NSUserDefaults standardUserDefaults] setObject:url.absoluteString forKey:DeepLinkPendingURL]; [[NSUserDefaults standardUserDefaults] synchronize]; UnitySendMessage(DeepLinkManager, ReceiveFromNative, url.absoluteString.UTF8String); } return YES; } return NO; }如果你用的是iOS 13以上、Unity支持的Scene生命周期还需要在SceneDelegate里实现continueUserActivity回调。Unity默认工程大部分还是走AppDelegate但如果你自己接了SwiftUI或者Scene管理这个点就要额外注意否则会出现“点击链接毫无反应”的问题。3.3 真机联调验证方法配置完之后最痛苦的一步是验证。模拟器上很多场景测不出来一定要上真机。我总结了一套验证顺序能帮你快速定位问题先在Safari地址栏输入mygame://room/123确认URL Scheme能不能正常唤起再打开备忘录输入https://你的域名/game/room/123点击链接确认Universal Links能不能唤起检查Xcode控制台有没有输出Unity的日志确认UnitySendMessage是否被调用用curl访问配置文件的URL确认文件内容、响应头是否有问题curl -I https://yourdomain.com/apple-app-site-association如果Content-Type不正确或者文件被CDN拦截就先处理网络层的问题。这里有个容易忽略的点在Safari里输入Universal Links的完整URL如果App已安装系统应该直接唤起但如果之前曾经跳过“用Safari打开”的提示系统会在短时间内记住用户选择导致后续测试不稳定。遇到这种情况可以删掉App重装或者去Safari设置里清除网站数据让系统重新选择处理方式。4. Unity 工程接入与原生桥接原生层拿到链接只是第一步接下来要把链接交到Unity手里。这里主要讨论两种方式Unity自带的Deep Link API以及原生层主动调用UnitySendMessage。4.1 Unity 自带的 Application.deepLinkActivatedUnity从2018.3版本开始提供Application.deepLinkActivated事件以及用于检查启动链接的Application.absoluteURL。在基础场景下直接用这套API就够了void Awake() { Application.deepLinkActivated OnDeepLinkActivated; } void Start() { if (!string.IsNullOrEmpty(Application.absoluteURL)) { OnDeepLinkActivated(Application.absoluteURL); } } private void OnDestroy() { Application.deepLinkActivated - OnDeepLinkActivated; } private void OnDeepLinkActivated(string url) { Debug.Log($DeepLink received: {url}); // 解析参数走业务逻辑 }这套API最大的优点是不用写原生代码。它能覆盖大部分URL Scheme和Universal Links场景。但我实际用下来发现两个问题第一事件触发的时机不稳定冷启动时如果场景里的脚本比引擎回调晚注册链接可能已经被标记为处理过导致没走业务逻辑第二如果想在游戏登录完成之后再根据参数发礼包Unity自带回调没有留控制“什么时候消费链接”的余地。所以我的做法是Unity自带API做兜底真正的主链路走原生层主动投递这样能完全控制参数到达Unity的时机还可以在链接进入游戏前做一层解密、验签逻辑。4.2 原生层主动投递UnitySendMessage 的正确用法原生层拿到URL后调用UnitySendMessage把链接文本传给Unity场景中某个GameObject上挂的脚本方法。原型是UnitySendMessage(GameObjectName, MethodName, param);需要注意三个要求GameObjectName必须是场景里真实存在的物体名称MethodName必须是对应脚本上的公开方法param必须是UTF-8编码的C字符串。前面AppDelegate代码里调用了UnitySendMessage(DeepLinkManager, ReceiveFromNative, url.absoluteString.UTF8String)所以Unity侧必须有一个名为DeepLinkManager的GameObject并且上面挂的脚本必须定义ReceiverFromNative公开方法。如果物体名写错、脚本没启用原生层调用时会直接报错。还有一个坑在Unity引擎完全启动之前UnitySendMessage函数指针可能还没注册这个阶段调用会崩溃或直接无效。所以原生层如果把“先发送”放在前面冷启动阶段就可能会丢。这也是我为什么强调要先用NSUserDefaults把URL存一份Unity就绪后再主动读取。4.3 用一个 Manager 统一管理 Deep Link 生命周期工程大了之后Deep Link的处理不能散落在各个界面脚本里最好统一收敛到一个Manager里。我常用的做法是public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } private string pendingLink; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); Application.deepLinkActivated OnDeepLinkActivated; pendingLink null; } private void Start() { // 冷启动兜底引擎就绪后主动检查 if (!string.IsNullOrEmpty(pendingLink)) { ProcessLink(pendingLink); pendingLink null; } if (!string.IsNullOrEmpty(Application.absoluteURL)) { ProcessLink(Application.absoluteURL); } } private void OnDestroy() { Application.deepLinkActivated - OnDeepLinkActivated; } public void ReceiveFromNative(string url) { if (string.IsNullOrEmpty(url)) { return; } if (!gameObject.activeInHierarchy || !isActiveAndEnabled) { pendingLink url; return; } OnDeepLinkActivated(url); } private void OnDeepLinkActivated(string url) { if (gameObject.activeInHierarchy isActiveAndEnabled) { ProcessLink(url); } else { pendingLink url; } } private void ProcessLink(string url) { // 在这里统一解析并按场景分发 } }DontDestroyOnLoad是为了让Manager跨场景存活免得切换场景后Deep Link回调丢失。如果你有多个场景或者用了Addressables建议把这个Manager放在启动场景的一个空物体上。5. 参数设计与 C# 层处理链接能通到Unity还不算完真正的业务逻辑要建立在参数正确解析之上。参数怎么拼、怎么传、怎么解这里需要统一约定。5.1 URL 参数格式约定我通常把Deep Link分成两种形态。一种是URL Scheme的自定义协议形态比如mygame://open?pageroomroomId123456inviteCodeABCDts1712345678signabc123另一种是Universal Links的标准HTTPS形态https://yourdomain.com/game/open?pageroomroomId123456inviteCodeABCDts1712345678signabc123两种形态取出参数的方法基本一致都是解析query部分。参数命名建议统一采用小驼峰与C#字段命名习惯保持一致比如campaignId、inviteCode、roomId。不要用带横杠或带空格的key不然解析和日志输出都痛苦。签名校验也很关键。Deep Link一旦上线就会被各种渠道转发恶意刷量的人也会拿链接到处改参数。我建议至少包含三个字段tsUnix时间戳防止链接长期有效被滥用nonce随机字符串配合服务端做一次性校验sign对核心参数做SHA256或HMAC签名防止参数被篡改。签名算法原则上是由App和服务端共享密钥。客户端校验只能防君子不能防完全逆向后篡改。但对绝大多数买量和邀请场景做到sign和ts两级校验已经够用。具体sign算法我一般放在服务端生成链接时算好客户端拿参数后调服务端校验比客户端本地验签更安全。5.2 C# 层参数解析实现拿到URL后在Unity里解析参数用System.Uri不需要额外库。下面是可以直接拿去用的解析方法public static Dictionarystring, string ParseQuery(string url) { var result new Dictionarystring, string(); if (string.IsNullOrEmpty(url)) { return result; } var uri new Uri(url); var query uri.Query.TrimStart(?); var pairs query.Split(, StringSplitOptions.RemoveEmptyEntries); foreach (var pair in pairs) { var index pair.IndexOf(); if (index 0) { result[Uri.UnescapeDataString(pair)] string.Empty; continue; } var key Uri.UnescapeDataString(pair.Substring(0, index)); var value Uri.UnescapeDataString(pair.Substring(index 1)); result[key] value; } return result; }调用时可以这样var dict ParseQuery(url); if (dict.TryGetValue(page, out var page)) { switch (page) { case room: if (dict.TryGetValue(roomId, out var roomId)) { RoomManager.EnterRoom(roomId); } break; case activity: ActivityManager.OpenActivity(dict); break; } }有几个解析细节要注意Uri.UnescapeDataString能对URL编码的内容解码中文、特殊符号都能处理如果参数值里有号在URL标准里通常代表空格但SDK拼链接时可能把加号原样传过来最稳妥做法是链接生成时就把加号编码成%2B如果一个key出现了多次我的实现是后者覆盖前者如果业务上有“同一参数多个值”的需求要改成List 。5.3 冷启动与热启动的处理差异热启动相对简单App已经运行在后台链接到达时Manager已经存在UnitySendMessage能立刻把URL送进来业务侧马上就能消费。冷启动要复杂得多。用户点击链接后系统拉起App这个过程里Unity引擎还在初始化场景还没加载脚本还没Awake。这时候原生层即使调用UnitySendMessage目标脚本也还没准备好。所以冷启动的完整流程是原生层在continueUserActivity或openURL回调里把URL存进NSUserDefaultsUnity场景启动DeepLinkManager的Awake/Start执行Start里主动读取缓存的URL我习惯用Application.absoluteURL再加一层原生主动拉取做双保险拿到URL后如果游戏还没登录完成先把参数存到内存等登录完成的回调里再消费。有个常见的需求是“首启送奖励”用户通过邀请链接激活安装第一次冷启动就需要拿到邀请人ID。最容易出现的bug是用户先通过链接唤起App系统开始打开App过程中用户又点击了另一个链接、或者经历了一次杀进程重启导致参数被覆盖或丢失。针对这种场景我会在原生层保存一个数组而不是单值每次新链接都追加Unity消费完再删除这样即使连续点击多条链接也不会丢。6. 实战踩坑记录与常见问题速查最后这部分是含金量比较高的内容。以下都是我在实际项目里踩过或帮别人排查过的坑直接给结论。6.1 高频问题与解决方案我整理了一个速查表遇到问题可以先对着查现象可能原因解决方案点击Universal Links没有唤起AppAssociated Domains没在开发者后台开启重新生成Provisioning Profile确保工程和后台都配置唤起时跳到了Safariapple-app-site-association没有部署成功删App重装、清理网页数据并检查文件是否可访问Unity收不到链接UnitySendMessage时机太早原生层先存NSUserDefaultsUnity启动后再主动拉取收到链接但参数是乱码URL编码不一致统一用Uri.UnescapeDataString链接生成时对中文参数做Encode冷启动首启礼包发错参数被后续链接覆盖原生层用数组缓存逐条消费微信里点击无法唤起内嵌浏览器对Universal Links支持受限走URL Scheme或引导用户到Safari打开测试时第二次点击失效系统记住了用户选择清除网站数据或重装AppiOS版本升级后无法唤起新系统对缓存、权限更严格确保配置文件最新重新抓取还有一个很容易忽略的问题URL Scheme注册不能和多个App冲突。两个App抢同一个scheme系统只会让其中一个响应。常见的系统级Scheme像tel、sms这些千万不能用来自定义。6.2 独家避坑细节补充第一apple-app-site-association文件对大小写、路径、Content-Type都很挑剔。我遇到过最诡异的一次是文件能访问但苹果就是不认最后发现是文件被HTTP服务自动加了一层HTML包装。解决办法是直接按文本返回不要经过任何落地页逻辑能直接吐JSON就最好。第二Unity侧解析参数时不要假设URL一定符合Uri类的规范。渠道SDK拼链接经常不按套路出牌有的把query放在路径后面有的根本没带scheme有的把完整链接做了二次编码。所以我在解析前会加try-catch解析失败时把原始URL打到日志里方便渠道排查。第三Debug和Release差异要提前测。Debug包里UnitySendMessage正常Release包可能因为代码剪裁、Managed Stripping Level设置过高导致调用不到。检查Player Settings里的Managed Stripping Level我一般用Low或Medium参数投递这类关键链路不能开太高。第四用了热更新框架或者SDK后处理器DeepLinkManager要小心被打包时改名。原生层UnitySendMessage里写的GameObject名字和C#脚本方法名都是字符串打包后这些字符串不会自动跟着脚本重命名走。如果改了脚本类名或物体名原生代码里的字符串也要同步改否则出现“Debug包正常、Release包收不到”的诡异问题。第五真机联调时机要看系统日志。iOS 15之后Universal Links相关的日志在Console.app里能看到机制比较复杂。我建议在原生层加几个统一前缀的NSLog比如“DEEPLINK_OPENURL”“DEEPLINK_USERACTIVITY”然后在Xcode的Console里按前缀过滤可以快速确定系统到底有没有把链接交给原生层。第六如果游戏用到了App Group共享容器可以在原生层把DeepLink写到共享UserDefaults里Unity侧的扩展组件再去读。这种方式适合Widget、Today Extension和主App联动好处是多个进程都能访问坏处是容易让参数混乱。我的建议是能不用就不用单一真源原则在Deep Link场景同样适用。还有一个实用技巧在Safari地址栏输入URL Scheme链接如果App唤起成功再切换前台后台验证热启动场景。Universal Links则建议通过备忘录或系统邮件触发不要用Safari手动输入因为Safari会缓存状态影响测试结果。最后分享一个和本主题最相关的实操体会Deep Link整套方案里最脆弱的不是配置而是参数消费时机。参数到达和业务准备完成的时间差决定了体验的成败。我习惯在Manager里维护一个状态机登录前后分别处理再加超时兜底——如果游戏初始化超过5秒还没就绪先把链接存本地等就绪后再消费。这样不管冷启动还是热启动不管用户在哪个页面都不会出现“明明点了链接却没进到对应页面”的尴尬。如果后续你要扩展这套系统可以考虑把房间、礼包、渠道奖励这些业务路由做成配置化让运营在后台改一份配置就能新增落地路径不需要每次发客户端版本。Deep Link的通用参数模型设计好后面再接新场景就不慌了。
返回列表