
做手游买量和老玩家召回的同学应该都遇到过同一个场景投放链接、Safari 打开的 H5 页面、或者微信里的分享卡片用户点了一下已经安装的游戏直接唤醒还没安装的落到下载页。这套能力在 iOS 上就是 Deep Link入口主要是 URL Scheme 和 Universal Links落地时最难的部分是把链接里带的参数完整、准确地送进 Unity 的 C# 层让业务逻辑知道用户从哪里来、要做什么。这篇文章我会完整走一遍 Unity iOS 深链的接入流程从 Xcode 侧配置两种入口到 AASA 文件的部署再到原生插件桥接和 C# 层参数投递最后把我这些年调试中踩过的坑一次性讲清楚。适合正在接深链的 Unity 客户端开发也适合刚接触 iOS 原生部分的跨端开发者参考。1. 这个链路到底在解决什么问题1.1 深链不是“打开 App”那么简单很多第一次接深链的人会以为深链就是把 App 拉起来而已。但实际上业务方要的远不止“打开”这一步。拿一个常见的买量场景举例用户在广告平台看到了创意素材点击后系统先跳到你的 H5 落地页H5 判断设备上已经装过游戏于是尝试直接唤起 App。唤起之后游戏内部要立刻知道这次点击来自哪个渠道、哪个 campaign、哪个创意这样用户进到游戏后看到的第一个页面、用的礼包码、绑定的归因参数才能对得上。如果只是把 App 拉起来却丢掉参数运营那边看到的转化数据永远是断的。换个场景也一样老玩家在微信里收到一条邀请链接点开后唤起游戏客户端需要知道邀请人 ID、邀请码、来源群聊。又比如 Push 推送点击、客服工单跳转、深链进入某个活动页本质上都是同一件事——通过一个 URL 把“外部世界”的信息带进“App 内部”并且要在合适的时机把这份信息交到合适的业务模块手里。所以做深链至少要解决三个问题入口怎么配URL 怎么解析参数在哪个时机、以什么形式投递给 C# 逻辑。任何一个环节没做好线上就会出现“能打开但没效果”的假成功。1.2 两条路线各有各的脾气iOS 上实现 App 唤起的方案主要是 URL Scheme 和 Universal Links 两条两者不是替代关系而是互补关系。URL Scheme 是最老牌的做法App 在 Info.plist 里注册一个类似mygame://的自定义协议外部链接照着这个协议写系统就会把链接交给你注册的 App。它的优点是接入简单、见效快不依赖服务器配置适用于 App 内部跳转和已知环境下的唤起。缺点是自定义协议可以被任何 App 声明如果某个 scheme 被你注册了别的 App 就不能用了而且如果手机上没有安装对应 AppSafari 打开一个unknown://链接只会报错体验很生硬。Universal Links 是 iOS 9 之后引入的方案它用普通的 HTTPS 链接来做唤起。链接本身是正常的云服务地址比如https://mygame.example.com/event?id123系统在用户点击时去服务器上取一个apple-app-site-associationAASA文件确认这个域名确实授权给你的 App然后直接在 Safari 顶部显示一个“在 App 中打开”的横幅点一下就直接激活。未安装时这个链接就是普通网页可以放下载引导体验完整得多。两条路线的取舍我放在下面的表里方便你按项目情况选。对比项URL SchemeUniversal Links配置成本低只在 Xcode 工程里改配置中需要 Xcode 配置 服务器 AASA 文件链接形态mygame://open?id1一眼假https://example.com/open?id1可信度高未安装体验系统报错无兜底打开网页可自行引导下载被其他 App 抢占存在冲突风险域名唯一不可抢占微信/QQ 内置浏览器无法直接唤起同样无法直接唤起两者都需要 H5 引导系统拦截提示提示相对生硬提示友好系统层面风险更低实战里我建议默认接入 Universal Links同时保留一个独一无二、不太容易和别人冲突的 URL Scheme 作为兜底。原因后面在踩坑部分会细说简单讲就是第三方 WebView 环境下 Universal Links 经常失效只有自定义 scheme 还能在一定程度上做补偿。2. iOS 侧入口配置从 Xcode 到服务器文件2.1 URL Scheme 配置实操URL Scheme 配置本身不复杂打开 Xcode 工程选中 Target切到 Info 标签页在 URL Types 里添加一项就行。CFBundleURLName 填一个便于识别的名字CFBundleURLSchemes 填你要用的协议名。keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.yourgame.deep/string keyCFBundleURLSchemes/key array stringyourgameopen/string /array /dict /array这里最关键的是 scheme 的唯一性。如果你的包名或品牌简写是可预测的别人完全可以抢注一个一模一样的 scheme到时候用户点击链接iOS 无法保证唤起的一定是你的 App。我见过有人顺手写了test://或者game://这种通用词上了线才发现 Android 端能唤起、iOS 端唤起时系统弹窗让用户选客户端体验非常差。建议用“品牌缩写 业务含义”的组合比如游戏叫《山海经》可以注册成shanhaijing_open://。越长越有识别度越不容易冲突。另外链接参数别用全角符号、中文裸字符串URL 里建议先做一次 encode。调试验证的时候最简单的命令是直接在 iOS 模拟器或真机 Safari 地址栏输入链接。也可以用 Xcode 自带的模拟器命令唤起xcrun simctl openurl booted yourgameopen://open?channeladcampaignsummer能看到 App 被拉起就说明 scheme 注册成功了。这一步的成功标准不是“打开了”而是打开之后你的回调方法有没有拿到完整 URL特别是 query 部分有没有被截断。2.2 Universal Links 配置实操Universal Links 配置比 scheme 多好几个环节但每一步都有标准做法照着走就不会乱。第一步去 Apple Developer 后台找到你的 App ID在 Capabilities 里打开 Associated Domains。这一步不做后面 Xcode 里配再多都没用。第二步回到 Xcode 工程在 Target 的 Signing Capabilities 里点击 “ Capability”添加 Associated Domains然后在这个 capability 下面加一条域名记录格式必须是applinks:mygame.example.com注意这里不写 https 前缀直接写 applinks: 加上你的业务域名。这个过程会同步生成一个 entitlements 文件打包后系统读取这个权限声明来判断 App 有没有资格处理对应域名的链接。第三步在域名服务器上放一个apple-app-site-association文件没有任何扩展名内容是一段 JSON{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.yourgame, paths: [*] } ] } }这个文件有两个可以放置的位置一个是域名根目录一个是.well-known目录。为了保证 CDN 或 Web 服务器不会拦截我建议两个位置都放一份一模一样的文件。path 字段决定哪些路径允许唤起填*表示全部开放线上正式环境建议收敛一下只开放你需要的路径前缀比如paths: [/open/*, /events/*, NOT /admin/*]否则任何指向这个域名的非法链接都可能唤起你的 App参数校验的工作量会大很多。AASA 文件里的appID是 Team ID 和 Bundle ID 的组合中间用点连接。Team ID 在开发者账号后台的 Membership 页面能看到Bundle ID 就是你 iOS 工程的 bundle identifier两个都对上才生效。部署完之后在浏览器直接访问https://mygame.example.com/apple-app-site-association能看回完整的 JSON 内容就说明服务器这边没问题。到这里Universal Links 的系统链路就通了用户点击 HTTPS 链接 → iOS 先从域名拉取 AASA 文件 → 匹配 appID 和 path → 找到设备上已安装的 App → 唤起并把 URL 传给 App 的 delegate 方法。2.3 服务器端 AASA 文件有哪些坑AASA 文件是 Universal Links 里最容易被忽略、也最容易出问题的环节我专门拿出来讲。第一个坑是缓存。很多游戏业务线用的是 CDN 域名AASA 文件一旦被 CDN 缓存更新配置后旧版本可能持续生效十几个小时甚至更久。排查时你会发现手机端一直取到旧文件清浏览器缓存也没用。解决方式是给 AASA 文件单独设置 CDN 缓存策略max-age 设小一点或者直接绕过缓存。第二个坑是格式。AASA 文件虽然看起来是 JSON但 Apple 的解析器对格式要求比较严格文件内不能有注释、不能有重复键、不能有 BOM 头。不少人习惯用 Windows 记事本存 UTF-8 带 BOM 的格式传上去之后 iOS 直接忽略文件表现就是 Universal Links 完全没反应。第三个坑是权限配置完整。有些团队 Web 服务器做了 URL 重写规则把/.well-known/apple-app-site-association重写到了其他路径或者对无扩展名文件返回了 404。这个文件必须能被公网直接访问不能用登录鉴权包裹。我习惯的验证命令curl -i https://mygame.example.com/apple-app-site-association正常响应应该包含Content-Type: application/json或纯文本内容HTTP 状态码是 200响应体是你看到的 AASA JSON。如果有跳转、重写或缓存标记第一步就要在服务端处理掉。3. Unity 侧桥接与 C# 层参数投递3.1 为什么建议原生插件做双保险接到 Unity 工程之后问题就变成iOS 原生层拿到的 URL怎么稳定地送到 C# 层。很多人第一反应是用 Unity 官方提供的事件Application.deepLinkActivated。这个事件确实能覆盖大部分情况官方文档里也写了 iOS、Android、Universal Windows Platform 等平台的支持。但在线上环境我仍然建议在原生层补一手双保险原因有三个。一是时机问题。冷启动时 Unity Engine 还没完成初始化系统通过continueUserActivity把 URL 传给 AppDelegate 时Unity 侧的脚本对象可能根本还不存在官方事件是否能可靠触发受 Unity 版本和工程配置影响。有的项目改过启动流程、接了自己的 SDK 初始化逻辑事件触发顺序就变了靠官方事件兜底容易丢参数。二是部分业务要求在打开的一瞬间就知道 URL比如广告归因 SDK 需要在下发配置前拿到点击参数。这时候原生层先缓存一份 URL再通过桥接层交给 C#比等 Unity 自家事件更可控。三是自定义逻辑的需要。有些团队要拦截不合规的来源、做本地签名校验这些逻辑放在原生层更安全不好逆向。我采用的是 UnityAppController 子类方案在 Xcode 生成的工程里写一个继承 UnityAppController 的类重写两个方法一个处理 URL Scheme一个处理 Universal Links。#import UIKit/UIKit.h #import UnityAppController.h interface DeepLinkAppController : UnityAppController end implementation DeepLinkAppController - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { if (url) { UnitySendMessage(DeepLinkHandler, OnDeepLinkFromNative, url.absoluteString.UTF8String); return YES; } return [super application:app openURL:url options:options]; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; if (url) { UnitySendMessage(DeepLinkHandler, OnDeepLinkFromNative, url.absoluteString.UTF8String); } } return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; } end注意 UnitySendMessage 的第一个参数是场景里 GameObject 的名称第二个参数是挂在它身上的 MonoBehaviour 的方法名。调用前要保证这个 GameObject 已经存在冷启动时可以先在 C# 侧缓存一个待处理的静态队列等 Awake 之后再取。另一个细节是自建子类必须保证链接进主工程不要被链接器优化掉。手动生成的 Xcode 工程里可以在 main.mm 中把原来的 UnityAppController 换成你的子类名用自动化打包脚本时也要在脚本里做这一步替换。Swift 项目同理在 AppDelegate 里同样拿到 URL 后调用 UnitySendMessage只是需要先 import UnityFramework并把 UnitySendMessage 视作 C 接口调用来处理。3.2 C# 层接收与参数解析C# 这边需要一个专门挂在空的 GameObject 上的脚本名字和原生层约定的一致。为了方便业务模块调用我一般把它做成单例所有外部 URL 统一走一个入口Load 到场景里后 DontDestroyOnLoad。using System; using System.Collections.Generic; using UnityEngine; public class DeepLinkHandler : MonoBehaviour { public static DeepLinkHandler Instance { get; private set; } private Queuestring _pendingUrls new Queuestring(); private bool _ready; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); Application.deepLinkActivated OnDeepLinkActivated; } public void MarkReady() { _ready true; while (_pendingUrls.Count 0) { Dispatch(_pendingUrls.Dequeue()); } } public void OnDeepLinkFromNative(string url) { FeedUrl(url); } private void OnDeepLinkActivated(string url) { FeedUrl(url); } private void FeedUrl(string url) { if (string.IsNullOrEmpty(url)) return; if (_ready) { Dispatch(url); } else { _pendingUrls.Enqueue(url); } } private void Dispatch(string url) { // 在这里解析、分发 Debug.Log($[DeepLink] Got URL: {url}); } }这里的核心逻辑是把“已经准备好的时机”和“URL 到达的时机”解耦。冷启动时先入队等游戏主流程初始化完成、登录回调拿到之后再统一消费这样就不会出现场景还在加载就跳到某个界面结果界面依赖的数据还没准备好的问题。参数解析同样要细致。URL 的形态可能是这样yourgameopen://open?channeladcampaignsummeruid1024 https://mygame.example.com/open?channeladcampaignsummeruid1024解析 query 时不要按位置去取用字典来转public static Dictionarystring, string ParseQuery(string url) { var dict new Dictionarystring, string(); int idx url.IndexOf(?); if (idx 0 || idx url.Length - 1) return dict; string query url.Substring(idx 1); string[] pairs query.Split(); foreach (var pair in pairs) { if (string.IsNullOrEmpty(pair)) continue; string[] kv pair.Split(); string key Uri.UnescapeDataString(kv[0]); string val kv.Length 1 ? Uri.UnescapeDataString(kv[1].Replace(, )) : ; dict[key] val; } return dict; }有个不起眼但线上一定会遇到的细节URL 编码里代表空格。直接用Uri.UnescapeDataString解出来的不会被转成空格导致参数“ab”变“ab”对签名校验这种场景就是致命的。所以我上面把手动 replace 成空格再交给解码函数这一点很多博客没提。分发时建议做几层校验。第一层校验来源Universal Links 链接里的域名必须是你自己的域名URL Scheme 链接要在业务层约定一个版本号或者固定前缀。第二层校验时效活动邀请链接最好带到期时间不能一个链接在生产环境能用三年。第三层校验幂等同一个 URL 重复唤起时不要重复触发“领奖”“弹活动页”这类副作用逻辑用一个字典缓存最近处理过的 URL hash。3.3 冷启动、热启动、后台恢复的分发时机深链分发里最容易翻车的就是生命周期判断。我见过不少项目明明 URL 拿到了但表现是“这次打开了没反应下次打开才生效”问题基本都在冷启动顺序上。冷启动时 App 进程从无到有iOS 原生可能先是拿到 Universal Link再创建 Unity 引擎然后才加载第一个场景。此时如果原生直接 UnitySendMessage发给谁场景里的对象还没创建消息会丢掉。所以要像前面代码那样在 C# 层准备一个待处理队列。热启动指 App 已经在运行用户通过链接再次唤起。这种情况场景对象都在消息可以直接分发但也要注意当前有没有弹窗、是否处于登录流程。如果用户在支付页面被深链打断分发一个跳转活动页的事件就会把支付流程顶掉引发支付状态和页面状态不一致。我的习惯是热启动时把事件丢给一个启动路由中心由当前场景栈决定“立即执行”还是“等当前业务结束后再执行”。后台恢复是最容易被忽略的。App 被用户滑到后台超过一段时间系统可能已经回收部分资源但进程还没死。这时 URL 到达Unity 侧事件触发场景里的管理器对象可能还活着但网络状态已经不再可靠。不能默认“收到事件 可以立刻发请求”要检查网络可达性、请求超时时间最稳妥的做法是先回主线程、延迟一帧再分发。我这边的参考实现是在 DeepLinkHandler 上再加一层 RouterRouter 统一监听启动流程状态private void Start() { // 最简单的方式等首帧结束再消费待处理深链 StartCoroutine(ConsumeAfterFirstFrame()); } private System.Collections.IEnumerator ConsumeAfterFirstFrame() { yield return null; MarkReady(); }如果你的项目有登录态、配置表下发等更重的初始化就把 MarkReady 挂到这些流程的完成回调里而不是在 Start 直接调。这个接口设计比堆一堆 delay 要干净得多。4. 调试与问题排查实录4.1 我用这些命令和方法验证调试深链跟调试普通业务代码不一样很多问题光看 Xcode 控制台看不出来得结合系统层日志、命令行工具、真机行为一起看。最常用的一条命令是 macOS 终端的日志流过滤。把 iPhone 连上 Mac打开 Xcode 的 Devices 窗口或者直接用log stream --predicate eventMessage contains apple-app-site-association然后在 Safari 里点一下你的 Universal Link再回来观察日志里有没有 AASA 拉取、匹配失败之类的系统提示。这个命令能直接看出 iOS 有没有成功访问你服务器上的 AASA 文件以及它解析出来的 appID 是什么。URL Scheme 可以用模拟器验证模拟器里可以直接执行xcrun simctl openurl booted yourgameopen://open?channeltest但 Universal Links 我强烈建议用真机验证因为模拟器环境和 AASA 拉取策略跟真机有差异模拟器上成功不代表真机就没问题。另外重签名的包和 App Store 包在很多行为上不一样。用个人开发者证书重签调试时Universal Links 偶尔会出现不稳定的唤起结果这不一定是代码问题而是 TestFlight 包、开发包、生产包对 Associated Domains 的信任粒度不同。遇到这种情况先换一台干净的、没有安装过其他版本 App 的设备再试一次。4.2 高频问题速查表我把这一年多排查总结的高频问题做成了速查表遇到问题直接对着查。现象可能原因排查方法点了 HTTPS 链接没任何反应AASA 文件未配置、appID 不匹配、Associated Domains 未加curl 检查文件内容确认 TeamID.BundleID 正确重新安装 AppSafari 顶部出现“在 App 中打开”但点了没反应AASA path 和 URL 不匹配或 App 被系统判断为不信任检查 paths 通配规则换真机测试URL Scheme 唤起成功但参数丢失Query 未编码或原生层未透传完整字符串原生层打印 absoluteString确认没截断微信/QQ 内打开的链接无法唤起 App内置 WebView 限制 Universal Links引导用户在 Safari 打开或用 URL Scheme 兜底冷启动链接丢失热启动正常Unity 场景对象尚未创建消息发空原生层先缓存C# 层用队列消费上线后某天突然所有链接都失效AASA 文件被 CDN 缓存了旧版本或服务器证书异常检查 CDN 缓存策略更新文件后强制刷新同一链接反复触发多次回调深链事件在多个生命周期重复派发C# 层做幂等处理记录最近处理过的 URL模拟器环境正常真机不跳转开发包与生产包 Associated Domains 权限差异换配置描述文件证书验证不看模拟器结果4.3 我踩过的三个典型坑第一个坑是 CDN 缓存导致的“版本分裂”。有一次我改了 AASA 文件里的 paths 规则测试手机怎么刷新都是旧规则过了几个小时才正常。后来 CDN 那边说是针对无扩展名文件做了默认缓存max-age 设成了 6 小时。从那以后我养成了两个习惯aasa 文件路径只走固定的两个位置服务器返回头主动带上Cache-Control: no-cache在 CDN 层面再做一次规则匹配。第二个坑是微信等内置浏览器根本不执行 Universal Links 的完整链路它只会把 URL 当作普通网页加载。业务方如果坚持要在微信里“点一下直接打开 App”就得先做一个中间页中间页用 JS 检测环境再引导跳转。遇到这种情况我不会硬刚而是让 H5 页面提供两个按钮一个按钮试 Universal Links另一个按钮展示“在 Safari 中打开”的操作指引同时记录点击时的来源参数等用户真正进游戏后再补发一次。第三个坑是重签名的调试包掩盖了配置错误。有一次同事报 bug 说链接完全没反应我看了半天代码没问题最后发现他用的越狱测试机上装的是重签包Associated Domains 权限根本没签进去系统自然不认。这个环境问题会浪费大量排查时间所以我现在调试前一定会先确认真机安装包的签名信息别在错误的前提下找代码问题。最后再分享一个我自己验证过的习惯把深链统一收敛成一个启动事件总线任何业务模块都不直接监听链接而是监听“深链已解析”和“启动流程已就绪”两个事件。这样无论链路来自 URL Scheme、Universal Links 还是 Push 点击最终走的都是同一套解析和分发路径避免各端各写一套也为后续接 Android App Links 留了一个干净的扩展点。