做手游客户端的同学,十有八九会遇到这个需求:运营说要做老带新邀请活动,玩家在微信里点一条链接,游戏要能直接拉起来,还能直接落到对应的房间页面;产品那边再补一句“把渠道参数也带上,我们好做归因”。这句话翻译成技术语言就是: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?x=1这种格式把你的 App 拉起来。它的优点是接入简单、跨 App 支持面广,缺点是唤起时会弹确认框,体验差一点,而且 scheme 是全局字符串,理论上存在被其他 App 抢注后冲突的可能。
Universal Links 是 iOS 9 引入的方案,它直接复用https://域名链接。系统会访问你服务器上一个叫apple-app-site-association的 JSON 文件,校验“这个域名的链接可以安全交给这个 App 处理”。校验通过后,用户在 Safari 里点一条https://yourgame.com/invite?roomId=123,系统不会先开网页,而是无缝唤起 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 Scheme:Info.plist 注册与启动参数
URL Scheme 的注册位置在 Info.plist 的CFBundleURLTypes数组里。最省事的方法是在 Unity 的 Player Settings 里找 iOS 相关的 “Supported URL schemes” 配置项,把你想要的名字直接填进去,出包时 Unity 会自动写进 Info.plist。但很多团队的出包流程是脚本控制,我建议直接在构建后处理脚本里统一改,更可控。最终生成的 Info.plist 内容长这样:
<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLName</key> <string>com.yourcompany.yourgame.deeplink</string> <key>CFBundleURLSchemes</key> <array> <string>mygame</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 Links:Associated Domains 与 AASA 文件
Universal Links 的配置比 Scheme 多两步,任何一步不对都白搭。
第一步,在 Apple Developer 后台把 App ID 的 Associated Domains capability 打开,然后在 Xcode 的 entitlements 文件里声明你关联的域名。entitlements 内容如下:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>com.apple.developer.associated-domains</key> <array> <string>applinks:yourgame.com</string> </array> </dict> </plist>Unity 工程默认不会生成这个 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 对大小写敏感,路径尽量全小写;文件不要有 BOM;content-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 NSMutableArray<NSString *> *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:(NSDictionary<UIApplicationOpenURLOptionsKey, 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.AddComponent<DeepLinkManager>(); } 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 Link,App 启动时收到的 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 Dictionary<string, string> Query = new Dictionary<string, 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 Dictionary<string, string> ParseQuery(string rawQuery) { var dict = new Dictionary<string, 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 List<IDeepLinkRoute> _routes = new List<IDeepLinkRoute>(); private readonly Queue<DeepLinkPayload> _pending = new Queue<DeepLinkPayload>(); 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里保留一个Queue<DeepLinkPayload>,业务层在登录成功、首场景加载完的时机调用MarkReady(),把缓存参数统一放行。
这里还要给缓存加两个策略。第一是超时丢弃:链接的时效性很强,一个半小时前的邀请链接现在才处理,用户体验很莫名其妙。我给每条 payload 记录ReceivedAtUnixMs,MarkReady时把超过 60 秒的直接丢掉,同时打日志保留现场。第二是幂等:冷启动时 Universal Links 存在“launchOptions 触发一次 + continueUserActivity 再触发一次”的情况,业务层如果不做去重,同一个房间请求会被发两次。我用的去重键是scheme + host + path + 参数排序拼接,并且加一个时间窗口而不是永久记录,这样用户第二次点同一条链接还是能正常响应。
private readonly Dictionary<string, long> _recentProcessed = new Dictionary<string, 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 Scheme:
xcrun simctl openurl booted "mygame://invite?roomId=888&inviterId=123&from=wechat"第二步,模拟器测试 Universal Links:
xcrun simctl openurl booted "https://yourgame.com/invite?roomId=888&inviterId=123&from=wechat"第三步,验证服务器上的 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 里点不醒 App | iOS 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 的环形日志,出问题时直接导出给运营,他们能立刻告诉你哪条链接、哪个渠道出的事,省掉大量来回沟通的功夫。