去年年底帮一个发行团队排查买量数据,广告平台点击率正常,但“点击唤起App”的转化却掉了近两成。顺着漏斗一层层拆,最后定位到根因:游戏压根没把 Deep Link 链路做完整,用户从 Safari 点了链接,App 要么没被唤起,要么起来了参数没传进去,归因平台自然记不到这一笔。
这个场景在 Unity 手游里太常见了。iOS Deep Link 分为 URL Scheme 和 Universal Links 两条路线,从系统层回调到 Unity C# 层,中间隔着 Objective-C 原生代码、Unity 引擎初始化时序、参数编码一堆细节。这篇文章就把 Unity 手游 iOS Deep Link 唤醒全流程讲透:如何注册 Scheme、如何配置 Universal Links、如何在原生层截获唤起、如何把参数安全投递到 C# 层,每一步都给出可以直接复用的方案。适合正在接买量归因、社交分享、活动页跳转的 Unity 客户端同学,通讯或原生开发经验不多也能照着做。
1. 为什么手游 Deep Link 必须做:买量归因和社交唤醒都在等这份参数
1.1 需求A:广告点击后把用户带回游戏
手游买量的标准路径是:用户刷到广告 → 点击 → 如果是已安装用户,直接唤起 App;如果是未安装用户,跳 App Store 下载。其中“已安装用户直接唤起”靠的就是 Deep Link。
这里的关键不只是“把 App 打开”,而是要把广告侧的参数(campaign、adgroup、creative、click_id 等)完整带进 App 里。归因平台就是靠这串参数确认“这个用户是从哪个广告进来的”。如果链路断了,参数丢了,一次有效点击可能被归因成自然量,广告平台的智能投放模型会直接学歪。
我见过不少项目只做了 URL Scheme 注册,没做 Universal Links,结果投放同学反馈“iOS 侧唤起率明显低于 Android”。原因很简单:现在很多广告平台在 iOS 上默认走 Universal Links,因为 iOS 系统对 URL Scheme 的唤起有弹窗、有隐私限制,而 Universal Links 是系统级无感唤起的标准方案。链路不完整,就等于主动放弃了一部分买量流量。
1.2 需求B:好友邀请和活动页的无感直达
社交分享场景同样依赖 Deep Link。玩家把邀请链接发到微信或群聊,好友点开链接,如果装了游戏,应该直接从当前页面切入游戏内的邀请奖励页面;如果没装,至少应该打开一个能下载的 H5 页面。
这个场景对“参数投递”的要求更细。链接里通常带 inviter_id、guild_id、activity_id、channel 等业务参数,游戏启动后要拿着这些参数去请求服务器,弹对应 UI。参数少了,或者路径解析错了,用户看到的就是一个平平无奇的主界面,裂变转化率马上掉下来。
这里有一个容易忽略的点:微信内直接点 Universal Link,iOS 的 WKWebView 行为会受到微信内置浏览器策略影响,有时走不到系统唤起。所以业务上一般会引导用户“在 Safari 中打开”,或者链接本身做成可识别的短链,再通过 JS 跳转。这些都是 Deep Link 上线后要配套处理的问题,后面展开讲。
1.3 两条技术路线选型:URL Scheme 还是 Universal Links
很多刚接触这个需求的同学会问:既然 URL Scheme 也能唤起 App,为什么还要搞 Universal Links?两个方案不是替代关系,而是互补关系。
我一般建议“双持”:Universal Links 做主链路,URL Scheme 做兜底。原因有三点:
- Universal Links 没有弹窗,体验顺滑,但要求域名必须支持 HTTPS,并且要放对 apple-app-site-association 文件,配置成本高。
- URL Scheme 配置简单,但唤起时系统会弹“是否在“xxx”中打开?”(iOS 新版本里弹窗更频繁),而且 scheme 容易冲突,你的游戏叫 mh,别人家的大力神App 也可能用 mh。
- 一些第三方 SDK 或合作 App 之间的跳转,还是只认 URL Scheme。比如通过某些平台 App 的分享面板回跳游戏,他们写死了 scheme,你只做 Universal Links 是接不到的。
所以不要非黑即白,两张牌都要握在手里。选型对比我先放在这,后面配置章节会按“双持”的做法一步步来。
| 对比维度 | URL Scheme | Universal Links |
|---|---|---|
| 配置复杂度 | 低,只需在 Info.plist 里声明 | 高,需要服务器 + HTTPS 证书 + AASA 文件 |
| 用户体验 | 可能弹窗确认 | 系统级无感唤起 |
| 参数携带 | 通过 URL query 携带 | 通过 URL query 携带 |
| 域名要求 | 无 | 必须 HTTPS,且域名不可被占用 |
| 冲突风险 | scheme 易冲突 | 域名唯一,基本无冲突 |
| 适用场景 | App 间跳转、SDK 回调 | 浏览器/广告/社交点击唤起 |
2. iOS 系统层的唤起逻辑:URL Scheme 为什么被限流,Universal Links 靠什么解决
2.1 URL Scheme 的注册表机制与隐私收紧
URL Scheme 的底层逻辑很简单:应用在 Info.plist 里声明自己认领的 scheme,系统维护一张注册表。当某处尝试打开mygame://invite?from=10086时,系统查这张表,找到对应 App,然后调起它。
这套机制在早期 iOS 版本里非常“粗暴有效”,但问题也在于“粗暴”:任何人都可以注册任何 scheme,没有任何鉴权。你的游戏注册了mygame,别人家的 App 也可以注册,系统会优先找最近安装的那个,冲突完全不可控。
更难受的是隐私收紧。从 iOS 9 开始,App 之间通过canOpenURL查询 scheme 是否可用,必须先在 Info.plist 里声明LSApplicationQueriesSchemes白名单;iOS 10 之后,系统对 scheme 唤起开始有更多限制提示;到了近几年,iOS 在唤起第三方 App 时普遍会弹“要在“xxx”中打开吗?”的确认框。每多一次弹窗,用户就多一次流失机会,买量团队最恨这个。
还有一个细节:URL Scheme 在 App 被杀死后的冷启动场景中,参数是放在 launchOptions 里的,处理时机在didFinishLaunchingWithOptions;如果 App 已经在后台,参数则走openURL回调。两种时机的处理代码不同,但很多教程没讲清楚,导致新手在冷启动时拿不到参数。
2.2 Universal Links 的域名鉴权与 AASA 文件
Universal Links 是 Apple 在 iOS 9 推的方案,核心思路是“用域名代替 scheme 作为 App 的身份标识”。系统在用户点击一个 HTTPS 链接时,会去请求该域名根目录下的apple-app-site-association(简称 AASA)文件,里面声明了哪些 AppID 可以处理哪些路径。
AASA 文件长这样:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.yourcompany.yourgame", "paths": ["*"] }, { "appID": "TEAMID.com.yourcompany.yourotherapp", "paths": ["/invite/*", "/event/*"] } ] } }这个文件必须放在https://yourdomain.com/apple-app-site-association或者https://yourdomain.com/.well-known/apple-app-site-association,没有扩展名,必须 HTTPS,并且不能被重定向到不安全的地址。
系统拿到文件的判定是:AppID 匹配 → 路径匹配 → 直接唤起 App,不弹窗。如果 App 未安装,系统则用浏览器打开页面。这就解决了 URL Scheme 的几个核心痛点:身份可信、无弹窗、未安装可优雅兜底。
但 Universal Links 也有自己的坑,最典型的是首次点击不一定生效。设备刚刚安装 App 或系统刚刚完成注册时,AASA 文件可能还没被系统抓取到,需要等待一段时间或者通过其他行为触发。这也是很多测试同学“明明配置了却唤起失败”的原因。
2.3 冷启动、热启动、后台挂起三种时序对参数的影响
做 Deep Link 最容易失控的其实是时序,不是代码。iOS 对 App 状态的处理有几种:
- 冷启动:App 进程被杀掉,从点击链接到 App 完全起来,是一个完整启动流程,参数在 launchOptions 里。
- 热启动:App 还活着,只是切到后台,此时系统通过
continueUserActivity或openURL回调把参数送进来。 - 后台挂起:App 被系统挂起但进程没被杀,收到链接时 iOS 会把 App 唤醒到后台,然后走热启动的回调路径。
对于 Unity 手游,时序问题会更复杂:即使 App 进程起来了,Unity 引擎不一定初始化完成,C# 侧的 MonoBehaviour 不一定已经挂上。如果原生层立刻调UnitySendMessage,消息可能发到空处,直接丢失。
所以我强烈建议:原生层先把参数缓存住,C# 侧准备就绪后主动来取。这个思路是整篇文章的核心,后面的章节会详细给代码。
3. Unity 导出工程的配置落地:每改一次 Build 都不丢这些设置
3.1 Info.plist 里的 URL Types 配置
先说 URL Scheme。Unity 导出 Xcode 工程后,需要在Info.plist里添加CFBundleURLTypes,里面声明一个或多个CFBundleURLSchemes。
手动操作路径是:Xcode 选中 Target → Info 标签 → URL Types 添加。填写URL Schemes为你的 scheme 名,例如mygame。这里建议 scheme 名不要用纯数字开头,尽量用品牌缩写加后缀,降低冲突概率。
但手动配置的问题是:Unity 每次重新 Build,Xcode 工程会被重新生成,所有手动改动都会被覆盖。如果你每次都手工重配,等于养了个定时炸弹,总有某次交付忘配了。正确做法是写一个PostProcessBuild脚本,在每次导出 Xcode 工程后自动写入配置。
3.2 Entitlements、Associated Domains、AASA 文件的三件套
Universal Links 的工程侧配置由两部分组成:
一部分在 Xcode 工程里开启 Associated Domains 能力,即给 App 添加com.apple.developer.associated-domains的 entitlement,值形如applinks:yourdomain.com。在 Unity 导出工程里,这个 entitlement 通常要生成独立的.entitlements文件,并把它挂到 Target 的CODE_SIGN_ENTITLEMENTS编译设置里。
另一部分在服务器端,把 AASA 文件放到域名下,文件内容上节已经给过。这里强调一个很隐蔽的细节:AASA 文件里的 appID 格式是TeamID.BundleID,TeamID 不是 App 名称,不是证书名称,而是你在 Apple Developer 后台看到的十位字符 ID。很多人栽在这里,拿 BundleID 当成 AppID 填进去,结果一直返回“路径不存在”。
如果你不想手写,可以使用 Apple 官方文档给出的校验方式,也可以用第三方在线生成工具生成 AASA 内容。但我个人建议手写一遍,因为只有自己写才知道哪些路径是业务真正需要暴露的,不要无脑写"paths": ["*"]。路径尽量收敛到/invite/*、/event/*这类业务前缀,避免把不需要唤起 App 的网页路径也交给 App 处理。
3.3 用 PostProcessBuild 脚本固定自动配置
以下是我工程里在用的自动化脚本骨架,基于 Unity 的IPostProcessBuild机制,在每次导出 iOS 工程后自动写入 URL Scheme 和 Associated Domains:
#if UNITY_IOS using System.IO; using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; public class iOSDeepLinkPostProcess : MonoBehaviour { [PostProcessBuild(1)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target != BuildTarget.iOS) return; // 1. 修改 Info.plist,添加 URL Scheme string plistPath = Path.Combine(path, "Info.plist"); PlistDocument plist = new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementArray urlTypes = plist.root.CreateArray("CFBundleURLTypes"); PlistElementDict urlType = urlTypes.AddDict(); urlType.SetString("CFBundleURLName", "com.yourcompany.yourgame.deeplink"); PlistElementArray schemes = urlType.CreateArray("CFBundleURLSchemes"); schemes.AddString("yourgame"); plist.WriteToFile(plistPath); // 2. 生成或修改 Entitlements 文件,添加 Associated Domains string entitlementsPath = Path.Combine(path, "Unity-iPhone.entitlements"); PlistDocument entitlements = new PlistDocument(); if (File.Exists(entitlementsPath)) entitlements.ReadFromFile(entitlementsPath); PlistElementArray domains = entitlements.root.CreateArray("com.apple.developer.associated-domains"); domains.AddString("applinks:yourdomain.com"); entitlements.WriteToFile(entitlementsPath); // 3. 将 Entitlements 文件挂到 Target 的 CODE_SIGN_ENTITLEMENTS string projectPath = PBXProject.GetPBXProjectPath(path); PBXProject project = new PBXProject(); project.ReadFromFile(projectPath); string targetGuid = project.GetUnityMainTargetGuid(); project.AddFile(entitlementsPath, "Unity-iPhone.entitlements"); project.AddBuildProperty(targetGuid, "CODE_SIGN_ENTITLEMENTS", "Unity-iPhone.entitlements"); project.WriteToFile(projectPath); } } #endif注意,Unity 不同版本对PBXProject的 API 略有差异,比如GetUnityMainTargetGuid是 Unity 2019.3 之后的写法,旧版本可能需要遍历或使用TargetGuidForCPPExtension。脚本跑一次如果报错,优先去查当前 Unity 版本对应的 Xcode API 文档,不要盲目照抄。
这个脚本的价值在于:团队任何人重新出包,配置都不会丢。即便你只是一个人维护,也建议做好这一步,因为“重新出包忘配置”这种低级错误往往发生在最忙的上线前夜。
4. 原生层接收与解析:在 UnityAppController 截获每一次系统唤起
4.1 接收 Deep Link 的四个回调方法
Unity 导出的 Xcode 工程里,AppDelegate 继承自 UnityAppController。你需要处理四个可能携带 Deep Link 信息的系统回调。
// 1. URL Scheme 回调(App 存活或从后台唤醒时) - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options { [[DeepLinkRouter shared] handleURL:url source:@"url_scheme"]; return YES; } // 2. Universal Links 回调(App 存活或从后台唤醒时) - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> *restorationHandler))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [[DeepLinkRouter shared] handleURL:userActivity.webpageURL source:@"universal_link"]; } return YES; }还有两个冷启动场景的回调。当 App 被杀死后通过 Deep Link 拉起来时,URL 参数不在上述两个方法里,而在 launchOptions 中:
// 3. 冷启动中的 URL Scheme - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSURL *url = launchOptions[UIApplicationLaunchOptionsURLKey]; if (url != nil) { [[DeepLinkRouter shared] handleURL:url source:@"url_scheme"]; } // 4. 冷启动中的 Universal Links NSDictionary *activityDict = launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; NSUserActivity *activity = activityDict[@"UIApplicationLaunchOptionsUserActivityKey"]; if ([activity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [[DeepLinkRouter shared] handleURL:activity.webpageURL source:@"universal_link"]; } return YES; }这里要提醒一句:UIApplicationLaunchOptionsUserActivityDictionaryKey是公开 API,但里面取 UserActivity 的那个 key 在文档里没有单独暴露为常量,不同 iOS 版本表现一致,但为稳妥起见,建议用字符串字面量并做非空判断。真机调试时如果发现冷启动 Universal Link 拿不到参数,优先检查这里。部分项目使用了 SceneDelegate,那么还要在scene:willConnectToSession、scene:continueUserActivity等回调里同样处理一遍。Unity 默认工程用的是 AppDelegate 生命周期,如果你自己集成过 iOS 14+ 的 Scene 生命周期,记得两头都接。
4.2 URL 解析与统一参数封装
截获到 URL 只是第一步,接下来要做的不是急着送给 Unity,而是先把 URL 解析成统一结构。我习惯把所有信息封装成 JSON,这样 C# 层只需要处理一个字符串,不牵扯 Objective-C 的内存和 GCD 细节。
解析 URL 的核心逻辑:
- (NSString *)serializeURL:(NSURL *)url source:(NSString *)source { NSMutableDictionary *params = [NSMutableDictionary dictionary]; params[@"scheme"] = url.scheme ?: @""; params[@"host"] = url.host ?: @""; params[@"path"] = url.path ?: @""; params[@"source"] = source ?: @""; NSMutableDictionary *query = [NSMutableDictionary dictionary]; NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; for (NSURLQueryItem *item in components.queryItems) { NSString *decoded = [item.value stringByRemovingPercentEncoding]; query[item.name] = decoded ?: item.value; } params[@"query"] = query; NSError *error = nil; NSData *data = [NSJSONSerialization dataWithJSONObject:params options:0 error:&error]; if (error == nil) { return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; } return @"{}"; }解析时有两个坑:一是 URL query 里的中文和特殊字符可能被多次编码,只做一次stringByRemovingPercentEncoding可能不够;二是一些广告平台会把参数拼成&分隔后再整体做一次 URLEncode,导致components.queryItems解析不出预期结果。我的处理方式是:先从 query 中拿到原始字符串,按&拆开再按=拆,每段分别做一次removingPercentEncoding,遇到解析失败就保留原始片段。宁可收到一个“乱七八糟的完整参数”,也不能在原生层就把参数拆丢了。
4.3 原生侧等待 Unity 就绪的缓冲队列
正如前面说的,Unity 引擎和 C# 脚本的初始化是异步的。你在didFinishLaunchingWithOptions里拿到 Deep Link 时,Unity 可能刚启动或者还没启动完成,这时调用UnitySendMessage就像往一个还没接线的电话里打电话,全丢。
我的方案是做一个DeepLinkRouter单例,内部维护一个 pending 队列。收到 URL 后先序列化成 JSON 字符串,放入队列,等 C# 侧告知“Unity 已就绪”后,再补发。
// DeepLinkRouter.h #import <Foundation/Foundation.h> @interface DeepLinkRouter : NSObject + (instancetype)shared; - (void)handleURL:(NSURL *)url source:(NSString *)source; - (void)notifyUnityReady; - (void)flushPendingEvents; @end// DeepLinkRouter.m #import "DeepLinkRouter.h" @implementation DeepLinkRouter { NSMutableArray<NSString *> *_pendingEvents; } + (instancetype)shared { static DeepLinkRouter *instance; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [[DeepLinkRouter alloc] init]; }); return instance; } - (instancetype)init { self = [super init]; if (self) { _pendingEvents = [NSMutableArray array]; } return self; } - (void)handleURL:(NSURL *)url source:(NSString *)source { NSString *json = [self serializeURL:url source:source]; @synchronized (self) { [_pendingEvents addObject:json]; } [self flushPendingEvents]; } - (void)notifyUnityReady { [self flushPendingEvents]; } - (void)flushPendingEvents { @synchronized (self) { for (NSString *json in _pendingEvents) { const char *msg = [json UTF8String]; if (msg != NULL) { UnitySendMessage("DeepLinkManager", "OnNativeMessage", msg); } } [_pendingEvents removeAllObjects]; } } @end这里有一个原理性细节:UnitySendMessage要求接收消息的 GameObject 名字和方法名准确,且在调用前脚本对象必须已经挂载。所以 C# 侧的DeepLinkManager必须是一个场景中常驻的 GameObject,不能用动态创建的对象。队列在notifyUnityReady之后不应再有积压,如果测试中发现消息被重复发送,多半是flushPendingEvents没有清空队列,或者 C# 侧消息处理逻辑没有做去重。
5. 跨到 C# 层的参数投递:UnitySendMessage 的时序坑与事件设计
5.1 UnitySendMessage 用法与局限
UnitySendMessage是 Unity 提供的原生 → C# 通信接口,签名如下:
extern void UnitySendMessage(const char *obj, const char *method, const char *msg);它只能调用挂载在指定 GameObject 上、且公开的 MonoBehaviour 方法。比如原生层发:
UnitySendMessage("DeepLinkManager", "OnNativeMessage", jsonString.UTF8String);那么 C# 侧对应:
public class DeepLinkManager : MonoBehaviour { public void OnNativeMessage(string json) { } }这个接口有两个痛处:一是必须在 Unity 引擎初始化后调用,否则消息静默丢失;二是 method 名不能重载,不能是静态方法,不能有多个参数。所以原生侧必须拼好 JSON 字符串,C# 侧只接收一个字符串。
我见过有人想绕过这个限制,在 C# 里用[DllImport("__Internal")]暴露原生方法,让 C# 主动去原生层取。这条路也通,适合参数频繁主动查询的场景。对于 Deep Link 这种“一次性事件 + 可能冷启动早于 C# 构造”的场景,最佳策略是“原生缓存 + C# 主动拉取 + 事件推送”三合一。
5.2 C# 侧主动拉取与事件分发
我建议在 C# 侧做两层结构:底层DeepLinkBridge负责和原生交互,业务层DeepLinkManager负责对外暴露事件。
using System; using System.Runtime.InteropServices; using UnityEngine; public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } #if UNITY_IOS && !UNITY_EDITOR [DllImport("__Internal")] private static extern void _DeepLink_NotifyUnityReady(); #endif public event Action<string> OnDeepLinkReceived; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } private void Start() { #if UNITY_IOS && !UNITY_EDITOR _DeepLink_NotifyUnityReady(); #endif } public void OnNativeMessage(string json) { OnDeepLinkReceived?.Invoke(json); } }Start里回调原生方法_DeepLink_NotifyUnityReady,原生侧收到后把队列里缓存的参数一次性UnitySendMessage过来。这样就解决了冷启动时 C# 还没挂载的问题。
同时,原生侧的真实情况是:App 启动后如果没有任何 Deep Link,OnDeepLinkReceived永远不会触发,这是正常的。业务层不要依赖这个事件做初始化逻辑,只把它当作“外部跳入信号”监听即可。
5.3 连续唤起、重复消息、参数丢失的处理
游戏运行中,玩家可能反复切后台、点链接、再回来,所以原生队列和 C# 事件都可能收到多条消息。如果每一条都直接广播,业务层可能会把同一个活动参数重复上报,或者让玩家重复弹同一个页面。
我的做法是给每一条 Deep Link 生成一个自增序号,C# 侧按序号去重。最简单的方式是在原生序列化 JSON 时加入link_id字段:
params[@"link_id"] = [NSString stringWithFormat:@"%lld", (long long)([[NSDate date] timeIntervalSince1970] * 1000)];C# 侧记录已处理的最大序号,小于等于当前序号的直接丢弃。对于同一活动参数在短时间内重复唤起的情况,也可以加时间窗口:比如 2 秒内重复消息直接忽略,防止原生层因为时序问题把同一条消息发了两次。
参数丢失还有一个隐蔽来源:URL 中+号会被解析成空格,&在 query 中也可能被错误切分。原生侧解析时务必统一处理,C# 侧拿到 JSON 后用JsonUtility或第三方库解析时也建议多一层兼容,不要在原生侧做 URL decode 后又期待 C# 层再做一次,双重解码会把原始参数搞乱。
6. 真机实测容易踩的坑和一套可复用的排查链路
6.1 弹窗、白屏、参数丢:三类高频问题现象
先列三个我在实践中见得最多的问题现象:
- 唤起时弹“是否在“xxx”中打开?”:只配了 URL Scheme 的项目基本都会遇到。这不是代码 bug,是系统行为。真想消除弹窗,只能把主路径切换到 Universal Links,或者接受弹窗作为兜底方案。
- 点击 Universal Link 白屏:通常不是工程配置问题,而是 AASA 文件里的路径没匹配上。比如 AASA 写的是
/invite/*,但分享出去的链接是https://domain.com/invite_9,对不上,系统就按普通网页打开了。还有一种情况是 AASA 文件被 CDN 缓存,旧内容还没失效。 - 参数丢失或 key 大小写错乱:常见于广告平台的参数名是
campaignId,业务层解析时写成了campaign_id。这类问题要在测试阶段就用固定测试链接固化参数清单,跑一遍完整日志。
这三类问题的排查思路不太一样,但第一步都是“确认哪一层断了”。
6.2 从 AASA 文件到系统注册的逐层排查方法
我建议按以下顺序逐层切:
- 验证 AASA 文件可达且合法。在终端执行
curl -I https://yourdomain.com/apple-app-site-association,确认返回 200、Content-Type 是application/json或application/pkcs7-mime。然后直接curl看内容和预期是否一致,注意路径是相对于域名的。 - 验证 App 的 entitlements 是否真的打进了签名。真机连上后,在终端用
codesign -d --entitlements - <App 可执行文件路径>查看输出的 entitlements 里是否有applinks:yourdomain.com。这一步能确认工程侧配置是否成功,排除“改了脚本但没重新出包”的情况。 - 验证系统是否认可这个注册。在 macOS 终端使用
swcutil工具可以查询当前连接的 iPhone 上 Universal Links 的注册状态,输出中如果看不到你的域名,说明系统侧还没信任。这通常是因为 AASA 延迟或 App 首次安装后系统需要时间刷新。等几分钟重启 Safari,通常能解决。 - 验证回调是否进入原生层。在
continueUserActivity和handleURL里都打上明显的 NSLog,点击测试链接后看 Xcode 控制台。能进原生但 C# 收不到,问题在时序或UnitySendMessage调用;连原生都进不来,问题在系统层。
这套链路切完,问题基本能定位到位。不要在没确认系统层 OK 之前,就去翻 C# 代码,那样很容易白费时间。
6.3 工程配置相关的隐蔽问题:重签名、Team 切换、模拟器
最后说几个非常容易在不同团队之间“传染”的坑:
第一,重签名后 Universal Links 失效。很多发行团队拿开发包或者第三方分发包做企业重签名,重签后 TeamID 或 BundleID 变了,但 AASA 文件里的 appID 还是旧的,系统直接拒绝。这类问题最坑,因为开发阶段一切正常,上测试包就废了。解决方案是:AASA 文件里的 appID 必须跟随最终签名证书一致,测试前先确认签名身份。
第二,多人协作时 Xcode 工程的 Team 切换。PostProcessBuild 脚本里写死的和手动配置的 conflg 可能互相覆盖。建议团队统一维护一份配置脚本,禁止手工改 Xcode 工程,不然每次总有人被“为什么我这边出了包就不能唤起”。
第三,模拟器上的表现不能当真。模拟器对 Universal Links 的支持存在不少偏差,AASA 抓取策略跟真机不一样,冷启动回调的时序也不一样。我见过模拟器上表现正常、真机一塌糊涂的情况。所以 Deep Link 功能从第一天起就要用真机测试,模拟器只看 UI,不看链路。
如果条件允许,准备一台专门的 iOS 测试机,安装测试包后用一组包含不同参数的测试链接(正常路径、不匹配路径、带中文参数、带编码参数)跑一遍完整用例,把每条链接对应的期望行为记成表格。这个测试表后期交给 QA 非常顺手,也能快速区分“配置变更导致的问题”和“业务代码引入的问题”。
我在实际项目中踩过最多次跟头的地方,从来不是 Universal Links 的配置本身,而是“测试时没在真机上验证”。每次改完都要重新签包、重新装、重新点链接,链路长一点没关系,但每跑一遍都要能明确告诉自己在哪一步断的。Deep Link 这种东西,配置正确时静默无感,配置错误时也毫无报错,只有把排查方法焊死,面对线上突发才不至于慌。