尧图精选

Unity手游iOS Deep Link全链路指南:从URL Scheme到Universal Links参数投递

🕒 发布时间:2026/10/1 5:33:19 📁 来源:尧图网络
做手游客户端的同学十有八九会遇到这个需求运营说要做老带新邀请活动玩家在微信里点一条链接游戏要能直接拉起来还能直接落到对应的房间页面产品那边再补一句“把渠道参数也带上我们好做归因”。这句话翻译成技术语言就是iOS 需要支持 Deep Link 唤醒而且唤醒之后链接里的参数要准确、稳定、不丢失地投递到 Unity 的业务层。这篇文章就围绕这条完整链路展开从 iOS 侧 URL Scheme 和 Universal Links 的配置到原生层回调、Unity 桥接、C# 层的参数解析与路由分发再到真机调试和线上常见的坑一次讲透。适合 Unity 客户端开发、客户端中台的同学参考如果你正被“链接能唤起但参数丢了”“冷启动收不到回调”“微信里点不醒游戏”这类问题卡住那这篇文章就是给你写的。1. 需求拆解手游的 Deep Link 到底在解决什么事1.1 从一个最常见的分享场景说起想象一下这个链路。玩家 A 在游戏里发起组队房间点“邀请好友”分享了一条链接给玩家 B。B 的微信收到链接点开之后如果 B 装了游戏游戏直接启动并自动加入 A 的房间如果没装Safari 打开一个落地页提示下载 App 并展示“你被邀请进房间”的信息。这个场景里技术上至少包含三件事第一让系统知道这个链接属于你的 App能把 App 唤起来这就是 Deep Link 的“唤起”能力第二唤起时把房间号、邀请人ID、渠道标识这些参数一起带进去这就是“参数投递”第三如果用户是第一次安装还要能在安装后通过归因方式把参数补上通常叫 deferred deep link那一般由 AppsFlyer、Branch、Adjust 这类归因平台来做不在本文展开。除了邀请Deep Link 还常见于活动页跳转、运营短信链接、广告投放回传、从邮件/浏览器/NFC 触达等多个场景。每一个场景的入口不同但落到客户端就两件事链接来了把语义翻译成业务动作。后面你会发现这两件事看着简单真正做扎实还挺讲究。1.2 URL Scheme 和 Universal Links 各管哪一段iOS 上的 Deep Link 技术方案主流就是两个URL Scheme 和 Universal Links。URL Scheme 是 iOS 从早期就支持的自定义协议在 Info.plist 里注册一个mygame之类的 scheme系统所有能发起跳转的地方——浏览器、其他 App、短信——都能通过mygame://host/path?x1这种格式把你的 App 拉起来。它的优点是接入简单、跨 App 支持面广缺点是唤起时会弹确认框体验差一点而且 scheme 是全局字符串理论上存在被其他 App 抢注后冲突的可能。Universal Links 是 iOS 9 引入的方案它直接复用https://域名链接。系统会访问你服务器上一个叫apple-app-site-association的 JSON 文件校验“这个域名的链接可以安全交给这个 App 处理”。校验通过后用户在 Safari 里点一条https://yourgame.com/invite?roomId123系统不会先开网页而是无缝唤起 App。体验好得多也没有确认框。但请注意一个关键差异iOS 12.2 之后第三方 App 内部的 WebView比如微信、QQ 的内置浏览器点击 Universal Link不再直接唤起目标 App而是尽量在自己 WebView 里打开页面。也就是说Universal Links 主要覆盖 Safari、系统邮件、系统短信这些第一方入口微信、QQ 这类 App 内部真正稳定的是 URL Scheme 或者渠道自己提供的跳转能力。所以结论很直接两条链路都接才能把场景覆盖全不要只做一个。1.3 本文的边界与前提下面的内容以 Unity 2020 以上版本为主iOS 14 到 iOS 18 都验证过Mono 和 IL2CPP 两种脚本后端都可以跑。工程里会涉及三个部分iOS 侧原生配置Info.plist、Entitlements、AASA 文件、原生到 Unity 的桥接Objective-C UnitySendMessage、Unity C# 层的解析与路由。归因平台 SDK、埋点上报这些不在本文范围但我会把接入它们时容易冲突的点提一下。我默认你已经能正常出 iOS 包并安装了真机Xcode 的基本操作没问题。下面所有代码我都会给出完整可直接贴的版本并说明每一步为什么要这么写。2. iOS 侧配置两条唤起通道的搭建细节2.1 URL SchemeInfo.plist 注册与启动参数URL Scheme 的注册位置在 Info.plist 的CFBundleURLTypes数组里。最省事的方法是在 Unity 的 Player Settings 里找 iOS 相关的 “Supported URL schemes” 配置项把你想要的名字直接填进去出包时 Unity 会自动写进 Info.plist。但很多团队的出包流程是脚本控制我建议直接在构建后处理脚本里统一改更可控。最终生成的 Info.plist 内容长这样keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.yourgame.deeplink/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array这样配置之后mygame://前缀的所有链接就都指向你的 App。Scheme 命名我有几个建议尽量用品牌级的长名字不要用game、test这种过于通用的否则一旦有其他 App 也注册了同名字段iOS 在唤起时会出现多选甚至被对方截胡的情况如果在测试环境和生产环境共用同一个 App 容器可以把 scheme 拆成mygame和mygamebeta两个方便灰度。另外一个非常容易被忽视的点是冷启动参数。如果游戏已经被杀掉了用户是点击链接把 App 拉起来的那么链接并不是通过openURL回调进来的而是在application:didFinishLaunchingWithOptions:的 launchOptions 里。只写openURL而不管 launchOptions就会出现“热的时候能唤起来冷启动参数丢失”的经典问题。这一点在下面的原生代码里我会一起处理。2.2 Universal LinksAssociated Domains 与 AASA 文件Universal Links 的配置比 Scheme 多两步任何一步不对都白搭。第一步在 Apple Developer 后台把 App ID 的 Associated Domains capability 打开然后在 Xcode 的 entitlements 文件里声明你关联的域名。entitlements 内容如下?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keycom.apple.developer.associated-domains/key array stringapplinks:yourgame.com/string /array /dict /plistUnity 工程默认不会生成这个 entitlements 文件你需要用 Xcode 手动加或者更推荐在 Unity 的IPostprocessBuildWithReport里自动写入。我这里贴一段构建后处理脚本的关键片段注意 Unity 版本不同 PBXProject 的 API 会有差异我在注释里标了#if UNITY_IOS using System.IO; using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEditor.iOS.Xcode; public class iOSDeepLinkPostProcess : IPostprocessBuildWithReport { public int callbackOrder 0; public void OnPostprocessBuild(BuildReport report) { string projPath report.summary.outputPath /Unity-iPhone.xcodeproj/project.pbxproj; PBXProject proj new PBXProject(); proj.ReadFromFile(projPath); // 旧版本 Unity 可能没有 GetUnityMainTargetGuid改成 TargetGuidByName(Unity-iPhone) string targetGuid proj.GetUnityMainTargetGuid(); string entPath report.summary.outputPath /Unity-iPhone.entitlements; PlistDocument ent new PlistDocument(); ent.root.CreateArray(com.apple.developer.associated-domains).AddString(applinks:yourgame.com); ent.WriteToFile(entPath); proj.AddFile(entPath, Unity-iPhone.entitlements); proj.SetBuildProperty(targetGuid, CODE_SIGN_ENTITLEMENTS, Unity-iPhone.entitlements); proj.WriteToFile(projPath); } } #endif第二步是服务器上的 AASA 文件。把下面这个 JSON 放到https://yourgame.com/apple-app-site-association同时建议在https://yourgame.com/.well-known/apple-app-site-association也放一份两个路径都部署可以省掉一些历史版本的兼容问题{ applinks: { apps: [], details: [ { appID: TEAMID12345.com.yourcompany.yourgame, paths: [/invite/*, /act/*, NOT /act/admin/*] } ] } }appID的格式是 Team ID 加 Bundle ID两者在 Apple Developer 后台都能找到。这个字段是配置里最容易出错的地方我见过大量“链接死活唤不起”的案例最后都栽在这一行建议用https://yourgame.com/apple-app-site-association直接打开看返回内容逐字核对。AASA 对大小写敏感路径尽量全小写文件不要有 BOMcontent-type 建议设成application/json文件最好也不要重定向虽然 Apple 会跟随跳转但重定向会明显拖慢首次识别速度线上排查也多一个变数。这里还要说清楚 AASA 的生效时机。App 首次安装、每次更新时iOS 会去服务器拉一次 AASA 文件做校验如果你改了服务器上的 AASA想让已经装机的用户立刻生效往往要重装 App 或者等系统在后台慢慢重试。所以调试 Universal Links 时的标准操作顺序是改 AASA → 等服务器生效 → 卸载 App → 重装 App → 再测链接。2.3 原生回调从系统到 Unity 的第一公里原生代码放在 Unity 工程的Assets/Plugins/iOS目录下Unity 打 Xcode 包时会自动把这些文件拷贝并编译进去。核心思路是无论是 URL Scheme 还是 Universal Links系统都是通过 App 代理的几个回调方法把链接交给我们的我们在这些方法里统一收口然后转发给 Unity。Unity 生成的 Xcode 工程默认使用UnityAppController作为 App 代理我建议新建一个继承它的类来挂回调。下面代码里的DeepLinkAppDelegate就是干这个活的// DeepLinkAppDelegate.h #import UIKit/UIKit.h #import UnityAppController.h interface DeepLinkAppDelegate : UnityAppController end实现文件里最要紧的一件事凡是覆盖UnityAppController的方法都要先调用super否则会破坏 Unity 自身的生命周期初始化表现就是画面黑屏或者某些系统事件失效。具体实现如下// DeepLinkAppDelegate.mm #import DeepLinkAppDelegate.h #import UnityInterface.h static NSMutableArrayNSString * *s_pendingLinks nil; static BOOL s_unityReady NO; implementation DeepLinkAppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { BOOL result [super application:application didFinishLaunchingWithOptions:launchOptions]; // 冷启动场景下URL Scheme 的链接会放在 launchOptions 里 NSURL *url launchOptions[UIApplicationLaunchOptionsURLKey]; if (url) { [self handleDeepLink:url.absoluteString]; } // 冷启动场景下Universal Links 的 NSUserActivity 也会出现在 launchOptions 里 NSDictionary *activityDict launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; if (activityDict) { NSUserActivity *activity activityDict[UIApplicationLaunchOptionsUserActivityKey]; if ([activity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [self handleDeepLink:activity.webpageURL.absoluteString]; } } return result; } - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { // 热启动App 在前台或后台存活时URL Scheme 走这里 if (url) { [self handleDeepLink:url.absoluteString]; return YES; } return NO; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSUserActivity *restorationHandler))restorationHandler { // Universal Links 的回调走这里冷启动时和 didFinishLaunching 可能各来一次 if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [self handleDeepLink:userActivity.webpageURL.absoluteString]; } return YES; } - (void)handleDeepLink:(NSString *)link { if (link.length 0) { return; } if (!s_pendingLinks) { s_pendingLinks [NSMutableArray array]; } synchronized (s_pendingLinks) { [s_pendingLinks addObject:link]; } [self trySendToUnity:link]; } - (void)trySendToUnity:(NSString *)link { if (!s_unityReady) { return; // Unity 运行时还没就绪消息留在缓存队列里 } dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [link UTF8String]); }); } end注意两个设计细节。第一所有的UnitySendMessage我都包了一层dispatch_async到主队列虽然系统回调本身在主线程但万一你后续把这些逻辑抽到工具类里被异步调用这句话能帮你挡住很多不明原因的消息丢失。第二这里的“Unity 是否就绪”是一个显式的标志位由 C# 侧在合适的时机调用原生方法来置位这一点是解决冷启动参数丢失的关键下一节详细展开。如果你接入的某个 SDK 强制开启了 UIScene 生命周期Xcode 工程里会出现 SceneDelegate上面的 AppDelegate 回调不会被调用。这种情况下需要把同样逻辑挂到scene:continueUserActivity:和scene:openURLContexts:上。Unity 默认不开 UIScene但这个问题在线上真实出现过排查时如果发现链接唤起来了但回调没进优先检查这个。3. Unity 层桥接把原生消息安全送进 C#3.1 UnitySendMessage 的约束与正确打开方式UnitySendMessage是原生层和 C# 通信最常用的接口但它的使用有严格约束很多人一开始都踩过目标 GameObject 的名字必须全名匹配不能写路径比如UI/DeepLinkManager是找不到的只能写DeepLinkManager目标对象上必须挂着一个 MonoBehaviour 脚本并且脚本里要有一个public void且只接收一个 string 参数的方法方法名严格区分大小写目标对象必须处于激活状态否则消息会被丢弃并输出一句 “GameObject is not active” 之类的日志调用时 Unity 必须已经初始化完成运行时未就绪时调用消息直接丢掉。为了满足“任何时刻对象都存在”这个条件我不建议在某个业务场景里挂这个接收器而是做一个独立的常驻单例对象在游戏启动早期创建并且DontDestroyOnLoad。下面这段代码就是个标准模板using UnityEngine; public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void Bootstrap() { GameObject go new GameObject(DeepLinkManager); DontDestroyOnLoad(go); go.AddComponentDeepLinkManager(); } private void Awake() { if (Instance ! null) { Destroy(gameObject); return; } Instance this; } // 原生层 UnitySendMessage 的统一入口 public void OnDeepLinkReceived(string link) { Debug.Log($[DeepLink] native - C# : {link}); if (!this || !isActiveAndEnabled) { return; } DeepLinkCenter.Instance.DispatchRaw(link); } }RuntimeInitializeOnLoadMethod会在第一个场景加载前创建这个对象所以哪怕游戏还没进主界面接收器也已经存在了。后面的OnDeepLinkReceived只做一件事转交给业务中心不要在接收方法里堆任何业务逻辑。3.2 启动时序问题缓存待发消息而不是直接推冷启动是 Deep Link 最容易翻车的场景。游戏被杀掉用户点击链接唤起 App系统那边在didFinishLaunchingWithOptions里就把链接告诉我们了可这时候 Unity 运行时其实还没起来。你要是直接在 didFinishLaunching 里调UnitySendMessage消息没有接收者直接丢你要是硬等一会儿再调也不知道等到什么时候因为热更、首场景加载、登录逻辑都可能还没准备好。我常用的处理思路是原生侧先缓存Unity 侧主动“就绪”后一次性拉取。也就是上面原生代码里那两个静态变量发挥作用的地方。现在补上 C# 侧就绪通知的代码using System.Runtime.InteropServices; public static class NativeDeepLinkBridge { #if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void UnityBridge_SetReady(); #endif public static void SetReady() { #if UNITY_IOS !UNITY_EDITOR UnityBridge_SetReady(); #endif } }原生侧对应实现就绪标志位并冲刷缓存void UnityBridge_SetReady(void) { s_unityReady YES; synchronized (s_pendingLinks) { for (NSString *link in s_pendingLinks) { dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [link UTF8String]); }); } [s_pendingLinks removeAllObjects]; } }C# 侧的DeepLinkManager在 Start 里调用一次NativeDeepLinkBridge.SetReady()。这里我特意放在 Start 而不是 Awake是为了等场景里的依赖对象都初始化完。这种“未就绪先缓存、就绪后冲刷”的模式比在原生层用定时器轮询要干净得多也完全规避了 Unity 初始化完成时间不确定的问题。这套设计不止用于 Deep LinkApp 启动时收到的 push 通知、远程配置等都可以沿用同一个套路。3.3 统一入口与日志标记原生层往 C# 层传消息我强烈建议只开一个入口方法也就是OnDeepLinkReceived(string)而不要出现OnInviteCome、OnActivityCome这类业务相关的多个方法。原因有两个一是原生回调本身很琐碎渠道多了之后你会很难判断哪条消息走了哪个方法二是业务层需要统一做去重、解析、路由如果每个业务各收各的参数逻辑就散掉了。统一入口之后要养成一个习惯在 C# 接收方法里第一时间打日志并且日志要能区分来源链路。我在生产环境一般打成[DeepLink] native - C# : url同时在原生侧再打一条[DeepLink] native received: url。这样线上排查时一看日志就能判断消息是死在原生没到 C#还是到了 C# 但业务没处理不用两边瞎猜。还有一个容易被忽略的点原生侧收到链接后先做一次基本的合法性判断再缓存。比如 URL 为空、字符数超过 2048 之类的就直接丢弃或打日志。链接虽然通常由我们自己生成但线上会有各种抓包改动、异常拼接出来的 URL 打进来入口处做防御永远比业务层到处判空要省事。4. C# 层参数解析、幂等与业务路由4.1 手动写 URL 解析器别迷信 System.Web链接进入 C# 之后第一步是解析。很多同学下意识想到System.Web.HttpUtility.ParseQueryString但 Unity 的 .NET 兼容级别往往不带这套 API用了就得改兼容配置或者引额外程序集为了一个解析函数不值当。更稳的做法是自己写一个小解析器逻辑清晰也好维护。我通常把解析结果封装成一个强类型对象里面保留原始 URL 和拆解后的字段using System; using System.Collections.Generic; public sealed class DeepLinkPayload { public string RawUrl; public string Scheme; public string Host; public string Path; public Dictionarystring, string Query new Dictionarystring, string(); public long ReceivedAtUnixMs; } public static class DeepLinkParser { public static DeepLinkPayload Parse(string rawUrl) { var payload new DeepLinkPayload { RawUrl rawUrl, ReceivedAtUnixMs DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; if (string.IsNullOrEmpty(rawUrl) || rawUrl.Length 2048) { return payload; } Uri uri; try { uri new Uri(rawUrl); } catch (UriFormatException) { return payload; } payload.Scheme uri.Scheme; payload.Host uri.Host; payload.Path uri.AbsolutePath; payload.Query ParseQuery(uri.Query); return payload; } private static Dictionarystring, string ParseQuery(string rawQuery) { var dict new Dictionarystring, string(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrEmpty(rawQuery)) { return dict; } string query rawQuery.TrimStart(?); string[] pairs query.Split(new[] { }, StringSplitOptions.RemoveEmptyEntries); foreach (string pair in pairs) { int idx pair.IndexOf(); if (idx 0) { string k Uri.UnescapeDataString(pair.Replace(, )); dict[k] string.Empty; continue; } string key Uri.UnescapeDataString(pair.Substring(0, idx).Replace(, )); string value Uri.UnescapeDataString(pair.Substring(idx 1).Replace(, )); dict[key] value; } return dict; } }这里有两个处理细节。第一Uri.UnescapeDataString只负责%XX的还原不会把转成空格而 URL 里如果带了表单编码风格是要当空格处理的所以我在解析前统一替换了一次。第二查询参数用忽略大小写的字典但业务取值时仍然要约定参数名统一小写避免线上线下因大小写不一致出问题。另外提一句 fragment 的处理iOS 的 Deep Link 一般不推荐带#fragment因为某些场景下 fragment 不会传给 App解析时直接忽略即可不要把关键业务参数放在锚点后面。4.2 单一入口加路由注册避免业务散落解析完成之后不要在每个业务模块里各写各的判断逻辑否则一个月之后这个项目会变成谁都不敢碰的蜘蛛网。我建议做一个简单的路由总线核心接口就两个当前链接能不能处理以及怎么处理。public interface IDeepLinkRoute { // 返回 true 表示这条链接归这个模块处理 bool CanOpen(DeepLinkPayload payload); // 执行真正的业务跳转 void Execute(DeepLinkPayload payload); }具体到邀请场景实现大概是public sealed class InviteRoute : IDeepLinkRoute { public bool CanOpen(DeepLinkPayload payload) { // mygame://invite 或者 https://yourgame.com/invite return payload.Path.Equals(/invite, StringComparison.OrdinalIgnoreCase) || (string.IsNullOrEmpty(payload.Path) payload.Host.Equals(invite, StringComparison.OrdinalIgnoreCase)); } public void Execute(DeepLinkPayload payload) { string roomId payload.Query.GetValueOrDefault(roomId); string inviterId payload.Query.GetValueOrDefault(inviterId); // 交给组队模块 TeamModule.TryJoinRoom(roomId, inviterId); } }路由总线维护一个路由列表按注册顺序分发public sealed class DeepLinkCenter { public static DeepLinkCenter Instance { get; } new DeepLinkCenter(); private readonly ListIDeepLinkRoute _routes new ListIDeepLinkRoute(); private readonly QueueDeepLinkPayload _pending new QueueDeepLinkPayload(); private bool _ready; public void RegisterRoute(IDeepLinkRoute route) { _routes.Add(route); } public void DispatchRaw(string rawUrl) { DispatchParsed(DeepLinkParser.Parse(rawUrl)); } public void DispatchParsed(DeepLinkPayload payload) { if (!_ready) { _pending.Enqueue(payload); return; } RouteNow(payload); } public void MarkReady() { _ready true; while (_pending.Count 0) { RouteNow(_pending.Dequeue()); } } private void RouteNow(DeepLinkPayload payload) { foreach (IDeepLinkRoute route in _routes) { if (route.CanOpen(payload)) { try { route.Execute(payload); } catch (Exception e) { Debug.LogError($[DeepLink] route failed: {payload.RawUrl}\n{e}); } return; } } Debug.LogWarning($[DeepLink] no route matched: {payload.RawUrl}); } }路由注册我放在一个统一的DeepLinkBootstrap静态方法里游戏启动时调用一次。业务模块只需要注册自己的路由实现不需要关心链接是从微信来、Safari 来还是系统短信来这在后续接更多渠道时会非常省心。4.3 参数投递的业务时序等待就绪与超时丢弃参数从原生到 C# 只是“到达”业务层能不能接得住是另一回事。最常见的情况是用户是从一条组队邀请链接唤起 App 的但游戏这时候还在首场景加载用户连登录都没完成直接执行“加入房间”显然不现实。所以要有一个业务层的就绪概念跟上一节原生层的“Unity 就绪”互相配合。原生层就绪是 C# 运行时准备好接收消息业务层就绪是登录完成、主场景可以承载跳转。我在DeepLinkCenter里保留一个QueueDeepLinkPayload业务层在登录成功、首场景加载完的时机调用MarkReady()把缓存参数统一放行。这里还要给缓存加两个策略。第一是超时丢弃链接的时效性很强一个半小时前的邀请链接现在才处理用户体验很莫名其妙。我给每条 payload 记录ReceivedAtUnixMsMarkReady时把超过 60 秒的直接丢掉同时打日志保留现场。第二是幂等冷启动时 Universal Links 存在“launchOptions 触发一次 continueUserActivity 再触发一次”的情况业务层如果不做去重同一个房间请求会被发两次。我用的去重键是scheme host path 参数排序拼接并且加一个时间窗口而不是永久记录这样用户第二次点同一条链接还是能正常响应。private readonly Dictionarystring, long _recentProcessed new Dictionarystring, long(); private bool IsDuplicate(DeepLinkPayload payload) { string sig ${payload.Scheme}|{payload.Host}|{payload.Path}|{BuildQuerySignature(payload.Query)}; long now DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); if (_recentProcessed.TryGetValue(sig, out long lastTime) now - lastTime 3000) { return true; // 3 秒内的重复链接直接忽略 } _recentProcessed[sig] now; return false; }这个设计的核心思想是投递不只是把字符串丢给业务而是要管理“业务何时能接、接几次、过期怎么办”。等你接的活动场景多了会越来越觉得这一段值得做好。5. 调试排障、参数安全与避坑实录5.1 用模拟器指令和 curl 快速自测Deep Link 的调试一定要有一套能在本地快速闭环的手段不能每次都在真机微信上点。我的习惯分三步。第一步模拟器直接唤起 URL Schemexcrun simctl openurl booted mygame://invite?roomId888inviterId123fromwechat第二步模拟器测试 Universal Linksxcrun simctl openurl booted https://yourgame.com/invite?roomId888inviterId123fromwechat第三步验证服务器上的 AASA 文件是否正常返回、内容是否正确curl -i https://yourgame.com/.well-known/apple-app-site-association curl -i https://yourgame.com/apple-app-site-associationcurl返回的 JSON 里重点看applinks.details的appID和paths。如果链接用的是https但证书有问题这里会直接 SSL 报错后面所有事情都不用谈。真机上的 Universal Links 测试有个更贴近用户行为的方法把链接发送到系统备忘录或短信直接点击系统会按 Universal Links 的流程处理。备忘录、短信测试通过之后再去测微信、QQ 里的表现记住这两个 App 内大概率不走 Universal Links唤起逻辑依赖 URL Scheme 或者它们自己的渠道配置。5.2 高频问题速查表下面这些是我这些年被反复问到的线上问题整理成速查表按“现象 - 原因 - 处理”的方式列出来现象常见原因处理办法链接点了完全没反应AASA 没生效或 App ID 的 Associated Domains 没开curl 确认文件内容删 App 重装再测Scheme 能唤起Universal Links 不行entitlements 缺失或 Team ID 拼错检查com.apple.developer.associated-domains核对 appID 里的 Team ID冷启动时参数丢失只写了 openURL没处理 launchOptions在didFinishLaunchingWithOptions里取UIApplicationLaunchOptionsURLKey热启动正常冷启动消息也没丢但没执行Unity 就绪前 UnitySendMessage 被丢弃按上文原生缓存 C# 就绪冲刷的模式改造同一个链接进来了两三次Universal Links 冷启动双重回调C# 层按时间窗幂等去重微信/QQ 里点不醒 AppiOS 12.2 后 WebView 不转交 Universal Links改用 URL Scheme或接渠道自己的开放平台跳转链接里的中文参数乱码未做 URL 解码用 Uri.UnescapeDataString 解码注意转空格UnitySendMessage 收不到GameObject 名不对、对象未激活、方法名大小写不对核对名字、激活状态和public void X(string)签名有一类问题特别容易误导排查方向AASA 文件明明验证没问题但真机第一次点 Universal Link 就是不起作用第二次才正常。这是因为系统在 App 安装后需要一点时间完成域名校验首次点击时校验还没结束。这时候不要急着改配置等十几秒再点一次如果每次都这样则优先查证书链和 AASA 的路径通配符是否写得太宽或太窄。5.3 几个只有线上才能踩出来的坑最后分享几个偏“工程经验”的坑这些在官方文档和示例代码里基本看不到。第一个是参数安全问题。Deep Link 链接等于给玩家开了一个“输入口令”的窗口URL 里的roomId、inviterId都可以被人为篡改。客户端拿到参数后可以做跳转但涉及加房间、领奖励、绑关系这类写操作服务端必须拿参数作为参考、以服务端数据为准做校验。我见过活动组队被刷子改 roomId 批量进房的案例不是特别严重但很恶心所以参数里永远不要放敏感信息也不要相信参数就是真身。第二个是 URL Scheme 和 Universal Links 的优先级。两个方案都接了之后同一个业务场景的链接协议要约定清楚首先邀请链接、活动链接用 Universal Links 的https://形式因为 Safari 和邮件场景下体验最好其次微信、QQ 内被 WebView 劫持的场景链路降级到 URL Scheme。落到工程上就是服务端下发或客户端生成链接时根据场景选择合适的协议头不要让两条链路同时存在一套参数里互相打架。第三个是别把 Deep Link 只做成“启动参数”。有的产品需求是“热更下载完再执行跳转”也就是说玩家点击链接唤起游戏时游戏可能还在下载更新包。这种情况下C# 层拿到链接后不要直接进路由而是把 payload 序列化后存到本地PlayerPrefs 或文件等热更完成、登录结束再读出来投递。我一般会在DeepLinkCenter里加一个“待恢复队列”把未处理完的链接持久化下次启动时检查并继续处理。这个设计成本很低但能避免大量“链接已经收了但业务没执行”的线上客诉。第四个是要注意和归因 SDK 的协作。接 AppsFlyer、Branch、Adjust 这类 SDK 时它们本身也会监听 Universal Links 和冷启动参数甚至可能先于我们的逻辑消费掉链接上下文。我见过因为两个 SDK 对同一个 Universal Link 事件发生了抢处理导致我们自己拿不到参数的案例。接归因平台时务必确认它们的延迟深度链接上报逻辑和我们自定义 Deep Link 的入口方法之间没有互相覆盖必要时把我们的入口放进 SDK 的和解回调之后。做这套东西这几年我最大的体会是Deep Link 的难点从来不在配置那几步而在于“消息到达”和“业务消费”之间存在巨大的时序差异。原生侧缓存、Unity 侧就绪、业务侧等待、超时幂等每一层都在解决这个差异。你把这条时序链路想透了再收到任何“链接唤起了但参数没到”“到 C# 了但业务没执行”的工单打开日志按层排查基本十分钟内就能定位问题。最后再分享一个我个人很喜欢的小习惯在DeepLinkCenter里记录最近 20 条原始 URL 的环形日志出问题时直接导出给运营他们能立刻告诉你哪条链接、哪个渠道出的事省掉大量来回沟通的功夫。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →