资讯动态

Unity手游Deep Link完整实现:iOS配置、C#层参数投递与冷热启动处理

发布时间:2026/10/2 15:40:41 来源:尧图企业网站定制
Deep Link 这个需求做 Unity 手游的同学应该都不陌生。尤其这两年买量渠道越来越看重回流和唤醒iOS 端从 Safari 点开链接直接拉起游戏、再把渠道参数透传给游戏内 C# 层已经成了标配能力。但这一套东西真要一次做对中间坑不少。我前前后后在好几个项目里趟过一遍从 URL Scheme 到 Universal Links从 AppDelegate 到 UnitySendMessage踩过的坑足够写一篇长文了。这篇把完整链路拆开讲清楚iOS 侧怎么配Unity 侧怎么收C# 层怎么把参数安全投递给游戏逻辑。照着做基本能覆盖绝大多数唤醒场景。1. 整体设计与方案选型1.1 先搞清楚你要解决什么问题Deep Link 本质上要解决三件事怎么把用户从外部拉进 App、怎么把链接里的参数带进 App、怎么把参数安全地递到游戏逻辑手里。iOS 侧有两个方案URL Scheme 和 Universal Links。两个方案不冲突成熟项目通常是两者共存——URL Scheme 走老链路兼容历史版本、从微信等无法直接使用通用链接的环境过来Universal Links 走官方推荐链路、体验更顺畅。下面这张对比算是我的血泪总结对比维度URL SchemeUniversal Links配置方式Info.plist 注册 schemeApple Developer 后台关联域名 App 内配置生效条件直接拉起 App无需额外权限需要 HTTPS 域名 apple-app-site-association 文件未安装 App 时报错“无法打开网页”可直接降级跳转网页是否符合苹果推荐仅做兼容官方推荐微信内拦截会被拦截需要中转直接识别未安装可降级到 App Store自定义参数携带URL Query 直接带URL Query 直接带实现复杂度极低中等Universal Links 最大的优势其实是未安装场景。买量渠道给你一个下载链接用户手机上没装游戏Universal Links 可以无缝把用户引导到 App Store装了游戏就拉起游戏。这个转化链路对买量来说太重要了所以新项目我基本都是 Universal Links 为主、URL Scheme 兜底。1.2 为什么参数投递要走 C# 层而不是原生层直接消费这个决策很多人一开始想不通。既然 iOS 原生都拿到链接了直接在 Objective-C 里解析参数、调用 SDK 初始化、埋点上报不是更快吗原因有两个。第一游戏业务逻辑全在 C# 层。渠道参数要决定游戏跳转到哪个活动页、新手引导要不要跳过、用哪个账号渠道登录这些逻辑全部在 Unity 的业务代码里原生层根本没有能力直接消费。第二参数投递时机很关键。App 启动时 iOS 原生层能拿到参数但 Unity 引擎可能还在初始化C# 层代码不一定准备好了。如果你在 Unity 还没起来的时候就往 C# 层塞消息轻则消息丢失重则崩溃。所以标准做法是原生层只负责完整接收链接、解析出关键参数、暂存参数等 Unity 准备好了再通过 UnitySendMessage 或者信息桥接层把参数投递进去。这个“等 Unity 准备好”的判断就是整个流程最容易出错的地方。1.3 整体链路图景我们用一张简化链路来建立全局视角用户点击链接短信 / Safari / 企业微信 / 邮件等场景iOS 系统根据域名匹配 Universal Links或根据 scheme 匹配 URL SchemeAppDelegate 收到回调冷启动 / 热启动两条路径原生层解析参数 → 暂存等待 Unity 就绪 → 原生层调用 C# 层方法C# 层持久化参数 → 游戏逻辑按需消费这里的关键词是路径差异。冷启动和热启动的处理完全不同这两条路径我会在实操章节里单独展开。2. 核心细节解析与实操要点2.1 Universal Links 配置的完整步骤Universal Links 的配置分三块Apple Developer 后台、服务端 HTML 文件、iOS 工程配置。哪一块少了都不行。先看 Apple Developer 后台。登录开发者账号进入 Identifiers找到你的 App ID开启 Associated Domains 能力。注意要重新生成 Provisioning Profile 并下载更新到 Xcode否则真机跑起来各种奇怪问题。这个坑我踩过改了 App ID 的 Capability 之后忘了更新描述文件Universal Links 怎么都测不通真是让人头大。然后是服务端文件。这个是重头戏apple-app-site-associationAASA文件必须放在你的域名根目录或.well-known目录下必须 HTTPS必须带正确 MIME 类型。文件内容大致是这样的{ applinks: { details: [ { appIDs: [ TEAMID.com.yourcompany.games ], components: [ { #: no_universal_links, exclude: true }, { /: /game/*, comment: 匹配形如 https://yourdomain.com/game/lobby?uid123 的链接 } ] } ] } }几点经验appIDs 是 Team ID Bundle ID 的组合两个部分一个都不能错。/game/*这种 path 匹配规则匹配https://yourdomain.com/game/路径用于投放渠道链接。*按路径段匹配不匹配 query string。加粗提示AASA 文件的 content-type 必须是application/json或application/pkcs7-mime。这两种都可以建议用前者因为拍错方便在浏览器里直接查看。更新 AASA 后iOS 有缓存机制测试时可以在 Safari 地址栏输入https://yourdomain.com/apple-app-site-association直接访问确认文件在线。要重新触发验证可以把 App 从后台杀掉再点链接。2.2 iOS 工程侧Associated Domains 与 AppDelegate 接收工程侧配置在 Xcode 里。Project Target → Signing Capabilities → 点 “” 添加 Associated Domains填你的域名格式是applinks:yourdomain.com。注意不是https://开头这地方格式写错了苹果直接不认。真正的接收逻辑在 AppDelegate 里。需要实现两个方法// 先处理 Universal Links - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; // 这里把 url 传给解析层store 起来等 Unity ready 再投递 [UnityDeepLinkHandler handleUniversalLink:url]; return YES; } return NO; } // 再处理 URL Scheme - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { [UnityDeepLinkHandler handleScheme:url]; return YES; }为什么要两个方法都实现因为 Universal Links 和 URL Scheme 的回调入口在 iOS 里完全独立。你只实现其中一个另一个链路通不了。有个细节iOS 13 之后应该使用scene:continueUserActivity:并在 SceneDelegate 里做分发因为你的工程可能是 SwiftUI 生命周期。Unity 模板工程老版本是 AppDelegate 生命周期但拿到 Xcode 14、iOS 16 SDK 之后建议检查一下 target 的 Application Scene Manifest 配置有 SceneConfiguration 就需要在 SceneDelegate 里补一份回调转发。很多人在这个点上麻痹了AppDelegate 里写了但就是不回调其实是走了 SceneDelegate。2.3 URL Scheme 的配置细节URL Scheme 配置相对简单。Info.plist 里加 CFBundleURLTypeskeyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.games/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array这里 scheme 的名字必须是全小写iOS 虽然在部分场景不区分大小写但 Unity 侧解析和某些渠道的链接生成是区分大小写的。统一小写最省心。URL Scheme 形式的链接长这样mygame://game/lobby?uid123channelxxx。这个方案在微信里会被直接拦截必须用weixin://中转或者通过二维码、浏览器中间页跳转。做买量投放的话这个背景知识要心里有数别天真地用裸 scheme 丢微信里测。2.4 Unity 侧的跨语言桥接层设计跨语言桥接层是整个方案里最容易写出“屎山”的部分。原生层拿到链接后怎么把参数递给 C#我见过不少实现是直接用UnitySendMessage硬往指定 GameObject 上发消息。问题是你的 C# 监听对象挂了没场景加载了没热更代码准备好了没更稳妥的做法是在原生层建立一个缓冲队列。原生解析完链接后不急着调用 C#而是把参数对象存进原生侧的一个数组/字典里。等 C# 层主动“拉取”时再一次性投递。“主动拉取”的好处是C# 层可以控制时机确保逻辑组件全部 ready 再接收参数。具体桥接设计原生层暴露getPendingDeepLink()方法返回第一个未消费的链接参数 JSON。原生层暴露hasPendingDeepLink()方法查询是否存在待消费链接。C# 层在Start()里轮询或者事件注册。但注意UnitySendMessage也可以通过一个常驻 GameObject 来规避时机问题。我最终用的方案是两者结合常驻监听 GameObject 原生侧队列。常驻对象在游戏启动早期就创建注册好监听方法原生层来一个消息就接一个但的 C# 业务逻辑组件还没就绪时先把消息存到自己 C# 层的静态队列里等业务组件启动时主动消费。3. 实操过程与核心环节实现3.1 原生层拿到链接的统一处理入口原生层需要写一个统一处理入口类不管是 Universal Links 还是 URL Scheme最后都汇聚到同一个解析逻辑里。这个类的职责接收 NSURL解析 host、path、query 参数把参数整理成 JSON 字符串暂存到队列如果此时 Unity 已 ready 则立即投递// UnityDeepLinkHandler.h #import Foundation/Foundation.h interface UnityDeepLinkHandler : NSObject (instancetype)shared; - (void)handleUniversalLink:(NSURL *)url; - (void)handleScheme:(NSURL *)url; - (NSString *)consumePendingLink; end// UnityDeepLinkHandler.m #import UnityDeepLinkHandler.h #import UnityAppController.h static NSString *const kPendingLinkKey pending_deeplink; implementation UnityDeepLinkHandler { NSMutableArrayNSString * *_pendingLinks; BOOL _unityReady; } (instancetype)shared { static UnityDeepLinkHandler *handler nil; static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ handler [[UnityDeepLinkHandler alloc] init]; }); return handler; } - (instancetype)init { self [super init]; if (self) { _pendingLinks [NSMutableArray array]; _unityReady NO; [[NSNotificationCenter defaultCenter] addObserver:self selector:selector(onUnityReady) name:UnityReadyNotification object:nil]; } return self; } - (void)handleUniversalLink:(NSURL *)url { NSString *json [self parseURL:url]; [self enqueue:json]; } - (void)handleScheme:(NSURL *)url { NSString *json [self parseURL:url]; [self enqueue:json]; } - (NSString *)parseURL:(NSURL *)url { NSMutableDictionary *params [NSMutableDictionary dictionary]; params[url] url.absoluteString; params[host] url.host ?: ; params[path] url.path ?: ; NSURLComponents *components [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; NSMutableDictionary *queryDict [NSMutableDictionary dictionary]; for (NSURLQueryItem *item in components.queryItems) { queryDict[item.name] item.value ?: ; } params[query] queryDict; // 支持 dictQuery 直接展开一层方便 C# 层直接用 NSMutableDictionary *flatParams [NSMutableDictionary dictionary]; for (NSString *key in queryDict) { flatParams[key] queryDict[key]; } params[params] flatParams; NSError *error nil; NSData *data [NSJSONSerialization dataWithJSONObject:params options:NSJSONWritingPrettyPrinted error:error]; if (error) { return {}; } return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; } - (void)enqueue:(NSString *)json { if (!json || json.length 0) return; [_pendingLinks addObject:json]; if (_unityReady) { [self flushToUnity]; } } - (void)onUnityReady { _unityReady YES; [self flushToUnity]; } - (void)flushToUnity { while (_pendingLinks.count 0) { NSString *json _pendingLinks.firstObject; [_pendingLinks removeObjectAtIndex:0]; UnitySendMessage(DeepLinkBridge, OnNativeMessage, [json UTF8String]); } } - (NSString *)consumePendingLink { if (_pendingLinks.count 0) return nil; NSString *json _pendingLinks.firstObject; [_pendingLinks removeObjectAtIndex:0]; return json; } end有两个细节值得解释我用 Notification 通知 Unity 就绪而不是自己通过UnityAppController判断。因为 Unity 的 C# 侧调用原生consumePendingLink时说明 C# 层已经准备好环境了。更可靠的onUnityReady触发点是 Unity 启动完成后 C# 层主动调用原生方法。UnitySendMessage的第一个参数是 GameObject 名字第二个是方法名第三个是参数字符串。这个 GameObject 必须实际存在于场景中且挂载的脚本必须用[DllImport(__Internal)]声明接收方法否则静默失败且控制台可能只打印一条很难排查的警告。这是这个方案里最隐蔽的坑。3.2 C# 层接收参数与投递C# 侧我建议不要直接在所有脚本里各自调 API而是搞一个单例的DeepLinkService。这个服务管理三件事启动阶段检查原生侧是否有待投递链接主动消费。监听常驻 GameObject 上的OnNativeMessage。解析 JSON、持久化到本地PlayerPrefs 可选、对外暴露事件和查询接口。核心脚本大致如下这里我拆成两块。第一块是常驻接收器using System.Collections.Generic; using UnityEngine; using AOT; [MonoBehaviour] public class DeepLinkBridge : MonoBehaviour { private static Queuestring _pendingMessages new Queuestring(); [DllImport(__Internal)] private static extern string consumePendingLink(); void Awake() { // 常驻不随场景销毁 DontDestroyOnLoad(gameObject); // 主动消费原生层已暂存的链接 #if UNITY_IOS !UNITY_EDITOR string pending consumePendingLink(); while (!string.IsNullOrEmpty(pending)) { _pendingMessages.Enqueue(pending); pending consumePendingLink(); } #endif } // 由 UnitySendMessage 调用注意方法名、参数类型必须与原生层完全匹配 public void OnNativeMessage(string json) { _pendingMessages.Enqueue(json); DeepLinkService.Instance.NotifyNewLink(json); } public static string DequeuePending() { return _pendingMessages.Count 0 ? _pendingMessages.Dequeue() : null; } }第二块是服务层public class DeepLinkService { public static DeepLinkService Instance { get; } new DeepLinkService(); public event System.ActionDeepLinkPayload OnDeepLinkReceived; private DeepLinkPayload _lastPayload; public void Init() { // 场景初始化完成后再消费此时各业务模块都准备好了 var pending DeepLinkBridge.DequeuePending(); while (pending ! null) { ProcessPayload(pending); pending DeepLinkBridge.DequeuePending(); } } private void ProcessPayload(string json) { var payload JsonUtility.FromJsonDeepLinkPayload(json); _lastPayload payload; OnDeepLinkReceived?.Invoke(payload); } public DeepLinkPayload GetLastPayload() _lastPayload; public string GetParam(string key) { if (_lastPayload?.paramsMap null) return null; return _lastPayload.paramsMap.TryGetValue(key, out var val) ? val : null; } } [Serializable] public class DeepLinkPayload { public string url; public string host; public string path; public ListParamItem params; [System.Serializable] public class ParamItem { public string key; public string value; } // 注意实际数组中取 key/value 要看原生层怎么序列化 }围绕这个 C# 层需要注意的点[DllImport(__Internal)]只会在 iOS 真机生效编辑器环境下必须用#if UNITY_IOS !UNITY_EDITOR包住否则 IDE 会报 EntryPointNotFoundException。OnNativeMessage 方法的调用线程是 iOS 主线程UnitySendMessage 本身会切换线程但你的 C# 逻辑不要做耗时操作直接入队即可。在JsonUtility处理字段时原生层如果输出 key 为中文或者有特殊字符解析会崩所以原生层统一用英文键名C# 侧做好映射。这一点是很多团队文本协议没对上导致的情况。3.3 冷启动与热启动两条路径的差异化处理这是最容易出问题的场景。冷启动App 未在后台运行用户点击链接系统直接把 App 拉起。AppDelegate / SceneDelegate 收到的回调发生在 Unity 引擎启动之前。此时所有 C# 侧脚本都没执行UnitySendMessage 调用会失败或丢失。解决思路就是原生层先存储等 C# 主动拉取。这也是上面设计里consumePendingLink存在的意义。热启动App 在后台被唤起Unity 引擎已经在跑C# 侧组件已就绪。此时回调触发立刻 UnitySendMessage 就能被收到。但是有一个坑如果用户在游戏内点了分享链接这个链接对你的业务可能意味着要弹活动页但时机不对比如正在战斗直接弹窗会打断体验。所以热启动链路里需要在 C# 层做延迟投递和排队机制进入前台或回到主场景后再消费。我最终的做法是冷启动路径走原生缓存 C# 拉取热启动路径走原生立即投递 C# 侧业务判定延迟消费。两条路最终都会汇聚到同一个ProcessPayload这样业务层只需要关心一个入口。4. 常见问题与排查技巧实录4.1 Universal Links 不生效怎么办这是高频问题。我按优先级给一套排查清单AASA 文件可访问性先确认 Safari 能打开https://yourdomain.com/apple-app-site-association且内容不为空、无 BOM 头。Team ID 和 Bundle ID检查appIDs数组里的 Team ID必须是你的开发者账号的 Team ID不是 App 前缀更不是 Bundle ID 混淆。Associated Domains 能力在 Xcode 里看 Signing Capabilities确认已添加且格式为applinks:yourdomain.com不能带 https。Provisioning Profile 更新老项目特别容易踩重新下载 profile 后 clean build彻底删除 App 再重装。iOS 缓存AASA 文件更新后系统可能缓存旧文件。把 App 删了重装、重启手机、或者用 Network Link Conditioner 模拟弱网状态来触发重新拉取。最直接的办法是等几分钟后再测。链接格式确认你点击的链接路径符合components里面的匹配规则。/game/*不匹配/game根路径这个要小心。4.2 URL Scheme 能拉起但参数丢失这大概率是回调时机问题。冷启动时AppDelegate 的openURL:被调用后Unity C# 侧会立刻收到。但如果你在这个时刻就调 UnitySendMessage而目标 GameObject 还没加载消息就丢了。处理方式参考前面的设计原生层存储、C# 拉取。如果你只想快速修复可以延迟 1~2 秒再触发但这是拙劣方案参数多、链路长时会不稳定。4.3 UnitySendMessage 静默失败这是最让人抓狂的原生层代码没报错C# 方法就是没执行。排查点是GameObject 是否存在UnitySendMessage 的目标必须是场景里已存在的 GameObject 名称不区分大小写但名称必须准确。脚本方法访问级别被调用的方法必须为public且无返回值。Objective-C 调用 C# 方法时参数类型必须是基本类型string/int。脚本是否挂在目标对象DeepLinkBridge 这个脚本必须挂在名为“DeepLinkBridge”的 GameObject 上且该对象在场景加载时不隐藏。原生侧与 C# 侧方法签名我见过原生传了 UTF8 字符串C# 收到的是带 BOM 或编码异常参数的情况解析失败但方法被调了。这种情况在日志里表现为乱码或不走 if 分支。4.4 微信内被拦截问题微信内置浏览器对 URL Scheme 有严格限制对 Universal Links 也做了一层风险判定。现在普遍做法是投放短信/邮件双通道链接时使用 Universal Links。从微信进入时用一个 H5 中间页引导用户点击“右上角”用 Safari 打开再走 Universal Links 或 scheme。这个方案不完美但已经是行业惯例了。如果你的用户大量从微信进来务必在上层做完整的引导页面设计。4.5 参数编码和特殊字符问题链接参数里如果带了签名信息通常是 urlEncode 过的比如%E5%BC%A0%E4%B8%89这种。原生层 NSURL 的queryItems会做自动 decode拿到的是中文原文这没问题。但如果你自己用stringByReplacingPercentEscapesUsingEncoding处理反而可能把%26这种符号错误解码。C# 侧拿到 JSON 后如果要用参数里的值做签名校验不要二次转码保持原生层 decode 后的原始形态。这个坑是安全审计时发现的因为二次转码把号转成了空格导致渠道签名对不上。4.6 推送与 Deep Link 结合时的时序问题很多项目做的是“推送通知 → 用户点击 → 拉起 App → 进活动页”。推送回调本身也有冷启动/热启动之分。推荐做法是推送回调发生在原生层时同样走一遍UnityDeepLinkHandler把推送带的 link 字段也当成 deep link 处理。这样 C# 层只需一个入口接受“外部跳转意图”不管来自推送还是点击链接。5. 工具链与调试技巧5.1 用 Safari 手动验证 Universal LinksSafari 地址栏输入完整链接如果 Universal Links 生效Safari 顶部会有一个“打开”按钮横幅iOS 13或者直接拉起 App。没有横幅说明 AASA 配置有问题直接进排查清单。5.2 Xcode 控制台看原生日志在 AppDelegate 回调里加NSLog确认是否收到链接、收到的 URL 是什么。如果 Native 层都没收到说明系统没把链接交给 App问题出在工程配置或 AASA 文件而不是 C# 层。5.3 Unity Console 看 C# 日志在ProcessPayload入口打日志确认 JSON 是否合法。JsonUtility对格式要求比较严格字段多了没关系字段少了会填充默认值类型不匹配则直接抛异常。日志里能看到字段名和数据类型异常往往是解析问题。5.4 用 Charles 抓包验证 AASACharles 配好 SSL 代理后查看 App 启动是否请求了apple-app-site-association。如果不请求或请求返回 404那基本就是服务端文件问题。这条排查链路比盲目改参数高效得多。6. 一个完整的项目落地清单最后我在多个项目里沉淀下来一份实施检查清单每一条都对应一个真实踩过的坑Apple Developer 后台开启 Associated Domains更新并重新生成 Provisioning Profile。服务端部署 AASA 文件确认 HTTPS 可访问、content-type 正确、无 BOM 头。Xcode 配置 Associated Domainsapplinks:yourdomain.com。确认 SceneDelegate 生命周期下 AppDelegate 回调也被正确转发。实现原生层 UnityDeepLinkHandler统一处理 Universal Links 和 URL Scheme。原生层实现 pending 队列缓存机制C# 层启动时主动消费。创建常驻DeepLinkBridgeGameObject挂载接收脚本。C# 层DeepLinkService统一对外提供事件和参数查询接口。在测试包中验证冷启动、热启动、未安装 App 三种场景。用 Charles 验证 AASA 文件请求正常。检查微信场景引导页可用性如业务需要。每一步都不复杂但串起来容易断。尤其是“原生层缓存 C# 层拉取”这套时序设计是整个方案的地基地基打好了后续渠道再多也能稳定接住。实际跑下来我最深的一个体会是**Deep Link 不是一个纯原生问题也不是一个纯 Unity 问题它是一条跨端链路。**两端各做一个模块各自都对但只要时序对接差了最终效果一定是坏的。调试时先用最简单的手段把“原生层是否收到链接”这个问题确认掉再谈 C# 层消费。这样定位问题的速度会快非常多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑