iOS Deep Link与Unity接入全流程:从Universal Links到C#路由实践

发布时间:2026/10/1 8:08:03
iOS Deep Link与Unity接入全流程:从Universal Links到C#路由实践 做手游拉活这事的同学几乎都会遇到同一个需求运营那边丢过来一个链接说“玩家点了这个链接装了咱们App的直接进到对应页面没装的给我落到下载页”。听起来不复杂但真把 iOS 这条链路跑通——从 URL Scheme、Universal Links到 iOS 原生回调再传到 Unity 的 C# 层最后路由到指定界面——里面值得注意的细节比想象中多得多。这篇文章我就把整套全流程梳理一遍顺便把我踩过的坑、最后怎么收敛的都摊开讲清楚。适合正在搭 Deep Link 链路、或者已经接了一半但被各种时序问题搞到头秃的 Unity 客户端开发同学参考。1. 拉活场景下的方案取舍为什么我弃纯 URL Scheme 走上 Universal Links1.1 一次投放需求逼出来的技术债最早我们项目用的就是最传统的 URL Scheme形式大概是realmgame://open?screenmailgiftId10086在 iOS 的 Info.plist 里注册 scheme玩家在 Safari 或第三方 App 里点realmgame://开头的链接系统直接把我们的 App 拉起来。这套方案在早期确实够用配置也简单老版本 iOS 全支持几十行代码能跑通。但它有几个很让人难受的痛点。第一scheme 是全局的理论上别的 App 也能注册realmgame://一旦冲突iOS 会把选择权交给用户弹出“在‘XX’和‘XX’之间选择”之类的提示这一下体验就毁了。第二URL Scheme 只能表达“我要打开这个 App”没法表达“我想打开这个 App 的哪个页面”业务参数完全靠自己约定。第三也是我们后来转型的核心原因App 没装的时候URL Scheme 会直接报“无法打开网页”没有任何优雅的降级路径。投放的同学最怕的就是这种“死链接”。后来我们开始接广告归因平台和渠道投放对方给到的回传链接基本都要求 Universal Links。以 AppsFlyer 为代表的归因平台很早就在自己的链接体系里默认走 Universal Link配合 OneLink 这种产品点击后可以做到“装了 App 直接唤起并带参数没装就跳转商店/落地页”。如果只靠 URL Scheme很多渠道的转化数据根本拿不全买量成本核算都会出问题。1.2 两个方案的对比表与选型结论在做技术选型时我整理过一张表基本能代表当前的主流看法对比维度URL SchemeUniversal Links最低系统版本iOS 6iOS 9注册方式Info.plist 里的 CFBundleURLTypesXcode Associated Domains 域名验证文件路径匹配能力只能精确到 scheme后面全凭自己解析可在 apple-app-site-association 里配置 path 通配是否会被其他 App 抢注会出现过同名 scheme 冲突不会基于域名和 TeamID 绑定未安装 App 时的行为系统直接提示无法打开Safari 打开对应网页可引导下载点击场景覆盖App 内 WebView、Safari、短信均可同上且对微信这类封闭环境更可控配置复杂度低相对高需要服务器放验证文件归因平台支持都支持主流平台更推荐尤其广告投放场景我的结论很简单如果是新项目或者准备认真做买量和拉活的存量项目主力方案直接上 Universal LinksURL Scheme 保留但只作为兼容老版本、内部测试、个别 SDK 强依赖场景的补充。两条链路同时存在并不冲突iOS 在多数情况下会优先把 Universal Link 的点击交给 App 处理而老版本系统则退回 URL Scheme。关键是两边的回调最终都统一进同一个 C# 分发入口业务层不用关心来源是哪个。2. iOS 原生层接入Capabilities、Info.plist 与回调代码2.1 URL Types 配置和两种回调路径的 ObjC 实现先说 URL Scheme 的配置。在 Xcode 工程的 Info.plist 里添加 CFBundleURLTypes内容类似这样keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.gamestudio.realm/string keyCFBundleURLSchemes/key array stringrealmgame/string /array /dict /arrayCFBundleURLName 一般填 bundle id 或反向域名scheme 就是链接开头的部分比如realmgame://open?screenmail。接下来是回调。URL Scheme 的唤起分两种情况App 被杀掉后的冷启动以及 App 已经挂在后台的热启动。冷启动时链接信息放在didFinishLaunchingWithOptions的 launchOptions 里- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSURL *url launchOptions[UIApplicationLaunchOptionsURLKey]; if (url ! nil) { [DeepLinkDispatcher dispatch:url.absoluteString isCold:YES]; } NSDictionary *activityDict launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; NSUserActivity *activity activityDict[UIApplicationLaunchOptionsUserActivityKey]; if (activity.webpageURL ! nil) { [DeepLinkDispatcher dispatch:activity.webpageURL.absoluteString isCold:YES]; } return YES; }热启动时系统会走另外两个回调URL Scheme 走openURL:options:Universal Link 走continueUserActivity:- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { [DeepLinkDispatcher dispatch:url.absoluteString isCold:NO]; return YES; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nonnull))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb] userActivity.webpageURL ! nil) { [DeepLinkDispatcher dispatch:userActivity.webpageURL.absoluteString isCold:NO]; } return YES; }这里有个容易踩的点如果工程里同时接了多个第三方 SDK比如统计、广告归因、登录 SDK这些 SDK 的文档也会教你在 AppDelegate 里写同样的回调方法。一不注意后一个实现就把前一个覆盖了结果就是某个 SDK 收不到链接或者我们自己的逻辑被顶掉。我的做法是在原生层保留一个统一的 DeepLinkDispatcher所有 SDK 和业务方法的回调都从这一个入口转发谁需要数据谁就注册。2.2 Associated Domains 与 AASA 文件部署Universal Links 的接入分两块工程配置加服务器文件。Xcode 里需要在 Signing Capabilities 中添加 Associated Domains填入applinks:games.example.com。这里的域名必须是 HTTPS且该域名对应的服务器要能放一个名为apple-app-site-association的 JSON 文件。文件可以放在域名根目录也可以放在.well-known目录下。内容如下{ applinks: { apps: [], details: [ { appID: ABC123DE45.com.gamestudio.realm, paths: [ /game/*, NOT /game/admin, /open/* ] } ] } }appID 是TeamID.BundleID的组合TeamID 在开发者后台个人账号信息里能看到。paths 支持通配符*匹配任意路径?匹配单个字符也支持NOT排除。如果你只是用于拉活直接配/open/*这类具体前缀就够了避免把整个域名下所有链接都交给 App。部署验证有个很实用的命令curl -s https://games.example.com/apple-app-site-association | jq .如果你能看到 JSON 输出说明服务器文件没问题。注意不要给这个文件做重定向部分场景下 Apple 的抓取服务对重定向容忍度很低文件里也不要带多余的内容嵌套直接就是这个 JSON。还有一个需要留意的点AASA 文件是 iOS 系统定期去拉的不是实时生效。你改了 paths 之后系统可能要在 24 小时到几天后才重新拉取测试阶段经常出现“明明配置对了手机上就是不行”的情况。我的经验是改完文件后把 App 删掉重装再点击一次 Universal Link大概率能触发重新校验。2.3 用 PostProcessBuild 把配置自动写进 Xcode 工程Unity 出包和原生工程不同每次用 Xcode 重新生成工程之前手动改的 Capabilities、Info.plist 都会消失。所以从第一天开始我就把所有 iOS 配置写成了 PostProcessBuild 脚本。每次构建完自动往 Xcode 工程里注入配置这样 CI 出包、QA 出包都走同一个流程再也不会漏using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; public static class DeepLinkPostProcess { [PostProcessBuild(1)] public static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject) { if (target ! BuildTarget.iOS) return; string projPath PBXProject.GetPBXProjectPath(pathToBuiltProject); PBXProject proj new PBXProject(); proj.ReadFromFile(projPath); string targetGuid proj.GetUnityMainTargetGuid(); ProjectCapabilityManager caps new ProjectCapabilityManager(projPath, Unity-iPhone.xcodeproj, targetGuid); caps.AddAssociatedDomains(new[] { applinks:games.example.com }); caps.WriteToFile(); string plistPath pathToBuiltProject /Info.plist; PlistDocument plist new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementArray urlTypes plist.root.CreateArray(CFBundleURLTypes); PlistElementDict dict urlTypes.AddDict(); dict.SetString(CFBundleURLName, com.gamestudio.realm); PlistElementArray schemes dict.CreateArray(CFBundleURLSchemes); schemes.AddString(realmgame); plist.WriteToFile(plistPath); } }AddAssociatedDomains这个方法在 Unity 2018.4 之后都有。如果你的项目还在用老版本那就得手动创建 entitlements 文件再挂到 pbxproj 的 codeSignEntitlements 里比较繁琐所以我也建议顺手把 Unity 升级到现代版本能省不少事。3. 原生到 Unity 的消息桥UnitySendMessage 的时机与缓存3.1 UnitySendMessage 的机制与坑点原生回调拿到了 URL接下来的核心任务是把 URL 字符串交给 C#。最常见的方式是UnitySendMessage它的签名是UnitySendMessage(GameObjectName, MethodName, 参数);它会向 Unity 场景中名为 GameObjectName 的物体发送一条消息调用该物体任意组件上的 MethodName 方法参数是 C 字符串。听起来很简单但实际用起来有四个坑第一目标 GameObject 必须真实存在于当前场景。如果场景还在加载或者承载方法的物体是后创建的消息就直接丢了Unity 只在 Console 里打一条警告。第二方法只能接收一个 string 参数想传结构化的业务参数只能在 URL 字符串里做文章。第三它必须在主线程调用好在我们处理 Deep Link 的所有回调都在 iOS 主线程。第四如果 URL 里带中文或特殊字符确保传给 UnitySendMessage 之前用 UTF-8 转成const char*否则 C# 侧解码会乱。3.2 冷启动时链接先到的时序控制方案真正让我一开始翻车的是时序问题。iOS 的didFinishLaunchingWithOptions回调发生时Unity 引擎往往还没完成初始化场景里的 MonoBehaviour 根本不存在。这时候如果直接调UnitySendMessage那条链接大概率会打空。所以原生层必须做一个简单的缓存等待机制static BOOL _unityReady NO; static NSString *_pendingLink nil; void SetUnityReady(void) { _unityReady YES; if (_pendingLink ! nil) { UnitySendMessage(DeepLinkManager, OnNativeDeepLink, _pendingLink.UTF8String); _pendingLink nil; } } implementation DeepLinkDispatcher (void)dispatch:(NSString *)url isCold:(BOOL)cold { if (_unityReady) { UnitySendMessage(DeepLinkManager, OnNativeDeepLink, url.UTF8String); } else { _pendingLink [url copy]; } } end这个设计的时序是iOS 回调先把链接暂存到原生静态变量里等 Unity 场景加载完成后C# 侧调用SetUnityReady()原生层再把缓存里的链接补发给 C#。这样冷启动丢失链接的问题就解决了。3.3 C# 侧接收消息常驻对象与 Ready 标记C# 侧需要一个常驻的接收器。我把它挂在游戏启动时就存在的对象上并且用DontDestroyOnLoad保证切换场景时不被销毁。Awake 里做单例注册Start 里通知原生层“Unity 已就绪”public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } private void Start() { #if UNITY_IOS !UNITY_EDITOR SetUnityReady(); #endif } public void OnNativeDeepLink(string url) { Debug.Log($[DeepLink] native forwarded: {url}); GameApp.HandleDeepLink(url); } #if UNITY_IOS [System.Runtime.InteropServices.DllImport(__Internal)] private static extern void SetUnityReady(); #endif }注意接收方法名OnNativeDeepLink必须和UnitySendMessage里写的方法名完全一致大小写也不能错。还有如果你的场景里存在多个相同名字的 GameObjectUnitySendMessage 只发给其中一个行为不可控所以名字尽量取项目里唯一的。4. C# 层参数解析与业务路由落地4.1 URL 规范设计与 query 解析App 实际会收到两类 URL一类是 URL Scheme 的realmgame://open?screenmailgiftId10086另一类是 Universal Link 的https://games.example.com/open?screenmailgiftId10086。业务参数统一放在 query string 里C# 侧用同一套解析逻辑处理这样入口就收敛了。我建议所有渠道生成的链接都遵循一对约定参数名服务端和客户端固定不要用中文键参数值统一做 URLEncode尤其是礼物 ID、活动 ID 这种带特殊字符的字段。解析时用Uri.UnescapeDataString做还原不要直接Replace(, )因为标准的 URLEncode 在 query 里会把空格编成%20会和 form 表单语义混淆public static Dictionarystring, string ParseQuery(string url) { var result new Dictionarystring, string(); int queryIndex url.IndexOf(?); if (queryIndex 0 || queryIndex url.Length - 1) return result; string query url.Substring(queryIndex 1); string[] pairs query.Split(); foreach (string pair in pairs) { int eq pair.IndexOf(); if (eq 0) continue; string key Uri.UnescapeDataString(pair.Substring(0, eq)); string val Uri.UnescapeDataString(pair.Substring(eq 1)); if (!result.ContainsKey(key)) result[key] val; } return result; }4.2 场景路由分发从链接到界面拿到参数之后我习惯在 GameApp 里放一个 HandleDeepLink 入口负责把链接映射到游戏内的目标界面。这里要明确Deep Link 路由和普通 UI 跳转有一个本质差异用户可能在 App 冷启动后直接进到指定页但游戏的主城、登录态、基础数据可能还没准备好。我的做法是把 Deep Link 目标封装成一个“待办任务”等游戏的核心流程走完再执行。具体实现是把路由参数存到静态字段并注册到游戏启动流程的后续节点中由主线状态机在执行到对应阶段时消费。比如游戏要求必须先登录那screenmail就得等到登录完成后再跳。如果你不管时机直接在主城 Awake 里跳转很可能会碰到 UI 框架还没注册完的报错。路由分发本身不难public static void HandleDeepLink(string url) { if (string.IsNullOrEmpty(url)) return; if (url.StartsWith(realmgame://) || url.StartsWith(https://games.example.com/)) { var args ParseQuery(url); string screen args.ContainsKey(screen) ? args[screen] : hall; switch (screen) { case mail: PendingNavigation () UIManager.OpenMail(args); break; case gift: PendingNavigation () UIManager.OpenGift(args); break; default: PendingNavigation () UIManager.OpenHall(); break; } TryFlushPendingNavigation(); } }4.3 参数没到的补拉与容错逻辑Deep Link 链路里除了时序还有一类问题是参数缺失或被截断。我遇到过链接里带了未编码导致 parse 后 giftId 变成空串的情况。这种问题源头不在客户端而在生成链接的服务端但客户端也要做兜底当关键参数缺失时路由默认落到主城并打一条明确定位到缺失字段的日志方便投放侧查问题。此外要预防同一个链接被重复消费。比如冷启动时原生缓存了一条玩家切后台再点另一条两条消息都到了业务层可能会连续跳两次页。我在 HandleDeepLink 里加了一个去重队列同一秒内相同 URL 直接丢弃或者用递增的 seq 保证只处理最新的一条。不要觉得这是多余实测这种重复唤起在 iOS 上相当常见。5. 收尾验证与线上死角实机清单、降级跳转和埋点5.1 真机与模拟器的验证清单整套链路搭完后千万别只在编辑器里点两下就宣布完成。Daily Build 出来后我至少会跑一遍下面这个清单URL Scheme 冷启动先杀掉 App再在 Safari 输入realmgame://open?screenmailgiftId10086观察是否能拉起且进入邮箱页。URL Scheme 热启动App 退到后台但不杀掉重复上一步观察openURL:options:是否触发。Universal Link 冷启动Safari 打开https://games.example.com/open?screenmailgiftId10086第一次可能会停顿看是否唤起 App。Universal Link 热启动App 挂后台再次点击链接。模拟器可以快速验证 schemexcrun simctl openurl booted realmgame://open?screenmail。验证降级把 App 删掉再点 Universal Link应该看到网页而不是报错。这里提醒一句Universal Link 在刚装完 App 的几分钟内可能不生效因为系统还没拉到最新的 AASA。测试时如果第一次没唤起不要急着改配置等一会儿再试或者重装 App 触发系统重新拉取。5.2 未安装 App 时的降级处理思路Universal Link 最大的优势就在降级。玩家没装 App 时点击链接会直接打开对应的网页也就是你服务器上那个 URL 的页面。所以服务器要为/open/*这类路径准备一个落地页页面上放游戏介绍、下载按钮或者接入苹果的 Smart App Banner。千万不要依赖“点击后自动跳 App Store”的脚本。iOS 上通过 Web 自动跳商店的体验越来越差很容易被 Safari 拦截而且苹果本身也不鼓励这种强跳转。落地页老老实实给一个下载按钮让用户自己决定是否去 App Store转化率反而不低。如果要配合渠道归因落地页 URL 上最好带渠道参数比如https://games.example.com/open?screenmailchannelcha_ad01这样用户在网页上的点击行为和服务端下发的包能对应起来后面做 LTV 分析才知道每个渠道的用户质量。5.3 埋点和日志用数据反哺投放最后一步是埋点。Deep Link 埋点不只是为了让数据看板好看它能直接反哺投放策略。我会在 C# 的 HandleDeepLink 入口统一上报以下信息链接来源 source广告平台、短信、邮件、站内分享活动标识 campaign关键业务参数如 screen、giftId冷启动还是热启动 coldStart路由是否成功、失败原因这些数据攒在本地日志缓冲区等游戏主流程稳定后再批量上报避免启动瞬间发请求跟首包资源下载抢带宽。也可以用现成的数据采集 SDK但无论如何上报字段要在 C# 层统一收口不要散落在 ObjC 和 C# 各报一份否则后面核对归因时会疯掉。我个人做这套链路时最大的体会是把“原生回调”和“C# 业务消费”两个环节彻底解耦原生层只管把链接送达C# 层只管路由和容错。中间所有可能导致消息丢失的时序问题都用“缓存待发”的思路解决。另一个值得养成的习惯是把 AASA 文件内容、TeamID、BundleID 这些信息写进团队的配置文档里因为这两串 ID 一旦对不上Universal Link 就是永远唤起不了而且排查起来特别费劲。建议所有做拉活的团队都把配置验证脚本和真机测试清单沉淀下来每次出包都跑一遍会比临时翻文档快得多。