
去年做SLG的iOS买量落地页市场同学丢给我一个链接要求很简单用户从Safari里点这个链接能直接唤起游戏还要带上邀请人ID和渠道标签。我一开始觉得这事没多难给App加个URL Scheme再在C#里监听起来不就行了结果从埋线到真正调通前后折腾了两周。Universal Links在微信内置浏览器里完全不生效、冷启动时UnitySendMessage发过去C#收了个寂寞、同一个链接被系统回调了两次导致游戏里连续弹两个邀请面板。这篇把从iOS系统层到Unity C#层的Deep Link唤醒完整流程梳理一遍包含两条技术路线的选型逻辑、原生层代码和Unity侧代码怎么配合以及一份可以直接照做的上线验收清单。刚接触这个需求的Unity开发者或者被Deep Link反复折磨过的老手都能从这里拿到点实际东西。1. 链条全览点击链接到 C# 拿到参数一共要过几道关卡先别看代码把整条链路画在脑子里。用户在某处看到一条链接可能是Safari、备忘录、短信、邮件也可能是微信内置浏览器。点击之后iOS系统根据链接特征决定路由方式。如果是URL Scheme系统直接判断有没有App注册了这个协议头如果是Universal Links系统会先去下载并校验域名下的apple-app-site-association文件确认域名所有权后决定唤起App还是打开网页。这一步的结果传达到App进程内部。进程不在就通过launchOptions带进启动参数进程在后台就通过AppDelegate的回调方法传进来。到这里为止所有信息都在原生层业务无法直接消费。接下来需要把这些参数转成统一格式通过桥接层投递给Unity引擎。投递的时机非常关键引擎没Ready时直接发消息会丢失。最后C#层拿到参数经过解析、去重、路由分发落到具体业务逻辑比如跳转活动页、弹邀请面板、记录归因渠道。这是完整的六步点击、系统路由、原生进程、桥接层、C#管理单例、业务消费。手游和普通App最大的区别在于原生层只是个壳业务全在C#。普通App可以在原生层直接做路由跳转手游不行Unity场景、UI、资源都没加载时原生层就算拿到Deep Link也没法处理。所以必须有一个稳定的桥接设计把原生层的参数安全递到C#层并处理好时序。三种典型唤起场景有必要分开理解它们进入C#层的路径完全不同场景App进程状态系统入口参数传递方式冷启动URL Scheme未运行didFinishLaunchingWithOptions里读launchOptions[UIApplicationLaunchOptionsURLKey]原生层缓存Unity启动后主动取或等待引擎Ready后补发冷启动Universal Links未运行didFinishLaunchingWithOptions里读 UserActivity 字典同上系统之后可能还会补调continueUserActivity需去重热启动在后台openURL或continueUserActivity直接回调引擎通常已Ready可直接UnitySendMessage理解这张表后面排坑就有方向了。大部分问题都出在冷启动时的时序以及两种Universal Links入口重复触发上。2. 路线选择URL Scheme 与 Universal Links 各自的脾气手游该主用谁很多团队第一次接Deep Link都是从URL Scheme开始的因为它简单在Xcode里给Target配置一个URL Types注册一个类似mygame://的协议头就能用。但用深了会发现这方案一身毛病。URL Scheme最大的问题有三个。第一所有权无法验证任何App都能声明相同的协议头系统弹窗时并不能保证唤起的是你的App。第二未安装App时Safari会直接提示“无法打开网页”体验很差必须在H5落地页里用定时器炸弹降级跳App Store这种方式在iOS上时灵时不灵。第三微信、QQ这类App的内置WebView会拦截大部分URL Scheme分享出去的链接在微信里点了根本没反应。Universal Links是Apple后来推的标准方案核心思路是使用权证文件验证域名所有权。需要在Apple Developer后台给App ID开启Associated Domains在Xcode的Entitlements文件里添加applinks:yourdomain.com然后在你自己的HTTPS域名根目录或.well-known目录下放一个apple-app-site-association文件。{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.yourgame, paths: [*] } ] } }我见过不少项目配这个文件时踩坑后面专门说。简单对比一下两条路线的核心差异对比项URL SchemeUniversal Links配置成本低Xcode里填一个协议名高需要域名、HTTPS、苹果后台、服务器文件是否弹确认框系统可能弹“在App Store中打开”不弹直接唤起未安装时行为报错或需要降级处理Safari正常打开你的网页微信内置浏览器大部分被拦直接失效安全性协议可被其他App抢占校验域名所有权防抢手游现在的主流做法是两者都配以Universal Links为主URL Scheme兜底。Universal Links负责Safari、备忘录、邮件这些系统场景体验干净URL Scheme作为微信OpenSDK跳转等特定场景的补位。毕竟国内社交分享绕不开微信Universal Links在微信内置浏览器里完全使不上劲。另外要注意如果项目接了AppsFlyer或Adjust这类归因SDK它们的深层链接配置同样依赖这两个能力。先跟第三方SDK文档核对一遍避免配置文件互相覆盖。我见过有团队自己写的aasa文件和AppsFlyer的配置指向同一个域名路径匹配规则一个用*一个用具体路径最后互相干扰排查了两天。针对纯Unity出包的项目还有一个很容易忽略的点如果你是用Unity生成Xcode工程再手动改配置那每次重新出包配置都会被冲掉。正确做法是写一个PostProcessBuild脚本在Xcode工程生成后自动添加Associated Domains、URL Scheme和Info.plist字段把配置固化到打包流程里。这个后面验收清单部分再展开。3. 原生层落地AppDelegate 两个入口加一个缓存队列打通到 Unity原生层在整个方案里的角色是翻译官把iOS系统给的URL或NSUserActivity转成C#能消费的JSON字符串并且在正确的时机投递过去。先看AppDelegate里的两个入口。URL Scheme和Universal Links走的是不同方法必须都实现然后在内部统一交给同一个桥接单例处理绝不能在两个方法里各写一套业务。// AppDelegate.m - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { if (url) { NSString *json [[DeepLinkBridge shared] serializeURL:url]; [[DeepLinkBridge shared] enqueuePayload:json]; } return YES; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; if (url) { NSString *json [[DeepLinkBridge shared] serializeURL:url]; [[DeepLinkBridge shared] enqueuePayload:json]; } } return YES; }还要处理冷启动。App进程没启动时两种链接的信息都藏在didFinishLaunchingWithOptions里。URL Scheme对应UIApplicationLaunchOptionsURLKeyUniversal Links对应UIApplicationLaunchOptionsUserActivityDictionaryKey取出后同样塞进桥接缓。- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSURL *url launchOptions[UIApplicationLaunchOptionsURLKey]; if (url) { NSString *json [[DeepLinkBridge shared] serializeURL:url]; [[DeepLinkBridge shared] enqueuePayload:json]; } NSDictionary *userActivityDict launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; NSUserActivity *userActivity userActivityDict[UIApplicationLaunchOptionsUserActivityKey]; if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *webURL userActivity.webpageURL; if (webURL) { NSString *json [[DeepLinkBridge shared] serializeURL:webURL]; [[DeepLinkBridge shared] enqueuePayload:json]; } } return YES; }这里有一个很多教程不会提的细节用Universal Links冷启动时系统在调用didFinishLaunchingWithOptions之后可能还会补调一次continueUserActivity。也就是说同一个链接存在被投递两次的可能。C#层必须做去重或者原生层在处理时打上一个时间戳标记。我习惯在桥接层记录最近一次处理的URL和Unix时间1秒窗口内相同URL直接丢弃。URL解析也有讲究。很多人直接取url.query再拆分遇到号、中文编码、多个时会出各种怪问题。正确做法是用NSURLComponents的queryItems系统帮你处理解码- (NSString *)serializeURL:(NSURL *)url { NSURLComponents *components [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; NSMutableDictionary *params [NSMutableDictionary dictionary]; for (NSURLQueryItem *item in components.queryItems) { if (item.value) { params[item.name] item.value; } } NSMutableDictionary *payload [NSMutableDictionary dictionary]; payload[route] components.path ?: ; payload[params] params; NSData *data [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; }接下来是整个人流程里最关键的一步UnitySendMessage的调用时机。Unity引擎从启动到C#脚本可接收消息中间有一段初始化时间。如果原生层在didFinishLaunchingWithOptions里拿到URL后立刻调UnitySendMessage这条消息大概率丢失而且不会报错排查起来非常隐蔽。我的做法是加一个缓存队列引擎未Ready时先把消息存起来等Unity通知原生层“我准备好了”再统一补发// DeepLinkBridge.h #import Foundation/Foundation.h interface DeepLinkBridge : NSObject (instancetype)shared; - (void)enqueuePayload:(NSString *)json; - (void)setUnityReady; - (BOOL)isUnityReady; end // DeepLinkBridge.m #import DeepLinkBridge.h #import UnityFramework/UnityFramework.h interface DeepLinkBridge () property (nonatomic, strong) NSMutableArrayNSString * *pendingPayloads; property (nonatomic, assign) BOOL unityReady; end implementation DeepLinkBridge (instancetype)shared { static DeepLinkBridge *instance; static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ instance [[DeepLinkBridge alloc] init]; instance.pendingPayloads [NSMutableArray array]; }); return instance; } - (void)enqueuePayload:(NSString *)json { if (self.unityReady) { [self sendToUnity:json]; } else { [self.pendingPayloads addObject:json]; } } - (void)setUnityReady { self.unityReady YES; NSArray *pending [self.pendingPayloads copy]; [self.pendingPayloads removeAllObjects]; for (NSString *json in pending) { [self sendToUnity:json]; } } - (void)sendToUnity:(NSString *)json { const char *objectName DeepLinkManager; const char *methodName OnDeepLinkReceived; const char *param [json UTF8String]; UnitySendMessage(objectName, methodName, param); } endC#侧在场景加载完成后通过一个空的GameObject调用原生层的setUnityReady触发补发。这一步做对了冷启动参数丢失这个问题就算彻底根治了。4. C# 层接收与分发冷启动、热启动、参数落地一个不能少原生层的活干完轮到Unity侧的接收端。C#层要做的事包括接收JSON、缓存未消费的参数、区分冷启动和热启动、去重、路由分发到具体业务。我习惯在一个挂到DontDestroyOnLoad空物体上的DeepLinkManager单例里集中处理。代码结构大致如下using System; using System.Collections.Generic; using UnityEngine; public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } // 冷启动兜底静态字段确保场景切换、管理器重建时参数不丢 private static string _pendingRawPayload; private readonly QueueDeepLinkPayload _payloadQueue new QueueDeepLinkPayload(); private bool _coreReady; private void Awake() { if (Instance ! null) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); if (!string.IsNullOrEmpty(_pendingRawPayload)) { OnDeepLinkReceived(_pendingRawPayload); _pendingRawPayload null; } } public void OnDeepLinkReceived(string json) { DeepLinkPayload payload DeepLinkPayload.Parse(json); if (payload null) return; if (_coreReady) { Dispatch(payload); } else { _payloadQueue.Enqueue(payload); CoreReadyEvent DispatchPending; } } public void NotifyCoreReady() { _coreReady true; DispatchPending(); } private void DispatchPending() { while (_payloadQueue.Count 0) { DeepLinkPayload payload _payloadQueue.Dequeue(); Dispatch(payload); } } private void Dispatch(DeepLinkPayload payload) { // 去重同一路由 1 秒内只分发一次 if (IsDuplicate(payload)) return; Debug.Log($[DeepLink] Dispatch route{payload.Route} params{payload.RawParams}); // 这里根据 payload.Route 分发到活动页、邀请、归因等业务模块 } }这里有个经验_pendingRawPayload静态字段非常关键。因为Unity场景切换时如果DontDestroyOnLoad没保住或者热更新框架重载了程序集Manager会被重建。静态字段在程序域存续期间不会丢失比实例字段稳得多。关于JSON解析要提醒一个Unity老坑JsonUtility对字典类型支持很差不能直接解析params这种嵌套JSON对象。两个方案一是包Newtonsoft.Json这是最省事的二是让原生层把参数拍平成routexxxkeyvalue的格式再透传。我目前生产环境走的是Newtonsoft因为业务要的字段结构越来越复杂拍平到后面没法维护。冷启动和热启动在C#侧看起来是同一套代码但因为时序不同要注意各自的场景冷启动时Unity引擎初始化阶段原生层缓存了参数等C#侧准备好了再补发。这个回调到OnDeepLinkReceived时游戏可能刚进主场景也可能还在启动画面。不能一收到就立刻切场景得等核心模块初始化完成。我在代码里用一个_coreReady标记配合队列逻辑上叫“延迟到安全时机分发”。热启动时App从后台被唤起Unity引擎还在运行消息能立刻到达。但这时候玩家可能正卡在战斗结算界面或者停在某个UI弹窗前。直接弹个邀请面板把当前流程打断体验非常差。建议在Dispatch加一个UI路由拦截和优先级机制比如战斗场景下只做数据记录不做界面跳转切到主城后再消费。关于去重除了原生层的时间戳去重C#层最好也做一道。原因很简单有些第三方SDK会在启动时主动重放一次Deep Link加上系统的补调同一参数进三次都正常。我用一个字典记最近投递的URL和到达时间时间差小于2秒的直接丢弃。分发到业务模块时也有一点要提醒不要在主线程里做重逻辑。路由分发本身很快但如果路由要拉活动配置、请求服务器、加载资源一定要丢到业务层的异步流程里。有些团队图省事在分发处直接同步加载场景卡顿明显在线上的反馈很糟糕。5. 真实踩坑记录aasa 不生效、微信内打不开、回调两次、参数丢半截这一节全是我实际踩出来的坑基本覆盖了接手Deep Link需求后一周内可能遇到的所有问题。坑一apple-app-site-association文件配了但就是不生效。这个文件有三个常见的无声杀手服务器返回的Content-Type不是application/json而是text/plain或application/octet-streamiOS不认文件位置放错应该放在域名根目录或.well-known下面域名做了重定向比如跳转到www子域或者CDN节点切换了域名。验证方式是在Safari直接访问https://yourdomain.com/apple-app-site-association看能不能原样看到JSON、响应头Content-Type对不对。如果返回的是HTML错误页那肯定不行。坑二iOS 13之后的Safari“打开”横幅不出来。系统对Universal Links的处理机制决定了如果用户之前在Safari里手动打开过同一个域名下的链接系统会记住这个偏好之后不再自动唤起App而是停留在网页里。测试时特别容易碰到第一次测还有横幅第二次就没了以为是自己改坏了。解决方式换一个没访问过的新路径或者清掉Safari历史记录再测。坑三微信内置浏览器里Universal Links直接失效。微信的WebView不遵循Safari对Universal Links的唤起行为所以分享出去的活动页在微信里打开点击“打开游戏”按钮大部分情况没反应。标准解法是先引导用户跳到Safari或者调用微信OpenSDK的拉起能力这需要配置微信开放平台那边的Universal Links逻辑跟普通H5点击不一样。很多团队上线后收到“微信里打不开”的客诉基本都栽在这里建议提前写进FAQ。坑四同一个Universal Links被回调两次。前面提到进程不在时系统可能先走didFinishLaunchingWithOptions再补调continueUserActivity。玩家侧的表现就是游戏里连续弹两个一样的邀请面板后台日志看是两个一模一样的Deep Link到达。原生层做1秒时间窗去重或者C#层再做一道都能解决。坑五参数变成一串乱码或者后半截没了。链接里有中文、emoji、、#这类字符时直接在url.query字符串上自己切割很容易出事。用NSURLComponents的queryItems取解码后的值是最稳的。原生层序列化成JSON时还要注意如果参数本身有引号或者换行直接用字符串拼接会破坏JSON结构必须用NSJSONSerialization。坑六UnitySendMessage静默失败。原生层调了UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, xxx)C#侧什么都没收到而且Xcode控制台没有任何报错。原因无非三种GameObject名字写错、方法名写错、方法不是public。还有一种是Unity引擎还没Ready消息发出去没人接。我们工程里在原生侧封装了一个带返回值的方法由C#侧主动拉取缓存而不是单纯靠推等于多了一条保底通道。坑七落地页没安装App时跳转App Store的逻辑不干净。用URL Scheme做主投递时未安装的场景会弹出“无法打开网页”用户体验极差。H5工程通常用定时器检测App是否被唤起超时后跳App Store。但iOS的Safari对页面切后台的判断很飘定时器经常误判。Universal Links天然解决这个问题未安装时直接在Safari打开你的网页你可以在网页里放App Store下载按钮不需要炸弹降级。所以现在的买量落地页都要求域名下的Universal Links能正常返回页面而不是被App“截胡”。这些坑我整理成了一张速查表接到告警时按图索骥特别有用现象大概率原因排查入口指定路径点击无效aasa没配好或Content-Type错误Safari直接访问aasa文件Safari没“打开”横幅用户偏好记忆换新路径或清历史游戏内重复弹窗双入口回调两次原生层时间窗去重中文参数乱码直接切割query字符串改用NSURLComponents微信里打不开微信WebView不支持Universal Links引导Safari打开C#未收到消息UnitySendMessage时机或名称问题原生层缓存主动拉取6. 上线前验收清单一件件核对晚上才睡得着按我现在的习惯每次给Unity手游接完iOS Deep Link上线前会从头到尾过一遍下面这些项缺哪项补哪项。首先是域名和服务器配置。确认apple-app-site-association文件能通过HTTPS直接访问Content-Type正确路径匹配规则符合预期。对应的App ID已经在Apple Developer后台开启了Associated Domains。Xcode工程的Entitlements里applinks:前缀的域名正确和证书里的Team ID拼出来的appID和aasa文件里的记录完全一致。接着验证原生层。用一个测试链接分别在三种场景下打点App完全退出后点击、App在后台点击、App在前台点击。每种都要看AppDelegate是否进入对应入口enqueuePayload是否被调用UnityReady之前消息是否被正确缓存。这一步可以在Xcode控制台打日志验证断点打在sendToUnity:方法里能直观看到消息有没有发出去。然后是Unity层。需要在启动流程里确认两件事DeepLinkManager的Awake没有丢失静态缓存NotifyCoreReady在核心模块初始化完成后被调用。然后分别测冷启动和热启动的路由分发确认去重逻辑生效重复点击不会重复弹窗。把参数里的中文、特殊字符都塞一遍测试确认解析没有乱码。还有一个容易漏的环节如果游戏接入了热更新或者程序集重载DeepLinkManager的重建时序会变化静态字段保底就是为这种场景准备的。在热重载后再发一条测试深链确认消息不丢。最后是渠道矩阵测试。把链接分别放到Safari、备忘录、短信、邮件、微信、QQ、企业微信里点一遍。每一条记录唤起是否成功、参数是否完整。微博、抖音这类第三方App的内置浏览器也值得测一下它们的行为跟微信可能不完全一样。测试结果整理成一张兼容表产品、运营各留一份后续客诉时能迅速判断是不是已知问题。打包自动化这块也建议一起固化。我之前手改Xcode工程被冲掉过太多次现在用PostProcessBuild脚本自动完成三件事往Info.plist写入URL Scheme、往Entitlements写Associated Domains、把Unity工程里的UnityAppController替换成处理了Deep Link的版本。出包后自动执行一遍检查脚本把关键配置项的值打出来。这样每次提审前都能批量核对不用靠人工记。最后再分享一个小习惯我会在原生层留一个环境开关打开后所有Deep Link链路日志打到Xcode控制台从系统回调、原生解析、队列缓存一直到UnitySendMessage每一行都有。C#侧同样留一个类似的宏开关把接收、解析、分发三条链路全打出来。上线前的真机联调把这个开关打开跑一遍快速定位问题正式包里关掉避免日志刷屏。这套链路日志救过我很多次尤其当QA报告“某个渠道的深链偶尔丢参数”时两边日志一对问题基本当场就能圈定。