尧图精选

Unity手游iOS Deep Link唤醒全流程:URL Scheme与Universal Links落地实践

🕒 发布时间:2026/10/1 8:51:20 📁 来源:尧图网络
许多做手游的朋友都遇到过这个需求运营给用户发了一条带参数的分享链接用户在微信或Safari里点了链接游戏没安装就跳转到App Store安装了就自动打开游戏并且精准进入活动页面、带上邀请码、恢复上次的登录态。这就是iOS Deep Link唤醒的完整闭环。但放到Unity工程里这件事的复杂度会立刻上一个台阶。原生iOS的URL Scheme和Universal Links只是把App叫起来真正麻烦的是如何把链路参数从系统层一路递到C#业务层还要处理用户点链接时游戏根本没启动、Unity托管环境还不存在的情况。这篇文章完整梳理一遍我在Unity手游项目里实现iOS Deep Link唤醒的流程从两套唤醒方案的选型取舍到Xcode配置、原生层捕获、C#参数投递、线上踩坑一整套走通适合正在做手游发行、分享裂变、广告归因的朋友直接拿去落地。1. 两条路线的取舍URL Scheme 和 Universal Links 到底该用谁Deep Link唤醒这个需求iOS平台上官方方案实际就两条线。很多人一开始会纠结我直接把两个都实现了不是更保险实际工程里两条线都做往往不是多一层保险而是多一堆兼容问题。先弄清楚两条路线的工作机制再谈取舍。1.1 URL Scheme老牌选手的工作机制和性格脾气URL Scheme是最早的唤醒方案本质上是给App注册一个自定义协议名。iOS系统里注册完成后当Safari或者其他App尝试打开这个协议的时候系统直接把事件抛给注册了这个协议的App。一个典型的URL Scheme长这样mygame://open?sceneshopitem_id10086frominvite这个链路有几个显著特点。首先是不需要服务器配置完全客户端侧行为纯本地注册调试起来非常快。其次是系统级的唤醒成功率极高从iOS 3时代就用到现在稳定性经过十几年验证几乎不存在打不开的情况。第三是可以被任何App主动调用包括浏览器、其他App、HTML页面里的JavaScript跳转。但URL Scheme有个非常疼的短板它是全局协议名任何人都能注册。如果你的协议名太通用比如game、gift就有被其他App抢先注册的风险。iOS系统里如果多个App注册了同一个Scheme系统只会把事件分发给其中一个而且不保证是哪一个。这就是以前经常遇到的微博打开了我的链接这类鬼故事。所以URL Scheme的命名必须尽量长、尽量带有项目辨识度比如mygreatgame2024://而不是game://。第二个短板是唤醒后会有一个系统弹窗确认跳转体验上多了一步。iOS上点击链接跳转的时候会有一个挺明显的提示部分场景下这个弹窗会劝退用户。1.2 Universal Links苹果官方亲儿子的工作机制Universal Links是苹果在iOS 9推出的替代方案思路完全不同。它不再依赖自定义协议而是直接使用普通的HTTPS链接来担当唤醒入口https://link.mygame.com/open?sceneshopitem_id10086这条链接看起来就是一个普通网页链接点击之后系统会做一次幕后裁决。大致流程是系统先访问链接对应的域名读取域名下托管的一份名为apple-app-site-association的JSON文件文件里声明了这个域名的哪些路径应该交给哪个App来处理。如果命中了声明规则就直接拉起App如果没命中则在Safari里打开网页。Universal Links最大的优势就是没有确认弹窗唤醒体验非常顺滑而且域名是你自己独有的不存在被抢注问题。在Safari内和部分内置浏览器里从链接直接跳转App的转化率明显高于URL Scheme。但它引入了新的依赖必须有一台能配置HTTPS文件的服务器而且苹果对AASA文件有格式、位置、校验策略的严格要求。这导致一个新的问题——配置错了排查起来特别费劲因为文件存在服务器缓存改了不一定立即使生效。团队里如果原生配置经验不足的人第一次搞Universal Links八成会在我已经配好了但就是调不通的阶段卡上一整天。1.3 实际项目里我一般怎么选我在真实项目里的做法分三档新上线项目、公司有自己的域名和运维能力以Universal Links为主URL Scheme作为兜底方案保留但不再作为主推入口。只做单链接分享、且暂时没有运维资源先只上URL Scheme快速跑通流程。因为URL Scheme不需要任何服务端配置客户端开发半天就能完成。广告投放、大型渠道买量场景通常两套都开。因为很多第三方Ad Network的SDK只认Universal Links而部分老旧的渠道SDK又只支持URL Scheme。这个场景下没有取舍余地两边都要接。也就是说方案选择很大程度上取决于你的业务和资源而不是单纯的技术偏好。文章后面会混着讲两套方案的配置细节因为工程落地时原子层面的步骤确实高度重叠原生层捕获逻辑几乎一套代码就能同时兜住两条路径。2. 环境配置里最容易翻车的几个细节配置这一步看着简单实际上90%的唤不醒问题都出在这里。我把两套方案需要的配置项拆开讲每一步都标注容易踩的坑。2.1 URL Scheme 在 Xcode 里的配置与命名注意在Xcode工程里选Target切到Info页签展开URL Types点加号新增一条。这里三个字段特别重要Identifier通常填Bundle ID全名主要作用是标识随便写个有意义的名字就行。URL Schemes这里填的是协议名前缀不带冒号。比如填mygame2024则生效的协议是mygame2024://xxx。Role默认Editor保持默认即可。这里的核心经验是Scheme名一定不要短。我见过有项目用ab这种两个字母的Scheme在测试机上直接被另一个App拦截了一部分调用。生成Scheme时建议使用品牌缩写项目代号年份的组合比如magamehd2024虽然长得丑但不容易冲突。还有一个细节URL Schemes支持填多个一行一个如果产品有多个业务入口可以都配上。但如果同一个Xcode工程里同时接了多个SDK有些SDK会要求你填一个固定的Scheme来把SDK内部页面拉起这个也要检查是否有冲突。最常见的坑是微信SDK的Universal Links要求和微博SDK的Redirect URL设置它们和Deep Link的Scheme是不同层级的东西别混在一起改。微信那边iOS SDK会强制要一项Universal Links用于微信OAuth和分享跳转这和游戏自己定义的Deep Link是两条独立的配置。2.2 Universal Links 的 Associated Domains 与 AASA 文件Universal Links的配置分散在三个地方开发者后台、Xcode、服务器。第一步开发者后台开启Associated Domains。在Apple Developer网站的App ID配置界面把Associated Domains能力勾上并保存。很多第一次做的人会忽略这一步直接跑去Xcode配置结果一直调不通。第二步Xcode里给Target添加Associated Domains能力。选Target - Signing Capabilities - 点加号搜索Associated Domains增加一项applinks:你的域名格式是applinks:前缀加你的域名。注意这里的域名不要带http协议头不要带路径。正确写法applinks:link.mygame.comapplinks:里如果加了https://系统完全不认这个错误在网上的报错帖里很常见。第三步服务器托管AASA文件。在你声明的域名根目录放一份名为apple-app-site-association的JSON文件注意没有后缀。路径有两种传统的放在域名根目录或者放在/.well-known/apple-app-site-association目录下。苹果官方推荐后者但两种写法都会被系统识别建议统一放.well-known目录。文件内容长这样{ applinks: { apps: [], details: [ { appID: TEAMID.com.mygame.ios, paths: [ /open/*, NOT /internal/* ] } ] } }这里面有三个容易翻车的地方。appID的格式是Team ID加Bundle ID中间用点连接。Team ID在开发者后台的Membership详情里可以找到是一串十位数的字母数字组合。很多人在这里把Bundle ID写成App ID的总称格式对不上系统直接不认。paths字段里支持通配符格式分三种*匹配任意路径/open/*匹配前缀路径可以用?占位单个字符也可以用NOT开头写排除规则。这里有一个历史坑早期iOS只支持精确路径和简单通配符新版本支持正则表达式格式但正则格式对旧系统兼容性不好。线上运营需要覆盖老设备的话建议尽量用通配符加排除规则不要用正则风格。第四步也是最多人忽略的一步AASA文件必须是有效的JSON格式且响应头不能带Content-Type: application/json以外的奇怪值需要HTTPS且证书链要完整。服务器上文件格式如果被错误地返回成text/plain部分系统版本也可能无法正常解析。配置完之后可以直接在Safari里访问这个JSON文件的地址确认浏览器能正常打开看到内容。验证文件是否合法除了在浏览器里看还可以用苹果官方的App Search API Validation Tool把App ID和域名输入进去它会返回系统解析这张文件的详细状态显示paths匹配结果这是排查Universal Links配置问题最高效的手段没有之一。2.3 AASA 缓存更新机制不是你改错是系统真的在缓调Universal Links时最容易产生挫败感的地方在于明明文件已经改对了新装的包还是唤不醒。这是因为iOS对AASA文件是有缓存的而且缓存策略极其保守在部分版本上只有重启设备、重新安装App或者卸载重装才会强制刷新。这是系统机制不是代码问题。几个实战技巧首次配置好之后最好在真机上重启一次设备再验证单纯杀掉App进程往往不够。同一台测试设备改过AASA文件后直接卸载重装测试包能强制触发重新拉取。iOS 16出现过一个与AASA缓存相关的坑系统在某些情况下需要App被用户主动打开过一次后Deep Link才能正常唤醒。所以测试时安装完先手动点开一次游戏再去点Universal Links如果在完全没启动过的机器上直接点链接有概率唤不起来。线上验证时别抱侥幸心理AASA文件的改动要留出至少24小时的缓存消化窗口大版本迭代时最好提前一天就把文件更新好。3. iOS 原生层怎么把唤醒请求接住AppDelegate 的完整处理逻辑配置做完之后真正的关键工程在原生侧。iOS每次有唤醒请求进入最终都会落到AppDelegate的一套生命周期方法上。这一节我按冷启动、热启动、后台恢复三种场景把处理逻辑完整写一遍并给出可直接复制进Unity iOS工程的Objective-C代码。为什么用Objective-C而不是Swift因为Unity的iOS工程默认就是Objective-C和C混编UnitySendMessage在Objective-C里调用最顺不用搞桥接文件少一层麻烦。3.1 冷启动、热启动、后台恢复三种场景一次理清先说清楚系统调用的分派时机冷启动场景App进程不存在用户点击链接后系统直接拉起进程。这个状态下走的回调是application:didFinishLaunchingWithOptions:和application:continueUserActivity:restorationHandler:而且不一定保证这两个方法的调用顺序。热启动场景App进程已存在页面处于前台。此时点击Universal Links系统走的是application:continueUserActivity:restorationHandler:。后台恢复场景App被切到后台进程还活着。点击链接则可能走application:openURL:options:对应URL Scheme或application:continueUserActivity:restorationHandler:对应Universal Links。这里有一个最常见且隐蔽的时序问题——冷启动时didFinishLaunching先执行但continueUserActivity在Unity引擎初始化完成之前可能就已经被调用了。也就是说你在原生层拿到参数时Unity C#侧还不存在直接调用UnitySendMessage等于把消息发到了一个还没创建的对象上消息会丢得无声无息。因此原生侧必须实现一个参数暂存机制。我在工程里的做法是先定义一个保存待投递参数的全局对象在回调方法里无论当前Unity是否就绪先把链接参数标准化并存进这个对象里等到Unity侧主动来取或者监听引擎ready信号后再自动投递。后面第四节的C#部分会细讲两者之间的配合。3.2 AppDelegate 关键方法实现Objective-C 可直接抄这一小节的代码是完整可用的。新建或修改iOS工程里的AppDelegate.mm在原有代码基础上增加以下内容。先说清楚整体思路handleUniversalLink:和handleURLScheme:是内部封装的两个解析方法负责把系统回调转成统一的字典和字符串分发语义一致而pushLinkToUnity:则判断Unity是否就绪就绪则直接发送未就绪则暂存。#import UnityAppController.h #import AppDelegate.h // 用于判断Unity是否真正运行起来的标志位。 // 在UnityAppController的startUnity:完成后再置为YES。 static BOOL _unityIsReady NO; static NSString *_pendingLink nil; static NSMutableDictionary *_pendingParams nil; // 对外提供接口通知Unity已就绪。 void UnityDeepLinkSetUnityReady(BOOL ready) { _unityIsReady ready; if (ready _pendingLink) { [AppDelegate pushPendingLinkIfNeeded]; } } implementation AppDelegate // URL Scheme入口老系统用openURL:options:iOS13之前的scene模式有所调整。 - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { NSString *rawUrl url.absoluteString; NSDictionary *params [AppDelegate parseDeepLink:rawUrl]; [self enqueueLink:rawUrl withParams:params from:UNITY_DELEGATE_SCENE_OPEN_URL]; return YES; } // Universal Links入口。 - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *webUrl userActivity.webpageURL; NSString *rawUrl webUrl.absoluteString; NSDictionary *params [AppDelegate parseDeepLink:rawUrl]; [self enqueueLink:rawUrl withParams:params from:UNITY_DELEGATE_SCENE_CONTINUE_ACTIVITY]; } return YES; } // 冷启动场景下如果参数在didFinishLaunching就已经带过来 // 同样统一走暂存逻辑。 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSURL *launchUrl launchOptions[UIApplicationLaunchOptionsURLKey]; if (launchUrl) { NSString *rawUrl launchUrl.absoluteString; NSDictionary *params [AppDelegate parseDeepLink:rawUrl]; [self enqueueLink:rawUrl withParams:params from:UNITY_DELEGATE_SCENE_LAUNCH]; } NSDictionary *userActivityDict launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; NSUserActivity *ua userActivityDict[UIApplicationLaunchOptionsUserActivityTypeKey]; if (ua.webpageURL) { NSString *rawUrl ua.webpageURL.absoluteString; NSDictionary *params [AppDelegate parseDeepLink:rawUrl]; [self enqueueLink:rawUrl withParams:params from:UNITY_DELEGATE_SCENE_LAUNCH]; } return [super application:application didFinishLaunchingWithOptions:launchOptions]; } // 暂存逻辑的核心如果Unity还没起来就挂起等待。 - (void)enqueueLink:(NSString *)rawUrl withParams:(NSDictionary *)params from:(NSInteger)scene { _pendingLink rawUrl; _pendingParams params; [AppDelegate pushPendingLinkIfNeeded]; } // 当Unity已就绪时真正把参数发过去。 (void)pushPendingLinkIfNeeded { if (!_unityIsReady) return; if (!_pendingLink) return; NSMutableString *paramString [NSMutableString string]; for (id key in _pendingParams) { [paramString appendFormat:%%, key, _pendingParams[key]]; } if (paramString.length 0) { [paramString deleteCharactersInRange:NSMakeRange(paramString.length - 1, 1)]; } // 发给Unity侧挂在DontDestroyOnLoad的GameObject UnitySendMessage(DeepLinkRouter, OnNativeLinkReceived, paramString.UTF8String); // 投递完成后清理暂存区 _pendingLink nil; _pendingParams nil; } // 解析链接生成统一的参数字典 (NSDictionary *)parseDeepLink:(NSString *)rawUrl { NSMutableDictionary *result [NSMutableDictionary dictionary]; NSURL *url [NSURL URLWithString:rawUrl]; if (!url) return result; // 记录完整的原始链接 result[full_link] rawUrl; // 取scheme://之后、问号之前的路径部分常用于区分打开位置 NSString *path url.path ?: ; result[path] path; // 解析query参数注意NSURLComponents会做一次自动解码 NSURLComponents *components [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; for (NSURLQueryItem *item in components.queryItems) { if (item.value ! nil) { result[item.name] item.value; } } return result; } end有几个关键点需要额外解释。UnitySendMessage这个方法第一个参数是场景中挂载了目标脚本的GameObject的完整名称不是脚本类名。很多人在这里写成了脚本类名DeepLinkRouter结果收不到消息还找不到原因。第二个参数是目标GameObject上某个MonoBehaviour公开的、返回值是void且接收一个string参数的方法名。第三个参数是字符串。方法如果不在和GameObject同挂载的某个组件上或者GameObject处于非活跃状态消息都会直接丢失没有报错调试时特别难定位。另外注意parseDeepLink里我用了NSURLComponents来做query解析它会自动把URL编码的参数解码一次。比如链接里传的是item_id10086%20abc在原生层拿到的就已经是item_id10086 abc了。如果你在C#层还用WWW.UnEscapeURL再解码一次空格百分号会被二次处理可能引入脏数据所以解码责任要明确只放在一端。我在项目里默认原生层只做传输、不负责最终的语义解码C#侧统一用Uri.UnescapeDataString处理。3.3 iOS 13 Scene 生命周期对 Deep Link 的影响如果你接到的是比较新的Unity版本工程会发现工程的AppDelegate下面还挂着一套UIScene生命周期。从iOS 13开始苹果把场景生命周期独立出来部分系统事件不再单纯走AppDelegate而是通过SceneDelegate转发。实际遇到的表现就是同样的一个URL Scheme在老系统里跑application:openURL:options:在新系统里直接没回调或者收到了但参数被系统吃掉一部分。处理方案也不复杂如果工程的SceneDelegate是开启状态需要在scene:openURLContexts:里手动接管URL并有选择地转递到AppDelegate统一处理- (void)scene:(UIScene *)scene openURLContexts:(NSSetUIOpenURLContext * *)URLContexts { for (UIOpenURLContext *context in URLContexts) { NSURL *url context.URL; if (url) { [[AppDelegate sharedDelegate] handleExternalOpenURL:url]; } } }handleExternalOpenURL:内部复用AppDelegate里的enqueueLink:逻辑。Unity的模板工程里UnityAppController已经对多种生命周期做了兼容但我遇到过第三方库或自定义SceneDelegate覆盖了默认实现的情况所以只要项目里有Scene机制就必须把这条分支看清楚。4. C# 层接收参数的三种姿势与核心分发设计原生层的参数已经组装好了接下来面临的问题是托管环境里谁来接收什么时候接收收到之后怎么分发到业务模块这是Unity游戏工程里最体现架构水平的一环直接决定Deep Link功能日后好不好扩展。4.1 挂接收脚本DontDestroyOnLoad 和空场景的问题网上很多Demo会教你新建一个空GameObject挂一个脚本然后DontDestroyOnLoad。这个思路本身没错但Unity端要意识到一个隐患如果游戏首场景是空场景启动时就直接跳转DontDestroyOnLoad上挂的GameObject仍然会被保留但如果你在启动的开场动画阶段就切了场景消息仍然会到只是GameObject不能是active false状态。我推荐的做法是建立一个专门的路由节点命名为DeepLinkRouter挂一个单例脚本并在Awake里执行DontDestroyOnLoad。同时脚本里预留一个isReadybool用于标记核心系统是否初始化完成using System; using System.Collections.Generic; using UnityEngine; public class DeepLinkRouter : MonoBehaviour { public static DeepLinkRouter Instance { get; private set; } private bool _coreReady false; private readonly Queuestring _pendingLinks new Queuestring(); // 业务模块注册的回调 private readonly ListActionstring, Dictionarystring, string _listeners new ListActionstring, Dictionarystring, string(); private void Awake() { if (Instance ! null) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } private void OnNativeLinkReceived(string paramString) { var dict ParseParams(paramString); EnqueueOrDispatch(dict); } private void EnqueueOrDispatch(Dictionarystring, string dict) { if (!_coreReady) { _pendingLinks.Enqueue(FlattenDict(dict)); return; } Dispatch(dict); } private void Dispatch(Dictionarystring, string dict) { foreach (var listener in _listeners) { try { listener?.Invoke(GetPath(dict), dict); } catch (Exception e) { Debug.LogError($DeepLink listener error: {e}); } } } public void RegisterListener(Actionstring, Dictionarystring, string listener) { if (listener ! null !_listeners.Contains(listener)) { _listeners.Add(listener); } } public void SetCoreReady() { _coreReady true; while (_pendingLinks.Count 0) { var raw _pendingLinks.Dequeue(); var dict ParseParams(raw); Dispatch(dict); } } private Dictionarystring, string ParseParams(string paramString) { var dict new Dictionarystring, string(); if (string.IsNullOrEmpty(paramString)) return dict; var pairs paramString.Split(); foreach (var pair in pairs) { var idx pair.IndexOf(); if (idx 0) continue; var key pair.Substring(0, idx); var val pair.Substring(idx 1); if (!dict.ContainsKey(key)) { dict[key] val; } } return dict; } private string GetPath(Dictionarystring, string dict) { return dict.TryGetValue(path, out var path) ? path : string.Empty; } private string FlattenDict(Dictionarystring, string dict) { var parts new Liststring(); foreach (var kv in dict) { parts.Add(${kv.Key}{kv.Value}); } return string.Join(, parts); } }这个脚本承担了三个职责接收原生消息、延迟缓存、通过事件分发。业务层不直接和原生通信而是向Router注册自己的回调这样可以解耦模块之间的依赖。比如活动模块只关心path /activity的参数商城模块只关心path /shop的参数各自注册即可。4.2 原生侧参数的两种投递姿势与时机控制C#侧接收原生消息除UnitySendMessage外还有另一套思路用[DllImport(__Internal)]直接声明原生方法让C#主动拉取参数。两种姿势各有适用场景。**被动接收UnitySendMessage**适合大多数情况原生侧在拿到链接后会主动推送过来。最大的问题就是时序冷启动时如果引擎没起来必须先暂存到原生侧等Unity就绪后再推。可是Unity侧怎么知道就绪业界常见的做法是在C#的Awake里通过DllImport调用原生方法主动告诉原生C#侧准备好了可以推消息了[DllImport(__Internal)] private static extern void UnityDeepLinkSetUnityReady(bool ready); private void Start() { // 核心系统初始化完成后调用 UnityDeepLinkSetUnityReady(true); }对应原生侧的那个UnityDeepLinkSetUnityReady正是3.2小节里已经实现的方法。它会触发暂存链接的补投递。这个模式是我用过的所有方案里最稳的C#主动通知原生侧补发双向配合不依赖任何魔法时序。**主动拉取Polling**适合一些极端场景——比如你确实没法判断Unity的就绪时间或者网络层有延迟导致参数来得晚。原型侧提供一个方法// 当C#侧调用时返回当前暂存的链接如果没暂存返回空串 const char* UnityDeepLinkGetPendingLink(void) { static const char* empty ; if (_pendingLink nil) return empty; return _pendingLink.UTF8String; }C#侧可以每帧检查一次拿到非空参数就消费掉并清空原生缓存。这个方式效率不高但作为兜底机制很实用。我一般只在排查问题的临时版本里开Polling线上版本还是优先用通知机制。4.3 业务分发设计路径前缀、活动码、JSON参数的三层结构有了Router之后真正要被业务消费的数据应该设计成三层结构。第一层是路径路由对应path字段。比如/activity代表活动页、/shop代表商城、/friend代表邀请关系。业务侧根据路径前缀决定走哪个模块未匹配路径的静默丢弃并打日志方便线上排查是否有伪造的Deep Link流量。第二层是语义参数对应query里的普通键值对。比如item_id10086、server1024这些是结构简单、一眼能看懂的参数。注意这里的键值对经过原始URL解析后有概率出现数组、嵌套对象这类复杂结构原生层那条NSURLComponents不会帮你解析所以复杂参数建议整体用单个key传JSON串例如payload%7B%22scene%22%3A%22shop%22%7D。C#侧拿到了再做二次JSON解析这样绕开了各种边界情况。第三层是受限参数用于归因或营销渠道的加密信息。比如sign字段由服务端签名防止伪造客户端不解析签名内容只把整体透传给服务端校验。如果你的Deep Link携带了激活归因参数务必要用这一层做避免客户端被伪造参数污染。一个完整的业务分发路径大致是这样点击 https://link.mygame.com/open?server1024item_id10086signxxx - 原生层解析出 path/open, server1024, item_id10086, signxxx - UnitySendMessage 发送 server1024item_id10086signxxx - Router 调用 Dispatch(path, dict) - ActivityModule 看到 path/open 且 item_id 不为空弹出活动弹窗 - ServerAPI 带着 sign 到服务端校验收货这套结构相对标准化团队里来了新人也能快速理解和扩展。5. 全链路联调冷启动、热启动、后台回流一个都不能漏配置完成、代码写完了最不能省的就是联调。很多项目在真机上点了链接发现打不开第一反应是代码有问题实际上有相当概率是系统侧的AASA缓存或者配置项没生效。我在工程里专门维护了一张联调测试矩阵表每次版本发布前按表逐项过。5.1 每一次发布前建议跑一遍的测试矩阵这张表是把常见的点击入口App状态两两组合之后得到的所有组合都必须用真机验证模拟器对Universal Links的支持不完整部分版本完全不响应建议直接放弃模拟器联调Deep Link。测试场景App当前状态唤醒方式预期结果Safari打开链接全新安装未启动Universal Links直接拉起App无弹窗Safari打开链接后台挂起Universal Links恢复到前台并收到参数微信内打开链接全新安装未启动Universal Links拉起App受微信内置浏览器策略影响需单独灰度短信内打开链接冷启动Universal Links拉起App并延迟收到参数自定义Scheme链接冷启动URL Scheme弹窗确认后拉起App自定义Scheme链接热启动URL Scheme直接唤起并收到参数未安装状态点链接未安装Universal Links跳转Safari显示web内容或App Store详情每次联调都必须记录三个关键结果是否成功拉起App、拉起后参数是否在预期时间内到达C#层、业务跳转是否完成。我遇到过很多次App能拉起但参数丢失的案例90%都出在原生层的暂存逻辑上。5.2 日志埋点和可视化调试的建议Deep Link链路跨了原生、C#、服务器三端没有日志几乎没法排错。我在原生和C#两侧都埋了结构化日志。原生侧重点的日志项包括didFinishLaunching是否有URL携带、continueUserActivity是否被调用、_pendingLink是否被清空、每次UnitySendMessage发出的完整字符串。C#侧重点的日志项包括OnNativeLinkReceived收到的内容、_coreReady的boolean状态、SetCoreReady被调用的时间、分发到每个模块的结果。联调时打开Xcode控制台和Unity Console同时观察判断标准是原生侧发出日志的时间和C#侧收到日志的时间差。如果原生发了但C#没收到问题基本出在GameObject名字或方法名不匹配如果C#收到了但业务没反应问题就出在Router分发层把path打印出来逐行对着查即可。5.3 和第三方SDK同一个链路上会不会打架实际游戏工程里几乎不会只有一套Deep Link逻辑。买量的游戏通常同时集成Adjust、AppsFlyer或Branch这类归因SDK它们同样需要监听到达链接的事件。于是乎就出现了一个非常现实的问题用户点一个链接系统回调过来三方归因SDK要拿参数我们自己也要拿参数两边能不能共存答案是可以共存但顺序要遵从一个原则归因SDK优先业务参数随后。原因在于归因SDK对链接的消费时机比较严格如果业务代码先把它读取了部分SDK就再也拿不到原始数据激活归因就会丢失。所以AppDelegate里正确顺序是先把原始URL交给归因SDK再执行自有解析逻辑。在联动测试时验证的指标不是自己的Deep Link参数能不能到而是激活归因是否正常回调了服务端只有两边都正常这条链才算真正畅通。6. 从线上实战中总结的避坑清单最后整理几项我在实际项目中反复踩过、查了半天才定位到的问题每条都附带规避方法。6.1 参数编码地狱中文、JSON串、Base64的二次转义最头疼的问题集中在参数编码上。运营那边配置的分享链接经常会带上中文昵称、活动标题或者直接把一段JSON塞进参数。这类数据一旦经过URL链接就要面对三层编码的叠加用户在浏览器地址栏看到的是URL百分号编码。原生层用NSURLComponents解析的时候queryItems里的值已经被自动解码了一次。如果C#侧的Router又用Uri.UnescapeDataString或者WWW.UnEscapeURL处理了一遍中文就可能在两次解码之间被破坏。我的项目里定的规矩是所有链接生成侧必须严格使用URLEncode一次所有接收侧统一只解码一次。C#侧Router收到参数后除了full_link保留原始值其他所有query参数一律按已经完成解码处理不再做二次Unescape。只要两边统一执行这个约定中文、表情符号、JSON串都不会出问题。另外如果业务参数含特殊字符比如用户的昵称里带了符号是query的分隔符直接塞进链接会把后面的参数截断。这种场景下必须把整段数据先URLEncode再放进去接收方先整体解码再拆分。6.2 模拟器与真机的行为差异我在模拟器上踩过最大的坑是Universal Links在模拟器上常常表现为完全不可用或者偶尔可用但参数偶尔缺失。iOS模拟器对Universal Links的支持从某几个版本开始变得极不稳定在模拟器上验证配置会得出错误的结论。建议把模拟器定位为只用于验证UI层展示效果Deep Link的配置正确性和参数投递完整性必须全部用真机测。真机至少在iPhone 12及以上测一批不同iOS版本覆盖iOS 15、16、17因为不同大版本的AASA解析策略与缓存策略确实有差异。6.3 未安装App场景落地页、App Store和剪贴板兜底如果用户手机上根本没装AppUniversal Links拉不起来任何东西系统会自动打开域名的网页。这个网页不能是空白页一般是运营配置的下载落地页包含App Store跳转按钮。但这里有一个体验矛盾用户点击落地页里的App Store按钮安装游戏安装完成后Deep Link参数已经丢了等你打开游戏之前链接里的邀请码、渠道字段全部失联。常见解决思路是用剪贴板缓存做兜底。落地页JS可以监听页面加载把链接参数写入剪贴板App首次启动后Unity侧读取剪贴板分析出参数后提示用户检测到邀请链接是否进入对应活动。这个方案不完美——剪贴板内容会被用户后续复制操作覆盖iOS也会在读取剪贴板时弹出系统权限提示——但在买量和裂变场景里它能把那部分点了链接没装App、装完又丢参数的用户拉回一部分。要不要做主要看你的业务对激活归因的敏感度有多高。最后再分享一个个人小经验单条Deep Link链接的path字段尽量发布前就定死不要随便改。线上跑了一段时间后旧链接可能已经印在了各种投放物料和用户分享里修改路径规则意味着那些存量链接全部失效。路径规则属于定义严格、扩展自由的部分新增路径没问题改动已有路径要慎之又慎。设计之初就按/open/*这种前缀来划分业务域比用/openShopNow2024这种具体路径要稳定得多这算是这些年做iOS Deep Link踩过来的一个长期经验。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →