资讯中心

Unity手游iOS Deep Link实战:URL Scheme与Universal Links参数透传C#

📅 2026/9/28 23:53:10
Unity手游iOS Deep Link实战:URL Scheme与Universal Links参数透传C#
1. 为什么手游团队绕不开 Deep Link 这件事做过手游运营的兄弟都清楚买量投放最怕的不是点击率低而是用户点了广告、装完 App、打开之后落到了首页完全找不到刚才广告里那个活动入口。这个转化漏斗每断一层买量成本就往上翻一截。Unity 手游 iOS Deep Link 唤醒要解决的就是这条链路让用户从浏览器、短信、社交 App、广告落地页点一个链接直接跳到游戏内指定页面并且把链接上带的参数区服、邀请码、活动 ID、渠道标识原封不动投递到 C# 业务层。标题里提到的两个关键词——URL Scheme和Universal Links——是 iOS 上实现这件事的两条腿。前者是老牌方案配置简单但体验有瑕疵后者是苹果主推的方案体验好但配置链路长、坑也多。而真正让 Unity 开发者头疼的往往不是原生层怎么配而是参数怎么从 Objective-C / Swift 层安全、准确地传到 C# 层还要处理冷启动、热启动、App 未安装跳 App Store 再回流的各种边界情况。这篇内容适合三类人看一是正在做手游买量归因、活动唤起的 Unity 客户端开发二是需要和原生 iOS 同学对接联调的技术负责人三是想搞清楚 Deep Link 全链路到底有哪些坑的独立开发者。我会按“方案选型 → 原生配置 → Unity 桥接 → 参数投递 → 问题排查”的顺序把整条链路拆开讲透代码和配置都能直接抄。2. 方案选型URL Scheme 和 Universal Links 到底怎么选2.1 两种方案的本质区别很多人把这两个东西混着用结果线上出现“点了没反应”或者“跳浏览器再跳回来”的诡异体验。先把本质讲清楚。URL Scheme是 App 自己注册的一个自定义协议头比如mygame://。系统收到这个协议的链接时会去查哪个 App 注册了它然后拉起。它的特点是配置极简只要在Info.plist里加一段就行但缺点也很明显——任何 App 都能注册同一个 Scheme存在被劫持的风险而且从 Safari 打开时如果 App 没装会直接弹一个“打不开”的错误体验很差。Universal Links是苹果在 iOS 9 之后推的方案本质是把你自己的域名和 App 绑定。用户点https://game.example.com/activity?id123这样的普通网页链接如果设备上装了你的 App系统会直接拉起 App 并把链接传进来如果没装就正常打开网页你可以在网页上引导下载。体验上无缝安全性也高因为域名归属是验证过的。2.2 选型决策表维度URL SchemeUniversal Links配置复杂度低改 Info.plist 即可高需要服务端配置 AASA 文件未安装 App 时体验报错体验差正常打开网页可引导下载安全性低可被其他 App 抢注高域名验证从 Safari 直接点击需二次确认弹窗直接唤起无弹窗从其他 App 内 WebView通常可用部分场景受限微信/QQ 内打开基本被拦截基本被拦截冷启动参数获取通过 launchOptions通过 continueUserActivity热启动参数获取openURL 回调continueUserActivity 回调我的实际建议是两个都配主用 Universal LinksURL Scheme 作为兜底。原因很现实——Universal Links 在某些场景下会失效比如用户从某些 App 的内置浏览器点击、或者 AASA 文件被 CDN 缓存了旧版本这时候 URL Scheme 能救场。反过来如果只配 URL Scheme买量落地页的转化率会明显吃亏。2.3 一个容易被忽略的坑AASA 文件的缓存Universal Links 依赖服务端根目录下的apple-app-site-association文件简称 AASA这个文件必须满足几个硬性条件HTTPS、无重定向、Content-Type 为 application/json、不能超过 128KB。更坑的是iOS 会缓存这个文件缓存策略由苹果控制你更新了文件之后设备可能几天都不生效。实测下来开发阶段可以用一个技巧强制刷新把设备上的 App 卸载重装或者在设置里关闭再打开“开发者模式”相关的网络缓存。生产环境更新 AASA 时建议同时保留旧路径的兼容避免老版本 App 突然失效。3. iOS 原生层配置把两条链路都打通3.1 URL Scheme 的配置在 Unity 导出的 Xcode 工程里找到Info.plist添加URL TypeskeyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.example.mygame/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array这样mygame://activity?id123就能拉起你的 App。注意CFBundleURLName建议用反域名格式避免和其他 App 冲突。3.2 Universal Links 的配置这一步分服务端和客户端两部分。服务端在https://game.example.com/.well-known/apple-app-site-association放一个 JSON 文件注意没有后缀名{ applinks: { apps: [], details: [ { appID: TEAMID.com.example.mygame, paths: [/activity/*, /invite/*, /share/*] } ] } }appID是TeamID.BundleID的拼接TeamID 在苹果开发者后台能看到。paths支持通配符但注意 iOS 13 之后推荐用components语法做更精细的控制。客户端在 Xcode 工程的Signing Capabilities里添加Associated Domains填入applinks:game.example.com这一步会写进 entitlements 文件。很多人配完发现不生效八成是 entitlements 没同步到 Unity 导出的工程里或者 Provisioning Profile 没重新生成。3.3 Unity 导出工程的自动化处理每次 Unity 重新导出 Xcode 工程Info.plist和 entitlements 都可能被覆盖。手动改一次两次还行天天改会疯。我的做法是写一个 PostProcessBuild 脚本在导出后自动注入配置#if UNITY_IOS using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; using System.IO; public class iOSDeepLinkPostProcess { [PostProcessBuild(999)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target ! BuildTarget.iOS) return; string projPath PBXProject.GetPBXProjectPath(path); PBXProject proj new PBXProject(); proj.ReadFromFile(projPath); string mainTarget proj.GetUnityMainTargetGuid(); // 注入 URL Scheme string plistPath Path.Combine(path, Info.plist); PlistDocument plist new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementArray urlTypes plist.root.CreateArray(CFBundleURLTypes); PlistElementDict urlDict urlTypes.AddDict(); urlDict.SetString(CFBundleURLName, com.example.mygame); PlistElementArray schemes urlDict.CreateArray(CFBundleURLSchemes); schemes.AddString(mygame); plist.WriteToFile(plistPath); // 注入 Associated Domains string entPath proj.GetEntitlementsFilePath(mainTarget); if (!string.IsNullOrEmpty(entPath)) { PlistDocument ent new PlistDocument(); ent.ReadFromFile(entPath); PlistElementArray domains ent.root.CreateArray(com.apple.developer.associated-domains); domains.AddString(applinks:game.example.com); ent.WriteToFile(entPath); } proj.WriteToFile(projPath); } } #endif这个脚本放在Editor目录下每次 Build 自动执行。注意PostProcessBuild的优先级参数设成 999确保在其他处理之后执行。4. Unity 与原生桥接参数怎么从 OC 传到 C#4.1 冷启动和热启动的区别这是整个链路里最容易出错的地方。冷启动指 App 没在后台运行用户点链接把它拉起来热启动指 App 已经在后台用户点链接把它切到前台。两种情况回调的入口完全不同。冷启动时参数在application:didFinishLaunchingWithOptions:的launchOptions里热启动时参数在application:openURL:options:URL Scheme或application:continueUserActivity:restorationHandler:Universal Links里。问题在于Unity 的AppDelegate是自动生成的你直接改会被覆盖。正确做法是写一个自定义的AppDelegate继承类或者用 Unity 提供的UnityAppController子类机制。4.2 自定义 AppDelegate 的写法在 Unity 导出的 Xcode 工程里创建一个DeepLinkAppController.mm#import UnityAppController.h #import Foundation/Foundation.h extern C { void UnitySendMessage(const char* obj, const char* method, const char* msg); } interface DeepLinkAppController : UnityAppController end implementation DeepLinkAppController - (BOOL)application:(UIApplication*)application didFinishLaunchingWithOptions:(NSDictionary*)launchOptions { NSURL *url launchOptions[UIApplicationLaunchOptionsURLKey]; if (url) { [self dispatchDeepLink:url.absoluteString]; } NSUserActivity *activity launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey][UIApplicationLaunchOptionsUserActivityTypeIdentifier]; if (activity [activity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [self dispatchDeepLink:activity.webpageURL.absoluteString]; } return [super application:application didFinishLaunchingWithOptions:launchOptions]; } - (BOOL)application:(UIApplication*)app openURL:(NSURL*)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id*)options { [self dispatchDeepLink:url.absoluteString]; return [super application:app openURL:url options:options]; } - (BOOL)application:(UIApplication*)application continueUserActivity:(NSUserActivity*)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring*))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [self dispatchDeepLink:userActivity.webpageURL.absoluteString]; } return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; } - (void)dispatchDeepLink:(NSString*)urlString { if (!urlString) return; const char *msg [urlString UTF8String]; UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, msg); } end IMPL_APP_CONTROLLER_SUBCLASS(DeepLinkAppController)关键点IMPL_APP_CONTROLLER_SUBCLASS这个宏会告诉 Unity 用你的类替换默认的UnityAppController。UnitySendMessage是 Unity 提供的原生到 C# 的通信接口第一个参数是场景里挂载的 GameObject 名字第二个是方法名第三个是字符串参数。4.3 C# 层的接收与解析在 Unity 场景里创建一个空 GameObject命名为DeepLinkManager挂上脚本using System; using UnityEngine; public class DeepLinkManager : MonoBehaviour { public static event ActionDeepLinkData OnDeepLinkParsed; private static DeepLinkManager _instance; void Awake() { if (_instance ! null) { Destroy(gameObject); return; } _instance this; DontDestroyOnLoad(gameObject); } // 由原生层通过 UnitySendMessage 调用 public void OnDeepLinkReceived(string url) { Debug.Log($[DeepLink] 收到链接: {url}); var data DeepLinkParser.Parse(url); if (data ! null) { OnDeepLinkParsed?.Invoke(data); } } }注意UnitySendMessage调用的是实例方法不是静态方法而且 GameObject 必须处于激活状态否则消息会丢。这是新手最常踩的坑之一。4.4 参数解析的健壮性设计链接格式可能是mygame://activity?id123channelwechat也可能是https://game.example.com/activity?id123channelwechat。解析器要同时兼容两种using System; using System.Collections.Generic; [Serializable] public class DeepLinkData { public string scheme; public string host; public string path; public Dictionarystring, string query; } public static class DeepLinkParser { public static DeepLinkData Parse(string url) { if (string.IsNullOrEmpty(url)) return null; try { var uri new Uri(url); var data new DeepLinkData { scheme uri.Scheme, host uri.Host, path uri.AbsolutePath, query new Dictionarystring, string() }; if (!string.IsNullOrEmpty(uri.Query)) { string query uri.Query.TrimStart(?); foreach (var pair in query.Split()) { var kv pair.Split(); if (kv.Length 2) { data.query[Uri.UnescapeDataString(kv[0])] Uri.UnescapeDataString(kv[1]); } } } return data; } catch (Exception e) { Debug.LogError($[DeepLink] 解析失败: {url}, {e.Message}); return null; } } }这里用Uri类而不是手动字符串切割是因为Uri会自动处理转义、端口、大小写等问题。但要注意mygame://activity?id123这种格式里activity会被解析成 Host 而不是 Path所以业务层判断时要同时看 host 和 path。5. 完整实操流程从点击链接到游戏内跳转5.1 端到端链路梳理把整条链路串起来看一次成功的 Deep Link 唤起经历这些环节用户在浏览器/短信/社交 App 点击链接iOS 系统判断是 Universal Link 还是 URL Scheme系统拉起 App冷启动或切到前台热启动原生层AppDelegate收到回调拿到完整 URL通过UnitySendMessage把 URL 传给 C# 层C# 层解析 URL提取业务参数业务层根据参数决定跳转到哪个界面如果 App 未安装走 App Store 下载首次启动时补投参数第 8 步是最容易被忽略的。用户点了广告App 没装跳到 App Store下载完打开这时候链接参数已经丢了。解决方案是延迟深度链接Deferred Deep Link通常需要借助第三方归因服务或者自己用剪贴板、IDFA 等做匹配。这部分超出本文范围但你要知道这个环节存在。5.2 冷启动场景的实测记录我在测试机上实测冷启动流程日志输出如下[DeepLink] 收到链接: mygame://activity?id8888channelad_001 [DeepLink] 解析结果: schememygame, hostactivity, path, id8888, channelad_001 [DeepLink] 业务层跳转到活动页 8888渠道 ad_001注意冷启动时UnitySendMessage可能在 Unity 引擎还没完全初始化时就被调用导致消息丢失。解决办法是在原生层做一个缓冲如果 Unity 还没准备好先把 URL 存起来等UnityReady之后再发。static NSString *pendingUrl nil; - (void)dispatchDeepLink:(NSString*)urlString { if (!urlString) return; if ([self isUnityReady]) { UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [urlString UTF8String]); } else { pendingUrl urlString; } } - (void)unityReady { if (pendingUrl) { UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [pendingUrl UTF8String]); pendingUrl nil; } }isUnityReady可以通过监听 Unity 的UnityDidFinishLaunching通知来判断。5.3 热启动场景的处理热启动相对简单因为 Unity 已经在运行UnitySendMessage能直接送达。但要注意如果用户连续点多个链接可能会触发多次回调业务层需要做去重或队列处理避免界面跳转错乱。我的做法是在 C# 层加一个简单的节流private float _lastHandleTime; private const float ThrottleInterval 0.5f; public void OnDeepLinkReceived(string url) { if (Time.realtimeSinceStartup - _lastHandleTime ThrottleInterval) { Debug.LogWarning([DeepLink] 触发过于频繁忽略); return; } _lastHandleTime Time.realtimeSinceStartup; // ... 正常处理 }5.4 参数投递到业务层的时机参数解析出来之后什么时候投递给业务层如果游戏还在加载 Logo 页业务层的 UI 可能还没初始化。我的经验是解析完先缓存等主界面加载完成后再消费。public class DeepLinkManager : MonoBehaviour { private DeepLinkData _pendingData; private bool _mainSceneReady; public void OnDeepLinkReceived(string url) { var data DeepLinkParser.Parse(url); if (data null) return; if (_mainSceneReady) { Dispatch(data); } else { _pendingData data; } } public void MarkMainSceneReady() { _mainSceneReady true; if (_pendingData ! null) { Dispatch(_pendingData); _pendingData null; } } private void Dispatch(DeepLinkData data) { OnDeepLinkParsed?.Invoke(data); } }主界面加载完成后调用MarkMainSceneReady()这样就不会出现“参数来了但界面还没准备好”的尴尬。6. 常见问题与排查技巧实录6.1 问题速查表现象可能原因排查方法点链接完全没反应Scheme 拼写错误 / AASA 未生效用 Safari 直接输入 scheme 测试Universal Link 跳浏览器AASA 文件格式错误或缓存检查 Content-Type 和路径冷启动参数丢失UnitySendMessage 时机太早加 pendingUrl 缓冲热启动参数重复多次回调未去重加节流或状态判断参数中文乱码未做 URL 解码用 Uri.UnescapeDataString微信内点击无效微信拦截了 scheme引导用系统浏览器打开部分机型不生效系统版本差异检查 iOS 版本和 AASA 兼容6.2 三个独家避坑技巧技巧一用 Safari 的开发者工具抓 AASA 请求。把 iPhone 连到 MacSafari 开发菜单里能看到设备上的网络请求直接看 AASA 文件有没有被正确请求、返回内容对不对。这比盲猜快十倍。技巧二Universal Links 测试时从备忘录里点链接。备忘录里的链接是系统级处理的能真实反映 Universal Links 是否生效。从 Safari 地址栏输入反而不准因为那是用户主动输入系统行为不同。技巧三参数里不要放特殊字符。、、#、空格这些字符在 URL 里有特殊含义业务参数一定要做 URL 编码。我见过有团队把用户昵称直接拼进链接结果昵称里有导致参数解析全乱。6.3 关于 Unity 版本差异的提醒Unity 2019 和 2021 在 iOS 导出结构上有差异UnityAppController的路径和IMPL_APP_CONTROLLER_SUBCLASS宏的行为略有不同。2021 之后 Unity 引入了新的UnityFramework结构UnitySendMessage的符号可能不在主 Target 里需要在UnityFramework的 Build Settings 里确认符号可见性。如果你遇到“编译通过但运行时找不到符号”八成是这个原因。7. 参数安全与业务层设计的一点经验7.1 不要信任链接里的任何参数Deep Link 的参数是用户可控的任何人都能构造一个mygame://activity?id999999来尝试越权。业务层拿到参数后必须做服务端校验。比如活动 ID 是否真实存在、邀请码是否有效、渠道标识是否合法这些都不能只靠客户端判断。我的做法是客户端解析出参数后先做格式校验长度、字符集、白名单然后把关键参数发给服务端做二次验证验证通过才执行跳转。这样即使有人伪造链接也拿不到实际利益。7.2 参数投递的日志埋点线上出问题时最怕的是“用户说点了没反应但你复现不了”。所以在原生层和 C# 层都要打日志并且把日志上报到监控系统。关键节点包括原生收到 URL、UnitySendMessage 调用、C# 收到消息、解析成功/失败、业务层消费。这样一旦出问题能快速定位是哪个环节断了。7.3 一个真实案例之前有个项目买量落地页的 Universal Link 在 iOS 15 上正常iOS 16 上有一半用户点了没反应。排查了两天才发现是 AASA 文件里用了旧的paths语法iOS 16 对components语法的支持更严格旧语法在某些路径匹配上行为变了。改成components语法后问题解决。这个坑当时没有任何报错只能靠对比测试发现。8. 关于扩展方向的一点个人看法这套链路跑通之后其实可以复用到很多场景。比如推送通知的点击跳转原理和 Deep Link 几乎一样只是入口从openURL变成了didReceiveRemoteNotification。再比如 App 内的分享回流用户分享出去的链接带上分享者 ID被分享者点开安装后自动绑定邀请关系这就是社交裂变的基础设施。我在实际项目里踩过的最大教训是不要等到上线前才测 Deep Link。这个链路涉及原生、Unity、服务端三方任何一方配置有问题都会导致整条链路断掉而且很多问题在开发机上复现不了必须用真机、用真实网络环境测。建议在项目中期就把这条链路搭起来留足联调时间。最后分享一个小技巧测试 Universal Links 时如果怎么都不生效先把设备上的 App 删掉重启手机重新安装。iOS 对 Associated Domains 的缓存非常顽固重启能清掉大部分缓存状态。这个土办法救过我很多次。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案