ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity iOS 手游 Deep Link 全链路实战:URL Scheme 与 Universal Links 选型、桥接与冷启动排查

Unity iOS 手游 Deep Link 全链路实战:URL Scheme 与 Universal Links 选型、桥接与冷启动排查 1. 为什么手游团队绕不开 Deep Link 这道坎做过 Unity 手游投放的同学大概都有过这种体验买量素材里明明写了点击直达游戏领福利用户点完却先跳 App Store 下载装完打开游戏人直接落在登录页活动页面连影子都没有。投放数据看着还行但活动页的转化率就是上不去运营天天追着问这批用户到底进没进活动。问题十有八九出在 Deep Link 没打通或者打通了但参数在某个环节被吃掉了。Deep Link 说白了就是一条能带着信息把用户送进 App 指定位置的链接。它要解决的核心问题有两个一是唤醒用户已经装了 App点链接要能直接拉起来而不是跳浏览器二是传参拉起的同时得把用户从哪来、要进哪个页面、带什么活动 ID这些信息一并送进去。iOS 上实现这件事有两条主流路线——URL Scheme和Universal Links两者机制完全不同坑也各不相同。而 Unity 项目因为多了一层 C# 与原生Objective-C/Swift的桥接参数从系统层一路投递到 C# 业务层的链路更长任何一个环节断掉表现都是链接点了没反应或者进去了但参数是空的。这篇内容面向的是正在做 Unity iOS 手游、需要接入或排查 Deep Link 的开发和运营技术同学。我会把从系统层接收到 C# 层消费的完整链路拆开讲包括两种方案的选型逻辑、Unity 侧的桥接写法、参数投递的时序陷阱以及我自己在项目里踩过的几个典型坑。看完你应该能独立把这条链路搭起来并且在出问题时知道从哪一层开始查。2. URL Scheme 与 Universal Links 的机制差异与选型2.1 URL Scheme 到底是怎么被系统识别的URL Scheme 是 iOS 最早支持的唤起方式形式就是自定义一个协议头比如mygame://。你在 Xcode 的Info.plist里注册CFBundleURLTypes系统在安装时就把这个协议和你的 App 绑定起来。之后任何地方打开mygame://open?pageactivityid123这样的链接系统会查表找到对应 App 并拉起同时把完整 URL 通过application:openURL:options:回调交给你的 AppDelegate。它的优点是实现简单、兼容性极好从很老的 iOS 版本就支持Unity 侧接起来也快。但缺点同样明显第一如果用户没装 App点链接会直接报错或者停在浏览器没有任何兜底第二任何 App 都能注册同名 Scheme存在被劫持的风险系统不保证一定拉起你的 App第三在微信、QQ、抖音这类内置浏览器里Scheme 经常被拦截点了没反应是常态。所以 Scheme 更适合作为已安装用户的补充唤起手段而不是投放落地页的主力方案。2.2 Universal Links 凭什么成为投放首选Universal Links 是 iOS 9 之后推出的方案它用普通的https://链接来实现唤起。原理是你在自己的域名下放一个apple-app-site-association简称 AASA文件声明哪些路径归哪个 App ID 处理。系统在安装 App 时会去下载这个文件并缓存之后用户点https://yourdomain.com/activity?id123时如果已安装 App系统直接拉起 App 并把 URL 交给application:continueUserActivity:restorationHandler:如果没装就正常在 Safari 打开网页你可以在网页上做下载引导。对比一下两者的关键差异维度URL SchemeUniversal Links链接形式mygame://xxxhttps://domain.com/xxx未安装兜底无自动打开网页安全性可被其他 App 抢注域名归属校验安全微信内表现常被拦截相对友好仍需配置配置复杂度低中依赖 AASA 文件参数传递URL 直接带URL 直接带实际项目里我的建议是两者都配Universal Links 为主Scheme 为辅。投放落地页统一用 Universal Links保证未安装用户有网页兜底App 内部跳转、老版本兼容、以及某些 Universal Links 被拦截的场景用 Scheme 补位。这样覆盖面最广也不会因为单一方案失效导致整条链路瘫痪。2.3 AASA 文件配置里最容易翻车的几个点AASA 文件必须放在域名根目录路径是https://yourdomain.com/.well-known/apple-app-site-association注意没有.json后缀Content-Type 是application/json且必须支持 HTTPS、不能有重定向。文件内容大概长这样{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.mygame, paths: [/activity/*, /share/*] } ] } }这里有几个我踩过的坑。第一appID是TeamID.BundleID拼起来的TeamID 在开发者账号里查BundleID 必须和 Xcode 里完全一致大小写都不能错。第二paths的匹配规则要写清楚/activity/*表示匹配该路径下所有子路径如果你只写/activity那带参数的链接就匹配不上。第三AASA 文件更新后系统缓存不会立即刷新开发阶段建议把 App 删掉重装来强制重新拉取否则你改了文件测试半天没反应会怀疑人生。第四苹果现在推荐用components字段替代paths功能更灵活但老项目用paths也没问题。提示AASA 文件可以用苹果的验证工具或者直接curl -I检查响应头确认返回 200、Content-Type 正确、无重定向。这一步没做对后面所有配置都是白搭。3. Unity 与 iOS 原生之间的桥接层怎么写3.1 从 AppDelegate 拿到 URL 之后往哪送Unity 导出的 iOS 工程里AppDelegate是系统回调的入口。URL Scheme 的回调是application:openURL:options:Universal Links 的回调是application:continueUserActivity:restorationHandler:。这两个方法默认在 Unity 生成的UnityAppController.mm里你需要重写它们把拿到的 URL 转交给 C# 层。最直接的做法是用UnitySendMessage它能把消息发给场景里某个 GameObject 上的某个方法- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id *)options { NSString *urlStr url.absoluteString; UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [urlStr UTF8String]); return YES; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSString *urlStr userActivity.webpageURL.absoluteString; UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [urlStr UTF8String]); } return YES; }UnitySendMessage有三个参数GameObject 名字、方法名、字符串参数。注意它只能传一个字符串参数而且 GameObject 必须处于激活状态、场景已加载否则消息会丢。这是第一个大坑后面会细说。3.2 C# 侧接收方法的签名与线程问题C# 这边接收方法必须是public void参数是stringpublic class DeepLinkManager : MonoBehaviour { public void OnDeepLinkReceived(string url) { Debug.Log($[DeepLink] received: {url}); // 解析并分发 } }看起来简单但有两个隐藏问题。第一UnitySendMessage是从原生主线程调过来的而 Unity 的脚本执行在主循环里这个调用实际是异步排队的不会立刻执行。第二如果 App 是被 Deep Link 冷启动的进程原本不在原生回调触发时 Unity 场景可能还没加载完DeepLinkManager这个 GameObject 还不存在消息直接丢失。这就是为什么很多人测试时热启动能收到、冷启动收不到。解决办法是在原生侧缓存 URL等 Unity 准备好之后再主动取。具体做法是在 AppDelegate 里用一个静态变量存住 URL然后暴露一个方法给 C# 调用static NSString *pendingDeepLink nil; - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id *)options { pendingDeepLink url.absoluteString; UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [pendingDeepLink UTF8String]); return YES; } extern C const char* GetPendingDeepLink() { if (pendingDeepLink nil) return strdup(); return strdup([pendingDeepLink UTF8String]); }C# 侧在Start里主动拉一次[DllImport(__Internal)] private static extern string GetPendingDeepLink(); void Start() { string pending GetPendingDeepLink(); if (!string.IsNullOrEmpty(pending)) { OnDeepLinkReceived(pending); } }这样冷启动时即使UnitySendMessage丢了C# 也能在初始化后主动补取双保险。3.3 参数解析别用字符串切割硬怼拿到 URL 之后要解析参数。很多人图省事直接url.Split(?)[1].Split()遇到 URL 编码、参数顺序变化、值里带特殊字符就崩。正确做法是用Uri和HttpUtilityusing System; using System.Web; void ParseAndDispatch(string url) { Uri uri new Uri(url); var query HttpUtility.ParseQueryString(uri.Query); string page query[page]; string id query[id]; // 分发到对应页面 }HttpUtility.ParseQueryString会自动处理 URL 解码%20、中文、特殊符号都能正确还原。注意Uri对mygame://这种自定义 Scheme 也能解析uri.Query一样能拿到问号后面的部分。如果参数里嵌套了 Base64 或者 JSON记得先解码再反序列化别在字符串层面硬拆。4. 参数投递的时序陷阱与冷启动处理4.1 冷启动、热启动、后台唤醒三种状态的区别Deep Link 触发时 App 可能处于三种状态处理逻辑完全不同冷启动App 进程不存在点链接后系统全新启动 App。此时原生回调先于 Unity 场景加载UnitySendMessage大概率丢失必须靠缓存补取。热启动前台App 正在前台运行点链接直接触发回调UnitySendMessage能正常送达但要注意当前场景是否已经初始化好接收方。后台唤醒App 在后台挂起点链接把它拉回前台回调能触发但场景状态是保留的要小心重复处理同一个链接。我见过最典型的 bug 就是热启动测试一切正常一上投放冷启动全挂。原因就是只写了UnitySendMessage没做缓存补取。所以冷启动路径必须单独测测试方法很简单把 App 从后台彻底划掉再点链接。4.2 用状态机管理链接的生命周期为了避免重复处理和时序错乱我习惯用一个简单的状态机来管理public class DeepLinkManager : MonoBehaviour { private static DeepLinkManager _instance; private string _pendingUrl; private bool _isReady; void Awake() { if (_instance ! null) { Destroy(gameObject); return; } _instance this; DontDestroyOnLoad(gameObject); } void Start() { _isReady true; string cached GetPendingDeepLink(); if (!string.IsNullOrEmpty(cached)) HandleUrl(cached); if (!string.IsNullOrEmpty(_pendingUrl)) HandleUrl(_pendingUrl); } public void OnDeepLinkReceived(string url) { if (!_isReady) { _pendingUrl url; return; } HandleUrl(url); } private void HandleUrl(string url) { // 解析 分发 } }核心思路是_isReady标记场景是否初始化完成没完成就把 URL 存起来Start里统一处理。DontDestroyOnLoad保证跨场景不丢。这套逻辑不复杂但能挡掉 90% 的时序问题。4.3 场景切换时的参数接力如果 Deep Link 要跳转的页面在另一个场景直接SceneManager.LoadScene会把当前场景的DeepLinkManager销毁除非挂了DontDestroyOnLoad。参数怎么接力我的做法是把解析后的参数结构体存到一个静态类里目标场景的控制器在Start里读public static class DeepLinkPayload { public static string Page; public static string Id; public static bool HasPending; }HandleUrl解析完就写进DeepLinkPayload然后加载目标场景目标场景读静态字段。简单粗暴但有效比用PlayerPrefs或者消息总线都轻量。注意用完记得把HasPending置回 false否则下次进这个场景会重复触发。5. 真机调试与线上问题排查的完整链路5.1 分层验证从系统层到业务层逐段确认Deep Link 出问题最怕的就是点了没反应你根本不知道断在哪一层。我的排查习惯是分层验证从下往上逐段确认系统层链接本身是否合法。Universal Links 用备忘录或短信发给自己点一下看是否拉起 AppScheme 直接在 Safari 地址栏输入看是否弹窗询问打开。原生层在 AppDelegate 回调里打日志确认 URL 有没有进来。用 Xcode 连真机看 Console 输出。桥接层确认UnitySendMessage有没有送达C# 侧OnDeepLinkReceived有没有打印。业务层确认参数解析对不对页面跳转逻辑有没有执行。哪一层没日志问题就在那一层。这个顺序能帮你快速定位而不是盲目改代码。5.2 用 Xcode Console 和 Unity 日志交叉定位真机调试时Xcode 的 Console 能看到原生日志Unity 的Debug.Log会输出到 Xcode 控制台前提是 Development Build 且勾选了日志。我通常会在原生回调里加一句NSLog(DeepLink received: %, urlStr);在 C# 里加Debug.Log两边时间戳对一下就能判断是原生没收到还是桥接丢了。有个细节UnitySendMessage如果 GameObject 名字写错或者对象不存在不会报错静默失败。所以桥接层一定要在 C# 侧加日志确认收到不能只看原生日志就以为成功了。5.3 线上环境 AASA 缓存导致的改了没生效线上最坑的问题是 AASA 文件更新后老用户设备上缓存还是旧的新路径匹配不上。苹果的缓存策略不透明可能几天才刷新。应对办法有两个一是尽量不改路径规则新增功能用新路径而不是改老路径二是如果必须改在 App 发版时引导用户重装或者接受一段时间的灰度期。我自己的经验是AASA 的paths设计要留足扩展空间比如一开始就规划好/activity/*、/share/*、/promo/*这些前缀后面加功能直接往里塞避免动根规则。5.4 微信、抖音内置浏览器的拦截应对国内投放绕不开微信和抖音的内置浏览器它们对 Scheme 拦截严重Universal Links 也时好时坏。实测下来微信内 Universal Links 在部分版本能直接拉起部分版本会先弹一个中间页。稳妥的做法是落地页做双保险优先尝试 Universal Links失败后引导用户点击右上角在浏览器打开浏览器里再用 Scheme 兜底。这个交互虽然多一步但比点了没反应强。另外落地页要能识别 UA针对微信环境做专门的引导文案。6. 几个我踩过的真实坑与经验总结6.1 参数里的中文和特殊字符被截断有一次活动链接带了中文参数?name新手礼包测试时发现 C# 收到的值是乱码。原因是原生侧UnitySendMessage传的是UTF8String但 URL 本身没做编码中文在传输过程中被截断。解决办法是生成链接时就对参数做 URL 编码HttpUtility.UrlEncode或者服务端编码都行C# 侧用ParseQueryString自动解码。这个坑不涉及技术难点纯粹是规范问题但线上出一次就够喝一壶。6.2 重复触发导致页面弹两次后台唤醒场景下application:continueUserActivity:可能被调用多次加上 C# 侧的缓存补取同一个链接被处理了两遍活动弹窗弹了两次。解决方法是加一个去重标记用 URL 的哈希或者时间戳判断短时间内相同 URL 只处理一次private string _lastUrl; private float _lastTime; private void HandleUrl(string url) { if (url _lastUrl Time.realtimeSinceStartup - _lastTime 2f) return; _lastUrl url; _lastTime Time.realtimeSinceStartup; // 正常处理 }两秒内的相同链接直接忽略简单有效。6.3 测试链接别用生产域名开发阶段我习惯用测试域名配 AASA但有一次偷懒直接用了生产域名测试结果测试数据混进了生产统计运营那边数据对不上排查了半天。后来定了个规矩测试环境独立域名、独立 AASA、独立 BundleID三样都不跟生产混。虽然配置麻烦点但数据干净出问题也好定位。6.4 上线前的检查清单每次发版前我会过一遍这个清单基本能挡住大部分 Deep Link 问题AASA 文件线上可访问Content-Type 正确无重定向appID的 TeamID 和 BundleID 与线上包一致URL Scheme 在Info.plist里注册正确冷启动、热启动、后台唤醒三种状态都测过参数带中文、特殊字符、超长的情况都测过微信、抖音内置浏览器里点链接的表现确认过未安装 App 时 Universal Links 的网页兜底正常这套流程跑下来Deep Link 这条链路基本就稳了。它不是什么高深技术但细节多、环节长任何一个点疏忽都会表现为用户点了没反应而用户是不会给你第二次机会的。把链路拆清楚、每层加日志、上线前逐项验证比事后救火省心得多。
返回列表