ARTICLE DETAIL

资讯详情

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

Unity iOS手游Deep Link接入全流程:配置、回调与冷启动处理

Unity iOS手游Deep Link接入全流程:配置、回调与冷启动处理 接手 iOS 买量项目时市场丢过来一个需求Safari 广告页点一下装了 App 的直接打开进活动页没装的就先去下载页。我当时以为这只是配置一个链接的事结果从 iOS 系统到原生壳再到 Unity 引擎每一层都有各自的脾气配置差一点就“点了没反应”参数传错环节就“冷启动丢消息”。这篇文章我把 Unity iOS 手游接 Deep Link 的完整链路拆开讲URL Scheme 和 Universal Links 两套机制怎么选、苹果后台和 AASA 文件怎么配、原生层拿到链接后怎么安全投递给 C#以及冷启动、热启动两类场景分别怎么处理。内容基于我在 iOS 包上实际接过的流程适合正在接 Deep Link、或者接完总感觉不稳定的 Unity 手游项目参考。1. URL Scheme 与 Universal Links差的不是一点半点1.1 URL Scheme 的启动链路与最大短板URL Scheme 是你最早在 iOS 上能用到的唤起方式原理就是给 App 注册一个自定义协议系统看到一个mygame://这样的请求就去查有没有 App 声明了这个 scheme有就直接唤起来。mygame://open?activityId123sourcesafari这个方案在早年几乎是唯一选择实现也简单在 Info.plist 里配一下CFBundleURLTypes就行。但它的短板在真实业务场景里非常致命第一用户从 Safari 点 scheme 链接系统会弹确认框。iOS 从很早的版本开始对外部 App 的 scheme 唤起就加了“是否打开”的二次确认。手游买量链路最讲究顺畅弹一下框转化率就掉一截这还没算误触“取消”直接流失的。第二用户没装 App 时scheme 链接会被 Safari 直接判定为“无法打开网页”。它是真的打不开而不是优雅地转到你的下载落地页。你最多通过 JS 在页面侦测失败后再跳 App Store这种“先报错、后补救”的体验本身就是用户流失点。第三微信等常用 App 内置浏览器会拦截第三方 scheme。很多买量链接是在微信公众号、朋友圈里传播的点开之后微信只放行自家业务和白名单你的 scheme 会被当成非法跳转直接拦掉。所以 scheme 不是不能用而是只能当兜底。真正的主链路得靠 Universal Links。1.2 Universal Links 的校验原理与 AASA 文件Universal Links 是 iOS 9 引入的能力核心思路是用一条普通的 HTTPS 链接同时代表“网页地址”和“App 唤起点”。系统检测到你点的 URL 属于某个 App 声明的关联域名就不再打开 Safari直接唤醒 App并把这个完整 URL 原样传给 App 侧。它的合法性建立在“双向声明”上App 侧Xcode 里开启 Associated Domains声明applinks:yourdomain.com。服务器侧在域名根目录或.well-known目录放一个apple-app-site-association文件里面写明TeamID.BundleID和允许唤起的路径。这两个声明都对上系统才会信任这条链接。文件格式是纯 JSON长这样{ applinks: { apps: [], details: [ { appID: TEAM12345.com.yourcompany.yourapp, paths: [ NOT /admin/*, /activity/*, * ] } ] } }这里有几个细节很容易被忽略appID必须是TeamID.BundleID的组合不是苹果后台里看到的 App 前缀。TeamID 在开发者账号后台右上角查看BundleID 要和 App 实际打包用的完全一致一个字符都不能错。paths匹配规则支持*匹配任意字符、?匹配单个字符NOT前缀表示排除。系统按数组顺序匹配命中任意一条就停止所以要把排除项放前面。我上面写法里/admin/*是排除的/activity/*是业务页面*兜底其他所有路径。iOS 13 之后苹果对 AASA 文件的抓取加了 SSRF 防护文件必须静态提供不能根据请求参数动态生成也不能有 301/302 重定向链更不能指向内网地址。CDN 上配了动态内容的基本都会被判定无效。系统会缓存关联文件改完配置不是立刻生效。实测缓存时间不稳定有人十几分钟就刷新有人等了大半天。最快的强制刷新办法是卸载重装 App装好之后系统会重新拉一次。1.3 我的选型结论主攻 Universal LinksScheme 只做兜底在项目里我最终定下来的方案是Universal Links 作为主唤起链路URL Scheme 保留但只用于特定兜底场景。如果你嘴上说不支持老版本 iOSUniversal Links 是 iOS 9 才有2025 年还考虑 iOS 8 的机型没什么意义完全可以放弃 scheme 主链路。但 scheme 没法全删原因有两个部分第三方归因平台在特定场景和旧版 SDK 回调里可能触发 scheme 唤起。自家企业内部跳转比如从另外一个 App 唤起用 scheme 更直接不用非走一遍域名校验。需要明确的是Universal Links 只管“已装 App 时唤起”管不了“未安装用户装完后再自动唤起”。用户先点了链接、再下载安装等他打开 App刚才那条链接的“落地页信息”不会自动递进来这属于安装归因的领域得靠 AppsFlyer 这类归因 SDK 结合落地页参数去服务端换取。很多做产品的人在这块想当然容易把需求往下游推实际是两套体系。2. 苹果后台、Xcode 工程与 AASA 文件的三方联动配置2.1 Associated Domains 能力开启与 entitlement 的坑在 Xcode 里给工程开启 Associated Domains路径是 Target - Signing Capabilities - 左上角加号 - 选 Associated Domains然后在列表里填写applinks:yourdomain.com。注意这里有个很容易踩的坑域名前面不能加https://。你写applinks:https://yourdomain.com是错的正确写法就是applinks:yourdomain.com。开启之后 Xcode 会生成一个.entitlements文件里面真正生效的核心是这段keycom.apple.developer.associated-domains/key array stringapplinks:yourdomain.com/string /array而且Associated Domains 是一个 Capability会在 Provisioning Profile 里体现。如果你用的是手动签名开发者后台的 App ID 必须勾选 Associated Domains然后重新生成描述文件下载。忘掉这一步Xcode 里配得再漂亮也没用真机点链接照样没反应还不会报错。做 Unity 项目更要小心一个问题Unity 每次重新导出 Xcode 工程工程文件是全新生成的。如果你不是每次构建完都在 Xcode 里手动加一次 Capability那么 CI 自动构建出来的包大概率没有 Associated Domains。项目里我最后是加了PostProcessBuild脚本在导出完成后自动往project.pbxproj注入 entitlements 文件并关联 target这样无论本地构建还是 CI 构建都能保持一致。#if UNITY_IOS using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEditor.iOS.Xcode; public class IOSPostProcess : IPostprocessBuildWithReport { public int callbackOrder 0; public void OnPostprocessBuild(BuildReport report) { if (report.summary.platform ! BuildTarget.iOS) return; string projectPath report.summary.outputPath /Unity-iPhone.xcodeproj/project.pbxproj; PBXProject project new PBXProject(); project.ReadFromFile(projectPath); string targetGuid project.GetUnityMainTargetGuid(); string entitlementPath Unity-iPhone/Entitlements.entitlements; var entitlement new PlistDocument(); entitlement.root.CreateArray(com.apple.developer.associated-domains).AddString(applinks:yourdomain.com); entitlement.WriteToFile(report.summary.outputPath / entitlementPath); project.AddFile(entitlementPath, entitlementPath); project.AddCapability(targetGuid, PBXCapabilityType.AssociatedDomains, entitlementPath); project.WriteToFile(projectPath); } } #endifAddCapability这种写法依赖 UnityEditor.iOS.Xcode 包里提供的 API在给 entitlements 文件指定 path 时要跟实际写出的相对路径保持一致否则 build 的时候 Xcode 可能找不到文件直接报签名错误。2.2 apple-app-site-association 文件格式与上传细节AASA 文件可以放在域名根目录也可以放在.well-known子目录里两个位置的优先级有差异我习惯放在.well-known/apple-app-site-association这也是苹果文档明确推荐的位置。文件内容不能带任何业务字段只服务和 Deep Link 关联{ applinks: { apps: [], details: [ { appID: TEAM12345.com.yourcompany.yourapp, paths: [ NOT /admin/*, /activity/*, * ] } ] } }上传之后用curl验证文件能不能直接拿到并检查返回的 JSON 语法是否合法curl -s https://yourdomain.com/.well-known/apple-app-site-association | jq .实际项目里我遇到过 CDN 给文件包了一层 gzip 或者加了重定向iOS 端有的版本能容忍有的版本直接判定失效。稳妥做法是让 CDN 对.well-known路径直接返回 200不做302跳转也不按请求头动态压缩。如果用的是对象存储加 CDN记得关闭“仅通过 CDN 回源鉴权”这类会导致重定向的配置。另外AASA 文件本身对大小没有硬性公开上限但苹果要求文件尽量精简只保留关联信息。别下意识把业务配置一起塞进去——文件越复杂拉取解析耗时越长冷启动时系统校验的延迟也会变长。2.3 Info.plist 的 URL Scheme 兜底配置Universal Links 是主链路但 scheme 兜底配置还是要加。在 Unity 里可以直接通过 Player Settings - iOS - Supported URL schemes 里写mygame也可以等导出 Xcode 工程后手动改 Info.plistkeyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.yourapp.ios/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array这个配置对应的是mygame://开头的那一类链接。scheme 名称最好选一个不常见的避免和其他 App 的 scheme 冲突。如果你随便写个gameiOS 上很可能有其他 App 已经注册了同样的 scheme唤起链路会被系统干扰弹出来的都不是你的 App。2.4 配置完成后第一步验证别急着跑 Xcode很多人配完第一反应是直接跑 Xcode 真机预览其实正确的顺序是先做“文件侧验证”再做“工程侧验证”。上传完 AASA 之后我习惯先拿curl确认文件能被公网正常访问并且appID字段确实包含自己的 TeamID 和 BundleID。然后打开一个浏览器直接输入落地页地址做一次“模拟点击”确认网页能正常打开。最后再去跑 Xcode 真机点一次链接看日志里有没有-application:continueUserActivity:回调。这看起来很基础但真能省不少时间。我有一次在真机上排查了半天最后才发现是 AASA 文件里的appID写错了——一个字母的差距系统静默失败又因为缓存机制不会立刻暴露折腾掉整个下午。先验证文件层能直接把这类低级错误隔离开。3. 原生层接收链路从 AppDelegate 到 UnitySendMessage3.1 冷启动与热启动的分流参数必须先落地再上抛Deep Link 在原生层最核心的问题不是“怎么拿”而是“拿了之后怎么安全地传给 Unity”。真正麻烦的是启动时序。我把场景分成两种热启动App 已经在后台Unity 状态完好收到链接后可以直接原生调 C# 方法。冷启动App 被链接拉起系统回调触发的时候Unity 引擎可能还在初始化阶段Unity 侧的 GameObject 甚至还没创建完这时候调 UnitySendMessage 十有八九丢消息。所以我的原生层处理原则是任何收到 Deep Link 的时刻都先存入原生变量再尝试向 Unity 转发。转发成功了 C# 立即处理转发失败也没关系C# 启动后会主动从原生侧把缓存的一段链接读走。传输协议我定义得很简单static NSString *gPendingDeepLink nil; static void DispatchDeepLink(NSString *link) { if (link.length 0) return; // 无条件缓存C# 启动阶段靠主动拉取兜底 gPendingDeepLink [link copy]; // 如果 Unity 已就绪走即时推送 if (UnityIsReady) { UnitySendMessage(DeeplinkBridge, OnReceiveLink, [link UTF8String]); } }UnityIsReady在原生侧是我自定义的一个标志位做法是继承 UnityAppController 后在startUnity的[super startUnity]之后置为 YES。这样不需要依赖 Unity 内部是否有公开的 ready 接口逻辑完全可控。3.2 在 UnityAppController 子类里处理 Universal LinksUnity 导出的 Xcode 工程里入口类是 UnityAppController它本身就是 AppDelegate 的实现。拿到一个新导出工程时先打开Classes/main.mm看UIApplicationMain最后一个参数字符串是谁那决定了实际生效的 AppDelegate 类。我项目里这一参数默认就是 UnityAppController所以我直接用继承的方式扩展它#import UnityAppController.h interface CustomAppController : UnityAppController end implementation CustomAppController - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; NSLog([DeepLink] Universal Link: %, url.absoluteString); DispatchDeepLink(url.absoluteString); return YES; } return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; } end然后再去main.mm里把第四参数字符串改成CustomAppController。这里有一个 Unity 版本差异要提醒有些版本导出的工程默认不是 UnityAppController而是 AppDelegate 类。不要只照着网上教程改先看清你工程里的实际入口。改完主入口后重新构建确认改动还在因为 Unity 重新导出工程时 main.mm 会被覆盖回默认内容。3.3 URL Scheme 的 openURL 回调实现Universal Links 的回调方法是continueUserActivityURL Scheme 对应的则是- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { NSLog([DeepLink] URL Scheme: %, url.absoluteString); DispatchDeepLink(url.absoluteString); return YES; }一个额外的冷启动入口是didFinishLaunchingWithOptions里的UIApplicationLaunchOptionsURLKey- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSURL *url launchOptions[UIApplicationLaunchOptionsURLKey]; if (url) { NSLog([DeepLink] Launch with URL: %, url.absoluteString); DispatchDeepLink(url.absoluteString); } return [super application:application didFinishLaunchingWithOptions:launchOptions]; }实际开发中如果冷启动是 URL Scheme 触发的系统通常会在didFinishLaunchingWithOptions里给你这个 URL同时也会在后续走一次openURL回调。如果两边都收到C# 侧必须做去重不然一个链接会被处理两次。3.4 UnitySendMessage 的调用时机与“C# 对象不存在”问题UnitySendMessage 的原型是void UnitySendMessage(const char *obj, const char *method, const char *msg);它依赖的场景是C# 场景里存在名为obj的 GameObject且挂在它身上的 MonoBehaviour 里定义了method这个方法。这个找对象的过程发生在 Unity 主线程触发时指针传过去消息本身不校验目标是否存在目标丢失会静默丢掉不打日志也不报错。所以如果你的游戏启动场景刚好切走或者DeeplinkBridge对象被DontDestroyOnLoad挂错了场景热启动链路也可能出现“链接收到了但 C# 没反应”的情况。我建议把接收器 GameObject 放在启动场景并且用单例方式常驻public sealed class DeeplinkBridge : MonoBehaviour { public static DeeplinkBridge Instance { get; private set; } void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } }原生侧为了避免竞态还有一个更稳的配合方案C# 启动时通过 P/Invoke 主动从原生取 pending 内容而不是干等 UnitySendMessage。[DllImport(__Internal)] private static extern string DeepLink_GetPendingLink();原生侧对应的 C 接口也要用extern C包住防止 C 符号表改名extern C const char *DeepLink_GetPendingLink() { if (gPendingDeepLink nil) return strdup(); const char *result strdup([gPendingDeepLink UTF8String]); gPendingDeepLink nil; return result; }这里用strdup生成一个新的 C 字符串再返回是避免返回指向临时 Objective-C 对象内部的指针。Unity 的 P/Invoke marshaling 拿到返回值后会自己处理拷贝但源头数据得是稳定内存这是个容易忽略的坑。4. C# 层参数投递从字符串到游戏内行为4.1 统一链接协议与 JSON 解析原生层转过来的参数本质上就是一条字符串可能长这样mygame://open?activityId188sourcesafari https://yourdomain.com/activity?activityId189sourcefacebook第一步先把 URL 规范化为一个内部结构。我建议团队提前定一个统一的 Deep Link 协议不管来源是 URL Scheme 还是 Universal Links解析之后都转成同一个对象。[Serializable] public class DeepLinkPayload { public string action; // open_activity / open_page / join_room public string activityId; public string source; // safari / facebook / google public string campaign; // 投放计划标识 public string scene; // 目标场景名 }解析方法不要依赖 Unity 的 JsonUtility 直接解析原始 URL先用一个工具函数把 query 转成字典再逐字段填充Dictionarystring, string ParseQuery(string query) { var result new Dictionarystring, string(); if (string.IsNullOrEmpty(query)) return result; query query.TrimStart(?); string[] pairs query.Split(); foreach (string pair in pairs) { int idx pair.IndexOf(); if (idx 0) continue; string key Uri.UnescapeDataString(pair.Substring(0, idx)); string value Uri.UnescapeDataString(pair.Substring(idx 1)); result[key] value; } return result; }这个解析函数简单直接没有把方案搞重。真没必要为了一条链接把第三方网络库和 JSON 库全拉进来。4.2 启动阶段的事件暂存与场景就绪后的重放游戏启动时Deep Link 到达的时机是不可控的。有可能到了主菜单也有可能还在加载资源、甚至登录流程都还没走完。一定不能在收到回调的瞬间立刻执行“打开活动页”这类业务逻辑得做事件暂存。我项目里的流程是这样的EnqueueLink收到原始链接解析成DeepLinkPayload进入待处理队列。主场景加载完成、登录状态就绪这两个条件同时满足时才真正开始执行动作。如果条件不满足继续排着等条件满足后再触发。具体实现上我用SceneManager.sceneLoaded事件和登录状态轮询配合IEnumerator TryExecutePending() { while (_pending.Count 0) { if (!IsMainSceneReady()) { yield return null; continue; } if (AccountManager.Instance ! null !AccountManager.Instance.IsLoggedIn) { yield return null; continue; } DeepLinkPayload payload _pending.Dequeue(); ExecutePayload(payload); } }有个容易漏掉的问题如果玩家在战斗场景中被 Deep Link 拉起直接弹活动页会打断操作。合理做法是记录“待展示弹窗”状态等玩家回到大厅后再弹出提示。所以ExecutePayload里要判断当前场景类型不是立刻弹 UI而是把“待处理消息”塞进游戏内消息系统。这块逻辑不复杂但很多团队不做上线后就会被玩家骂“打着打着跳个活动页”。4.3 重复链接拦截、参数清洗与埋点去重是很容易被忽略的一环。一个链接在冷启动 热启动的组合下有可能被原生层触发多次。C# 侧收到同一个链接处理两次就可能出现“活动页连开两次”或者“重复请求领奖接口”。我的做法是用一个固定大小的去重集合private readonly HashSetstring _processedLinks new HashSetstring(); private readonly Queuestring _processedOrder new Queuestring(); bool IsDuplicate(string link) { if (_processedLinks.Contains(link)) return true; _processedLinks.Add(link); _processedOrder.Enqueue(link); // 只保留最近 50 条防止内存无限增长 while (_processedOrder.Count 50) { _processedLinks.Remove(_processedOrder.Dequeue()); } return false; }参数清洗和埋点一起提。C# 侧在做ExecutePayload之前记录一个deep_link_received事件里面带上source、campaign、activityId方便和投放平台的数据对账。执行成功后再埋一个deep_link_processed。如果你们接了三方归因 SDK这些 SDK 自己会上报一部分 Deep Link 参数但不要完全依赖它做内部流程追溯。投放到归因 SDK 看到的数据和客户端真正收到的链接经常存在几个小时的延迟。自家埋点的价值在于点击已经到达了客户端、参数完整、处理结果是什么这些信息越早拿到越容易定位投放链路问题。5. 真机实测矩阵与我在项目里踩过的坑5.1 必测的 5 种场景Deep Link 的测试特别依赖真机。iOS 模拟器上 Universal Links 的支持一直不太稳定我碰到过模拟器里点了链接完全不回调、换真机就好的情况所以以真机结果为准。上线前我至少强制跑这 5 个场景测试场景操作方式预期结果常见问题冷启动 Universal Links杀干净 App 后台Safari 点落地页链接直接拉起 App落到目标活动页C# 启动阶段没去原生取 pending参数丢失热启动 Universal LinksApp 切后台Safari 点链接立即回到前台并打开目标页UnitySendMessage 竞态目标对象还没 Ready冷启动 URL Scheme杀干净 App 后台Safari 输入mygame://open?...系统弹确认框后拉起 App首次弹窗打断体验未安装点链接卸载 AppSafari 点 Universal LinkSafari 打开网页落地页落地页缺少下载引导微信内点链接微信公众号菜单或聊天记录中打开链接不同 iOS 版本表现不一致大概率不唤起必须引导用户右上角“在浏览器打开”第 5 个场景在手游买量里非常常见。微信对 Universal Links 的唤起有平台层面的拦截策略这属于外部环境限制不是你的代码问题。与其在技术上硬刚不如在产品层面加引导语让用户在浏览器里打开。5.2 点链接没反应、参数丢失的常规排查路径我把出过问题的环节整理成固定排查顺序每次先按顺序对一遍不要东查一榔头西查一棒子确认 AASA 文件可访问curl -s https://yourdomain.com/.well-known/apple-app-site-association | jq .看返回里appID是否和当前包的 TeamID.BundleID 一致。确认工程项目里 Associated Domains 还在重新导出 Xcode 工程后很容易被覆盖。检查 Signing Capabilities 页面或者打开.entitlements文件直接看。确认描述文件更新过手动签名的项目尤其要注意Capability 变更后旧描述文件不会自动带新 entitlement需要去开发者后台重新生成。原生回调进没进在continueUserActivity和openURL里加NSLog真机连 Xcode 跑一遍看日志打印的是哪条链路。C# 侧收没收到在OnReceiveLink和EnqueueLink里都打日志。如果原生日志里有、C# 日志里没有重点查 GameObject 名称和 method 签名。缓存问题改完 AASA 或者重新安装了包系统缓存可能导致旧的关联关系还在测试前把 App 卸载重装一次再点。上述六步走完90% 的“没反应”都能定位到具体层。剩下的多半是 CDN 缓存配置问题刷新 CDN 缓存后再验证。5.3 几个你可能没意识到但很要命的细节第一AASA 文件的路径匹配对 query 参数不敏感但请求仍会带完整 query 过去。也就是说https://yourdomain.com/activity和https://yourdomain.com/activity?fromfacebook只要 paths 里配了/activity两条都能唤起而且 C# 收到的都是完整链接。解析时一定要兼容带参和不带参的情况。第二P/Invoke 的函数名不要用 C 默认签名。在.mm文件里写 extern C编译出来才是 C 符号Unity 的[DllImport(__Internal)]才能对上。写 C 方法的话符号名会被编译器加上重载修饰直接找不到函数。第三别把 AASA 文件和业务配置放一起。文件加不进任何业务逻辑重定向和动态内容会导致校验失败。用静态 JSON 上传其他需求都放另一个文件。第四Unity 重新导出会覆盖 main.mm 的修改。我一开始手动改工程每次构建完都要重新操作一遍后来改成构建脚本统一改才彻底治本。凡是要改原生入口的操作都进工程自动化别指望人工记忆。最后给同行的经验整套跑通之后再回头看这个需求价值最高的其实是把“冷启动参数不丢”这件事想明白了。只要原生层先缓存、再转发C# 层启动时主动拉取同时保留即时推送路径三大场景冷启动、热启动、Unity 初始化竞态就都能兜住。还有一点很实际上线后没人会天天盯着 Xcode 日志Deep Link 的埋点一定要提前做。把“链接到达客户端”和“活动页成功打开”两个事件拆开埋哪天投放平台说点击多但游戏内活动参与少你能立刻定位到是链接断了、还是游戏内跳转逻辑出了问题。这套链路平时没人注意一上线到大流量投放就会变成事故排查的瓶颈提前把根基打顺后面省下的都是加班的命。
返回列表