如果你现在正在做Unity手游而且市场投放或运营同学某天跑过来跟你讲“我们活动页要能直接拉起游戏带上渠道号、玩家ID、活动ID这样用户就不用重新登录了”── 那你就必须把iOS Deep Link这套东西吃透。我最早接触这个需求是在一款中重度卡牌游戏里当时运营要做“老玩家召回”需要从短信、H5活动页直接唤起App还要把邀请人ID一路带到游戏内发奖励。刚开始我以为这只是个简单的URL Scheme注册结果真落地的时候涉及iOS的Universal Links、AppDelegate生命周期、UnitySendMessage桥接时机、参数编码链条比想象的长得多。这篇文章我会完整梳理Unity手游在iOS侧做Deep Link唤醒的全流程从URL Scheme和Universal Links两种方案的原理与选型到Xcode工程里的原生配置再到冷启动/热启动场景下如何安全地把参数投递到C#层。文章会给出可以直接抄的Objective-C代码、C#接收脚本、常见坑点排查内容更多的偏实践经验适合正在接这个功能的客户端开发者。1. 先搭框架Unity手游里的Deep Link到底解决什么问题1.1 业务价值与典型场景先简单说概念Deep Link指的是通过一个链接直接跳转到App内部指定页面或执行指定逻辑而不是用户点击之后先落在App首页。对于Unity手游来说最常见的诉求就几个渠道投放归因链接里带渠道ID玩家点开唤起游戏客户端上报给数据统计平台邀请活动链接里带邀请人UID被邀请人唤起游戏后自动建立关系发双方奖励跨场景引导比如用户分享了一段公会战录像其他人点开后直接跳转到游戏内的录像回放下页面老用户召回从短信、邮件、Push点击进来自动识别账号诉求直接进入活动页而不是让玩家自己找半天。这些场景有一个共同点App是被动唤醒的一方唤醒时系统会交给App一段完整的URL里面通常包含scheme、路径、query参数。你的客户端要做的就是抓住这段URL把它转化为C#层能理解的数据结构再交给游戏逻辑消费。整个过程设计不好就会出现经典的“用户被拉起来了但活动ID没带进去奖励发错人”的线上事故。我个人的经验是在动手前先跟运营和产品对齐一个参数协议明确哪些参数是必带的、哪些可空、用什么字段名。这一步比写代码重要得多因为如果链接里都没有固定的参数约束客户端这边解析逻辑就会变成无底洞。1.2 URL Scheme与Universal Links的选择iOS上实现Deep Link有两条主流路线一个是老牌的URL Scheme一个是苹果在iOS 9推出的Universal Links业内常简称UL。两者核心差别在于对比项URL SchemeUniversal Links配置方式Info.plist里注册scheme如game://开发者账号开启Associated Domains配置applinks域名系统校验不校验任何人可注册同样的scheme需要请求https域名下的apple-app-site-association文件校验唤起体验首次唤起弹“是否打开”确认框且不在同一个App内打开点击链接直接无缝进入AppSafari无弹窗被接管风险多个App注册同一个scheme时系统行为不确定域名可精确匹配不会出现歧义iOS 13以后趋势苹果逐步压缩scheme使用场景限制部分API调用官方推荐的必选方案降级方案在Web端可以无验证拉起如果域名/文件配置错误则完全失效从纯游戏角度讲新建项目我基本建议直接用Universal Links为主URL Scheme作为降级或辅助保留。但有个客观问题Universal Links对环境要求比较严格需要服务器能托管一个JSON文件还需要HTTPS而且开发环境下联调很麻烦。很多中小团队为了快速上线就先只做URL Scheme等品牌域名的文件配置好了再切换或共存。两种方案可以同时存在系统会先尝试Universal Links失败时会再走URL Scheme逻辑。你还要考虑国内iOS环境的特殊性很多第三方分享/统计SDK比如友盟、微信开放平台也依赖URL Scheme在Info.plist里会出现一堆scheme配置。为了避免冲突我建议给游戏设计一个足够独特的scheme前缀不要用太通用的像game://、app://这种否则很容易被另一个抢注的App截胡。2. 原生侧配置从Info.plist到apple-app-site-association2.1 URL Scheme配置步骤这里以Unity 2021版本、使用Unity导出的Xcode工程为例。整体步骤是先加scheme再处理回调方法最后让Unity工程中的对象能收到消息。在Xcode里选中Target切到Info面板往下拉到URL Types栏点击加号后配置两项Identifier一般填bundle id反向域名比如com.yourcompany.gameURL Schemes填你设计的scheme比如ygtgame。配置完成后工程里的Info.plist会多出一段类似这样的内容keyCFBundleURLTypes/key array dict keyCFBundleTypeRole/key stringEditor/string keyCFBundleURLName/key stringcom.yourcompany.game/string keyCFBundleURLSchemes/key array stringygtgame/string /array /dict /array个人建议直接用Source Code方式查看Info.plist防止Xcode的图形界面自动改了一些隐藏格式。另外如果你的项目用Unity的Build or iOS会每次重新生成工程那么在Xcode里手改的配置会在下次导出时丢失所以更稳妥的做法是使用Unity的iOS Build PostProcessor脚本在Xcode工程生成后用PbxProject API去自动添加URL Types。这样CI打包和本地打包都不会漏配置。2.2 Universal Links配置步骤Universal Links需要三端的配合Apple开发者账号、你的HTTPS服务器、Xcode工程配置。第一步在Apple Developer后台为你的App ID开启Associated Domains能力。注意这里不是个人开发者团队也能开但需要在证书里包含这个entitlement。第二步Xcode工程的Signing Capabilities面板添加Associated Domains capability并在列表中填入你打算使用的域名格式是applinks:example.com。如果支持子域名还可以写applinks:*.example.com。第三步服务器上放置关联文件。苹果要求这个文件必须能通过https://example.com/apple-app-site-association直接访问文件名是固定不能改的。文件内容是JSON简短版本大概是{ applinks: { details: [ { appIDs: [ TEAMID.com.yourcompany.game ], components: [ { /: /game/*, comment: 匹配所有以/game/开头的路径 } ] } ] } }其中TEAMID是你开发者账号的Team ID在Apple Developer后台的Membership Details里能看到格式是一串10位字符。这里有一个特别容易被忽略的点苹果对文件Content-Type有要求服务器返回这个路径时必须带上Content-Type: application/json头而且响应不能经过重定向。我用Nginx的时候遇到过因为没加header导致模拟器能识别、真机却识别不了的情况。你可以在服务器端加上location /apple-app-site-association { default_type application/json; }另外要注意苹果的设备偶尔会缓存这个文件短时间修改后不一定立即生效换个WiFi或重启设备通常能帮助测试。之前苹果官方提供过一个验证URL但那个工具后来停掉了现在更实用的验证方式是把链接直接发给iPhoneSafari打开看顶部是否有直达App的提示。2.3 场景区分冷启动与热启动的系统回调差异配置完链接只是第一步真正代码里要处理两种系统状态这也是新手容易出错的分水岭冷启动Cold StartApp进程完全不存在用户点击链接导致系统直接拉起进程热启动Warm StartApp进程还在后台用户点击链接系统把链接交给前台App。在Xcode工程中xxxAppDelegate.m里需要同时处理对应的回调。Unity默认生成的AppDelegate其实是继承自UnityAppController这个类你需要在原文件里增加这些方法注意别覆盖了Unity框架自身的实现。我通常是直接在UnityAppController的一个扩展分类Category里去处理Deep Link不修改Unity自动生成的主文件这样升级Unity版本时不容易出冲突。3. 拿到链接后AppDelegate中的两套回调处理3.1 启动时与运行时的系统回调我在实际项目里同时维护四个入口分别是URL Scheme和Universal Links的冷启动与热启动。代码长这样// 热启动 URL SchemeiOS 9 之后仍然能走 - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { if ([url.scheme isEqualToString:ygtgame]) { [self handleDeepLink:url]; return YES; } // 第三方SDK自己也要处理openURL不要全部吞掉 return NO; } // 冷启动 URL Scheme一般不会走这个但老系统兼容 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSURL *url [launchOptions objectForKey:UIApplicationLaunchOptionsURLKey]; if (url) { [self handleDeepLink:url]; } return YES; } // Universal Links 热启动 - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring *restorableObjects))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [self handleDeepLink:userActivity.webpageURL]; return YES; } return NO; } // Universal Links 冷启动也走continueUserActivity但didFinishLaunching里要设标志注意Universal Links在冷启动场景下同样会回调continueUserActivity只是会比didFinishLaunching晚一点触发。很多人误以为冷启动要走didFinishLaunchingWithOptions的UIApplicationLaunchOptionsUserActivityKey实际那个key也是可以拿到的但主流的可靠做法是统一在continueUserActivity里处理再配合一个“App启动完成”标志位给C#层做缓存。这样不论冷热启动业务逻辑都在持续的同一入口内完成避免把参数拆成两套逻辑。3.2 把参数安全地转交给C#层拿到NSURL之后需要把完整的absoluteString传递到Unity层。最简单也最可靠的方式是iOS原生层的UnitySendMessage函数。它是Unity提供的原生回调方法签名如下UnitySendMessage(GameObjectName, MethodName, param);三个参数的含义分别是挂载脚本的GameObject名字、脚本上的方法名、传给方法的字符串参数。这看起来简单坑其实不少我后面章节专门说。先把原生侧的完整处理路径写出来- (void)handleDeepLink:(NSURL *)url { NSString *urlString [url absoluteString]; // 这里先做一次备份因为C#层可能还没准备好 if ([UnityAppController sharedApplication].unityReady) { UnitySendMessage(DeepLinkHandler, OnReceiveDeepLink, [urlString UTF8String]); } else { // 把链接存到临时变量等Unity Ready之后再投递 self.pendingDeepLinkUrl urlString; } }实际工程里我会在C#脚本连接桥接层时提供一个“拉取待处理链接”的原生接口这样冷启动时不管Unity什么时候ReadyC#那边都能在初始化后主动把缓存链接取走。这个模式比原生层猜“Unity现在准备好没有”更稳健也是我在多个项目里用过之后沉淀下来的做法。如果你不想在AppDelegate里写得太多也可以把Deep Link处理独立成一个DeepLinkManager类在UnityAppController的启动流程里手动调用初始化方法。这种方式更适合多人协作避免每个人都在AppDelegate里堆逻辑。4. C#层接收与投递UnitySendMessage与参数消费4.1 桥接与脚本组织在Unity工程里我会专门创建一个空GameObject名字和AppDelegate里的第一参数严格一致比如就叫DeepLinkHandler上面挂一个MonoBehaviour脚本。脚本里写这样的接收入口using UnityEngine; public class DeepLinkHandler : MonoBehaviour { public static string LastDeepLinkParam; private void Awake() { // 让这个对象跨场景存活 DontDestroyOnLoad(gameObject); } // 这个方法名必须和UnitySendMessage的第二参数一致且必须是公开的 public void OnReceiveDeepLink(string url) { LastDeepLinkParam url; Debug.Log($[DeepLink] raw url {url}); // 触发游戏内事件让业务监听 DeepLinkDispatcher.Instance.OnLinkReceived(url); } }有几个容易踩的点需要专门强调UnitySendMessage调用的方法必须是非静态的public方法并且挂在指定名字的GameObject上。如果脚本没挂、对象名写错一个大小写回调就静默丢失而且原生侧不会报错。接收方法默认是主线程执行的不用额外做线程切换。这是UnitySendMessage优于一些自定义回调方式的地方。如果用UnitySendMessage丢给一个未创建的对象会输出类似“GameObject not found”的日志这算好的怕的是对象存在但方法名错误日志都不明显。DontDestroyOnLoad必须放在挂载脚本的所在对象上这样切换场景或者加载界面时不会丢。4.2 冷启动缓存与热启动即时投递在真实场景里“热启动时Unity已经Ready”和“冷启动时Unity还没Ready”的处理方式完全不同。热启动好办原生层拿到链接后直接UnitySendMessage冷启动则要确保Unity引擎初始化完再投递。我习惯的做法是原生层在handleDeepLink:只负责存一份变量然后尝试直接发送。如果Unity还没Ready直接发送会无效所以就必须等UnityReady回调。Unity提供的UnityFramework在启动完成后会发一个UnityDidReadyNotification。我的DeepLinkManager监听这个通知[[NSNotificationCenter defaultCenter] addObserver:self selector:selector(unityDidReady) name:UnityDidReadyNotification object:nil]; - (void)unityDidReady { if (self.pendingDeepLinkUrl) { UnitySendMessage(DeepLinkHandler, OnReceiveDeepLink, [self.pendingDeepLinkUrl UTF8String]); self.pendingDeepLinkUrl nil; } }同时在C#层我也增加一条保险在Start事件里主动向原生层请求“是否有缓存链接”通过Unity的Application.OpenURL没法主动拉取所以需要自己写一个原生导出函数。这里建议直接在C#里定义[DllImport(__Internal)] private static extern string GetPendingDeepLink();用iOS原生实现一个返回pending链接的函数C#在初始化时调用一次。这样即使通知时机错乱最终参数也不会丢。4.3 参数编码格式设计接下来是被问得最多的部分链接里的参数怎样处理才能避免中文乱码和嵌套解析问题。我见过很多团队直接在URL后面拼?user_id123activity_id456C#拿到后按字符串分割这种做法在参数少时确实可以但一旦参数里有中文、特殊符号或者URL嵌套就非常头疼。推荐的做法是在任何带复杂对象的Deep Link里把参数组装成JSON字符串然后再用Base64编码拼到URL里。例如H5前端拼出这样的链接ygtgame://game/goto?payloadbase64_json其中的base64_json解码后是{ activityId: winter2025, userId: uid_003, channel: sms, ts: 1735689600 }C#接收端先解出URL、再根据scheme和host判断业务类型然后解析query得到payloadbase64解码成JSON再用JsonUtility或LitJson转对象。整个链条这样设计有几个好处避免中文字符在URL拼写阶段被转义搞乱避免参数里出现、等符号把query切错后续加字段时只需要扩展JSON不需要改协议结构Base64后的字符串基本是URL-safe的但严格说要替换/或者直接用System.Web.HttpUtility.UrlEncode再拼。不过Base64不是银弹和/在URL里也可能被当成特殊字符所以我在C#侧解析时会先做Uri.UnescapeDataString再做Base64解码。如果不想考虑Base64的safe问题也可以直接做双重URLEncode反正原则是“一层层编一层层解”每一步都打印日志出问题才好定位。5. 前端配合H5页面、扫码、短信等入口如何携带参数5.1 H5唤起App的标准动作Deep Link不光是客户端的事触发端同样重要。最常见的就是H5活动页上放一个按钮点击后先尝试唤醒App如果失败就引导去App Store。iOS上通用的实现是把链接写到window.location或者动态创建一个a标签点击。这里说的链接可以是Universal Links也可以是URL Scheme。如果目标链接是URL SchemeH5侧的跳转很简单function goApp() { var deeplink ygtgame://game/goto?payload encodeURIComponent(btoa(JSON.stringify(payload))); window.location.href deeplink; }但URL Scheme存在一个致命的体验问题如果设备里没安装AppSafari会弹一个“打不开此网页”的错误提示并不会静默失败。所以通常会配合一个降级先隐藏跳转用一个隐藏的iframe或location尝试拉起同时启动一个计时器比如2秒后仍然停留在本页就认为是App未安装跳转到App Store下载页。这段降级逻辑网上方案很多我提醒一点页面如果被微信内置浏览器打开URL Scheme基本无法正常拉起App微信会在Scheme层做拦截所以必须引导用户用Safari打开。这个点如果不在需求前期和运营说清楚上线后活动页反响“没反应”大概率就是这个原因。5.2 失败降级与App Store跳转降级跳转的目标地址可以直接用App的App Store链接比如https://apps.apple.com/app/idxxxxxxxx。在H5里判断navigator.userAgent捕获iOS设备后再决定是否走这个流程。如果你用的是Universal Links那么即使没装App点击Universal Links本身在Safari里就会打开你的官网这时候可以在官网页面上做一个“立即下载”按钮让用户能顺手进App Store而不是卡在错误页。我做国内渠道发行的时候还遇到过一种情况iOS部分版本对Universal Links的支持不稳定同一套配置在部分低版本手机上就会出现点击无反应。所以投产前必须拿覆盖系统版本的测试机过一遍至少确认iOS 13、15、17这几个档位。值得单独说一下的是“ios浏览器唤起安装app”这个需求。iOS由于系统生态的原因浏览器永远不可能像Android那样直接下载安装apk设计师和产品经理必须接受这个事实你只能引导用户进App Store。所以不要浪费精力去搞什么自动安装做一条安全的降级路径比什么都重要。6. 常见问题排查与调试实录6.1 Universal Links打不开的经典原因我总结了一套排查的顺序按这个来基本能定位80%的问题现象可能原因验证方式Safari打开链接无任何反应apple-app-site-association文件无法访问浏览器直接访问https域名下的文件看是否返回JSON文件能访问但系统不识别Content-Type不正确或者文件被重定向用curl -I查看响应头有提示但点击无跳转App没有开启Associated Domains或TeamID错误Xcode的Signing Capabilities里复查只有部分设备正常缓存问题或域名跨域问题更换网络环境、重启设备、重新安装App最隐蔽的一个问题如果你在Apple Developer后台开启了Associated Domains但还没更新Provisioning Profile并重新下载那也会导致设备上完全不生效。Xcode自动管理签名时一般会自动处理但手动管理证书的老项目就很容易漏。还有一点不太被注意但很关键iOS的Universal Links如果是从Safari手动输入地址访问的有时不会触发跳转。苹果在某个版本后规定地址栏手动输入URL默认不当作user activity处理用户必须是在外部的Safari点击链接才能正常触发。所以测试的时候别傻傻地在Safari地址栏里输入链接要用备忘录、钉钉、或者Messages里的链接文本点过去。6.2 参数丢失或乱码的排查如果链接能唤起App但C#层拿到的参数不对问题通常集中在编码和处理时机两处。编码检查H5拼URL时是否做了encodeURIComponent检查原生层是否用了absoluteString还是URLWithString的路径解码下发到C#的字符串如果中间被进制转换过最好都统一为UTF-8。时机最典型的错误是AppDelegate里收到链接时Unity还没有创建好游戏对象。如果直接发UnitySendMessage消息会被丢弃一定要走到unityDidReady通知后重发或者在C#端存这个链接等脚本启动后主动拉取。另外一个我不止一次遇到的坑Unity的UnitySendMessage只支持传递一个const char*参数如果URL字符串中包含\0或者中文长度超出一定范围也会出现截断。所以大体积JSON格式的payload不要直接塞URL尽量精简到必要字段把真正完整的业务数据放到你的服务器然后由C#拿参数再请求服务器补充。6.3 测试工具与操作清单最后分享一下我测试Deep Link时用到的工具和方法组合系统方案手机上用备忘录编辑一段文字包含URL Scheme和Universal Links两种链接点一下看行为Safari调试用Mac的Safari开发者菜单通过USB连接iPhone查看浏览器控制台观察Universal Links的相关日志服务器验证curl命令直接访问apple-app-site-association文件确认没有跳转、格式正确Charles抓包如果App内部还要请求服务器用抓包确认客户端携带的payload与H5发出的完全一致。注意测试Universal Links前一定要确认手机Safari的“智能搜索”设置没有干扰部分用户关闭了某些Safari能力也会影响跳转行为。这属于极端环境但确实在用户反馈里出现过。至于iOS开发者模式的问题如果真机是iOS 16及以上版本需要先在设置里开启开发者模式才能安装调试包。这不是Deep Link特有的但往往在这个功能联调时第一次撞上。遇到“安装失败、无法验证App”这类情况请先在iPhone的设置-隐私与安全性-开发者模式里把开关打开再试。收尾的一块经验回看整个链路我最想提醒的不是某段代码怎么写而是先把链路画出图来H5或短信发出什么URL、iOS系统如何分发、原生AppDelegate哪个方法处理、Unity何时能安全接收、C#解析成什么模型、游戏逻辑在哪消费。这一步理不清楚后面写代码一定反复改。日志也很重要。在原生入口、UnitySendMessage调用前后、C#接收方法第一个print、JSON解析后这四个点都打上日志并且把URL参数上报到你的统计后台。这样就算以后用户反馈“点击没反应”你也能通过日志定位是链接没到App还是App收到了但业务逻辑没消费。最后再分享一个小技巧如果你接的是多个发行渠道每个渠道都希望用不同的scheme或域名做统计就尽量把Deep Link解析和业务处理解耦。原生层只负责把URL投递给C#C#层通过一个统一的“链接路由表”去分发。新渠道接入的时候只加一行路由配置不用再改原生代码也不会影响已经上线的逻辑。兼容性、可维护性都会好上一个台阶。