ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity手游iOS Deep Link接入全攻略:从URL Scheme到Universal Links实战

Unity手游iOS Deep Link接入全攻略:从URL Scheme到Universal Links实战 刚把手头一款Unity跑的手游接到iOS Deep Link上时我本以为就是配两个URL Scheme就能收工结果从Xcode签名到C#层拿到参数折腾了整整两天。整个过程踩了不少坑也把网上零零碎碎的资料拼接完整了。这篇文章把完整的链路梳理一遍从URL Scheme到Universal Links的选型到iOS原生侧配置再到Unity引擎里参数投递的可靠方案全程都是可直接用的代码和配置。适合正在给Unity手游接入iOS拉新唤醒、广告归因、分享回流或客服直达场景的开发者参考。1. 先搞懂Deep Link的两种打开方式为什么Universal Links会取代URL Scheme先说结论如果你的App还在用URL Scheme做深度链接建议尽早把Universal Links也一起接上。Adobe、AppsFlyer、Adjust这些归因平台对外发送的点击链接基本都已经切换为Universal Links。如果你只支持URL Scheme会遇到两个非常现实的问题第一用户在浏览器点击推广链接时如果App未安装浏览器会报错而不是跳到商店第二无法校验跳转来源任何App都能拼接URL Scheme把用户拉进你的App安全隐患很大。1.1 URL Scheme的本质与局限URL Scheme的形式是自定义协议名例如mygame://在iOS中通过Info.plist注册然后在App部署后系统会告知系统“这个App认识mygame://开头的链接”。当别的App或者Safari遇到这种链接时会询问是否跳转到你的App。这里要认清URL Scheme三个绕不开的坑来源不可信。任何应用都可以在UIApplication.openURL方法中传入你的scheme你没有手段验证“这次唤醒是不是从我的正规渠道来的”。对于广告归因来说方案商只能通过手动拼接参数再配合时间戳做弱校验链路长、易被伪造而且每次归因都依赖归因平台二次请求不是实时结果。首次安装无法唤醒。如果用户手机上没安装你的App在浏览器点击一个mygame://链接Safari一定会弹窗提示“无法打开网页”同时不会自动引导用户去App Store下载。这是URL Scheme做买量投放时最大的痛点。部分App内WebView会拦截。最典型的就是微信内置浏览器它默认会拦截URL Scheme唤起操作只会提示“请在浏览器打开”。所以如果你的深度链接是发在微信场景的URL Scheme基本等于废了。1.2 Universal Links如何补上这个缺口Universal Links是Apple在iOS 9推出的方案核心思路是把“链接”变成你的域名下的普通HTTPS链接。系统会在App启动前后去固定地址下载一份Apple App Site Association文件验证域名与App的绑定关系。真正值得留意的优点有两个未安装时浏览器照常打开网页。点击https://yourdomain.com/play?room123如果没装AppSafari正常显示网页内容你可以在网页上做一键跳App Store的引导这是转化路径的保底手段。已安装时无需确认弹窗。整个唤起过程是静默的尤其对于投放、分享回流场景少一个弹窗就少一步流失。但它也有一个容易让人忽视的前提需要HTTPS域名且服务器的证书链必须是Apple可信的正式证书。开发和测试阶段用HTTP是不行的AASA文件不会生效。从实践角度看Universal Links和URL Scheme从来不是二选一而是双轨运行Universal Links兜底点击唤起和安装引导URL Scheme用来处理一些老渠道还在使用的短链、二维码扫码场景两者在原生层汇聚成同一条消息送到Unity。2. iOS原生层配置详解从Info.plist到Associated Domains这一节先给结论无论你最终是否保留URL Scheme下面两步都需要配置。耗时最多、也最容易在Xcode升级后翻车的是签名和Associated Domains。2.1 URL Scheme的注册方式在Xcode选中Target - Info在URL Types里添加一条URL SchemesmygameIdentifier可以写bundle id例如com.yourcompany.mygame这一步生成后Xcode会自动把这条写进Info.plist。一个App可以注册多个Scheme我就见过有些产品为了兼容老版本注册过四五个。这里有个细节URL Scheme是大小写敏感的MyGame://和mygame://是两个完全不同的协议配置时确认和投放侧保持一致。2.2 Universal Links的专业配置流程配置Universal Links要同时做三件事缺一不可在Xcode的Signing Capabilities面板添加Associated Domains能力里面写applinks:yourdomain.com。如果能力列表里没有Associated Domains通常意味着你的开发者账号没有申请相关权限需要去开发者后台确认。准备并发布AASA文件Apple App Site Association。这是一个无扩展名的JSON文件需要放在服务器的根目录或.well-known/目录下。下面是一个实用配置示例{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.mygame, paths: [ /play/*, /share/*, NOT /admin/* ] } ] } }上传后使用https://yourdomain.com/apple-app-site-association地址进行校验。iOS对AASA文件的校验不依赖“你在浏览器里打开能否看到”这件事它有自己的下载链路所以不要用浏览器打开正常就以为配置好了。2.3 AASA文件的路径匹配规则paths支持精确匹配和通配符匹配。*代表0个或多个字符?代表任意单个字符。例如/play/*能覆盖https://yourdomain.com/play/room/abc或https://yourdomain.com/play?roomabc但注意它不会匹配https://yourdomain.com/play没有斜杠。如果你希望根路径也能触发需要单独写一份/play或/play*。另外一个坑是AASA文件直接从服务器返回时响应头里的Content-Type不要写成application/json很多服务器默认这么做。Apple文档建议返回application/json但实践中某些iOS版本对Content-Type并不敏感倒是编码问题更常见文件必须纯ASCII字符中文路径规则一律用URL编码后的写法。2.4 域名与证书的常见翻车点AASA只能放在HTTPS域名下且证书必须有效。开发阶段如果要测试可以把yourdomain.com替换成测试域名例如test.yourdomain.com在Associated Domains里配好之后Xcode会重新生成描述文件必须在开发者后台把该域名加入App ID的Associated Domains权限里。这里分享一个特别容易踩的坑只改了Xcode里的Capabilities忘记同步开发者后台的App ID配置结果真机一直无法唤起。排查方法是在Xcode里查看Target的签名信息确认Associated Domains capability是否包含你的域名。3. 原生侧代码处理冷启动、热启动和UIScene的心智模型现在终端配置已经就绪进入编码阶段。iOS侧代码的风格和Unity场景有一点天然冲突iOS的App生命周期有冷启动、热启动、后台恢复三种状态而Unity的场景加载通常要几秒钟。如果处理不好很容易出现“我就收不到参数”的现象。3.1 AppDelegate与SceneDelegate两条线的配合iOS 13之后苹果引入了UIScene生命周期Deep Link的回调会被分割到两个Delegate中这是一个特别容易写漏代码的地方使用URL Scheme拉起时如果是冷启动或后台状态回调会落到application:openURL:options:方法使用Universal Links拉起时如果是冷启动回调会落到continueUserActivity:restorationHandler:方法如果App处于前台活跃状态且通过URL Scheme唤起那么回调同样走application:openURL:options:但此时AppDelegate与SceneDelegate同时存在你的接收逻辑只写在一个地方很容易漏掉。一个比较稳妥的做法是把接收逻辑写在一个独立的单例类DeepLinkHandler中无论回调来自哪个Delegate最终都调用同一个入口。在AppDelegate中做冷启动的兜底处理在SceneDelegate中做前台及未来场景的补充处理。3.2 原生代码最小实现下面是一段可以直接嵌入Unity导出工程AppDelegate或独立原生插件中的代码// DeepLinkHandler.h #import Foundation/Foundation.h interface DeepLinkHandler : NSObject (instancetype)sharedHandler; - (BOOL)handleUniversalLink:(NSURL *)url; - (BOOL)handleSchemeLink:(NSURL *)url; end// DeepLinkHandler.m #import DeepLinkHandler.h #import UnityFramework/UnityFramework-Swift.h // 如果使用的是Unity导出的AppDelegate可以直接依赖unity生成的入口 implementation DeepLinkHandler (instancetype)sharedHandler { static DeepLinkHandler *instance; static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ instance [[DeepLinkHandler alloc] init]; }); return instance; } - (BOOL)handleUniversalLink:(NSURL *)url { if (!url) return NO; // 将Universal Link转换成字典再投递给Unity NSString *json [self jsonFromURL:url source:universal]; [self sendToUnity:json]; return YES; } - (BOOL)handleSchemeLink:(NSURL *)url { if (!url) return NO; NSString *json [self jsonFromURL:url source:scheme]; [self sendToUnity:json]; return YES; } - (NSString *)jsonFromURL:(NSURL *)url source:(NSString *)source { NSMutableDictionary *payload [NSMutableDictionary dictionary]; payload[url] url.absoluteString; payload[path] url.path ?: ; payload[source] source; if (url.query) { NSMutableDictionary *query [NSMutableDictionary dictionary]; for (NSString *pair in [url.query componentsSeparatedByString:]) { NSArray *kv [pair componentsSeparatedByString:]; if (kv.count 2) { query[[kv[0] stringByRemovingPercentEncoding]] [kv[1] stringByRemovingPercentEncoding]; } } payload[query] query; } NSData *data [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; } - (void)sendToUnity:(NSString *)json { // 如果Unity尚未准备好就缓存这条数据等待App启动后主动取 NSString *cacheKey PendingDeepLinkPayload; NSUserDefaults *defaults [NSUserDefaults standardUserDefaults]; [defaults setObject:json forKey:cacheKey]; [defaults synchronize]; // 调用UnitySendMessage时需要Unity的GameObject存在 UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [json UTF8String]); } end这个设计里我把参数同时写进了NSUserDefaults这一步比较关键UnitySendMessage的时机非常脆弱如果Unity引擎还没加载完成消息会丢失。缓存之后Unity侧启动时可以主动读取一次双保险。这段代码用[key stringByRemovingPercentEncoding]对query做了解码确保中文参数不会变成一串百分号。3.3 冷启动路径在didFinishLaunching里抢跑在AppDelegate的启动方法中有一项必须在Unity启动前处理如果App是被Universal Link拉起的系统在didFinishLaunchingWithOptions里的launchOptions[UIApplicationLaunchOptionsUserActivityKey]会存在NSUserActivity需要在调用Unity主启动流程前缓存参数。- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { if (launchOptions[UIApplicationLaunchOptionsUserActivityKey]) { NSUserActivity *activity launchOptions[UIApplicationLaunchOptionsUserActivityKey]; if ([activity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [[DeepLinkHandler sharedHandler] handleUniversalLink:activity.webpageURL]; } } return YES; }调用handleUniversalLink:后由于Unity还处于启动流程UnitySendMessage不会生效但NSUserDefaults缓存会在Unity启动后被成功读取。3.4 热启动与SceneDelegate路径如果在App已运行时点击Link唤醒AppDelegate的application:openURL:options:或continueUserActivity:restorationHandler:会被触发。注意iOS 13以后如果使用UIScene这里还需在SceneDelegate里补一份- (void)scene:(UIScene *)scene openURLContexts:(NSSetUIOpenURLContext * *)URLContexts { for (UIOpenURLContext *context in URLContexts) { [[DeepLinkHandler sharedHandler] handleSchemeLink:context.URL]; } } - (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [[DeepLinkHandler sharedHandler] handleUniversalLink:userActivity.webpageURL]; } }这里有个常见误区需要说明很多团队只在AppDelegate里写了两行旧的回调方法以为兼容了所有场景结果在iOS 13以上设备上前台被Universal Links唤醒时根本无法触发。原因是系统优先把事件交给了SceneDelegate你写的AppDelegate方法压根没有执行。所以判断你的原生代码是否是全的标准就是“冷启动、热启动、前后台、两种链接类型”这四个维度每一个都要有入口。4. C#层统一收口参数缓冲与业务调度的可靠性设计原生层处理完剩下的事情全在Unity侧。一个好的Deep Link模块设计应该能应对以下几种情况玩家直接在Safari输入链接唤醒App通过分享卡片点击Universal Link唤醒App未启动靠等待业务场景就绪后再处理参数参数到达时游戏还没进入主界面比如还在Loading阶段。把参数直接丢给某个UI界面是万万不可的。正确做法是设计一个DeepLinkManager单例它负责收参数、排队、派发再配合业务层事件解耦。4.1 完整可用的C#管理器using System; using System.Collections.Generic; using System.Runtime.InteropServices; using UnityEngine; public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } // 用于接收iOS端原生回调 private readonly Queuestring _pendingPayloads new Queuestring(); private bool _isReady; // 业务层注册的处理器 public event ActionDeepLinkPayload OnDeepLinkProcessed; // 标记是否需要主动拉取缓存冷启动场景 [DllImport(__Internal)] private static extern string _GetCachedDeepLink(); private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); _isReady true; } private void Start() { // Unity发起的启动请求主动从原生缓存拿一次防止消息早于Awake到达 #if UNITY_IOS !UNITY_EDITOR string cached _GetCachedDeepLink(); if (!string.IsNullOrEmpty(cached)) { EnqueuePayload(cached); } #endif } // 由UnitySendMessage调用此方法名必须和原生代码字符串一致 public void OnDeepLinkReceived(string json) { EnqueuePayload(json); } private void EnqueuePayload(string json) { if (string.IsNullOrEmpty(json)) return; lock (_pendingPayloads) { _pendingPayloads.Enqueue(json); } ProcessQueue(); } private void ProcessQueue() { while (_pendingPayloads.Count 0) { string json; lock (_pendingPayloads) { if (_pendingPayloads.Count 0) return; json _pendingPayloads.Dequeue(); } try { var payload JsonUtility.FromJsonDeepLinkPayload(json); // 如果有UI或系统尚未初始化可以在这里挂起等待外部调用 OnDeepLinkProcessed?.Invoke(payload); } catch (Exception e) { Debug.LogError($[DeepLink] 解析失败: {e.Message}); } } } // 如果游戏需要等待登录完成后再消费参数可以暴露这个方法 public void Flush() { ProcessQueue(); } [Serializable] public class DeepLinkPayload { public string url; public string path; public string source; public Dictionarystring, string query; } }需要说明几点设计思路队列 事件派发所有参数先进队列业务层通过订阅OnDeepLinkProcessed事件获取数据谁需要谁去注册不需要DeepLinkManager知道业务细节。_GetCachedDeepLink这是原生侧的主动查询接口用于冷启动时Unity引擎已经启动但原生回调已经错过的场景把缓存的参数拉回来执行一遍。DontDestroyOnLoad确保场景切换时单例不被销毁否则一次场景切换会让后续参数无处派发。4.2 别忘了在原生实现GetCachedDeepLink上面的C#代码引用了一个原生方法_GetCachedDeepLink需要同步补上Objective-C实现const char * _GetCachedDeepLink() { NSString *cached [[NSUserDefaults standardUserDefaults] objectForKey:PendingDeepLinkPayload]; if (!cached) return strdup(); return strdup([cached UTF8String]); }4.3 业务层的调度案例以一个实际场景来说玩家在Safari打开了https://yourdomain.com/play?roomabcmoderankApp被拉起后原生层把这段链接转成JSON投递到UnityUnity再根据path和query做路由。下面是业务层的一个典型处理逻辑private void OnEnable() { DeepLinkManager.Instance.OnDeepLinkProcessed HandleDeepLink; } private void OnDisable() { if (DeepLinkManager.Instance ! null) { DeepLinkManager.Instance.OnDeepLinkProcessed - HandleDeepLink; } } private void HandleDeepLink(DeepLinkManager.DeepLinkPayload payload) { if (payload.path.StartsWith(/play)) { string roomId payload.query ! null payload.query.ContainsKey(room) ? payload.query[room] : ; string mode payload.query ! null payload.query.ContainsKey(mode) ? payload.query[mode] : normal; GameEntryController.Instance.EnterRoom(roomId, mode); } else if (payload.path.StartsWith(/share)) { // 打开分享回调页 } }业务层订阅、退订的时机必须处理干净否则在登录流程中点击了一次链接又切换了场景很容易造成重复进入房间。建议在进入主界面前只订阅“主界面可交互”事件并在进入房间那一刻立刻退订。5. 参数设计、编码规范和测试方法论参数是整个Deep Link链路里最容易被忽视、出错后最难看懂的环节。我在实际项目中总结了一套相对规范的做法在这里展开讲透。5.1 链接参数的结构设计推荐所有链接统一采用下面这种结构https://yourdomain.com/{action}/{sub}?k1v1k2v2signxxx例如拉人组队https://yourdomain.com/play/room/abc123?moderankinviter10086活动页https://yourdomain.com/activity/spring?fromshareuid10086客服id跳转https://yourdomain.com/cs/10010?channelappstore参数至少包含action、id、from三类。from会对接下来的埋点报错排查有帮助也可以在业务层判断“进入一场房间”后给不同的提示文案。5.2 编码规则与大小写陷阱URL中不能直接出现空格、中文、等保留字参数传递前一定要做URLEncode。在C#侧解码时我遇到过几次参数值里有加号却没被正确解码的情况换成stringByRemovingPercentEncoding才能把还原成空格。统一规范是投放侧拼链接时全部encodeC#解析时统一decode链路中不再做任何额外处理。另一个容易踩的坑是path的大小写。iOS的Universal Links对path是区分大小写的AASA里写了/Play就只能匹配/Play你在链接里写/play就唤醒不了。可以统一约定为小写路径并且在AASA文件里尽量用通配符配合正则规则减少大小写带来的误配置。5.3 模拟测试的完整方案测试Deep Link并不需要每次都用真机点链接。下面这套组合拳基本上能把90%的问题测出来模拟Safari输入在真机的Safari地址栏直接输入Universal Link如果App已安装系统会直接拉起App如果不行先看是否弹出了网页这是最快的信号。Mac模拟器命令行在Mac终端对模拟器执行xcrun simctl openurl booted https://yourdomain.com/play?roomabc123。注意这个命令不会触发Universal Links它直接调用系统openURL只会命中URL Scheme所以可以用来单独测试URL Scheme的回调链路。Xcode环境变量调试在Xcode中运行App通过Environment Variables设置AppleLanguages和AppleLocale没有直接作用真正有用的是在项目中临时加一段application:didFinishLaunchingWithOptions日志打印launchOptions内容来观测冷启动是否拿到userActivity。抓包验证AASA在Mac上使用curl -I https://yourdomain.com/apple-app-site-association查看返回状态码同时确认响应体完整。注意不要把Apple域名相关的访问行为误当成诊断依据。5.4 一条链接四段日志彻底定位问题如果线上反馈“用户点击分享链接进不了房间”建议在以下四个节点各加一条日志原生接收链接记录原始URL字符串原生投递Unity记录JSON字符串长度C#解析结果记录解析后的payload内容业务派发结果记录是否成功进入房间。这四个节点的日志可以一次性判断出问题处在哪个环节。我的一个真实案例投放平台发出的链接中roomId是纯数字但到了第二步日志里发现roomId变成了空字符串原因是投放平台在拼接链接时把roomId放在query但key写成了room_id和C#端解析的room对不上。如果只盯着C#代码查恐怕会浪费很长时间。6. 上线前最容易被忽略的检查清单如果项目已经走到了联调尾声下面这份清单建议逐条过一遍。很多问题在开发机上一切正常一上测试环境就翻车基本都出在这些环节。检查项验证方式常见坑AASA文件是否可访问curl -I 验证服务器乱改Content-Type导致部分机型下载失败证书是否有效打开链接看浏览器是否报错自签名证书一律无效Associated Domains是否配全Xcode查看后台查看App ID只改本地忘记后台URL Scheme是否唯一模拟器里用另一个App测试与其他产品冲突系统随机唤起冷启动是否回读缓存杀掉AppSafari打开链接UnitySendMessage时序竞争参数是否编码链接里塞中文昵称测试decode时机不一致是否双Delegate覆盖前台后台冷启动分别测一遍只写了AppDelegateiOS13失效re-signing后是否失效重签后立刻测试Associated Domains entitlement丢失6.1 常见问题快查表现象可能的根因点击链接没有任何反应域名没有配置Associated Domains或AASA路径写错已安装App但打开了浏览器网页AASA中appID与bundle id不匹配已安装App但弹窗询问是否打开说明这次走的还是URL SchemeUniversal Links未生效冷启动后参数丢失launchOptions没取或缓存被清参数中中文乱码编码和解码不一致参数能到C#端但业务没反应事件订阅时机不对或path大小写不匹配App Store审核被拒Universal Links的Demo链接无效或没有错误提示页7. 把整套能力封装成调试面板是我最后的建议这份流程做完之后我还额外做了一个Deep Link调试面板一个挂在场景上的UI只用一个字符串数组保存最近接收到的5次Deep Link payload再用一个按钮主动调用一次Application.OpenURL写测试链接。每次接新渠道或者改投放参数时不用重新编译原生工程直接在游戏里点一下就能看到原生层和C#层是否都对。这个调试面板省下来的时间比写整个DeepLinkManager还多。如果你也在集成过程中遇到类似场景不妨也顺手做一个测试效率会有明显提升。
返回列表