ARTICLE DETAIL

资讯详情

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

Unity手游iOS端Deep Link全流程指南:Universal Links与URL Scheme双链路实战

Unity手游iOS端Deep Link全流程指南:Universal Links与URL Scheme双链路实战 做手游发行这几年Deep Link 这玩意儿平时不起眼但一到买量投放、老玩家召回、活动页拉新的时候它就是最关键的命根子。用户从广告位点进来能不能一键唤起你的 App直接决定次留、转化、付费这些核心指标。我在 Unity 手游 iOS 端反复折腾过这套流程从 URL Scheme 到 Universal Links再走到 C# 层把参数安全投递给业务逻辑中间踩了不少坑也沉淀出一套适合游戏项目的完整方案。这篇文章就把这套全流程掰开揉碎讲清楚适合正在做 Unity iOS 游戏、并且需要接入 Deep Link 拉新召回能力的开发同学参考。1. 方案选型为什么 Universal Links 是主力URL Scheme 却必须保留1.1 URL Scheme 的底层机制与致命短板URL Scheme 是最老牌的唤起方式原理简单粗暴你在 Info.plist 里注册一个自定义协议比如mygame://系统在收到这类协议的打开请求时会去找注册了这个协议的 App 并唤起它。它的最大优势是配置极其简单不用服务器、不用证书五分钟就能跑通。很多团队早期只靠它做 App 互相跳转比如从社区 App 跳游戏或者从客服系统跳回游戏充值页。但在实际投放场景里URL Scheme 有几个天生缺陷第一无法判断用户是否安装了 App。用户没装 App 时点击链接系统只会弹一个打不开网页因为地址无效的错误页你连降级引导到 App Store 的机会都没有。iOS 9 之后canOpenURL还被限制只能在 Info.plist 里预先声明可以查询的 Scheme否则返回 false这导致你没法在 H5 端提前检测唤起成功率。第二唤起时会有系统弹窗确认。用户点击链接后iOS 会弹一个在我的游戏中打开吗的确认框多一步操作就多一层转化损耗买量场景里这 5% 的流失都让人肉疼。第三在 WebView 环境里越来越不稳定。微信、抖音这类超级 App 的内置浏览器对 URL Scheme 的拦截越来越严格经常出现点了没反应的情况。1.2 Universal Links 的原理与优势Universal Links 是苹果官方力推的替代方案它的工作方式是你的 App 声明自己要接管某个域名下的特定路径系统验证你对该域名拥有控制权后当用户在 Safari 或系统内点击该域名下的链接且 App 已安装时就会直接唤起 App没有任何弹窗确认。如果用户没装 AppSafari 会直接打开对应网页你可以在这个网页上放 App Store 下载引导实现平滑降级。这里面的关键机制有三层Associated Domains 能力在 Xcode 里声明 applinks 域名、apple-app-site-association 文件放在你服务器上的 JSON 验证文件证明你拥有这个域名、以及NSUserActivity 回调系统通过continueUserActivity把网页 URL 交给 App。对比下来Universal Links 在体验和安全层面全面优于 URL Scheme无弹窗、可降级、域名鉴权防冒用。但它的配置成本也高得多涉及服务器、HTTPS 证书、开发者后台、Xcode 签名等多处联动任何一个环节出错链接就死在 Safari 里。1.3 游戏项目里的最终选型结论我经手过的项目最终都采用了双链路并存的架构Universal Links 作为主力唤起链路用于广告投放、H5 活动页拉起、邮件营销等所有外部流量入口。URL Scheme 作为兜底链路用于 App 间跳转、部分 WebView 场景有些 WebView 对 Universal Links 支持不佳但 URL Scheme 反而能触发一次系统级跳转以及老版本 iOS 的兼容。这种双保险不是画蛇添足而是实际投放中你会发现Universal Links 在某些内嵌 WebView 里依然会被吞掉此时 URL Scheme 能救命。两条链路在 C# 层最终汇聚到同一个参数分发入口业务侧完全不需要关心是从哪条链路进来的。2. iOS 侧配置实操从 Xcode 到服务器每一步的坑都写在这里2.1 URL Scheme 的注册与验证URL Scheme 的配置位置在 Xcode 的 Info 面板里但 Unity 项目每次重新导出 Xcode 工程都会覆盖手动改动所以建议你在 Unity 的 Player Settings 里直接配置保证每次导出后配置还在。打开 Unity 的Player Settings iOS Other Settings Configuration找到URL Scheme一栏填入你的自定义协议名例如mygame。等价于在 Info.plist 里生成这样的结构keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.yourgame/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array注意Scheme 名不要用太通用的单词比如game、share这类很容易和其他 App 冲突。一旦冲突iOS 的唤起行为会变得不可预测。建议用产品名缩写 业务标识例如mygamepay、mygameact。配置完成后在 Safari 地址栏输入mygame://test?room10086系统弹窗确认后你的 App 被唤起说明 Scheme 链路通了。2.2 Universal Links 的三步配置一步都不能少Universal Links 的配置分三块开发者后台、Xcode 工程、服务器文件。第一步在 Apple Developer 后台开启 Associated Domains 能力。进入 Certificates, Identifiers Profiles找到你的 App ID勾选 Associated Domains。注意这里改完要重新生成 Provisioning Profile否则 Xcode 里会报签名错误。第二步在 Xcode 里添加 Associated Domains 域名。选中工程 Target在Signing Capabilities里点击 号添加Associated Domains然后填入applinks:yourdomain.com。这个域名必须是 HTTPS 可达的。这里有个 Unity 特有的坑Xcode 工程每次由 Unity 重新导出你在 Xcode 里手动加的 Capability 会被清掉。解决方案有两个使用 Unity 的iOS 原生插件通过PBXProject的 API 在导出时自动注入 applinks 能力或者用Xcode 的 xcconfig 文件在 Unity 导出后通过脚本自动修改工程文件。我自己写了一个 Unity Editor 脚本在OnPostProcessBuild回调里用PBXProject添加com.apple.developer.associated-domains这个 entitlement 键值这样 Xcode 工程导出后 Universal Links 能力自动带上省掉了每次都手动加一遍的重复劳动。第三步在服务器上部署 AASA 文件。文件名必须精确为apple-app-site-association不能有 .json 后缀放在你域名的根目录或/.well-known/下。文件内容如下{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.yourgame, paths: [/open/*] } ] } }这里最关键的两个字段appID是Team ID Bundle ID的组合中间是点号paths是你允许唤起 App 的路径规则支持通配符。比如/open/*代表所有形如https://yourdomain.com/open/...的链接都会唤起 App。AASA 文件部署完成后务必用https://yourdomain.com/apple-app-site-association在浏览器里访问一次确认能返回 JSON 内容且响应头里Content-Type包含application/json。额外提醒AASA 文件的更新存在最长 24 小时的缓存周期苹果的 CDN 会缓存这个文件。改完文件后不要立刻以为生效了拿测试机多试几次或者过期后强制联网刷新。2.3 双链路的参数协议设计既然两条链路最终指向同一个业务参数格式必须统一。我推荐的统一格式是https://yourdomain.com/open/path/to/page?key1value1key2value2其中path/to/page是业务页面标识query参数是具体业务数据。URL Scheme 链路则复用同样参数只是前缀换成mygame://open/path/to/page?key1value1。业务层只认一个统一的数据结构类似public class DeepLinkData { public string path; // 页面标识 public Dictionarystring, string query; // 参数表 }这样无论从哪条链路进来C# 层解析出的模型都是一样的思路清晰代码也好维护。参数命名统一采用下划线风格不要混用驼峰和下划线避免解析时还要做兼容映射。3. Unity C# 层参数投递冷启动、热启动、时序问题全解3.1 直接硬编码 vs 原生桥接怎么选Unity 从某个版本开始提供了Application.deepLinkReceived事件和Application.absoluteURL属性很多开发者以为直接用这个就行。但实际上这个事件在 iOS 端的表现依赖 Unity 版本和原生层的桥接能力尤其在冷启动场景下事件触发时机可能早于你业务代码的初始化导致你收到回调时业务模块还没准备好参数丢了。我的建议是核心链路自己写原生桥接Unity 的事件作为旁路辅助。理由很简单原生桥接可以在didFinishLaunchingWithOptions里第一时间拿到冷启动 URL 并缓存等 Unity 引擎完全就绪后再投递时序可控原生层可以同时处理 URL Scheme 和 Universal Links 两套回调统一逻辑你可以精确控制投递时机比如等待主场景加载完成后再丢给业务层避免业务层因为场景未就绪而空转。3.2 原生侧桥接代码实现Unity iOS 的原生桥接本质是继承UnityAppController并重写生命周期方法。新建一个 Objective-C 文件代码如下#import UnityAppController.h interface MyAppDelegate : UnityAppController property (nonatomic, strong) NSString *pendingDeepLink; end implementation MyAppDelegate // 冷启动App 被 URL Scheme 或 Universal Links 唤起时 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [super application:application didFinishLaunchingWithOptions:launchOptions]; // URL Scheme 冷启动 NSURL *url launchOptions[UIApplicationLaunchOptionsURLKey]; if (url) { self.pendingDeepLink url.absoluteString; } return YES; } // 热启动App 已在后台运行通过 URL Scheme 唤起 - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id *)options { [super application:app openURL:url options:options]; self.pendingDeepLink url.absoluteString; [self sendPendingDeepLink]; return YES; } // 热启动通过 Universal Links 唤起 - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { self.pendingDeepLink userActivity.webpageURL.absoluteString; [self sendPendingDeepLink]; } return YES; } // 如果 Unity 引擎已经就绪立刻把 URL 投递给 C# 层否则等 C# 层主动拉取 - (void)sendPendingDeepLink { if (!self.pendingDeepLink) return; // 判断 Unity 是否已准备好 if (UnityIsInitialized) { UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [self.pendingDeepLink cStringUsingEncoding:NSUTF8StringEncoding]); self.pendingDeepLink nil; } } // 供 C# 层在初始化后主动拉取缓存的 URL - (const char *)getPendingDeepLink { if (self.pendingDeepLink) { const char *cString [self.pendingDeepLink cStringUsingEncoding:NSUTF8StringEncoding]; self.pendingDeepLink nil; return cString; } return NULL; } end IMPL_APP_CONTROLLER_SUBCLASS(MyAppDelegate)这里面UnityIsInitialized是否真实存在取决于你用的 Unity 版本。如果不好判断最保险的策略是统一缓存到本地C# 层启动后主动通过外部函数拉取逻辑更可靠。上面代码里getPendingDeepLink就是干这个用的。老版本 Unity 的用户可能需要在main.mm里手动改入口新版本用IMPL_APP_CONTROLLER_SUBCLASS(MyAppDelegate)这个宏就能自动替换默认的 AppController不用动 main.mm推荐这种方式。3.3 C# 层接收与分发重点是时序C# 侧的核心是建立一个名为DeepLinkManager的单例挂在启动场景的空物体上提供两个入口一个是供 UnitySendMessage 回调的OnDeepLinkReceived(string url)一个是初始化时主动向原生层拉取冷启动缓存的 URL。public class DeepLinkManager : MonoBehaviour { public static event System.Actionstring OnDeepLink; [System.Runtime.InteropServices.DllImport(__Internal)] private static extern string getPendingDeepLink(); private void Awake() { DontDestroyOnLoad(gameObject); } private void Start() { // 冷启动时先尝试从原生层拿缓存的 URL #if UNITY_IOS !UNITY_EDITOR string cachedLink getPendingDeepLink(); if (!string.IsNullOrEmpty(cachedLink)) { HandleDeepLink(cachedLink); } #endif } // 供 UnitySendMessage 调用的入口 public void OnDeepLinkReceived(string url) { HandleDeepLink(url); } private void HandleDeepLink(string url) { StartCoroutine(DispatchWhenReady(url)); } private System.Collections.IEnumerator DispatchWhenReady(string url) { // 等主场景加载完成后再投递 while (SceneManager.GetActiveScene().name ! Main) { yield return null; } // 等一帧确保场景内所有模块完成初始化 yield return null; Debug.Log($[DeepLink] 收到参数: {url}); OnDeepLink?.Invoke(url); } }这个DispatchWhenReady是整个投递机制里最容易踩坑的地方。如果一个 Universal Links 在 App 启动后 3 秒才被触发此时如果你的主场景加载很慢协程会卡在while循环里空转这没问题。但如果你没有做这个等待直接把参数丢给一个还没初始化的业务模块可能拿到空对象或者干脆崩溃。参数解析我建议统一走一个工具类把 URL 拆成 path 和 querypublic static DeepLinkData Parse(string url) { var data new DeepLinkData(); data.query new Dictionarystring, string(); var uri new System.Uri(url); data.path uri.AbsolutePath.TrimStart(/); string query uri.Query.TrimStart(?); if (string.IsNullOrEmpty(query)) return data; foreach (var pair in query.Split()) { var kv pair.Split(); if (kv.Length 2) { data.query[kv[0]] System.Uri.UnescapeDataString(kv[1]); } } return data; }注意不要自己写replace(, )之类的逻辑直接用Uri.UnescapeDataString做 URL 解码能正确处理大多数编码边界情况。如果发现某些参数里有中文或特殊符号先检查生成链接的那一侧是否做了Uri.EscapeDataString编码两边对称才不会乱。4. 联调测试与参数安全本地怎么测、线上怎么验4.1 本地方案两个链路都要有独立的测试入口联调阶段最怕的就是不知道当前到底走通了哪个环节。我习惯的做法是把两条链路的测试入口做成独立的、可区分的地址。测试 URL Scheme直接在 iOS 的 Safari 地址栏输入mygame://open/test?debug1观察 App 是否弹窗唤起。建议在原生桥接里加一行 NSLog 日志打印每次收到的 URL通过 Xcode 控制台直接确认原生层有没有拿到参数。测试 Universal Links在 iOS 的备忘录里输入完整链接https://yourdomain.com/open/test?debug1长按链接选择在Safari中打开。如果 Universal Links 生效Safari 顶部会出现一条横幅在我的游戏中打开点击横幅App 唤起。如果没有横幅说明 AASA 文件、Associated Domains 配置或签名可能有问题按第 5 节的排查表逐项查。还有个更贴近线上环境的测试方法用xcrun simctl对模拟器发送 Universal Link。命令长这样xcrun simctl openurl booted https://yourdomain.com/open/test?debug1但这只能验证模拟器行为Attention模拟器对 Associated Domains 的支持有历史问题建议重点在真机上测。4.2 参数设计与安全红线Deep Link 链接会出现在广告后台、日志里、用户分享的社媒内容中属于半公开信息。所以参数设计上必须有红线意识第一不要在链接里传敏感数据比如用户手机号、身份证号、支付 token。链接一旦被转发或爬取这些信息就泄露了。需要身份信息的场景只传一个短期有效的 ticket 或 code让客户端拿它去服务器换真实数据。第二服务端必须校验来源。所谓校验不是说 H5 页面校验而是 App 内发起后续网络请求时携带的 Deep Link 参数必须由服务端二次确认防止有人构造恶意链接诱导 App 发起异常请求。第三客户端不要 信任 参数里的操作指令。比如某个参数叫actionlogin你不能直接执行登录你要把它当成用户意图最终还是要走正常的鉴权流程。4.3 与归因 SDK 的共存问题大多数买量项目集成了 AppsFlyer、Adjust 这类归因 SDK它们也依赖 Deep Link 机制。需要注意的坑是归因 SDK 和你的业务代码不要同时消费同一个 Universal Link否则可能出现重复处理或者互相覆盖。我处理的方法是原生层拦截到 Universal Link 后先分发给归因 SDK 处理再投递给 Unity 的DeepLinkManager。流程上做成先归因、后业务因为归因 SDK 拿到参数后有可能在本地写入数据业务侧的落地页参数不能抢先消费掉链接里的归因字段。具体实现也不复杂在continueUserActivity里先调用[[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:restorationHandler]这类方法再把自己的pendingDeepLink存下来。注意如果你的归因 SDK 冷启动时也监听了didFinishLaunchingWithOptions两边在冷启动场景下都要调而且顺序不能反。5. 常见问题与排查技巧实录5.1 问题速查表症状可能原因排查方向Universal Links 点击后直接打开网页不唤起 AppAASA 文件未生效或配置错误浏览器访问 AASA 地址看返回内容检查 appID 是否填错唤起 App 成功但 C# 层收不到参数UnitySendMessage 已投递但目标对象没监听确认 DeepLinkManager 已挂载且命名一致查看原生层日志冷启动丢失 URL Scheme 参数didFinishLaunchingWithOptions里没正确处理确认拿到launchOptions[UIApplicationLaunchOptionsURLKey]URL Scheme 唤起时系统弹窗过于频繁本身 Scheme 机制限制尽量改用 Universal Links 作为主链路微信等 App 内点击链接无法唤起WebView 拦截了 Universal Links / Scheme引导用户用 Safari 打开或做提示页配好 AASA 后 24 小时仍不生效苹果 CDN 缓存更换文件路径版本号加时间戳路径Xcode 导出后 Associated Domains 消失Unity 覆盖了 Capabilities用脚本自动注入或导出后手动检查5.2 三个最容易让人抓狂的细节第一个是AASA 文件的 appID 写错成 Bundle ID。很多人只填了 Bundle ID 没填 Team ID导致苹果无法把域名和 App 关联起来Universal Links 永远不触发。打开 Apple Developer 后台翻一下你的 Team ID拼成TEAMID.com.company.game的完整格式放进去。第二个是Universal Links 在 iOS 15 的 Safari 上表现不同。新版本 Safari 默认在首次访问时不会直接唤起 App而是先展示横幅。如果你的测试人员以为必须直接唤起才算成功可能误判为失败。联调前先明确行为预期。第三个是热启动和三小时规则。苹果规定如果用户在一段时间内约三小时初次进入 App之后点击 Universal Links 可能会直接走网页而不是唤起 App这是系统层面的防骚扰机制。线上投放量大的游戏你可能需要在 H5 落地页上给用户明确的指引比如点击右上角按钮在 App 中打开。5.3 复盘一个真实的线上事故有次我们上线了一个拉新活动页投放量很大结果第二天数据反馈唤起率比预期低了 20%。排查发现不是 Universal Links 配置问题而是活动页里 H5 跳转用的按钮绑定的域名写错了子域。AASA 配置里只声明了www.yourdomain.com但活动页用的是campaign.yourdomain.com导致 Universal Links 匹配失败全部走了网页降级。那次事故之后我把团队的 Universal Links 配置加了一条规范线上所有投放域名的子域都要在 AASA 的 paths 里显式声明或者在路径规则里用通配符统一接住。大促活动临时新增子域的情况很常见提前规划好域名规则能省掉一大笔流量损失。写在最后如果你问我在这个项目里最大的体会是什么我会说 Deep Link 这套东西表面上是技术配置实际上是把用户从哪儿来和用户要去哪儿重新用链接串起来的过程。方案选型别贪新Universal Links 确实好用但 URL Scheme 的兜底价值在 WebView 场景里依然不可替代参数投递别图省事冷启动和热启动的时序差异不上真机踩一次坑永远想象不到。每次新游戏上线我都会把第 4 节的联调 checklist 完整跑一遍确认两条链路、三个生命周期入口、C# 层的场景等待逻辑全都没问题才敢把投放链接交出去。希望这套流程能让你少走几个月的弯路。
返回列表