☰
Unity手游iOS Deep Link全链路解析:URL Scheme与Universal Links参数分发实践
2026/9/29 13:40:18 网站建设 项目流程

做手游 iOS 端的 Deep Link,表面看就是“用户点个链接把 App 唤醒”,真正接起来才发现这是一条横跨 Web、原生、Unity 三层的链路。我在 Unity 项目里从只配一个 URL Scheme 起步,到后来 Universal Links、SceneDelegate、冷启动事件队列、C# 层统一分发全走完,中间踩过的坑比需求文档还厚。这篇就把整条链路从头到尾拆开讲:URL Scheme 和 Universal Links 怎么选、原生侧收到的回调怎么处理、参数怎么稳妥地投递到 C# 层,以及那些不实测根本发现不了的细节。

1. 项目背景与需求拆解:一条唤醒链接到底背了多少活

1.1 Deep Link 在手游运营生态里的真实位置

买量归因、老玩家召回、活动页跳转、主播分享,这些场景最终都落在一个动作上:用户在浏览器或者外部 App 里点了一个链接,希望直接打开游戏并带上参数。对运营来说,这条链接承载的是来源渠道、活动 ID、用户 ID 这些信息;对客户端来说,它要回答三个问题:App 被唤醒了吗、参数拿到了吗、参数在正确的时机交给正确的业务了吗。

iOS 之所以要单独拉出来讲,是因为它和 Android 完全不是一套玩法。Android 的 intent filter 配好之后,链接处理相对直接;iOS 这边有 URL Scheme 和 Universal Links 两套机制,还有 iOS 13 之后的生命周期变化,稍不注意参数就会在冷启动时丢掉。加上我们客户端是 Unity 引擎,原生层拿到的链接还得跨过 OC/C# 的桥才能到达游戏逻辑层,每一步都可能翻车。

1.2 一条链接里到底装了什么参数

先定参数规范,再做技术选型,这个顺序不能反。不管用户从哪个入口进来,最终落到 C# 层的数据模型应该是一致的。我们项目里统一用这样一套参数:

  • scene:要跳转到游戏内哪个界面,比如活动页、签到页、分享落地页
  • source:来源渠道,比如 sms、push、web、koc
  • campaign:具体活动标识,运营看报表用
  • uid:可选,被邀请人或者投放素材里的用户标记
  • ts:生成链接的时间戳,防过期
  • ext:扩展参数,预留给将来加配额解析

URL Scheme 的链接长这样:mygame://open?scene=activity&source=sms_0923&uid=10086&ts=1726000000。Universal Links 的链接是正常 HTTPS 地址:https://game.example.com/open?scene=activity&source=sms_0923&uid=10086&ts=1726000000。

关键在于:两条通道进入后,原生层要把它们解析成同一种结构再传给 Unity,而不是各传各的。否则 C# 层要写两套解析逻辑,埋点也会乱。

1.3 真正难啃的不是链接,而是时序

把参数从链接里读出来,这是最不需要动脑子的部分;真正的难点全在时序上。

第一个时序是引擎未就绪。App 冷启动时,链接参数在didFinishLaunchingWithOptions里就能拿到,但 Unity 引擎这时候还没加载完,UnitySendMessage发出去就是石沉大海。

第二个时序是场景未就绪。就算引擎起来了,主城/登录界面可能还没加载完,这时候把活动参数抛给业务层,业务层根本接不住。

第三个时序是重复回调。Universal Links 和 URL Scheme 在冷启动和热启动时回调的入口不一样,有的第三方 SDK 也会拦截 openURL,处理不好就会出现一次点击、两次投递。

所以这篇文章的实质,就是在讲怎么把这三道时序问题一个一个熨平。

2. 方案选型:URL Scheme 与 Universal Links 怎么分工

2.1 URL Scheme:当不了主力,但不能没有

URL Scheme 是 iOS 从远古时代就支持的机制,在 Info.plist 里注册一个自定义协议,然后浏览器、短信、邮件里都能通过mygame://把 App 唤起来。

但它有几个硬伤。第一,首次安装场景下,用户第一次点击链接不能靠它直接唤起 App,如果没有别的机制,链接点了只会报错。第二,从 Safari 调自定义 Scheme 会有弹窗确认,多一次打断。第三,Scheme 是全局的,别的 App 也能注册同样的协议,存在被抢注或者被恶意调用的风险,不过手游场景里这个威胁面相对可控。

那为什么还不能没有它?因为我们自己测试、App 内部跳转、以及一些不支持 Universal Links 的 WebView 环境都还要靠它兜底。Universal Links 需要 HTTPS 域名和 AASA 文件,有些内嵌浏览器对 Universal Links 支持很烂,反而对 Scheme 的老套路有响应。

2.2 Universal Links:iOS 9 之后的正确打开方式

Universal Links 是 iOS 9 引入的,核心思路是:你的域名经过验证,Apple 知道这个域名下的哪些路径属于你的 App,所以当用户在 Safari 或者支持系统级跳转的场景里点击链接时,系统可以直接唤起 App,而且不弹窗。

它解决了 URL Scheme 的三大问题:有域名验证,安全性高;没有确认弹窗,转化率高;未安装 App 时链接会正常打开网页,可以降级到下载页或者活动页,而不是报错。

代价也很明显:配置复杂度上了一个台阶。你需要付费开发者账号、需要 HTTPS 域名、需要上传apple-app-site-association文件、需要在 Xcode 里开 Associated Domains 权限。任何一个环节出问题,链接就只能在浏览器里打开网页,看起来像是“没做 Deep Link”。

我做方案选型的时候列过一张对比表,你可以直接拿去参考:

维度URL SchemeUniversal Links
系统要求一直可用iOS 9+
用户确认弹窗有无
未安装 App 时浏览器报错或无反应自动打开网页降级
配置成本低,Info.plist 即可高,Entitlement + AASA + HTTPS 域名
安全性弱,可被抢注强,域名验证
适用场景内部跳转、测试、WebView 兜底买量、召回、网页跳转主力

2.3 我的双通道兜底策略

最终我们采用的策略是:Universal Links 做主力,URL Scheme 做兜底,两条通道都解析、都投递。

这个“都投递”要特别小心。如果同一个场景里两个通道同时触发,就可能出现重复投递。我们的做法是原生侧引入一个去重逻辑:从 URL 里提取参数后,以scene+source+uid+ts拼出一个指纹,在一小段时间窗口内相同指纹只投递一次。

另外还有一个兜底细节:如果用户是用 App 内嵌 H5 打开的链接,内嵌 WebView 通常不支持 Universal Links,这时候我们在 H5 页面上放一个“打开游戏”按钮,按钮走 URL Scheme。H5 由前端同学判断环境,客户端只要保证 Scheme 通道始终可用就行。

2.4 iOS 13 生命周期改动对 Deep Link 的影响

iOS 13 引入了 Scene 生命周期,理论上 App 可以不再走传统的 AppDelegate 那一套,而改用 UISceneDelegate 管理前台后台。这对 Deep Link 的直接影响是:链接回调可能从application:openURL:options:挪到scene:openURLContexts:,Universal Links 也对应有 scene 版本的回调。

Unity 生成的工程默认还是 AppDelegate 生命周期,UnityAppController本身没有实现 scene 相关的代理方法。只要你不主动往工程里塞 UIApplicationSceneManifest,一般不会走到 Scene 那套。但有个现实情况是:某些第三方登录/推送 SDK 会往工程里注入 Scene 支持代码,一旦 Scene 被启用,链接回调就会分叉。

我的建议是:能不开 Scene 就不开;如果因为 SDK 原因被迫启用,必须在 SceneDelegate 里把openURLContexts和continueUserActivity同样转发到自己的 Bridge,并且和 AppDelegate 侧做互斥,避免同一份参数被投两次。

3. 原生层配置与回调接收:最容易绕晕的一段

3.1 Info.plist 配置 URL Scheme

在 Xcode 导出工程里,URL Scheme 的配置位置是Info.plist的CFBundleURLTypes,一份配置大概长这样:

<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLName</key> <string>com.yourcompany.gamename</string> <key>CFBundleURLSchemes</key> <array> <string>mygame</string> </array> </dict> </array>

然后链接就是mygame://open?...。

这里有个容易踩的细节:Scheme 建议全小写。iOS 在匹配时虽然大小写不敏感,但运营配置链接、测试同学手敲链接时,大写出现的概率很高,一旦用惯了大写,后面排查成本很高。还有,Scheme 里不要用下划线,Apple 的规范是字母开头,只能包含字母、数字、点和连字符,下划线会导致某些场景下链接解析异常。

如果你是在 Unity 的 Build 流程里自动化配置,可以用UnityEditor.iOS.Xcode.PlistDocument在构建后处理脚本里往 plist 里塞这段,避免每次导出工程后手改。

3.2 Associated Domains 与 AASA 文件

Universal Links 的配置分两层:一层在 Xcode 工程里,一层在服务器上。

Xcode 里要打开 Associated Domains 能力,添加applinks:game.example.com。注意这个能力需要付费开发者账号,免费的个人账号在 Xcode 里这个 Capability 是灰的。如果你用 Unity 自动化导出,需要在 Xcode API 的PBXProject里对 target 添加 entitlements,写出来大概是:

com.apple.developer.associated-domains = ["applinks:game.example.com", "applinks:www.game.example.com"]

服务器上要放一个apple-app-site-association文件,能通过 HTTPS 直接访问,不能有重定向,Content-Type 最好是application/json。文件内容:

{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.yourcompany.gamename", "paths": [ "/open/*", "*" ] } ] } }

这个文件可以放在域名的根路径/apple-app-site-association,也可以放在/.well-known/apple-app-site-association,两个位置 Apple 都会尝试请求。调试时用curl -i https://game.example.com/apple-app-site-association看返回状态码和内容,必须返回 2xx。

paths的匹配规则我吃了不少亏:*是通配全部路径,/open/*是只匹配/open/开头的路径。注意它是大小写敏感的,运营如果配了/Open而你的路径是/open,链接就不会唤起 App,只会开网页。另外,路径匹配是按 URL path 匹配,query 参数不影响匹配。

3.3 AppDelegate 回调实现:以 UnityAppController 子类为例

Unity 生成的 Xcode 工程里,真正的 AppDelegate 是Classes/UnityAppController.mm里的UnityAppController,main.mm通过UIApplicationMain把它指定成代理类。所以改法有两种:直接改 UnityAppController.mm(每次升级 Unity、重新导出工程都要再改一次,不推荐),或者写一个子类,再让 main.mm 用你的子类。我推荐第二种。

先建一个MyDeepLinkController.h/.mm:

// MyDeepLinkController.h #import "UnityAppController.h" @interface MyDeepLinkController : UnityAppController @end
// MyDeepLinkController.mm #import "MyDeepLinkController.h" #import <UIKit/UIKit.h> @interface MyDeepLinkController () @property (nonatomic, strong) NSMutableArray<NSString *> *pendingDeepLinks; @end @implementation MyDeepLinkController - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options { if (url) { NSString *raw = [url absoluteString]; NSLog(@"[DeepLink] scheme open: %@", raw); [self handleRawLink:raw source:@"scheme"]; return YES; } return NO; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url = userActivity.webpageURL; if (url) { NSLog(@"[DeepLink] universal open: %@", url); [self handleRawLink:[url absoluteString] source:@"universal"]; return YES; } } return NO; } - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // 必须调用 super,Unity 引擎初始化逻辑都在里面 BOOL result = [super application:application didFinishLaunchingWithOptions:launchOptions]; NSURL *coldURL = launchOptions[UIApplicationLaunchOptionsURLKey]; if (coldURL) { NSLog(@"[DeepLink] cold scheme: %@", coldURL); [self handleRawLink:[coldURL absoluteString] source:@"scheme_cold"]; } NSDictionary *activityDict = launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; NSUserActivity *activity = activityDict[UIApplicationLaunchOptionsUserActivityKey]; if ([activity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *webURL = activity.webpageURL; if (webURL) { NSLog(@"[DeepLink] cold universal: %@", webURL); [self handleRawLink:[webURL absoluteString] source:@"universal_cold"]; } } return result; } - (void)handleRawLink:(NSString *)raw source:(NSString *)source { // 先按 source + 去重指纹处理,然后转成 JSON NSString *json = [DeepLinkParser parseToJSON:raw source:source]; if (json == nil) return; // 这里先缓存,等 C# 侧注册后再投递 [self cacheOrSendJSON:json]; } @end

注意我在didFinishLaunchingWithOptions里先调了[super ...],这一步漏了 Unity 引擎根本起不来。冷启动 URL Scheme 拿到的是UIApplicationLaunchOptionsURLKey,Universal Links 拿到的是UIApplicationLaunchOptionsUserActivityDictionaryKey,两个 key 都要处理。

改了 main.mm 里的指定类:

// main.mm return UIApplicationMain(argc, argv, nil, NSStringFromClass([MyDeepLinkController class]));

自动化方面,我建议在 Unity 构建后处理脚本里把main.mm中的UnityAppController字符串替换成MyDeepLinkController,并把子类文件拷贝进 Xcode 工程,这样每次导出都不用手工动工程。

3.4 冷启动缓存:先存起来,等引擎就绪再投

上面代码里的cacheOrSendJSON是整条链最关键的一个函数。UnitySendMessage只有在 Unity 的UnityAppController初始化完成、对应场景里存在指定 GameObject 之后调用才有效。冷启动时,didFinishLaunchingWithOptions执行的时候引擎还没起来,这时候直接调用 UnitySendMessage 等于白调。

所以做法是:原生侧维护一个数组当缓存队列,所有 Deep Link 先转成 JSON 字符串存进去,同时尝试调一次 UnitySendMessage。如果 C# 侧的注册动作还没发生,UnitySendMessage 会失败并打日志,JSON 留在队列里;等 C# 侧主动调用注册接口时,原生侧再把队列里的所有 JSON 一次性冲刷出去。

- (void)cacheOrSendJSON:(NSString *)json { if (self.unityBridgeGameObjectName.length > 0) { UnitySendMessage(self.unityBridgeGameObjectName.UTF8String, "OnNativeDeepLink", json.UTF8String); } else { @synchronized (self) { [self.pendingDeepLinks addObject:json]; } } } // C# 侧调用注册接口后触发: - (void)flushPendingDeepLinks { @synchronized (self) { for (NSString *json in self.pendingDeepLinks) { UnitySendMessage(self.unityBridgeGameObjectName.UTF8String, "OnNativeDeepLink", json.UTF8String); } [self.pendingDeepLinks removeAllObjects]; } }

这个模式我在项目里用了很久,逻辑简单、不会丢参数、也不依赖复杂的调用链。你要是不放心,还可以在缓存里带一个时间戳,C# 侧收到超时过久的参数可以选择丢弃。

3.5 原生侧先做参数规整

传给 Unity 之前,原生侧要把mygame://open?scene=activity&...和https://game.example.com/open?scene=activity&...解析成同一种结构。我用 NSURLComponents 做,而不是手写字符串截取:

+ (NSString *)parseToJSON:(NSString *)raw source:(NSString *)source { NSURL *url = [NSURL URLWithString:raw]; if (url == nil) return nil; NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; NSMutableDictionary *params = [NSMutableDictionary dictionary]; // host + path 共同决定 scene,scheme 和 universal 的路径规则不一样 if ([url.scheme isEqualToString:@"mygame"]) { params[@"scene"] = url.host ?: @"open"; } else { NSString *path = components.path ?: @""; if ([path hasPrefix:@"/open/"]) { params[@"scene"] = [path substringFromIndex:6]; } else { params[@"scene"] = @"home"; } } for (NSURLQueryItem *item in components.queryItems) { params[item.name] = item.value; } params[@"source"] = source; params[@"raw"] = raw; NSData *data = [NSJSONSerialization dataWithJSONObject:params options:0 error:nil]; return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; }

这里有两个细节:第一,query 里的 key 如果重复,上面的写法会互相覆盖,如果你运营会配重复参数,自己决定用数组还是用最后一个。第二,item.value拿到的已经是百分号解码后的字符串,如果链接里嵌了中文或者特殊符号相对安全;但有些第三方渠道会做双重编码,C# 侧最好再加一层容错。

4. C# 层参数接收与投递:从 UnitySendMessage 到统一分发

4.1 UnitySendMessage 是最直接但最容易翻车的通道

原生层向 C# 层传参,最常用的就是UnitySendMessage,三个参数:接收的 GameObject 名、方法名、字符串消息。它简单直接,但有几个使用前提:

接收的 GameObject 必须在当前场景里存在。如果你的接收物体不在常驻场景,场景一切换就收不到。我习惯建一个名为 DeepLinkManager 的 GameObject,在Awake里DontDestroyOnLoad。

方法必须是 public 的,参数类型必须是 string。写内部私有方法的话,UnitySendMessage 调用时不会报错,但你的方法就是不会被执行,排查起来极其折磨。

如果消息里要传的是 JSON 字符串,注意里面的引号和转义。只要你走UnitySendMessage传字符串,C# 侧接到的就是一个原始字符串,直接JsonUtility或LitJson解析即可,不存在二次转义的问题。真正需要警惕的是在原生侧拼 JSON 时用NSJSONSerialization,不要手写字符串拼接,否则中文、引号都会出问题。

4.2 C# 侧注册与队列冲刷

我在 C# 侧用一个单例管理所有 Deep Link,逻辑很直白:

public class DeepLinkManager : MonoBehaviour { private static DeepLinkManager _instance; public static DeepLinkManager Instance => _instance; private string _gameObjectName; private readonly List<DeepLinkPayload> _pending = new List<DeepLinkPayload>(); private bool _isEngineReady; private bool _isNativeRegistered; public event Action<DeepLinkPayload> OnDeepLinkArrived; private void Awake() { if (_instance != null && _instance != this) { Destroy(gameObject); return; } _instance = this; DontDestroyOnLoad(gameObject); _gameObjectName = gameObject.name; #if UNITY_IOS && !UNITY_EDITOR NativeDeepLinkBridge.Register(gameObject.name); #endif } private void Update() { if (!_isNativeRegistered || _isEngineReady) return; if (LoadMainSceneFinished() && !_isEngineReady) { _isEngineReady = true; FlushPending(); } } public void OnNativeDeepLink(string json) { var payload = ParsePayload(json); if (payload == null) return; if (!_isEngineReady) { _pending.Add(payload); return; } Dispatch(payload); } private void Dispatch(DeepLinkPayload payload) { // 去重:相同指纹只派发一次 if (payload.timestamp > 0 && DateTime.UtcNow.ToUnixTimeSeconds() - payload.timestamp > 86400) { Debug.Log($"[DeepLink] 过期参数丢弃: {payload.raw}"); return; } OnDeepLinkArrived?.Invoke(payload); } }

iOS 的调用通过[DllImport("__Internal")]进入原生侧,这个语法只对真机有效:

public class NativeDeepLinkBridge { #if UNITY_IOS && !UNITY_EDITOR [DllImport("__Internal")] private static extern void UnityDeepLinkBridge_Register(string gameObjectName); [DllImport("__Internal")] private static extern void UnityDeepLinkBridge_Flush(); #endif public static void Register(string goName) { #if UNITY_IOS && !UNITY_EDITOR UnityDeepLinkBridge_Register(goName); #endif } }

原生侧拿到Register调用后,把unityBridgeGameObjectName记下来,然后冲刷之前缓存的队列。这个设计保证了无论链接是多早进来的,只要引擎准备好、C# 侧注册过,参数就不会丢。

4.3 参数模型与业务分发事件

C# 侧参数模型保持和原生侧一致即可:

[Serializable] public class DeepLinkPayload { public string scene; public string source; public string campaign; public string uid; public long timestamp; public string raw; }

业务层通过事件订阅来接收,不要在 DeepLinkManager 里直接写跳转逻辑。比如登录页订阅了它,收到scene=activity就跳活动页;主界面订阅了它,收到scene=friend_invite就弹邀请弹窗。这样 DeepLinkManager 保持纯净,任何一个业务模块都能按需接入。

这里补一个实操心得:不要在OnNativeDeepLink里立刻调用SceneManager.LoadScene。如果你在收到参数的同一帧里切场景,场景加载会打断当前帧的派发流程,部分业务回调可能丢失。正确做法是把参数缓存下来,等场景加载完再派发,或者至少放到下一帧执行。

4.4 参数下发时机与去重规则

时机问题在 C# 侧就是前面代码里的_isEngineReady。什么时候把_isEngineReady置为 true,取决于你的游戏把哪个节点定义为“可接深链”。我见过团队卡在登录流程,认为必须登录成功才能接深链,结果运营的召回链接全在登录前被丢弃。更合理的节点是“主界面可交互”而不是“玩家已登录”,参数先派发给一个业务分发器,由分发器决定是跳转活动页还是先引导登录再跳转。

去重规则上,我们用的是scene + source + uid + ts拼接后的字符串做 key,放在一个最近处理的缓存里,过期时间 5 秒。这个窗口期足够覆盖同一次点击导致的 AppDelegate 和 SceneDelegate 双回调,又不会误杀几秒后自然重发的业务链接。

5. 联调、埋点与踩坑实录

5.1 本地验证链路

真机是必须的,模拟器上 Universal Links 表现不稳定,不能作为最终判定。我把调试流程固定成一套:

先在 Safari 地址栏输入mygame://open?scene=test&source=local,验证 URL Scheme 通道通不通。Safari 会弹一个“是否在 App 中打开”的确认框,点确认后 App 被唤起。断点打在application:openURL:options:里,确认原生层收到了原始链接。

再验证 Universal Links:在备忘录里输入https://game.example.com/open?scene=test&source=local,长按链接可以直接打开。如果长按菜单出现“在游戏名中打开”,说明系统已经识别了这个域名;如果只有“打开 URL”,说明 AASA 没生效,先查服务器文件。

服务器 AASA 的检查用 curl:

curl -i https://game.example.com/apple-app-site-association

确认返回内容里有你的 Team ID 和 Bundle ID,注意 Team ID 是 10 位字母数字,Bundle ID 要和 Xcode 里完全一致,包括大小写。

在真机上验证完,最终用 Xcode 的 Console 和 Unity 的日志串起来看一遍:链接原生侧打了日志,C# 侧 OnNativeDeepLink 打了日志,业务派发打了日志,三段日志能对上,这条链路才算真的通。

5.2 常见坑速查表

现象原因解决办法
Safari 里点链接没反应Scheme 大小写不对或没注册检查 Info.plist,Scheme 统一小写
长按 Universal Link 没有“在 App 中打开”AASA 没生效或 Team ID/Bundle ID 不匹配用 curl 验证文件,核对 entitlements
冷启动后参数丢失在 didFinishLaunching 里直接调 UnitySendMessage改成缓存队列,等 C# 注册后冲刷
同一次点击收到了两次参数AppDelegate 和 SceneDelegate 同时处理原生侧做指纹去重
链接里带中文参数乱码百分号编码只做了一层,有些渠道做双重编码C# 侧先手动 decode 一次再解析
改了 AASA 后很长时间不生效Apple CDN 缓存换一个带 query 的新路径测试,必要时重启设备
UnitySendMessage 调了但方法没执行接收方法不是 public 或参数不是 string检查接收函数签名

5.3 埋点与防刷的一些建议

Deep Link 链路里最容易出问题的就是“看得见但查不了”,所以埋点必须比业务先行。我在三个关键节点埋了日志:

  • 原生层收到链接的时间点和原始 URL
  • C# 层OnNativeDeepLink收到通知的时间点和解析后的参数
  • 业务层最终派发的时间点和目标场景

这三条日志的时间差,就能定位时延卡在哪一段。比如原生到 C# 差了几秒,大概率是缓存队列冲刷时机不对;C# 到业务差了几秒,大概率是_isEngineReady置位太晚。

防刷方面,我们对ts做过期校验,超过 24 小时直接丢弃链接;对充值返利类的跳转参数,运营生成的链接会带一个签名串,C# 侧只做格式校验,具体签名验证放在服务端,客户端不做,因为客户端签名逻辑很容易被反编。

5.4 我反复踩过并且每次都想骂人的三个细节

第一,Universal Links 的 AASA 文件更新后,真机上经常“过了一晚才生效”,这不是玄学,是 Apple CDN 缓存。调试时我习惯用新路径加 query 的形式测试,绕开旧路径缓存。生产环境 AASA 就不要随便动了,改动前通知所有端联调。

第二,某些第三方统计和归因 SDK 会抢openURL回调,它会先返回 YES,iOS 就不会再把链接事件交给后面的代理。我接归因 SDK 时,先把自家 Deep Link 处理逻辑放到最前面,确认拿到参数后直接把流程交给 SDK。两个都想要的时候,就要在回调里先调 SDK 再处理自己的逻辑,并且返回值只能给一个。

第三,UnitySendMessage找不到 GameObject 的时候不会抛异常,你只会发现消息丢了。所以我会在原生侧记录每次发送的目标名和方法名,联调时全部打出来,一旦发现发送成功但 C# 没反应,先查目标物体是不是被场景切换干掉了。

最后再分享一个小技巧:把整套 Deep Link 参数解析和派发逻辑写成一个独立的 SDK 模块,不要散落在各个业务页面里。以后不管是接新的买量平台、做新的召回活动,还是从 URL Scheme 平滑迁移到 Universal Links,都只改一个入口,运营侧也不用跟着你改链接格式。这套链路做完之后,我在项目里基本再没为“用户点链接进不来”这个问题熬过夜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询