
做手游 iOS 端的 Deep Link表面看就是“用户点个链接把 App 唤醒”真正接起来才发现这是一条横跨 Web、原生、Unity 三层的链路。我在 Unity 项目里从只配一个 URL Scheme 起步到后来 Universal Links、SceneDelegate、冷启动事件队列、C# 层统一分发全走完中间踩过的坑比需求文档还厚。这篇就把整条链路从头到尾拆开讲URL Scheme 和 Universal Links 怎么选、原生侧收到的回调怎么处理、参数怎么稳妥地投递到 C# 层以及那些不实测根本发现不了的细节。1. 项目背景与需求拆解一条唤醒链接到底背了多少活1.1 Deep Link 在手游运营生态里的真实位置买量归因、老玩家召回、活动页跳转、主播分享这些场景最终都落在一个动作上用户在浏览器或者外部 App 里点了一个链接希望直接打开游戏并带上参数。对运营来说这条链接承载的是来源渠道、活动 ID、用户 ID 这些信息对客户端来说它要回答三个问题App 被唤醒了吗、参数拿到了吗、参数在正确的时机交给正确的业务了吗。iOS 之所以要单独拉出来讲是因为它和 Android 完全不是一套玩法。Android 的 intent filter 配好之后链接处理相对直接iOS 这边有 URL Scheme 和 Universal Links 两套机制还有 iOS 13 之后的生命周期变化稍不注意参数就会在冷启动时丢掉。加上我们客户端是 Unity 引擎原生层拿到的链接还得跨过 OC/C# 的桥才能到达游戏逻辑层每一步都可能翻车。1.2 一条链接里到底装了什么参数先定参数规范再做技术选型这个顺序不能反。不管用户从哪个入口进来最终落到 C# 层的数据模型应该是一致的。我们项目里统一用这样一套参数scene要跳转到游戏内哪个界面比如活动页、签到页、分享落地页source来源渠道比如 sms、push、web、koccampaign具体活动标识运营看报表用uid可选被邀请人或者投放素材里的用户标记ts生成链接的时间戳防过期ext扩展参数预留给将来加配额解析URL Scheme 的链接长这样mygame://open?sceneactivitysourcesms_0923uid10086ts1726000000。Universal Links 的链接是正常 HTTPS 地址https://game.example.com/open?sceneactivitysourcesms_0923uid10086ts1726000000。关键在于两条通道进入后原生层要把它们解析成同一种结构再传给 Unity而不是各传各的。否则 C# 层要写两套解析逻辑埋点也会乱。1.3 真正难啃的不是链接而是时序把参数从链接里读出来这是最不需要动脑子的部分真正的难点全在时序上。第一个时序是引擎未就绪。App 冷启动时链接参数在didFinishLaunchingWithOptions里就能拿到但 Unity 引擎这时候还没加载完UnitySendMessage发出去就是石沉大海。第二个时序是场景未就绪。就算引擎起来了主城/登录界面可能还没加载完这时候把活动参数抛给业务层业务层根本接不住。第三个时序是重复回调。Universal Links 和 URL Scheme 在冷启动和热启动时回调的入口不一样有的第三方 SDK 也会拦截 openURL处理不好就会出现一次点击、两次投递。所以这篇文章的实质就是在讲怎么把这三道时序问题一个一个熨平。2. 方案选型URL Scheme 与 Universal Links 怎么分工2.1 URL Scheme当不了主力但不能没有URL Scheme 是 iOS 从远古时代就支持的机制在 Info.plist 里注册一个自定义协议然后浏览器、短信、邮件里都能通过mygame://把 App 唤起来。但它有几个硬伤。第一首次安装场景下用户第一次点击链接不能靠它直接唤起 App如果没有别的机制链接点了只会报错。第二从 Safari 调自定义 Scheme 会有弹窗确认多一次打断。第三Scheme 是全局的别的 App 也能注册同样的协议存在被抢注或者被恶意调用的风险不过手游场景里这个威胁面相对可控。那为什么还不能没有它因为我们自己测试、App 内部跳转、以及一些不支持 Universal Links 的 WebView 环境都还要靠它兜底。Universal Links 需要 HTTPS 域名和 AASA 文件有些内嵌浏览器对 Universal Links 支持很烂反而对 Scheme 的老套路有响应。2.2 Universal LinksiOS 9 之后的正确打开方式Universal Links 是 iOS 9 引入的核心思路是你的域名经过验证Apple 知道这个域名下的哪些路径属于你的 App所以当用户在 Safari 或者支持系统级跳转的场景里点击链接时系统可以直接唤起 App而且不弹窗。它解决了 URL Scheme 的三大问题有域名验证安全性高没有确认弹窗转化率高未安装 App 时链接会正常打开网页可以降级到下载页或者活动页而不是报错。代价也很明显配置复杂度上了一个台阶。你需要付费开发者账号、需要 HTTPS 域名、需要上传apple-app-site-association文件、需要在 Xcode 里开 Associated Domains 权限。任何一个环节出问题链接就只能在浏览器里打开网页看起来像是“没做 Deep Link”。我做方案选型的时候列过一张对比表你可以直接拿去参考维度URL SchemeUniversal Links系统要求一直可用iOS 9用户确认弹窗有无未安装 App 时浏览器报错或无反应自动打开网页降级配置成本低Info.plist 即可高Entitlement AASA HTTPS 域名安全性弱可被抢注强域名验证适用场景内部跳转、测试、WebView 兜底买量、召回、网页跳转主力2.3 我的双通道兜底策略最终我们采用的策略是Universal Links 做主力URL Scheme 做兜底两条通道都解析、都投递。这个“都投递”要特别小心。如果同一个场景里两个通道同时触发就可能出现重复投递。我们的做法是原生侧引入一个去重逻辑从 URL 里提取参数后以scenesourceuidts拼出一个指纹在一小段时间窗口内相同指纹只投递一次。另外还有一个兜底细节如果用户是用 App 内嵌 H5 打开的链接内嵌 WebView 通常不支持 Universal Links这时候我们在 H5 页面上放一个“打开游戏”按钮按钮走 URL Scheme。H5 由前端同学判断环境客户端只要保证 Scheme 通道始终可用就行。2.4 iOS 13 生命周期改动对 Deep Link 的影响iOS 13 引入了 Scene 生命周期理论上 App 可以不再走传统的 AppDelegate 那一套而改用 UISceneDelegate 管理前台后台。这对 Deep Link 的直接影响是链接回调可能从application:openURL:options:挪到scene:openURLContexts:Universal Links 也对应有 scene 版本的回调。Unity 生成的工程默认还是 AppDelegate 生命周期UnityAppController本身没有实现 scene 相关的代理方法。只要你不主动往工程里塞 UIApplicationSceneManifest一般不会走到 Scene 那套。但有个现实情况是某些第三方登录/推送 SDK 会往工程里注入 Scene 支持代码一旦 Scene 被启用链接回调就会分叉。我的建议是能不开 Scene 就不开如果因为 SDK 原因被迫启用必须在 SceneDelegate 里把openURLContexts和continueUserActivity同样转发到自己的 Bridge并且和 AppDelegate 侧做互斥避免同一份参数被投两次。3. 原生层配置与回调接收最容易绕晕的一段3.1 Info.plist 配置 URL Scheme在 Xcode 导出工程里URL Scheme 的配置位置是Info.plist的CFBundleURLTypes一份配置大概长这样keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.gamename/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array然后链接就是mygame://open?...。这里有个容易踩的细节Scheme 建议全小写。iOS 在匹配时虽然大小写不敏感但运营配置链接、测试同学手敲链接时大写出现的概率很高一旦用惯了大写后面排查成本很高。还有Scheme 里不要用下划线Apple 的规范是字母开头只能包含字母、数字、点和连字符下划线会导致某些场景下链接解析异常。如果你是在 Unity 的 Build 流程里自动化配置可以用UnityEditor.iOS.Xcode.PlistDocument在构建后处理脚本里往 plist 里塞这段避免每次导出工程后手改。3.2 Associated Domains 与 AASA 文件Universal Links 的配置分两层一层在 Xcode 工程里一层在服务器上。Xcode 里要打开 Associated Domains 能力添加applinks:game.example.com。注意这个能力需要付费开发者账号免费的个人账号在 Xcode 里这个 Capability 是灰的。如果你用 Unity 自动化导出需要在 Xcode API 的PBXProject里对 target 添加 entitlements写出来大概是com.apple.developer.associated-domains [applinks:game.example.com, applinks:www.game.example.com]服务器上要放一个apple-app-site-association文件能通过 HTTPS 直接访问不能有重定向Content-Type 最好是application/json。文件内容{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.gamename, paths: [ /open/*, * ] } ] } }这个文件可以放在域名的根路径/apple-app-site-association也可以放在/.well-known/apple-app-site-association两个位置 Apple 都会尝试请求。调试时用curl -i https://game.example.com/apple-app-site-association看返回状态码和内容必须返回 2xx。paths的匹配规则我吃了不少亏*是通配全部路径/open/*是只匹配/open/开头的路径。注意它是大小写敏感的运营如果配了/Open而你的路径是/open链接就不会唤起 App只会开网页。另外路径匹配是按 URL path 匹配query 参数不影响匹配。3.3 AppDelegate 回调实现以 UnityAppController 子类为例Unity 生成的 Xcode 工程里真正的 AppDelegate 是Classes/UnityAppController.mm里的UnityAppControllermain.mm通过UIApplicationMain把它指定成代理类。所以改法有两种直接改 UnityAppController.mm每次升级 Unity、重新导出工程都要再改一次不推荐或者写一个子类再让 main.mm 用你的子类。我推荐第二种。先建一个MyDeepLinkController.h/.mm// MyDeepLinkController.h #import UnityAppController.h interface MyDeepLinkController : UnityAppController end// MyDeepLinkController.mm #import MyDeepLinkController.h #import UIKit/UIKit.h interface MyDeepLinkController () property (nonatomic, strong) NSMutableArrayNSString * *pendingDeepLinks; end implementation MyDeepLinkController - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { if (url) { NSString *raw [url absoluteString]; NSLog([DeepLink] scheme open: %, raw); [self handleRawLink:raw source:scheme]; return YES; } return NO; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; if (url) { NSLog([DeepLink] universal open: %, url); [self handleRawLink:[url absoluteString] source:universal]; return YES; } } return NO; } - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // 必须调用 superUnity 引擎初始化逻辑都在里面 BOOL result [super application:application didFinishLaunchingWithOptions:launchOptions]; NSURL *coldURL launchOptions[UIApplicationLaunchOptionsURLKey]; if (coldURL) { NSLog([DeepLink] cold scheme: %, coldURL); [self handleRawLink:[coldURL absoluteString] source:scheme_cold]; } NSDictionary *activityDict launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; NSUserActivity *activity activityDict[UIApplicationLaunchOptionsUserActivityKey]; if ([activity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *webURL activity.webpageURL; if (webURL) { NSLog([DeepLink] cold universal: %, webURL); [self handleRawLink:[webURL absoluteString] source:universal_cold]; } } return result; } - (void)handleRawLink:(NSString *)raw source:(NSString *)source { // 先按 source 去重指纹处理然后转成 JSON NSString *json [DeepLinkParser parseToJSON:raw source:source]; if (json nil) return; // 这里先缓存等 C# 侧注册后再投递 [self cacheOrSendJSON:json]; } end注意我在didFinishLaunchingWithOptions里先调了[super ...]这一步漏了 Unity 引擎根本起不来。冷启动 URL Scheme 拿到的是UIApplicationLaunchOptionsURLKeyUniversal Links 拿到的是UIApplicationLaunchOptionsUserActivityDictionaryKey两个 key 都要处理。改了 main.mm 里的指定类// main.mm return UIApplicationMain(argc, argv, nil, NSStringFromClass([MyDeepLinkController class]));自动化方面我建议在 Unity 构建后处理脚本里把main.mm中的UnityAppController字符串替换成MyDeepLinkController并把子类文件拷贝进 Xcode 工程这样每次导出都不用手工动工程。3.4 冷启动缓存先存起来等引擎就绪再投上面代码里的cacheOrSendJSON是整条链最关键的一个函数。UnitySendMessage只有在 Unity 的UnityAppController初始化完成、对应场景里存在指定 GameObject 之后调用才有效。冷启动时didFinishLaunchingWithOptions执行的时候引擎还没起来这时候直接调用 UnitySendMessage 等于白调。所以做法是原生侧维护一个数组当缓存队列所有 Deep Link 先转成 JSON 字符串存进去同时尝试调一次 UnitySendMessage。如果 C# 侧的注册动作还没发生UnitySendMessage 会失败并打日志JSON 留在队列里等 C# 侧主动调用注册接口时原生侧再把队列里的所有 JSON 一次性冲刷出去。- (void)cacheOrSendJSON:(NSString *)json { if (self.unityBridgeGameObjectName.length 0) { UnitySendMessage(self.unityBridgeGameObjectName.UTF8String, OnNativeDeepLink, json.UTF8String); } else { synchronized (self) { [self.pendingDeepLinks addObject:json]; } } } // C# 侧调用注册接口后触发 - (void)flushPendingDeepLinks { synchronized (self) { for (NSString *json in self.pendingDeepLinks) { UnitySendMessage(self.unityBridgeGameObjectName.UTF8String, OnNativeDeepLink, json.UTF8String); } [self.pendingDeepLinks removeAllObjects]; } }这个模式我在项目里用了很久逻辑简单、不会丢参数、也不依赖复杂的调用链。你要是不放心还可以在缓存里带一个时间戳C# 侧收到超时过久的参数可以选择丢弃。3.5 原生侧先做参数规整传给 Unity 之前原生侧要把mygame://open?sceneactivity...和https://game.example.com/open?sceneactivity...解析成同一种结构。我用 NSURLComponents 做而不是手写字符串截取 (NSString *)parseToJSON:(NSString *)raw source:(NSString *)source { NSURL *url [NSURL URLWithString:raw]; if (url nil) return nil; NSURLComponents *components [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; NSMutableDictionary *params [NSMutableDictionary dictionary]; // host path 共同决定 scenescheme 和 universal 的路径规则不一样 if ([url.scheme isEqualToString:mygame]) { params[scene] url.host ?: open; } else { NSString *path components.path ?: ; if ([path hasPrefix:/open/]) { params[scene] [path substringFromIndex:6]; } else { params[scene] home; } } for (NSURLQueryItem *item in components.queryItems) { params[item.name] item.value; } params[source] source; params[raw] raw; NSData *data [NSJSONSerialization dataWithJSONObject:params options:0 error:nil]; return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; }这里有两个细节第一query 里的 key 如果重复上面的写法会互相覆盖如果你运营会配重复参数自己决定用数组还是用最后一个。第二item.value拿到的已经是百分号解码后的字符串如果链接里嵌了中文或者特殊符号相对安全但有些第三方渠道会做双重编码C# 侧最好再加一层容错。4. C# 层参数接收与投递从 UnitySendMessage 到统一分发4.1 UnitySendMessage 是最直接但最容易翻车的通道原生层向 C# 层传参最常用的就是UnitySendMessage三个参数接收的 GameObject 名、方法名、字符串消息。它简单直接但有几个使用前提接收的 GameObject 必须在当前场景里存在。如果你的接收物体不在常驻场景场景一切换就收不到。我习惯建一个名为 DeepLinkManager 的 GameObject在Awake里DontDestroyOnLoad。方法必须是 public 的参数类型必须是 string。写内部私有方法的话UnitySendMessage 调用时不会报错但你的方法就是不会被执行排查起来极其折磨。如果消息里要传的是 JSON 字符串注意里面的引号和转义。只要你走UnitySendMessage传字符串C# 侧接到的就是一个原始字符串直接JsonUtility或LitJson解析即可不存在二次转义的问题。真正需要警惕的是在原生侧拼 JSON 时用NSJSONSerialization不要手写字符串拼接否则中文、引号都会出问题。4.2 C# 侧注册与队列冲刷我在 C# 侧用一个单例管理所有 Deep Link逻辑很直白public class DeepLinkManager : MonoBehaviour { private static DeepLinkManager _instance; public static DeepLinkManager Instance _instance; private string _gameObjectName; private readonly ListDeepLinkPayload _pending new ListDeepLinkPayload(); private bool _isEngineReady; private bool _isNativeRegistered; public event ActionDeepLinkPayload OnDeepLinkArrived; private void Awake() { if (_instance ! null _instance ! this) { Destroy(gameObject); return; } _instance this; DontDestroyOnLoad(gameObject); _gameObjectName gameObject.name; #if UNITY_IOS !UNITY_EDITOR NativeDeepLinkBridge.Register(gameObject.name); #endif } private void Update() { if (!_isNativeRegistered || _isEngineReady) return; if (LoadMainSceneFinished() !_isEngineReady) { _isEngineReady true; FlushPending(); } } public void OnNativeDeepLink(string json) { var payload ParsePayload(json); if (payload null) return; if (!_isEngineReady) { _pending.Add(payload); return; } Dispatch(payload); } private void Dispatch(DeepLinkPayload payload) { // 去重相同指纹只派发一次 if (payload.timestamp 0 DateTime.UtcNow.ToUnixTimeSeconds() - payload.timestamp 86400) { Debug.Log($[DeepLink] 过期参数丢弃: {payload.raw}); return; } OnDeepLinkArrived?.Invoke(payload); } }iOS 的调用通过[DllImport(__Internal)]进入原生侧这个语法只对真机有效public class NativeDeepLinkBridge { #if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void UnityDeepLinkBridge_Register(string gameObjectName); [DllImport(__Internal)] private static extern void UnityDeepLinkBridge_Flush(); #endif public static void Register(string goName) { #if UNITY_IOS !UNITY_EDITOR UnityDeepLinkBridge_Register(goName); #endif } }原生侧拿到Register调用后把unityBridgeGameObjectName记下来然后冲刷之前缓存的队列。这个设计保证了无论链接是多早进来的只要引擎准备好、C# 侧注册过参数就不会丢。4.3 参数模型与业务分发事件C# 侧参数模型保持和原生侧一致即可[Serializable] public class DeepLinkPayload { public string scene; public string source; public string campaign; public string uid; public long timestamp; public string raw; }业务层通过事件订阅来接收不要在 DeepLinkManager 里直接写跳转逻辑。比如登录页订阅了它收到sceneactivity就跳活动页主界面订阅了它收到scenefriend_invite就弹邀请弹窗。这样 DeepLinkManager 保持纯净任何一个业务模块都能按需接入。这里补一个实操心得不要在OnNativeDeepLink里立刻调用SceneManager.LoadScene。如果你在收到参数的同一帧里切场景场景加载会打断当前帧的派发流程部分业务回调可能丢失。正确做法是把参数缓存下来等场景加载完再派发或者至少放到下一帧执行。4.4 参数下发时机与去重规则时机问题在 C# 侧就是前面代码里的_isEngineReady。什么时候把_isEngineReady置为 true取决于你的游戏把哪个节点定义为“可接深链”。我见过团队卡在登录流程认为必须登录成功才能接深链结果运营的召回链接全在登录前被丢弃。更合理的节点是“主界面可交互”而不是“玩家已登录”参数先派发给一个业务分发器由分发器决定是跳转活动页还是先引导登录再跳转。去重规则上我们用的是scene source uid ts拼接后的字符串做 key放在一个最近处理的缓存里过期时间 5 秒。这个窗口期足够覆盖同一次点击导致的 AppDelegate 和 SceneDelegate 双回调又不会误杀几秒后自然重发的业务链接。5. 联调、埋点与踩坑实录5.1 本地验证链路真机是必须的模拟器上 Universal Links 表现不稳定不能作为最终判定。我把调试流程固定成一套先在 Safari 地址栏输入mygame://open?scenetestsourcelocal验证 URL Scheme 通道通不通。Safari 会弹一个“是否在 App 中打开”的确认框点确认后 App 被唤起。断点打在application:openURL:options:里确认原生层收到了原始链接。再验证 Universal Links在备忘录里输入https://game.example.com/open?scenetestsourcelocal长按链接可以直接打开。如果长按菜单出现“在游戏名中打开”说明系统已经识别了这个域名如果只有“打开 URL”说明 AASA 没生效先查服务器文件。服务器 AASA 的检查用 curlcurl -i https://game.example.com/apple-app-site-association确认返回内容里有你的 Team ID 和 Bundle ID注意 Team ID 是 10 位字母数字Bundle ID 要和 Xcode 里完全一致包括大小写。在真机上验证完最终用 Xcode 的 Console 和 Unity 的日志串起来看一遍链接原生侧打了日志C# 侧 OnNativeDeepLink 打了日志业务派发打了日志三段日志能对上这条链路才算真的通。5.2 常见坑速查表现象原因解决办法Safari 里点链接没反应Scheme 大小写不对或没注册检查 Info.plistScheme 统一小写长按 Universal Link 没有“在 App 中打开”AASA 没生效或 Team ID/Bundle ID 不匹配用 curl 验证文件核对 entitlements冷启动后参数丢失在 didFinishLaunching 里直接调 UnitySendMessage改成缓存队列等 C# 注册后冲刷同一次点击收到了两次参数AppDelegate 和 SceneDelegate 同时处理原生侧做指纹去重链接里带中文参数乱码百分号编码只做了一层有些渠道做双重编码C# 侧先手动 decode 一次再解析改了 AASA 后很长时间不生效Apple CDN 缓存换一个带 query 的新路径测试必要时重启设备UnitySendMessage 调了但方法没执行接收方法不是 public 或参数不是 string检查接收函数签名5.3 埋点与防刷的一些建议Deep Link 链路里最容易出问题的就是“看得见但查不了”所以埋点必须比业务先行。我在三个关键节点埋了日志原生层收到链接的时间点和原始 URLC# 层OnNativeDeepLink收到通知的时间点和解析后的参数业务层最终派发的时间点和目标场景这三条日志的时间差就能定位时延卡在哪一段。比如原生到 C# 差了几秒大概率是缓存队列冲刷时机不对C# 到业务差了几秒大概率是_isEngineReady置位太晚。防刷方面我们对ts做过期校验超过 24 小时直接丢弃链接对充值返利类的跳转参数运营生成的链接会带一个签名串C# 侧只做格式校验具体签名验证放在服务端客户端不做因为客户端签名逻辑很容易被反编。5.4 我反复踩过并且每次都想骂人的三个细节第一Universal Links 的 AASA 文件更新后真机上经常“过了一晚才生效”这不是玄学是 Apple CDN 缓存。调试时我习惯用新路径加 query 的形式测试绕开旧路径缓存。生产环境 AASA 就不要随便动了改动前通知所有端联调。第二某些第三方统计和归因 SDK 会抢openURL回调它会先返回 YESiOS 就不会再把链接事件交给后面的代理。我接归因 SDK 时先把自家 Deep Link 处理逻辑放到最前面确认拿到参数后直接把流程交给 SDK。两个都想要的时候就要在回调里先调 SDK 再处理自己的逻辑并且返回值只能给一个。第三UnitySendMessage找不到 GameObject 的时候不会抛异常你只会发现消息丢了。所以我会在原生侧记录每次发送的目标名和方法名联调时全部打出来一旦发现发送成功但 C# 没反应先查目标物体是不是被场景切换干掉了。最后再分享一个小技巧把整套 Deep Link 参数解析和派发逻辑写成一个独立的 SDK 模块不要散落在各个业务页面里。以后不管是接新的买量平台、做新的召回活动还是从 URL Scheme 平滑迁移到 Universal Links都只改一个入口运营侧也不用跟着你改链接格式。这套链路做完之后我在项目里基本再没为“用户点链接进不来”这个问题熬过夜。