
做手游买量的兄弟应该都懂渠道那边动不动就给你提需求老用户收到一条带链接的短信点一下直接打开 App 进活动页广告素材点击之后跳 App Store装好了第一次启动也能把归因参数带进来自家游戏之间互相导量不同 App 任意跳转。这几件事背后的技术闭环核心就是 iOS Deep Link 唤醒。具体拆开就是 URL Scheme 和 Universal Links 两条链路加上参数从系统层一路穿过原生层、最终投递到 Unity C# 层的完整流程。这篇文章我会把从工程配置到原生代码拦截、再到 C# 层接收参数的整个链路按实操顺序写清楚。里面有一部分是我在多个线上游戏项目里实际踩过的坑特别是冷启动时序、SceneDelegate 回调、UnitySendMessage 的竞态问题这些常规文档几乎不会写的内容。不管是 Unity 客户端开发、iOS 原生开发还是做买量归因对接的同事按这篇文章的顺序走一遍基本能把 Deep Link 这块完全拿捏。1. 为什么非做 Deep Link 不可三个真实业务场景很多团队一开始觉得 Deep Link 就是个跳转进 App的小功能优先级排得很低。直到运营和市场开始提具体需求才发现这事牵扯的链路远比想象中广。我结合手游的实际业务把最常见的场景拆成三类。1.1 场景一老用户召回与活动直达短信召回、Push 推送、社群链接这三类是手游运营的日常。链接背后的目的不是打开 App 就算完而是要带着参数直达指定页面签到活动、限时礼包、新玩法上线、公会战入口。举个例子运营发一条短信链接是https://example.com/promote?sceneanniversarylevel5用户点击后如果设备上装了游戏系统直接唤醒游戏如果没装跳到 App Store 或者落地页。游戏启动后 C# 层拿到sceneanniversary这串参数就能在登录完成后自动弹出周年庆活动页。没有 Deep Link用户顶多就是打开游戏看个公告转化率差着一个量级。1.2 场景二广告投放归因的最后一公里买量是手游绕不开的环节。广告平台Meta、Google、TikTok、国内各家渠道都希望把广告曝光-点击-下载-激活-付费这条链路完整串起来。这里的核心逻辑是用户在广告里点击时系统带着广告平台的归因参数启动游戏。这份参数必须在一开始就投递到业务层并和广告 SDK 的激活回调做匹配。很多团队在 App 启动后主动去广告平台拉取归因数据但主动拉取存在延迟和不确定性。通过 Deep Link 直接把参数带给游戏相当于一出生就知道是谁带来的归因准确率会高很多。1.3 场景三游戏交叉导量与直播间跳转自家发多款游戏的情况很普遍。老游戏给新游戏导量、新游戏反过来带老游戏活跃最常见的做法就是互相挂 Deep Link。直播间里挂个链接、主播口播引导、社区帖子带链接用户点击直接唤醒目标游戏。交叉导量场景对参数的要求简单但对直跳成功率要求极高。如果点击链接后在 Safari 里弹个中间页流失率立刻上去如果点击没反应玩家对游戏的信任感也会受损。我在实际项目里见过因为 Universal Links 配置错误导致 iOS 端导量页面反复刷新、App 却无法唤醒的惨案这个放到后面排雷部分细说。2. 双方案选型URL Scheme 与 Universal Links 的取舍很多人会问既然要接 Deep Link到底用 URL Scheme 还是 Universal Links我的回答是成年人全都要。但全都要的前提是你要理解两条链路各自的脾气。2.1 URL Scheme简单直接但门槛低URL Scheme 的格式很像 URL但它并不是真正的互联网标准请求而是 iOS 系统层面的注册协议。典型写法是mygame://open?scenexxx。优点是接入成本极低在 Xcode 里给 Info.plist 加个 URL Types 就能跑通。不需要服务器配置不需要域名校验。缺点是它的进入门槛非常低也就是说任何 App 都可以声明同样的 Scheme。比如你声明了mygame://别人也可以声明用户在点击时系统如果遇到多个 App 注册了同一个 Scheme会弹窗让用户选择这种体验在线上活动中是灾难。更麻烦的是部分内置浏览器会拦截未知 Scheme 的跳转出现无法打开网页的提示页。2.2 Universal Links苹果钦定的正统方案Universal Links 是 iOS 9 推出的设计上就是为了解决 URL Scheme 的一堆毛病。核心原理你的网站域名比如example.com上放一个 JSON 文件apple-app-site-association声明哪些路径可以被哪个 App 接管。然后在 Xcode 里开启 Associated Domains填入applinks:example.com。当用户点击一个指向该域名的 HTTPS 链接系统会先去请求那个 JSON 文件验证域名与 App 的绑定关系验证通过后直接唤醒 App。它的优势很明显没有 Scheme 冲突因为是域名级绑定域名是唯一的。没装 App 时链接会在 Safari 里正常打开你的网页可以放下载引导体验不中断。系统在唤醒时不会弹出是否打开的确认框如果配置正确。缺点也实在必须拥有 HTTPS 域名、必须能上传文件到域名根目录或指定路径、iOS 版本差异会导致验证缓存问题排错起来比 Scheme 麻烦不少。2.3 为什么我建议两条腿走路线上项目里我都是无条件两个一起接。URL Scheme 作为兼容兜底因为 iOS 8 以下没有 Universal Links还有部分 WebView 场景对 Universal Links 支持不好Scheme 仍然有效。Universal Links 作为主链路因为它体验最顺。一条链接同时配置两种能力iOS 系统优先尝试 Universal Links验证失败或低版本系统走 Scheme 兜底。这里要特别注意一个细节Universal Links 只对用户直接在 Safari / 系统浏览器中点击链接生效如果链接是在 WebView 内被 JS 拦截跳转Universal Links 不会触发必须由 WebView 代理层手动处理成 Scheme 跳转。做游戏内嵌 H5 活动页时经常遇到这个问题后面我会再展开。3. 工程配置实操从 Apple Developer 到 Xcode工程配置阶段出错后面一切都是白搭。这一节完整过一遍两条链路的配置步骤和验证方式。3.1 URL Scheme 配置五分钟上手在 Xcode 里选中 Target切到 Info 标签底部找到 URL Types。点击加号填两个关键字段URL Schemes填你的自定义协议比如mygame。URL Identifier一般填你的 bundle identifier 反向域名比如com.example.game。这一步完成后理论上系统就能识别mygame://开头的链接。但要注意URL Scheme 的字符只能包含小写字母、数字、加号、减号和点号大小写不敏感但建议全小写。团队里统一命名规则很重要别 iOS 端叫myGame市场侧的短信链接里写mygame这种低级错误在联调时特别耗时间。验证方式最简单拿到设备上后在 Safari 地址栏输入mygame://test回车系统弹出确认框或直接拉起 App 就算成功。真机验证是必须的模拟器对某些 URL 处理行为和真机有差异。3.2 Universal Links 配置关联域名与 apple-app-site-association步骤拆成四段Apple Developer 后台找到你的 App ID开启 Associated Domains 能力capability。Xcode 里 Target - Signing Capabilities添加 Associated Domains填入applinks:你的域名例如applinks:dl.example.com。注意这里只能写域名不要写路径。准备 HTTPS 服务器的apple-app-site-association文件。这是一个 JSON 文件无需任何后缀内容大致如下{ applinks: { apps: [], details: [ { appID: TEAMID.com.example.game, paths: [*, /promote/*, /not/*] } ] } }把这个文件放到域名根目录下如https://dl.example.com/apple-app-site-association确保直接通过浏览器访问能看到 JSON 内容。部分团队喜欢把它放到.well-known目录苹果的规则是根目录和.well-known都认但我实测根目录最稳。appID 的格式是团队ID.BundleID。Team ID 在 Apple Developer 后台的 Membership 页面能看到是一串 10 位字母数字组合。这个 ID 填错是最常见的配置失败原因我在测试中发现 Xcode 开发者证书的名称与 Team ID 容易混淆别填成证书的 CN 名称。验证配置是否生效可以用这个命令curl -i https://dl.example.com/apple-app-site-association正常返回 HTTP 200 和 JSON 内容。接着在真机上打开你的 App让 App 在前台停留几秒再去 Safari 访问你配置的路径如https://dl.example.com/promote/test看是否直接拉起 App。首次访问可能不生效因为系统需要拉取并缓存配置文件等待 10-30 秒再试。3.3 ATS 与 HTTPS 要求容易忽略的两处细节第一个坑是 iOS 对 Universal Links 的硬性要求域名必须支持 HTTPS且证书链是系统可信的。自签名证书、过期证书、证书链不完整的域名Universal Links 直接不工作。调试时如果域名是临时搭的不要在证书环节省时间。第二个坑是 App Transport Security。虽然 Universal Links 是系统层面发起的请求和 App 内的 ATS 没有直接关系但apple-app-site-association文件的服务器如果证书有问题ATS 策略可能介入导致首次验证失败。第三个坑是配置文件不能有 BOM 头。很多编辑工具保存 UTF-8 文件时会加入 BOMcurl看起来是正常的但系统的解析器对 BOM 敏感可能导致解析失败。用文本编辑器另存为 UTF-8 without BOM 可以避开。4. 原生层拦截链路AppDelegate 与 SceneDelegate 的每一个入口配置完成只是第一步真正的分水岭在原生层代码。iOS 的 App 生命周期有冷启动、热启动、后台恢复几种状态Deep Link 在不同状态下的回调入口完全不一样。这一节是文章的核心我尽量把代码写完整。4.1 冷启动还是热启动回调时机完全不同必须分清楚先画一个标准概念图口头描述冷启动App 进程不存在用户点击链接系统先拉起进程再在启动流程中把链接传给 App。热启动App 进程存活但在后台用户点击链接系统直接激活 App把链接传给对应代理方法。挂起状态介于两者之间进程还在但没活跃系统从挂起状态恢复并传参。这个区分决定了你把处理代码写在哪。放在AppDelegate的某个方法里可能只覆盖了一部分状态。先说 URL Scheme 的三个入口iOS 8 及以下application:openURL:sourceApplication:annotation:iOS 9 及以上无 Sceneapplication:openURL:options:iOS 13 及以上启用 SceneDelegatescene:openURLContexts:和scene:continueUserActivity:Universal Links 的入口application:continueUserActivity:restorationHandler:SceneDelegate 下scene:continueUserActivity:关键点在于 iOS 13 引入了 Scene 生命周期之后如果项目启用了 SceneDelegate部分回调只走 Scene不走 AppDelegate。很多 Unity 老项目直接沿用 Unity 生成的 AppDelegate 模板没有处理 Scene 方法结果在 iOS 13 以上的设备上 Universal Links 能拉起 App但参数永远到不了 C# 层。这个坑极其隐蔽。4.2 URL Scheme 的完整处理代码Objective-CUnity 生成的 iOS 原生层以 Objective-C 代码为主我就以 Objective-C 写逻辑可以平移给 Swift。// AppDelegate.m - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { return [self handleDeepLinkURL:url]; } // iOS 13 SceneDelegate 也要添加 // SceneDelegate.m - (void)scene:(UIScene *)scene openURLContexts:(NSSetUIOpenURLContext * *)URLContexts { for (UIOpenURLContext *context in URLContexts) { [self handleDeepLinkURL:context.URL]; } }handleDeepLinkURL的责任是把 URL 的 host、path、query 拆解出来整理成一段字符串转发给 Unity。我习惯的做法是把整个 URL 的absoluteString直接传给 C#由 C# 侧统一解析避免原生层和 C# 层各自维护一套解析逻辑导致两边对参数理解不一致。- (BOOL)handleDeepLinkURL:(NSURL *)url { if (url nil) return NO; NSString *urlString url.absoluteString; if (urlString.length 0) return NO; // 关键这条消息必须发给一个存活的 GameObject具体见第 5 节 UnitySendMessage(DeepLinkBridge, OnDeepLinkReceived, [urlString UTF8String]); return YES; }4.3 Universal Links 的完整处理代码// AppDelegate.m - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; if (url ! nil) { UnitySendMessage(DeepLinkBridge, OnDeepLinkReceived, [url.absoluteString UTF8String]); return YES; } } return NO; } // SceneDelegate.m - (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; if (url ! nil) { UnitySendMessage(DeepLinkBridge, OnDeepLinkReceived, [url.absoluteString UTF8String]); } } }这里有一个容易忽略的点continueUserActivity不保证在主线程调用。UnitySendMessage本身要求在主线程执行所以更稳妥的写法是在收到回调后手动切到主线程dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage(DeepLinkBridge, OnDeepLinkReceived, [urlString UTF8String]); });4.4 为什么必须同时处理两套生命周期iOS 13 之后的项目理论上 SceneDelegate 接管了 UI 生命周期但 Unity 生成的 App 并不会主动创建 SceneDelegate——需要开发者自己判断工程结构。如果工程是 Unity 生成的默认结构AppDelegate是单例路径不启用 Scene那 Universal Links 只有application:continueUserActivity:restorationHandler:这一个入口。如果后来为了适配 iPad 多窗口或其他需求启用了 Scene 生命周期就必须双写。我在项目里验证过一种情况工程没有重写scene:openURLContexts:但在 AppDelegate 里写了application:openURL:options:。测试 iOS 14 设备App 后台被杀状态点击 Universal LinksApp 能启动但 URL 丢失App 在后台存活状态点击URL 正常。原因是冷启动时系统把链接交给了 Scene 代理而热启动时走了 AppDelegate 路径。所以不管用不用 Scene最终方案里两套代理全部实现各自做幂等去重这是最省心的做法。5. 原生到 C# 的桥接UnitySendMessage 的高级用法原生层拿到 URL 后接下来的问题是怎么交给 C#。这一步踩坑的密度最高值得专门写一节。5.1 UnitySendMessage 的同步与异步UnitySendMessage的声明在 Unity 的 iPhone 类里void UnitySendMessage(const char* obj, const char* method, const char* msg);它的语义是向名为obj的 GameObject 上的所有 MonoBehaviour 组件发送一个名为method的方法调用参数为msg字符串。官方文档说它是同步调用但实际在 iOS 上它有一个隐蔽限制如果原生层所在线程不是主线程UnitySendMessage 的行为不可靠。我在实测中发现从后台队列调用 UnitySendMessage 时消息有时延迟到达有时直接丢失。所以正确姿势是前面提到的先切主线程再发消息。另一个限制是如果目标 GameObject 不存在或者消息发送时 C# 脚本还没有注册完消息会直接丢弃不会有任何报错。这个问题在冷启动场景下尤其致命原生层拿到 URL 的时间可能早于 Unity 场景加载完成那个叫DeepLinkBridge的对象根本还没创建。5.2 桥接对象的挂载与生命周期管理我的解决方案是双保险第一层Unity 侧创建一个不依赖场景的桥接脚本。把它挂在一个常驻的 GameObject 上这个对象不随场景切换销毁public class DeepLinkBridge : MonoBehaviour { private static DeepLinkBridge _instance; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void AutoInit() { var go new GameObject(DeepLinkBridge); DontDestroyOnLoad(go); _instance go.AddComponentDeepLinkBridge(); } }RuntimeInitializeOnLoadMethod会在场景加载前执行这样能保证桥接对象先于场景里任何业务脚本存在。虽然DontDestroyOnLoad是异步注册的但脚本组件本身在创建那一刻就注册到引擎了UnitySendMessage能找到它。第二层原生层侧维护一个待发送队列。如果调用UnitySendMessage时 C# 还没准备好消息只能丢所以要在 Unity 向原生层发出我已就绪的信号后原生层再补发缓存的 URL。interface DeepLinkNativeManager : NSObject property (nonatomic, strong) NSMutableArray *pendingURLs; property (nonatomic, assign) BOOL isUnityReady; end implementation DeepLinkNativeManager (instancetype)sharedInstance { static DeepLinkNativeManager *instance; static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ instance [DeepLinkNativeManager new]; instance.pendingURLs [NSMutableArray array]; }); return instance; } - (void)onUnityReady { self.isUnityReady YES; for (NSString *urlStr in self.pendingURLs) { dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage(DeepLinkBridge, OnDeepLinkReceived, [urlStr UTF8String]); }); } [self.pendingURLs removeAllObjects]; } - (void)enqueueURL:(NSString *)urlString { if (!self.isUnityReady) { [self.pendingURLs addObject:urlString]; return; } dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage(DeepLinkBridge, OnDeepLinkReceived, [urlString UTF8String]); }); } endUnity 侧在Start方法里通知原生层private void Start() { #if UNITY_IOS !UNITY_EDITOR DeepLinkNativeManagerBridge.SendUnityReady(); #endif }原生层实现SendUnityReady的 Objective-C 函数并在实现里调用上面的onUnityReady。这一套组合打下来冷启动参数丢失的问题基本可以杜绝。5.3 JSON 序列化与参数边界传给UnitySendMessage的msg是 UTF-8 C 字符串理论上没有长度限制但平台内部对单次消息长度是有限制的我习惯控制在 2KB 以内。超长参数要拆分或者只传关键 ID其余让 C# 侧请求。参数的编码必须统一。如果 URL 里带中文参数、带特殊字符最稳妥的做法是先把整条 URL 转成 Base64再传给 C#。C# 侧收到后反解码再解析能避开绝大多数的转义问题。NSData *data [urlString dataUsingEncoding:NSUTF8StringEncoding]; NSString *base64 [data base64EncodedStringWithOptions:0]; UnitySendMessage(DeepLinkBridge, OnDeepLinkReceived, [base64 UTF8String]);C# 侧接到消息后的第一步是 Base64 解码再交给业务层处理。6. C# 层参数接收与业务侧的落地处理原生层桥接做扎实之后C# 层反而简单但依然有几个设计决策影响后续功能扩展。6.1 参数结构设计与容错我先定义一个轻量级的数据模型覆盖绝大多数 Deep Link 场景[Serializable] public class DeepLinkPayload { public string scheme; // mygame 或 https public string host; // dl.example.com public string path; // /promote/anniversary public string scene; // 业务场景如 anniversary、guild public string adId; // 广告归因 ID public string campaignId; // 投放活动 ID public string rawUrl; // 原始链接 }接收方法里做防御性编程public void OnDeepLinkReceived(string msg) { if (string.IsNullOrEmpty(msg)) return; string json null; try { // 如果原生层做了 Base64这里先解码 var raw System.Text.Encoding.UTF8.GetString(System.Convert.FromBase64String(msg)); json raw; var payload JsonUtility.FromJsonDeepLinkPayload(raw); HandlePayload(payload); } catch (System.Exception e) { Debug.LogWarning($DeepLink parse failed: {e.Message}, raw{json ?? msg}); } }这里必须用try-catch包住解析因为线上的链接来自市场、运营、其他团队谁也不能保证参数格式统一。解析失败的链接不能直接吞掉要在日志里打印方便问题追查。6.2 等待链与延迟处理Deep Link 参数到达 C# 层时游戏可能正处于以下阶段还没完成登录新手引导进行中主场景加载中正在播放闪屏不能一拿到参数就立刻跳转。要有一个统一的 DeepLinkCenter 类维护待处理列表等游戏状态满足条件后再执行路由public class DeepLinkCenter : MonoBehaviour { private QueueDeepLinkPayload _pendingQueue new QueueDeepLinkPayload(); private bool _isReady false; public void Enqueue(DeepLinkPayload payload) { _pendingQueue.Enqueue(payload); TryProcess(); } public void SetReady(bool ready) { _isReady ready; TryProcess(); } private void TryProcess() { while (_isReady _pendingQueue.Count 0) { var payload _pendingQueue.Dequeue(); RouteToScene(payload); } } }SetReady(true)的触发时机我建议放在登录成功后、大厅界面初始化完成时。埋点日志里把接收时间和处理时间都记下来可以量化 Deep Link 的处理延迟方便定位是网络延迟还是业务等待延迟。6.3 与广告归因 SDK 的衔接如果项目接了 AppsFlyer 或 Branch 这类归因 SDK要注意它们的 Deep Link 回调与系统级 Deep Link 可能同时触发导致业务层重复处理。我的做法是在 C# 层维护一个processedKeys集合去重键用campaignId adId path拼接。首次处理过的组合标记已处理后续拦截。同时广告 SDK 的回调如果需要等到登录之后才触发DeepLink 中心要在就绪后主动询问 SDK 是否还有待处理归因数据防止系统链接先到、SDK 归因后到导致的归因丢失。7. 排雷实录我踩过的七个典型坑这一节集中写线上项目里最容易踩的坑每个都有明确的定位思路和修复方案。7.1 Universal Links 点击无反应这是出现频率最高的故障。排查顺序要固定先用curl确认apple-app-site-association文件可访问且内容格式正确。确认文件里paths覆盖了目标路径。paths写[*]最省事但有安全问题写精确路径时要注意大小写。真机锁屏或首次点击可能失败让用户再多点一次。确认 Xcode 的 Associated Domains 里填的是applinks:域名而不是https://域名。这个填错系统静默失败。确认 ATS 没拦截。可以在调试阶段临时加NSAllowsArbitraryLoads验证但正式包要删掉。如果以上都不行删掉 App 重装或者重开系统设置里的对应开关。7.2 参数中文乱码与转义问题URL 里的中文、特殊字符默认会做 URL 编码。如果原生层直接把absoluteString传给 C#C# 侧解析时一定要先做System.Uri.UnescapeDataString解码。反过来如果 C# 层要拼接参数回传要先做 URL 编码。最省事的方法还是我在 5.3 节说的原生层统一做 Base64C# 层解码后再走Uri.UnescapeDataString。7.3 冷启动时 gameObject 还没就绪这是UnitySendMessage的经典坑。我在 5.2 节已经给了完整的双保险方案这里再强调一遍RuntimeInitializeOnLoadMethod配合原生层待发送队列是解决这个问题的标准组合。7.4 内嵌 WebView 拦截链接导致 Universal Links 失效游戏内嵌的 H5 页面里用户点击一个指向激活域名的链接这个点击发生在 WKWebView而 WKWebView 默认不走 Universal Links 的系统验证逻辑需要 WebView 的导航代理在decidePolicyForNavigationAction里判断请求 URL 是否匹配 Deep Link 域名匹配的话手动切到 UIApplication 打开- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { NSURL *url navigationAction.request.URL; if ([url.host isEqualToString:dl.example.com]) { // 手动跳到系统层级触发 Universal Links [[UIApplication sharedApplication] openURL:url options:{} completionHandler:nil]; decisionHandler(WKNavigationActionPolicyCancel); return; } decisionHandler(WKNavigationActionPolicyAllow); }7.5 SceneDelegate 双写遗漏导致冷启动丢参数这个坑我在 4.4 节详细讲过。项目启用 Scene 生命周期后冷启动的 Universal Links 和 URL Scheme 都可能只走 Scene。我的建议是AppDelegate 和 SceneDelegate 两套方法全实现内部都走同一个handleDeepLinkURL入口配合一个时间戳去重比如同一 URL 在 100ms 内到达两次算重复双保险且不产生副作用。7.6 归因 SDK 与系统 Deep Link 的重复触发前置条件同时接了 AppsFlyer 的 deferred deep link 和系统 Universal Links。用户点击广告、未安装 App、跳 App Store 下载安装后首次启动AppsFlyer 会回调归因 link系统并不会自己带 Universal Links因为点击时还没装 App。但如果用户已经装了 App广告点击触发的就是系统 Universal LinksSDK 的实时回调也会触发。业务层必须做好去重。建议统一由 DeepLinkCenter 分发不要业务方各自监听。7.7 调试工具推荐curl检查配置文件最基础。Safari 开发者工具查看 Universal Links 是否命中可以在 iOS 的 Safari 设置里打开高级-网页检查器。原生层加日志在continueUserActivity和openURLContexts入口打 OSLog用 Xcode Console 过滤看是否走到了对应方法。Unity 侧用Debug.unityLogger打印接收日志再把日志重定向到文件方便拿到外部测试设备的数据。模拟器测试 Deep Link 可以手动执行xcrun simctl openurl booted mygame://test快速验证 Scheme 链路。写在最后的个人体会把 Deep Link 全流程跑通之后你会发现真正难的不是某个单一环节而是状态的把控系统回调在哪个生命周期触发、Unity 桥接对象是否就绪、业务层是否允许立即跳转。这三个状态只要有一个错位用户看到的都是点击了没反应。线上问题最难排查的也恰恰是这些状态错位导致的偶现 bug。从工程管理角度我强烈建议把 Deep Link 相关的原生代码单独抽成一个 Pod 或本地模块不要和业务代码混在一起。我见过太多项目把处理逻辑写在 AppDelegate 里随意粘贴改一次要全局搜索多次最后谁也说不清哪段代码在生效。模块化之后参数格式、回调时机、去重策略都集中在两个文件里后面接新的渠道、改新的场景路由都轻松得多。如果你正在接这类需求按照文章顺序先配好两端工程文件再写好原生层双入口处理然后桥接层做好待发送队列兜底最后 C# 层设计好等待链和去重。走完这一遍线上的 Deep Link 基本不会再有让你半夜爬起来排查的意外了。