
1. 为什么手游团队绕不开 Deep Link 这件事做过手游投放或者运营活动的人大概率都碰过这样一个场景用户在朋友圈、短信、落地页里点了一条链接理想状态是直接打开已经装在手机上的游戏并且跳到指定活动页现实往往是浏览器先打开一个网页用户还得自己手动切回桌面找图标中间流失掉一大半人。这个转化漏斗的损耗就是 Deep Link 要解决的核心问题。我最早接触这块是在一个卡牌项目上当时买量落地页的二次唤起率低得离谱投放同学天天追着问能不能让链接直接拉起 App。后来把 URL Scheme 和 Universal Links 两条路都跑通配合 Unity 侧的参数投递才算把这条链路理顺。这篇就把整个流程从 iOS 系统层到 C# 层完整拆一遍包括配置、代码、参数解析和一堆踩过的坑。需要说明的是这里讨论的是合规的 App 内跳转与营销唤起也就是让用户从网页、短信、其他 App 跳到自己家游戏指定页面属于正常的增长和运营技术范畴。整个链路涉及三个层面iOS 系统层的链接注册与拦截、Unity 原生桥接层的参数接收、C# 业务层的参数分发与页面跳转。任何一层出问题用户看到的就是点了没反应或者打开了但停在登录页。适合谁看如果你正在做 Unity 手游的 iOS 版本需要接投放落地页唤起、活动分享回流、推送点击跳转这类需求那这篇基本可以照着抄。哪怕你之前没碰过原生桥接只要会写 C#、能看懂一点 OC 代码跟着走也能落地。2. 两条技术路线URL Scheme 与 Universal Links 怎么选2.1 URL Scheme 的原理与它的硬伤URL Scheme 是最早也最简单的方案。你在 Xcode 的 Info.plist 里注册一个自定义协议比如mygame://系统看到这个前缀的链接就会尝试唤起对应 App。配置长这样keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.mygame/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array它的优点是接入成本极低Unity 侧几乎不用改什么原生层拿到 URL 透传过来就行。但硬伤也很明显第一如果用户没装 App点击mygame://xxx在 Safari 里会直接弹一个打不开的错误提示体验很差第二从 iOS 系统层面Scheme 的唤起会有一个确认弹窗尤其是跨 App 场景用户点取消就断了第三任何 App 都能注册同名 Scheme存在被劫持的风险。所以 Scheme 更适合已经确认用户装了 App 的场景比如 App 内部 H5 页面跳原生页、推送通知点击跳转。做投放落地页这种不确定装没装的场景单靠 Scheme 是不够的。2.2 Universal Links 解决了什么Universal Links 是苹果后来推的方案本质是用标准的https://链接关联到你的 App。用户点击https://game.yourdomain.com/activity?id123如果装了 App 就直接打开 App 并带上完整 URL没装就正常打开这个网页通常网页上放下载引导。整个过程没有确认弹窗体验顺滑得多。它的配置比 Scheme 复杂核心是三个东西必须对齐Apple Developer 后台在 App ID 的 Associated Domains 能力里开启并配置你的域名。服务器上的apple-app-site-association文件简称 AASA放在域名根目录或.well-known目录下声明哪些路径归这个 App 管。Xcode 工程在 Signing Capabilities 里添加 Associated Domains填入applinks:game.yourdomain.com。AASA 文件的内容大概是这样{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.mygame, paths: [/activity/*, /share/*] } ] } }这里的TEAMID是你的开发者团队 IDpaths决定哪些路径会被拦截到 App。注意路径匹配是精确的/activity/*能匹配/activity/123但匹配不了/activity不带斜杠。这个细节坑过不少人。2.3 两条路线的取舍对照维度URL SchemeUniversal Links接入成本低改 plist 即可中高需服务端 后台 工程三处配置未安装体验报错体验差打开网页可引导下载唤起确认弹窗有无安全性可被劫持域名绑定较安全适用场景App 内跳转、推送投放落地页、分享回流实际项目里我的建议是两条都配按场景分流。投放和分享用 Universal Links 兜底App 内部和推送用 Scheme 图省事。Unity 侧接收逻辑做成统一的不管从哪条路进来最终都归一成一个 URL 字符串处理。3. Unity 与 iOS 原生桥接的三种实现方式3.1 桥接方案选型为什么我最终选了原生插件Unity 调用 iOS 原生代码常见有三条路一是纯DllImport直接调 OC 的 C 函数二是写一个.mm文件作为原生插件放进Plugins/iOS三是用第三方桥接框架。我实测下来第二种最稳因为你可以完全控制生命周期回调尤其是 Deep Link 这种需要在 App 启动和运行中都能收到事件的场景。纯DllImport的问题在于它适合无状态的函数调用但 Deep Link 需要在AppDelegate里拦截系统回调这必须有一个原生类去实现UIApplicationDelegate的方法。所以正确姿势是写一个继承自UnityAppController的类或者用 Unity 提供的UnityAppController子类机制在里面重写application:openURL:options:和application:continueUserActivity:restorationHandler:两个方法。3.2 关键回调两个入口必须都接很多人只接了 Scheme 的回调结果 Universal Links 死活不生效就是因为漏了continueUserActivity。这两个方法分工明确application:openURL:options:处理 URL Scheme 唤起。application:continueUserActivity:restorationHandler:处理 Universal Links 唤起。原生层拿到 URL 后通过UnitySendMessage把字符串发给 Unity 场景里的某个 GameObject。这里有个经典坑如果 App 是被 Deep Link 冷启动的回调触发时机可能早于 Unity 场景加载完成此时UnitySendMessage会发到一个还不存在的对象上消息直接丢失。解决办法是原生层先把 URL 缓存起来等 Unity 侧主动来取或者等场景加载完再发。3.3 参数投递的时机问题我踩过最深的坑就是这个时机。冷启动场景下openURL在didFinishLaunchingWithOptions之后很快就被调用而 Unity 的Awake、Start还没跑完。如果这时候直接UnitySendMessageUnity 侧收不到。我的做法是原生层维护一个pendingURL变量Unity 侧在Start里调用一个GetPendingURL的原生方法主动拉取拉完清空。这样无论冷启动还是热启动都不会丢。热启动App 在后台被唤起就简单了Unity 场景还在直接UnitySendMessage就能收到。所以完整逻辑是原生层判断当前 Unity 是否 readyready 就直接发没 ready 就缓存等拉取。4. 原生层完整实现与代码拆解4.1 创建原生桥接类在 Unity 工程的Assets/Plugins/iOS目录下新建一个.mm文件比如DeepLinkBridge.mm。内容结构分三块静态变量缓存、C 函数供 Unity 调用、AppDelegate 回调重写。#import Foundation/Foundation.h #import UIKit/UIKit.h #import UnityAppController.h static NSString *_pendingURL nil; static NSString *_lastURL nil; extern C { // Unity 侧主动拉取待处理的 URL const char* _GetPendingDeepLink() { if (_pendingURL nil) return strdup(); const char* result strdup([_pendingURL UTF8String]); _pendingURL nil; return result; } // Unity 侧查询最近一次 URL热启动用 const char* _GetLastDeepLink() { if (_lastURL nil) return strdup(); return strdup([_lastURL UTF8String]); } }这里用strdup是因为UnitySendMessage和返回值跨语言边界时字符串内存管理容易出问题复制一份最保险。返回空字符串而不是 NULL避免 Unity 侧解析时崩溃。4.2 重写 AppDelegate 回调Unity 生成的 Xcode 工程里UnityAppController是主控制器。我们要做的是在它基础上扩展。有两种写法一种是直接改 Unity 生成的UnityAppController.mm不推荐每次出包会被覆盖另一种是写一个 Category 或者用 Unity 的IMPL_APP_CONTROLLER_SUBCLASS宏。推荐后者interface DeepLinkAppController : UnityAppController end IMPL_APP_CONTROLLER_SUBCLASS(DeepLinkAppController) implementation DeepLinkAppController - (BOOL)application:(UIApplication*)app openURL:(NSURL*)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id*)options { [self handleDeepLink:url.absoluteString]; return [super application:app openURL:url options:options]; } - (BOOL)application:(UIApplication*)app continueUserActivity:(NSUserActivity*)userActivity restorationHandler:(void(^)(NSArray*))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [self handleDeepLink:userActivity.webpageURL.absoluteString]; } return [super application:app continueUserActivity:userActivity restorationHandler:restorationHandler]; } - (void)handleDeepLink:(NSString*)urlString { if (urlString nil || urlString.length 0) return; _lastURL urlString; if (UnityIsReady()) { UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [urlString UTF8String]); } else { _pendingURL urlString; } } endIMPL_APP_CONTROLLER_SUBCLASS这个宏是 Unity 提供的它会自动把我们的子类注册为 App 的主控制器不用手动改生成代码。UnityIsReady()是 Unity 内部函数判断引擎是否初始化完成。4.3 一个容易忽略的细节URL 编码从网页传过来的 URL 里参数值往往带中文或者特殊字符比如活动名夏日狂欢。这些在 URL 里是百分号编码的原生层拿到的absoluteString也是编码后的。如果直接透传给 UnityC# 侧解析出来就是乱码。所以要么在原生层stringByRemovingPercentEncoding解码要么在 C# 侧用Uri.UnescapeDataString处理。我习惯在 C# 侧统一处理因为原生层解码后再拼接可能引入新的转义问题。5. C# 层参数接收与业务分发5.1 接收器脚本的编写Unity 场景里需要一个常驻的 GameObject挂上接收脚本。名字必须和原生层UnitySendMessage的第一个参数一致这里是DeepLinkManager。using System; using System.Runtime.InteropServices; using UnityEngine; public class DeepLinkManager : MonoBehaviour { [DllImport(__Internal)] private static extern string _GetPendingDeepLink(); [DllImport(__Internal)] private static extern string _GetLastDeepLink(); public static event ActionDeepLinkData OnDeepLink; private void Awake() { DontDestroyOnLoad(gameObject); } private void Start() { // 冷启动主动拉取 string pending _GetPendingDeepLink(); if (!string.IsNullOrEmpty(pending)) { Dispatch(pending); } } // 热启动原生层主动推送 public void OnDeepLinkReceived(string url) { Dispatch(url); } private void Dispatch(string url) { var data DeepLinkParser.Parse(url); if (data null) return; Debug.Log($[DeepLink] 收到跳转: {data}); OnDeepLink?.Invoke(data); } }注意DllImport的库名在 iOS 上固定写__Internal这是 Unity 的约定不是笔误。另外Start里拉取 pending URL 的时机很关键太早场景没初始化完太晚用户已经看到登录页了。放在Start里配合DontDestroyOnLoad是比较稳的。5.2 参数解析器的设计URL 格式无非两种Scheme 的mygame://activity?id123fromshare和 Universal Links 的https://game.yourdomain.com/activity?id123fromshare。解析逻辑要能同时吃下这两种。public class DeepLinkData { public string Host; // activity / share public string Path; // 完整路径 public Dictionarystring, string Params; public string RawUrl; public override string ToString() { return $Host{Host}, Params{Params.Count}, Raw{RawUrl}; } } public static class DeepLinkParser { public static DeepLinkData Parse(string url) { if (string.IsNullOrEmpty(url)) return null; try { var uri new Uri(url); var data new DeepLinkData { RawUrl url, Host uri.Host, Path uri.AbsolutePath, Params new Dictionarystring, string() }; // 解析 query string query uri.Query; if (!string.IsNullOrEmpty(query)) { query query.TrimStart(?); foreach (var pair in query.Split()) { var kv pair.Split(); if (kv.Length 2) { string key Uri.UnescapeDataString(kv[0]); string val Uri.UnescapeDataString(kv[1]); data.Params[key] val; } } } return data; } catch (Exception e) { Debug.LogError($[DeepLink] 解析失败: {url}, {e.Message}); return null; } } }这里有个细节Scheme 链接mygame://activity?id123用new Uri解析时Host会是activityQuery是?id123能正常工作。但如果是mygame:///activity?id123三个斜杠Host就空了Path才是/activity。所以业务分发时最好把Host和Path拼起来判断兼容两种写法。5.3 业务分发与页面跳转解析出数据后怎么跳转到对应页面取决于你的项目架构。如果是单场景 UI 栈管理就根据Host打开对应面板如果是多场景就加载对应场景。我一般会做一个路由表private void HandleDeepLink(DeepLinkData data) { string route string.IsNullOrEmpty(data.Host) ? data.Path : data.Host; switch (route) { case activity: string actId data.Params.ContainsKey(id) ? data.Params[id] : ; UIManager.OpenActivity(actId); break; case share: string shareCode data.Params.ContainsKey(code) ? data.Params[code] : ; ShareManager.HandleInvite(shareCode); break; default: Debug.LogWarning($[DeepLink] 未知路由: {route}); break; } }关键点在于跳转前要确保登录完成。如果用户冷启动进来Deep Link 事件可能在登录流程之前就触发了这时候直接开活动面板会出问题。我的做法是把 Deep Link 数据缓存起来等登录成功后再消费。这个状态机管理是业务层最容易出 bug 的地方。6. 联调与测试怎么验证整条链路6.1 本地测试 URL SchemeScheme 测试最简单模拟器或者真机连上后用xcrun simctl openurl booted mygame://activity?id123就能触发。真机的话在 Safari 地址栏直接输mygame://activity?id123回车也行。如果没反应先检查 Info.plist 里的 Scheme 拼写再看原生回调有没有断点命中。6.2 Universal Links 的测试难点Universal Links 不能直接在 Safari 地址栏输入测试因为苹果设计上要求从其他 App 或者特定入口点击才触发。测试方法有几种一是用备忘录写一条https://game.yourdomain.com/activity?id123长按链接选择打开二是用xcrun simctl openurl配合真机三是发一条短信给自己点击。AASA 文件生效有延迟苹果的 CDN 会缓存改完可能要等一段时间。调试时可以在设备上删掉 App 重装强制重新拉取 AASA。另外 AASA 文件必须是application/json类型不能有重定向不能要求鉴权这些都会导致校验失败。6.3 常见问题速查表现象可能原因排查方向点击链接无反应Scheme 未注册 / AASA 未生效检查 plist、AASA 可访问性打开了但停在登录页参数未投递 / 时机太早检查 pendingURL 逻辑、登录后消费参数乱码未做 URL 解码C# 侧加 UnescapeDataString冷启动丢参数UnitySendMessage 早于场景加载改用主动拉取模式Universal Links 打开网页域名不匹配 / paths 不匹配核对 appID 和 paths 通配符热启动重复触发回调被调用两次加去重逻辑记录 lastURL7. 几个只有踩过才知道的实操心得第一个心得关于AASA 的 paths 通配符。很多人写paths: [/activity]结果/activity?id123匹配不上因为 query 不参与路径匹配但/activity/123这种带子路径的必须用/activity/*。我现在的习惯是直接写paths: [*]全量接管然后在 App 内部判断路由省得路径规则写错。第二个心得关于Unity 出包后的 Xcode 工程覆盖问题。如果你直接改 Unity 生成的UnityAppController.mm下次 Build 会被覆盖。用IMPL_APP_CONTROLLER_SUBCLASS宏写在独立.mm文件里就不会有这个问题。这个宏是 Unity 官方支持的扩展点比改生成代码优雅得多。第三个心得关于参数安全。Deep Link 的 URL 是用户可控的任何人都能构造mygame://activity?id../../etc这种恶意参数。业务层解析参数后一定要做校验尤其是涉及资源加载、跳转路径的地方别直接把参数拼进文件路径或者反射调用。我见过因为 Deep Link 参数没校验导致越权跳转的案例这个坑必须提前防。第四个心得关于多平台兼容。Android 侧的 Deep Link 是 Intent Filter和 iOS 完全两套。如果项目要双端建议在 C# 层做统一抽象原生层各自实现业务层只认DeepLinkData这个结构。这样新增平台时业务代码不用动。最后说个调试技巧在原生handleDeepLink里加一行NSLog把收到的原始 URL 打出来配合 Xcode 的 Devices 日志窗口看。Unity 侧的Debug.Log在真机上要通过 Xcode 控制台看别只盯着 Unity EditorEditor 里是收不到原生消息的。这个链路必须真机联调模拟器测 Universal Links 经常不准。整个流程跑通之后你会发现 Deep Link 本身不复杂复杂的是时机和状态管理。冷启动、热启动、登录前、登录后这几个状态组合起来就是一堆边界情况。把 pending 机制和登录后消费这两点做扎实基本就稳了。