Unity手游iOS Deep Link实战:URL Scheme与Universal Links配置与踩坑
年前接了手游项目里的一个拉新需求运营提了两条很实际的要求用户在浏览器、短信或者社交软件里收到一条链接点开后如果手机装了App要直接唤起并且跳到指定的活动页如果没装则走引导下载。这类需求在行话里就是Deep Link也就是深度链接。我当时的方案很常规——iOS端同时接URL Scheme和Universal Links原生层收到回调再通过UnitySendMessage把完整参数投递给C#层。原本以为半天能跑通实际上从证书配置、关联域文件到冷启动回调时序踩了不少坑。这篇文章就把整个流程拆开讲把我趟过的坑和最终跑通的方案完整记录下来给在Unity项目里做iOS Deep Link的同行一个参照。这套方案适合谁看你大概率是在Unity手游项目里负责客户端或者客户端与原生联调的人需要把落地页、推送、跨App跳转落到游戏内的具体业务也可能是刚接手iOS原生配置、对Apple的关联域机制还不太熟的同学。下面就从链路设计讲到原生配置再到C#层接收最后给一份高频问题速查表。1. 整体设计一条链接如何找到你的App1.1 一条链接的完整落地点先理解本质。Deep Link要解决的核心问题只有一个把一个互联网上的资源标识映射到手机里某个App的具体页面或参数上。映射关系建立后点击链接这个行为就会触发系统级的查找和路由——系统拿到链接的协议头和域名判断应该交给哪个App处理然后把这个链接原封不动地传给那个App。iOS里目前App能接住的链接入口有两种。一种是URL Scheme本质是自定义协议格式长这样mygame://open?moduleshopitem_id10086这套机制从iOS 2.0就存在了原理很像域名解析系统维护了一张“协议名到App”的映射表只要App在Info.plist里声明过自己可以处理mygame://那么其他App或Safari一碰到这种协议就会直接唤起对应App。另一种是Universal Links微信公众号文章里经常见到。它的格式就是一个普通HTTPS链接https://game.example.com/open?moduleshopitem_id10086iOS靠Associated Domains关联域机制把某个域名“通配”给App。系统在后台访问你服务器上的apple-app-site-association文件校验这个域名确实声明了“让这个App处理它的链接”校验通过后点击HTTPS链接时系统就会直接唤起App而不经过Safari那一下跳转。1.2 两种方案的取舍逻辑那手游里到底用哪个我的结论是有条件就两个都接让链接形态决定走哪套。这不是技术洁癖而是iOS侧既有的分裂现实。按我的经验两条链路各有明确的适用场景对比维度URL SchemeUniversal Links配置成本低一个URL Types声明即可高证书、关联域、服务器文件三处配合链接形态mygame://open?xx1非常态网页链接https://你的域名/open?xx1普通网页链接用户感知点击后可能弹确认部分场景直接唤起体验好点击后App直接被动唤起被系统限制无法在部分iOS内嵌浏览器中直接唤起整体限制少但依赖HTTPS可达与文件校验域名归属不需要域名必须是自己可控制HTTPS服务的域名Web和App的衔接SFSafariViewController里容易失败从网页到App无缝衔接我项目里的实际做法是推广链接对外全部用Universal Links因为它是一条正经的HTTPS URL运营在短信、落地页、二维码里都好放用户也不会有戒心。URL Scheme则保留作为内部跳转和兜底比如游戏内推送点击后跳到某个页面或者某些受限环境无法识别Universal Links时还能靠Scheme唤起。两个都接还有一个隐藏好处同一套参数结构两条路都可以送。原生层收两次回调统一转换成一个标准字符串往Unity侧投递一次即可。后面所有C#逻辑只需要面向这个统一的字符串解析不需要关心来源是哪条链路。2. iOS原生侧从工程配置到系统回调2.1 URL Scheme的声明和回调实现URL Scheme侧我建议先在Xcode里把URL Types配好。路径是Target - Info - URL Types新增一项URL Schemes填自定义协议名比如mygame。这里有几个细节是我实测容易翻车的协议名不要用太通用的词比如game、share这种很容易和其他App冲突。更合理的是“品牌缩写业务缩写”比如mygame、mgsdk这种组合。一旦冲突iOS系统只会把链接分给其中一个App谁先装谁说了算非常不可控。大小写问题URL Scheme是大小写敏感的MyGame和mygame是两码事。我一般统一用小写配置和调用保持一致。声明后重新安装App旧缓存里有同名Scheme时会莫名不触发卸载重装能解决。配置好之后需要在AppDelegate里实现系统回调方法。Unity新建的iOS工程会自动生成UnityAppController它是实际接收系统事件的C类通常我会在UnityAppController.mm里加扩展处理或者直接子类化它。相关的核心代码如下- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { NSString *rawUrl url.absoluteString; if (rawUrl.length 0) { UnitySendMessage(DeepLinkBridge, OnDeepLinkReceived, [rawUrl UTF8String]); return YES; } return NO; }要注意这里的AppDelegate不一定是你新建项目里那个AppDelegate.m在一些Unity版本模板中入口在UnityAppController里。检查方式是看工程里有没有一个继承UnityAppController的类。反正原则就是确保实现了application:openURL:options:而且消息要发到Unity场景里真实存在的游戏对象上。2.2 Universal Links的配置闭环Universal Links是三件事的闭环缺一不可。第一件事打开Target - Signing Capabilities添加Associated Domains能力填入applinks:game.example.com这个域名必须是你能控制服务器内容的域名因为接下来要往这个域名的根目录放校验文件。第二件事准备校验文件apple-app-site-association。这是一个JSON文件需放到域名的HTTPS根目录下或者https://game.example.com/.well-known/apple-app-site-association。文件内容如下{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.mygame, paths: [ /open/* ] } ] } }其中TEAMID是你在Apple Developer后台的Team IDcom.yourcompany.mygame是App的Bundle ID。这两处拼错了整个关联就会失败属于最高频的配置错误之一。我的建议是配完文件后先在浏览器直接访问这个路径能正常以纯文本或JSON形式显示出来再说如果服务器对这个文件做了登录拦截或返回HTML页面iOS端校验必定失败。还有两个点常被忽视。一个是在服务器响应头里最好带上Content-Type: application/json不然有些服务器会返回text/html过去某些iOS版本会直接校验失败虽然新版本宽松了一些但建议还是规范设置。另一个是路径规则里*匹配所有我一般按业务收敛到具体前缀比如/open/*避免全站链接都被App劫持。第三件事等文件就绪后去Apple Developer后台确认Associated Domains已包含在App ID的配置中。通常没有遇到什么问题的话不需要在后台额外勾选但这个点容易被含糊掉我会习惯性登录后台检查一遍再进入联调。2.3 冷启动与热启动的两种入口差异iOS的Universal Links回调入口和URL Scheme并不完全一样。Universal Links走的是continueUserActivity这是基于NSUserActivity机制的回调方法而URL Scheme走的是openURL。我的客户端里两个方法都要实现- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *webpageURL userActivity.webpageURL; if (webpageURL) { UnitySendMessage(DeepLinkBridge, OnDeepLinkReceived, [webpageURL.absoluteString UTF8String]); return YES; } } return NO; }但还有一种情况绕不开冷启动。用户从链接唤起App时如果App进程已经被系统杀掉那么didFinishLaunchingWithOptions会在所有回调之前执行此时launchOptions里携带了启动时的那条链接信息。对应的处理方式是这样的- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSURL *coldUrl [launchOptions objectForKey:UIApplicationLaunchOptionsURLKey]; NSDictionary *userActivityDict [launchOptions objectForKey:UIApplicationLaunchOptionsUserActivityDictionaryKey]; if (coldUrl) { [self bridgeToUnityWithUrl: coldUrl]; } else if (userActivityDict) { NSUserActivity *activity userActivityDict[UIApplicationLaunchOptionsUserActivityKey]; if (activity.webpageURL) { [self bridgeToUnityWithUrl: activity.webpageURL]; } } return YES; }这里有个非常典型的时序坑冷启动时Unity场景还没加载出来UnitySendMessage的接收对象还不在场景里消息大概率直接丢掉了。我第一版就栽在这里测试发现从后台杀掉App再点链接进了游戏后毫无反应。后来我的处理方案是原生层先把这个链接字符串缓存到一个静态变量s_pendingDeepLink里等Unity侧主动调原生方法索取时才返回。这个方案的代价是代码多一点点但对生命周期完全可控。3. C#层参数投递从原生回调到Unity逻辑3.1 UnitySendMessage的正确打开方式UnitySendMessage是Unity提供给原生侧的通信APIextern void UnitySendMessage(const char *objName, const char *methodName, const char *msg);它有三个参数第一个是场景里挂脚本的GameObject名称第二个是要调用的方法名第三个是参数内容。这意味着你在原生侧调用前Unity场景里必须有一个名字完全一致的GameObject且上面的脚本里有一个确切的方法。我项目里的做法是起一个常驻的、不随场景销毁的桥接对象。在C#侧写一个DeepLinkBridge挂载脚本加载首场景时创建并且调用DontDestroyOnLoad(gameObject)让它躲过场景切换的销毁。脚本结构大概是public class DeepLinkBridge : MonoBehaviour { public static DeepLinkBridge Instance { get; private set; } public static string PendingLink { get; private set; } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void AutoInit() { var go new GameObject(DeepLinkBridge); DontDestroyOnLoad(go); go.AddComponentDeepLinkBridge(); } void Awake() { Instance this; } public void OnDeepLinkReceived(string rawUrl) { PendingLink rawUrl; Debug.Log($[DeepLink] - {rawUrl}); // 分发给业务层... } }注意一点UnitySendMessage只能调用接收对象上的无返回值、单字符串参数的方法。如果你原生侧想传递更复杂的结构其实不太方便所以我在参数格式上做了统一约定下面细说。3.2 参数格式约定与解析示例就算iOS侧拿到的是https://game.example.com/open?moduleshopitem_id10086sourcepushC#侧收到的还是一个完整URL字符串。我不建议直接把原始字符串丢给业务层让每个人各自解析那样以后参数多了必然失控。我习惯在C#侧做一次标准解析转成字典后再下发。这里给出一个常用的解析工具using System; using System.Collections.Generic; using System.Net; public static class DeepLinkParser { // 例如传入 https://game.example.com/open?moduleshopitem_id10086sourcepush public static Dictionarystring, string Parse(string rawUrl) { var dict new Dictionarystring, string(); if (string.IsNullOrEmpty(rawUrl)) return dict; Uri uri; if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out uri)) { // 部分URL Scheme可能没有标准域名退化处理 return ParseQueryString(rawUrl); } string query uri.Query; if (!string.IsNullOrEmpty(query)) { query query.TrimStart(?); foreach (var pair in query.Split()) { var idx pair.IndexOf(); if (idx 0) continue; string key WebUtility.UrlDecode(pair.Substring(0, idx)); string val WebUtility.UrlDecode(pair.Substring(idx 1)); dict[key] val; } } return dict; } static Dictionarystring, string ParseQueryString(string rawUrl) { var dict new Dictionarystring, string(); int qIndex rawUrl.IndexOf(?); if (qIndex 0 || qIndex rawUrl.Length - 1) return dict; string query rawUrl.Substring(qIndex 1); foreach (var pair in query.Split()) { var idx pair.IndexOf(); if (idx 0) continue; string key WebUtility.UrlDecode(pair.Substring(0, idx)); string val WebUtility.UrlDecode(pair.Substring(idx 1)); dict[key] val; } return dict; } }这里有三个实操心得所有参数值必须做URL Decode。运营投放的链接里经常带编码后的中文、空格、加号%E6%B4%BB%E5%8A%A8不解码的话业务侧看到的就是一堆乱码。号会被解码成空格如果你参数里真有加号语义建议在链接侧先做二次编码或者在解析时用Uri.UnescapeDataString配合替换逻辑这个跟服务端的编码习惯有关可以先约定好。key的大小写敏感问题。moduleshop和Moduleshop在字典里是两项如果投放平台自动重写了链接的大小写你做活动页分流时就会失效。我的做法是解析后统一ToLower()除非后续有严格区分大小写的参数需求。3.3 生命周期错位与业务缓存队列Deep Link接收没有你想象中那么“即点即用”。我在实际调试中发现回调发生时Unity侧大概率处于以下三种状态之一首场景刚加载完主逻辑尚未初始化用户还没完成登录但目标页面需要userId服务器资源表还没拉下来跳过去会触发错误提示所以C#侧不能直接把链接往目标页面上怼。我的设计是多了一层意图缓存public class DeepLinkIntent { public string RawUrl; public Dictionarystring, string Params; public bool IsConsumed; } public static class DeepLinkService { static readonly ListDeepLinkIntent PendingIntents new ListDeepLinkIntent(); public static void Dispatch(string rawUrl) { var intent new DeepLinkIntent { RawUrl rawUrl, Params DeepLinkParser.Parse(rawUrl), IsConsumed false }; if (IsReadyToHandle(intent)) { ProcessIntent(intent); } else { PendingIntents.Add(intent); } } public static void TryFlush() { for (int i PendingIntents.Count - 1; i 0; i--) { if (IsReadyToHandle(PendingIntents[i])) { ProcessIntent(PendingIntents[i]); PendingIntents.RemoveAt(i); } } } static bool IsReadyToHandle(DeepLinkIntent intent) { // 这里登记业务侧的就绪条件 return LoginManager.IsLoggedIn GameConfigManager.IsInitialized !string.IsNullOrEmpty(intent.Params.GetValueOrDefault(module)); } }这样的好处是无论推送跳转还是落地页跳转都按同一规则排队业务没就绪就等就绪了再消费。联调阶段这块很容易测出诡异问题你在App还没登录时点了链接进了游戏后却什么都没发生。有了缓存队列就直接变成进入主界面后再弹一个跳转确认框运营那边的埋点数据也正常用户也不会觉得点击了没反应。4. 全流程调试与排障实录4.1 真机调试前置准备在Xcode里跑真机调试前有几件事千万别省拿一台独立的测试iPhone建议关闭所有其他可能注册了相同Scheme的App减少路由干扰。确认App的Bundle ID和Provisioning Profile一致。Universal Links校验失败时很多人忽略这里证书里 Bundle ID和后台配置对不上系统直接拒绝唤起。用Safari访问https://game.example.com/open?moduletestkey1一旦App安装且关联成功在Safari页面上往下拉一点就会看到顶部横幅提示“在App中打开”。这个横幅没出现基本说明Universal Links还没配通不要进下一步。调试时我习惯在Xcode的Console里先加一条原生日志在continueUserActivity和openURL里都打印一下收到的URL把日志过滤词设为DeepLink。然后分别测三种场景操作场景测试步骤预期结果热启动-链接唤起App在前台或后台Safari点击链接App回到前台C#侧回调触发冷启动-链接唤起杀掉AppSafari点击链接App启动延迟后C#侧收到缓存参数冷启动-短信/微信链接从其他App点Universal Link同上但部分内嵌浏览器可能走Scheme兜底4.2 高频坑位速查表把这段时间里遇到的、以及帮别人排查过的问题整理成一份速查表现象可能原因解决办法Universal Links点击后一直在浏览器里打开App没反应关联域文件不存在、JSON格式错误、appID拼错浏览器直接访问文件路径用格式化工具检查JSON核对Team ID和Bundle ID首次点击链接跳转到了App Store这是Universal Links的正常兜底行为App未安装或文件校验不通过确认App安装、确认文件路径可达测试时用已安装的App包App被唤起但C#侧收不到回调UnitySendMessage的GameObject名字不存在或场景未加载确保常驻桥接对象在首场景创建好原生层用缓存方案等待Unity就绪URL Scheme明明配置了点击却无反应工程里其他App占用了相同scheme卸载测试机上所有可能冲突的App换一个更偏门的协议名冷启动进入App后参数丢失没有在didFinishLaunchingWithOptions里读取launchOptions按2.3节代码补上冷启动读取逻辑中文参数乱码链接侧没有做URL Encoding或解码被跳过服务端生成链接时统一做EncodeC#侧解析时做Decode关联域文件更新后不生效iOS对apple-app-site-association有缓存有耐心等通常几小时真机调试可切换飞行模式强制刷新必要时重启设备Universal Links在某个App内不能唤起少数内嵌浏览器对Universal Links支持不完整在该App内改用Scheme兜底或引导用户用Safari打开4.3 几个我踩过的细节细节一不要在continueUserActivity里直接同步弹窗。系统对Universal Links回调的响应有一定的执行时间限制如果你在里面做网络请求或阻塞操作可能造成链接无法正常传递。原生层收到后交给Unity逻辑处理就够了别在回调里做重活。细节二UnitySendMessage用字符串传参数时如果URL里带了双引号或反斜杠原生侧要小心转义。我的解决方法是原生侧统一用jsonString一句话用NSDictionary打包成JSON后再转成字符串传给Unity这样很多字符问题都被JSON序列化吞掉了。细节三同一个链接在短时间内被系统重复分发时C#侧可能收到两次OnDeepLinkReceived。这不是玄学冷启动路径和热启动路径组合时偶尔会重复。我一般在桥接层做简单的去重记录上次处理过的URL字符串时间窗内相同的不再处理。这种细节不处理运营的埋点数据会虚高活动页还可能被连续弹两次。5. 一条链路打通之后还能做点什么这套链路跑通后我一个很深的体会是与其在群里喊“iOS又进不了落地页了”不如把整条Deep Link的边界都掌握住。当你能控制原生层缓存时机、C#层解析方式和业务层的意图队列时运营那边提再多变体需求——比如加推广渠道号、跳新手引导、或者唤起后自动带出邀请人ID——都只是一次参数解析的小改动不需要再调动原生开发者。根据我的实际使用经验你落地时最值得关注的三个点原生层的冷启动缓存一定不能省C#层的常驻对象一定不能依赖单一场景解析逻辑一定要统一收口。这三样做好了整个Deep Link链路就算稳了一大半。真机上反复测三种场景跑通之后你会发现手游里看似复杂的“一条链接唤起App直跳指定页”真正吃透后也就是配置、回调、解析、缓存这几板斧的事。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →