ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity iOS Deep Link全流程实践:Scheme与Universal Links参数投递

Unity iOS Deep Link全流程实践:Scheme与Universal Links参数投递 做手游的特别是做 iOS 发行、带买量投放和活动运营的对 Deep Link 这东西肯定不陌生。但真要说能把整个链路理清楚从 iOS 系统的 URL Scheme 到 Universal Links再一路把参数干干净净地递进 C# 层让 Unity 侧业务丝滑拿到邀请码、渠道号、活动参数这中间的文章其实很深。我不止一次看到有人把 Universal Links 配置好了、AASA 文件也放了结果一进 App 参数空上天冷启动拿不到、热启动事件不触发最后只能靠埋点硬凑。这篇文章就把我从头到尾踩过的坑、落地的完整流程写一遍。内容涵盖方案选型、Xcode 工程配置、AASA 文件部署、C# 层代码实现、冷热启动时序处理以及“App 没装”这种最坑的场景下怎么把参数补回来。不管你是刚接触 Deep Link还是已经接了一半卡住了这篇应该都能帮上忙。1. 为什么 iOS 的 Deep Link 一直是 Unity 开发者的心病1.1 一次运营活动引出的完整链路问题先说个很典型的场景。运营要做一个邀请回归活动老玩家分享一个链接给新用户新用户点开链接如果装了游戏就直接唤起并自动绑定邀请关系如果没装跳去 App Store下载完打开游戏首次启动时还得能识别出来“这个人是因为谁邀请才下载的”。听起来好像不复杂——一个链接的事。但真正在 iOS 上动手你会发现这个“链接”从被点击到操作系统把数据交付给 App再到 Unity 引擎真正拿到参数每一环都有各自的规矩和脾气。URL Scheme 给得干脆但功能单薄Universal Links 功能强大但配置繁琐服务器那边还得放一个叫apple-app-site-association的文件。对 Unity 项目来说还得面对原生层和 C# 层之间的那座桥。更要命的是时序问题。用户点链接那一刻你的 App 可能是死的、是活的、是刚从后台回来的也可能是压根不存在的。每一种状态下系统投递参数的路径都不一样。参数明明传了但你家 Unity 代码拿到的时机不对等于白传。1.2 两个体系的本质区别URL Scheme 与 Universal Links理解这两个东西的区别是解决整个问题的地基。URL Scheme 有点像“手机号直拨”。你在 Xcode 里给自己注册一个协议头比如mygame://别的 App 或者网页拿到这个地址直接呼叫系统“帮我打开 mygame 这个 App”。系统一看确实装了就唤起它然后把完整的 URL 字符串交给 AppDelegate。这个方案的好处是简单、直接、稳定几乎不会失败。但缺点也很明显没有安装 App 的时候这个链接直接失效浏览器会提示“打不开网页”另外 iOS 系统对这类唤起会有限制重复操作时弹确认框体验不太好。Universal Links 走的是另一套逻辑用的是 HTTPS 域名。你在自己的网站上放一个 JSON 配置文件告诉苹果“我这个域名以下这些路径统统领到某某 App”。用户点击链接时系统会先去验证这个域名是否与已安装的 App 绑定。验证通过就直接在你 App 里打开而不是在浏览器里。因为地址本身是标准 HTTPS没装 App 时也能正常落到网页上由网页自己决定后续行为——跳 App Store或者引导下载。这才是原生 iOS 推荐的方向。一句话总结Scheme 是“备用钥匙”Universal Links 是“自动门禁”。现代 iOS 产品的做法通常是两个都配以 Universal Links 为主、Scheme 兜底。后面我会详细说怎么配。2. 方案选型什么时候用 Scheme什么时候用 Universal Links2.1 URL Scheme 的配置与使用边界URL Scheme 的配置在 Xcode 里非常轻量。选中 Target切到 Info 页签往下拉找到 URL Types加一条填上 URL Schemes。比如说我填mygame那么mygame://invite?uid123就能唤起 App。但这个简单有个前提你必须在 AppDelegate 里实现对应的方法去接住这条链接。iOS 9 之前用handleOpenURLiOS 9 之后系统改成了这个- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { return YES; }对于 Unity 项目来说这个方法大概率是写在iOS 原生插件里或者更常见的做法是直接用UnityAppController分类Category来重写生命周期拿到 url 之后再UnitySendMessage丢给某个 C# 挂点。方案配置上有一个容易被忽略的地方URL Scheme 的字符串会被系统当作 App 的唯一标识之一跟别的 App 撞了会很麻烦。大的服务商和游戏公司都有占位习惯但小团队经常随便填一个撞上了就会出现“我在浏览器打开唤起的是别人家 App”的灵异事件。这一点一定要提前警惕Scheme 写一个自己产品独有的、带品牌标识的完整单词别用game、app这种俗名。2.2 Universal Links 的配置与 AASA 文件细节Universal Links 玩的就是apple-app-site-association文件下文简称 AASA。你先要有一个支持 HTTPS 的域名然后在域名的根目录或/.well-known/目录下放这个文件系统会自动去取。文件内容大概长这样{ applinks: { apps: [], details: [ { appID: ABCDE12345.com.example.mygame, paths: [ /invite/*, /game/* ] } ] } }然后回到 Xcode在工程里打开 Background Modes 和 Associated Domains 能力加一条applinks:yourdomain.com。整套配置里坑最多的就是 AASA 文件本身。第一路径写错了系统直接不认。/invite/*这个写法匹配/invite/下面任意子路径但不会匹配/invite本身也不匹配/inviteabc。第二查询参数不会参与路径匹配也就是说https://yourdomain.com/invite?uid123走的是https://yourdomain.com/invite这个路径是否被 profiles 覆盖。如果你 AASA 里只写了/invite/*那么上面的链接在路径匹配阶段就会失败。推荐做法是AASA 的 path 写宽一点比如/*然后在业务层自己判断路径前缀或者干脆把所有带参数的活动链接统一指向同一个不带参数的路径参数全部放 query string 里。实际上正式环境里 AASA 配置得越简单越不容易出问题权限控制在你自己服务器上做就好了。第三AASA 文件不能有重定向。iOS 请求这个文件时如果服务器返回一个 302 跳转系统大概率直接判定失败。还有 HTTPS 证书必须有效不能用自签名证书。很多团队把文件放在 CDN 上CDN 又带了重定向策略这种情况是最难排查的。验证方法很简单用电脑 curl 看一下curl -v https://yourdomain.com/apple-app-site-association盯着 response headers 有没有 3xxContent-Type 是否正常以及最底下的 JSON 是不是完整可解析。2.3 双通道并行加降级策略才是生产环境该有的姿态有人会纠结到底是接 Scheme 还是接 Universal Links我的建议是别纠结两个都接。Scheme 作为兜底Universal Links 作为主通道。为什么Universal Links 虽然体验好但它有个很现实的问题不是所有点击场景都能成功唤起。比如在微信内置浏览器里Universal Links 经常不稳定很多情况下链接会直接停在网页里不会唤起 App此时你可以打开一个带 Scheme 的跳转地址让浏览器把控制权交还给系统通过系统机制唤起 App。这其实就是业内常见的“H5 检测 双通道唤起 失败落商店”方案。反过来Universal Links 成功唤起时体验远比 Scheme 好系统不会出现多余的弹窗确认且链接本身是普通 HTTPS分享出去也不那么“像广告”用户信任度更高。所以落地时的判断逻辑大致如此H5 页面先监听document visibilitychange记录 App 是否被切走如果 3 秒内页面还活着说明 Universal Links 没起来那就改走 SchemeScheme 要是也失败了比如没安装就等着页面 JS 把用户送到 App Store 去。判断“App 有没有被唤起”这个逻辑在移动端 H5 里属于常规操作了。核心就是页面切到后台的瞬间不一定会立刻触发所以业内普遍用“定时检测 visibilitychange”的组合。这个方案虽然糙但管用。3. C# 层参数投递打通原生与 Unity 的最后一步3.1 老方案iOS 原生插件 UnitySendMessage很多 Unity 项目的 Deep Link 接入第一步都是照着远古时代流传下来的模板写一个 Objective-C 的插件挂到 AppController 生命周期里。重写刚才说的openURL方法在拿到 url 之后调用UnitySendMessage(DeepLinkHandler, OnReceiveURL, [url.absoluteString UTF8String]);C# 侧挂一个名为 DeepLinkHandler 的 GameObject挂载脚本实现OnReceiveURL(string url)这就完成了一次参数投递。这个方案本身没有任何问题我直到今天还在一些老项目里用。它最大的优点是可控性强——原生层能百分百拿到 URL 字符串你想在原生层做参数预处理、存本地、或者延迟投递都完全由你说了算。但缺点是麻烦Unity 版本升级后 AppController 的名称和行为可能变化分类写的稍有不慎Xcode 编译直接报错调试也不方便C# 侧的报错信息基本对不上原生层的问题。3.2 新方案Application.deepLinkActivated 与 absoluteURL如果项目用的是 Unity 2019.4 LTS 以上的版本我强烈建议直接用 Unity 自带的能力省去原生插件这一大坨。Unity 在 iOS 平台上封装了两个关键入口Application.absoluteURLApp 启动完成后用于获取本次启动时系统传进来的 Deep Link。特性是只在冷启动链路有效。Application.deepLinkActivatedC# 层的事件App 已经处于运行状态时突然被一个 Deep Link 重新拉起来这个事件就会触发。特性是热启动专用。很多新手容易把这两个搞混。我给你们一个简化的理解App 是死的被链接唤醒用absoluteURL去查 App 是活的被链接顶起来用deepLinkActivated去监听。所以标准的 C# 层接入模板是这样public class DeepLinkHandler : MonoBehaviour { private void OnEnable() { Application.deepLinkActivated OnDeepLinkActivated; } private void OnDisable() { Application.deepLinkActivated - OnDeepLinkActivated; } private void Start() { // 冷启动,可能是通过DeepLink拉起,查一次绝对地址 string coldLink Application.absoluteURL; if (!string.IsNullOrEmpty(coldLink)) { ProcessDeepLink(coldLink); } } private void OnDeepLinkActivated(string url) { // 热启动,事件回调里拿到的就是完整URL ProcessDeepLink(url); } }你看代码量比原生插件方案小了一个量级而且因为这个链路是 Unity 官方封装的性能和行为稳定性都有保障。但我还是要多提醒一句不要完全放弃原生插件。Unity 封装毕竟是黑盒出了问题你无从下手。等后面排查问题的时候你就会明白一个能把原始 URL 打日志打到 Xcode 控制台的原生插件在 Debug 时有多值钱。3.3 参数编码、短链与去重的坑C# 层拿到 URL 字符串之后第一件事不是屁颠屁颠去解析参数而是先做三件事解码、去重、判有效性。解码这方面URL 里经常会带中文、带特殊符号。你从Application.absoluteURL拿到的字符串大概率是编码后的状态比如%E9%82%80%E8%AF%B7。所以解析参数时先整体把url做一次Uri.UnescapeDataString再拆 query。注意不要拆完单独解码 key 和 value顺序错了也会出乱码。static Dictionarystring, string ParseQueryString(string url) { var result new Dictionarystring, string(); var uri new Uri(url); var query uri.Query.TrimStart(?); if (string.IsNullOrEmpty(query)) return result; foreach (var pair in query.Split()) { var idx pair.IndexOf(); if (idx 0) continue; var key Uri.UnescapeDataString(pair.Substring(0, idx)); var val Uri.UnescapeDataString(pair.Substring(idx 1)); result[key] val; } return result; }去重这件事很多团队会忽略。用户从同一个唤起的链接进入 App理论上只会触发一次事件但在 Universal Links 和 Scheme 双通道并存的情况下存在极小的概率两条链路都投递成功C# 层会收到两次一模一样的 URL。如果你不对参数做去重“绑定邀请关系”这个逻辑就会执行两次轻则前端界面闪一下重则后端多出一条绑定失败记录。去重的思路很简单用一个字典缓存最近处理过的 URL 的哈希值相同的在短时间内忽略掉。短链是另一个容易踩坑的地方。活动链接参数一多URL 就很容易长得离谱。iOS 对 URL 长度虽然没有硬性限制但 URL 越长分享传播的可靠性就越差部分 IM 工具还会截断链接。所以运营侧的常规做法是走短链服务器把完整参数存在服务端回传一个短码比如https://yourdomain.com/invite/abc123。然后 App 端拿到这段短链接之后再请求一次服务器接口把完整参数换回来。这个方案能规避绝大多数 URL 长度问题代价是客户端需要多做一次网络请求且要考虑换参接口失败时的重试逻辑。4. 实操完整落地一个 Deep Link 全流程4.1 第一步域名与 AASA 文件部署AASA 文件的部署是整个链路里第一个核心动作部署错了后面全白搭。文件位置有两种系统会优先查https://yourdomain.com/.well-known/apple-app-site-association你也可以放在https://yourdomain.com/apple-app-site-association。为了保险我两个位置都放。文件内容刚才给过一个示例这里再补充一个生产环境的推荐写法{ applinks: { apps: [], details: [ { appID: ABCDE12345.com.example.mygame, paths: [ /invite/*, /game/* ] } ] } }部署完以后用curl自检curl -v https://yourdomain.com/apple-app-site-association检查重点有三个响应是不是 200有没有 301/302 跳转响应体是不是纯 JSON有没有被包在 HTML 里appID是不是TeamID.BundleID的完整拼接。很多团队会漏掉请求头这层。确保服务端返回的Content-Type是application/json或者application/pkcs7-mime虽然系统对 Content-Type 的容忍度比网上流传的要高但正确设置能省掉很多莫名奇妙的兼容问题。4.2 第二步Xcode 工程配置AASA 文件是服务器端的事接下来看客户端。在 Xcode 打开工程Target - Signing Capabilities点加号添加 Associated Domains。注意你的开发者账号必须是付费的免费的个人开发账号在真机上使用 Universal Links 会有问题。添加域名时前缀必须写applinks:比如applinks:yourdomain.com。同一个页面里如果你想让 App 支持极高的定制化可以加一段 App Clips 之类的配置但那是另一个话题了这里不展开。另外在 Info 页签的 URL Types 里把兜底的 URL Scheme 也配上。填法就是前面说过的在 URL Schemes 里填一个独特的字符串。确保 App 的 Bundle Identifier、Team ID、AASA 文件里的 appID 完全对得上这是配置层最容易忽略、也最容易让 Universal Links 永久不可用的一点。4.3 第三步Unity C# 侧代码实现把 C# 侧的代码做完整一些。除了前面演示的监听逻辑一个能上生产的 DeepLinkHandler 应该还要承担这些职责解析参数、参数投递给业务层、处理冷启动时序。冷启动时序是最大的坑之一。Start方法里读Application.absoluteURL如果在Start执行的那一帧URL 还没有被系统投递进来你读到的就是空字符串。为什么因为 Unity 引擎自身初始化需要时间iOS 系统把启动参数交给 Unity 和 Unity 执行到你的业务脚本之间存在一段间隙。放心这种情况是小概率但环境切换时偶尔会出现。应对办法是做一个简单的轮询或延迟补偿private IEnumerator CheckColdLinkWithDelay() { yield return new WaitForSeconds(0.5f); string url Application.absoluteURL; if (!string.IsNullOrEmpty(url)) { ProcessDeepLink(url); } }0.5 秒不会影响玩家的启动体验却能显著提升冷启动场景的 Deep Link 捕获率。如果你不想用协程也可以用一个简单的计时器在 Update 里做两次检查。个人实测延迟检查的效果在 iOS 模拟器和旧机型上尤为明显。线上环境我见过冷启动丢参数率 3% 左右的项目加了延迟补偿之后降到接近 0。完整的处理器里业务层投递我用的是一个 C# 事件public static event ActionDictionarystring, string OnDeepLinkParsed; private void ProcessDeepLink(string url) { Debug.Log($[DeepLink] raw url: {url}); if (_recentLinks.Contains(url)) return; _recentLinks.Add(url); var parsed ParseQueryString(url); OnDeepLinkParsed?.Invoke(parsed); }业务层谁关心这个事件谁去订阅。例如邀请活动的模块在初始化时挂一个监听收到参数就拉起绑定流程。这样 DeepLinkHandler 就不用去耦合业务逻辑了往后新增活动也方便。4.4 冷启动、热启动、Deferred Deep Link 三种场景的处理现在统一梳理一下三种常见场景下系统行为和代码逻辑的对应关系。冷启动App 未运行用户在 Safari、微信或扫码工具里点击链接系统唤起 AppUnity 引擎完整走一遍启动流程。拿参数的路径是Application.absoluteURL。需要注意部分情况下absoluteURL不是马上非空需要那个 0.5 秒延迟补偿。热启动App 已运行游戏正挂在后台或者正在前台用户点击链接后 App 被顶起来系统走deepLinkActivated事件。Unity 的官方封装做得不错这个事件基本稳定可靠。但要注意如果你同时在OnEnable和Start里都做了处理逻辑要防止热启动时Start里的冷启动查询把同一个链接处理两次。我的做法是把冷启动和热启动的两个入口都汇合到ProcessDeepLink让去重逻辑统一兜底。Deferred Deep LinkApp 未安装这是最麻烦的一种。用户点链接时手机根本没装游戏Universal Links 会自动把网页加载出来网页运营文案完了以后跳 App Store 下载游戏。这时候链接里的邀请参数App 本身是拿不到的——安装是一个全新启动系统不会再告诉你“用户是因为哪个链接装的”。这种场景必须引入归因能力最常见的就是接 Adjust、AppsFlyer、Branch 这类平台它们有 SDK 级的能力把安装前的点击与安装后事件关联起来。但假如你只是做活动运营不想接一堆 SDK也可以自己做一个轻量方案用户在 H5 页面时把参数写进剪切板App 首次启动时去读剪切板内容识别出是邀请链接再上报后端。这个方案体验稍差需要用户授权剪切板权限但在一些轻量场景下足够用了。5. 常见问题与排错实录5.1 Universal Links 打不开 App在浏览器里原地停留这在测试阶段出现频率极高。我的排查顺序是这样的先用系统自带的 Safari 打开链接看能不能正常唤起。如果 Safari 能唤起微信里不能唤起那八成是微信的 Universal Links 限制问题走双通道降级方案解决。如果 Safari 也不行先检查 AASA 文件是否可访问、appID 是否正确。然后检查 Associated Domains 有没有带上applinks:前缀域名有没有写错。如果确认都没问题再等个十几秒重试——iOS 对 AASA 的缓存相当顽固它不会立刻刷新有时候你把文件改对了系统还要过一阵子才生效。调试系统是否真的拉到了 AASA 文件可以用 Xcode 的 Console 配合系统日志查看也可以直接跑真机用断点观察 AppDelegate 有没有回调。Universal Links 用模拟器测试不靠谱最好直接上真机这句话我记不清说了多少遍了。5.2 参数中文乱码或特殊字符丢失链接里带中文参数是非常普遍的事比如邀请人昵称。URL 里的中文必须编码后再拼接不要在 H5 端生成链接时直接用原始中文。后端生成链接时统一用encodeURIComponent对参数值编码客户端解析时用Uri.UnescapeDataString解码。特殊字符里最坑的是#。URL 结构里#之后的部分是 fragment根本不会发给服务器很多链接在拼接时把参数值里的#漏编码了导致参数被截断。这种问题排查起来非常隐蔽因为你光看 URL 不容易察觉。5.3 冷启动拿不到参数热启动却正常这种情况我见得太多了。热启动正常说明 Universal Links 链路本身是通的系统投递逻辑没问题就是冷启动时 C# 层没接住。最常见的两个原因一是Application.absoluteURL读取时机太早按前面的 0.5 秒延迟补偿方案处理二是你自定义了TEXT或者 AppDelegate 生命周期破坏了 Unity 的默认转发链路。如果项目里集成了其他 iOS 原生插件极有可能互相覆盖了 AppController 的分类方法区段导致 Unity 引擎在启动阶段没拿到 url。排查这种问题我的习惯是在原生层加日志拿 unity 的 AppController 分类在里面把openURL、continueUserActivity全部 hook 一遍打日志到控制台对比 C# 层收到的事件是否一致。如果原生层收到了但 C# 层没收到问题出在投递如果原生层自己都没收到问题出在 AASA 或域名配置。5.4 Debug 构建一切正常Release 构建就失灵这是一个容易被忽略的打包差异。Release 构建如果开了 Bitcode或者做了依赖裁剪某些系统框架行为会不一样。Universal Links 本身不因此受影响但如果你在 Release 里去掉了某些插件或者 PlayerSettings 里关闭了 Deep Link 相关的回调就有可能出现“Debug 好好的Release 拉垮了”的诡异问题。更常见的情况是你的 Release 构建包和 Debug 构建包的 Bundle Identifier 不一样。很多团队开发包和生产包的包名不同而 AASA 文件里的 appID 只对应生产包名。这时候 Debug 包自然是唤不起 Universal Links 的。检查方式很简单把两个包的 Bundle ID 和 AASA 里的配置逐一比对。6. 调试链路的心得与建议这一节不聊配置聊点真正干活时候的小习惯。第一务必养成日志打点意识。Deep Link 链路长、跨端多任何一个环节断了都不容易看出来。我的做法是在 H5 端、原生层、C# 层三层都打上唯一标识的日志比如链接里带一个traceid参数每一层都把这个 ID 打出来排错时用同一个 ID 串起整条链路哪里断了立刻就能看出来。这个习惯帮我省了大量猜测的时间。第二iOS 缓存是个友善的坑。AASA 文件的缓存周期不稳定即使你把服务端文件改对了客户端本地缓存可能还在用它第一次拉到的旧文件。所以改完配置后测试时要“杀”掉游戏进程切换飞行模式再关掉重新连网或者干脆重启手机。网上很多人说要等 24 小时我没验证过这么极端的周期但至少做上述操作后重测在我接触的项目里基本都能正常。第三千万不要在模拟器上排查 Universal Links 问题。模拟器对 Universal Links 的支持有历史上限和版本差异你在模拟器里测出来的行为基本不具备参考价值。老老实实上真机关掉 Debug 日志构建模拟真实用户操作流程比任何模拟器配置都管用。第四建立主动降级的思维。不要以为配置完美了就万事大吉。iOS 版本迭代很快每年大版本都可能对 Deep Link 行为做调整——比如 iOS 18 之后 Safari 唤起 App 时的动作就有些变化微信等内置浏览器对 Universal Links 的支持也在不停变动。一个可靠的生产方案必须包含“Universal Links 失效之后自动走 SchemeScheme 失效之后落到网页再由网页引导”的完整降级链。这套链路的兜底才是真正的保险。我自己的体会是Deep Link 接通不难真正难的是把事情做扎实。链路里每一环都想当然最终就会在某个不起眼的角落摔倒一次。希望这篇内容能帮你少踩几个我踩过的坑也欢迎你带着项目里的实际问题来讨论。
返回列表