ARTICLE DETAIL

资讯详情

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

Unity iOS深度链接方案:URL Scheme与Universal Links

Unity iOS深度链接方案:URL Scheme与Universal Links 有些坑只在真机上等你先说个真实场景你辛辛苦苦做了一款 Unity 手游上线到 iOS 平台之后运营跑过来跟你说“我们要跟别的 App 互相导量你接一下 Deep Link”。然后又补了一句“微信里打开链接也能直接跳进游戏最好把活动页参数带上这样我们好做归因”。这就是这篇东西要解决的问题从 iOS 端的 URL Scheme / Universal Links 一步步接到 Unity C# 层把参数安全投递到业务脚本里。听起来不复杂但如果你没踩过里面那些坑真的会在这个看似“加个回调就行”的需求上耗掉一整天。我会从原生侧讲起一直讲到 Unity 侧的代码组织。不是抄文档那种讲解是我实际在项目里跑通过的做法。看完之后哪怕你之前完全没接触过 iOS 原生开发也能按着这套思路去落代码。1. 先搞清楚 iOS 端的两套唤醒体系1.1 URL Scheme老牌的协议唤醒方案URL Scheme 说白了就是给 App 注册一个自定义协议比如mygame://。当 iOS 系统发现某个链接的 scheme 是mygame://时就会找到注册了这个 scheme 的 App 并把它唤起。实现方式很直接。在 Unity 导出的 Xcode 工程里找到 Info.plist加一段 CFBundleURLTypeskeyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.yourgame/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array然后在 AppDelegate 里处理回调- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { if ([url.scheme isEqualToString:mygame]) { NSString *deepLink [url absoluteString]; // 投递给 Unity const char *params [deepLink UTF8String]; UnitySendMessage(DeepLinkHandler, OnDeepLinkReceived, params); } return YES; }就这么简单。但简单是有代价的。从 iOS 9 开始苹果就在推 Universal Links 来替代 URL Scheme因为 URL Scheme 有俩硬伤。第一个硬伤是你没法确认链接背后对应的域名。任何人只要知道你注册的 scheme就能构造一个mygame://foo把你 App 唤起来这就给恶意调用留下了空间。就算你后面做了一堆参数校验用户被不明链接唤醒的体验也谈不上好。第二个硬伤更恶心从 iOS 10.2 开始具体版本记不太清了大概这个阶段如果用户设备上装了多个注册了相同 scheme 的 App系统会弹一个选择框问“用哪个 App 打开”这个问题在 iOS 13/14 上已经变成每次跳转都可能弹窗了对用户体验伤害非常大。你要是做过国内 App 间互导量就知道了弹窗一出来转化率肉眼可见地掉。1.2 Universal Links苹果钦定的正规军Universal Links 是苹果从 iOS 9 开始推的标准方案。核心思想是明明你有一个 HTTPS 域名那就用域名来关联 App。用户点击https://yourdomain.com/game/event/123系统检查这个域名是否关联到某个 App如果是直接唤起否则就在 Safari 里正常打开网页。想在 App 里启用 Universal Links需要做三件事第一在开发者后台配置 Associated Domains。你需要在 Apple Developer 的 App 能力配置里把这个能力打开然后下载一个新的 provisioning profile里面会带上这个 entitlement。注意这里有个坑就是要重新下载描述文件不是说你代码里写一下就行的。第二在 Xcode 工程的 Signing Capabilities 里把 Associated Domains 加上domain 填applinks:yourdomain.com。第三把你那个 HTTPS 域名的根目录放一个 JSON 文件路径固定是https://yourdomain.com/.well-known/apple-app-site-association新版本也兼容不带.well-known的路径内容长这样{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.yourgame, paths: [ * ] } ] } }这里的appID是你的 Team ID 加 Bundle IDpaths可以精确到某个路径也可以用通配符。注意 iOS 会因为 CDN 或者 AASA 文件更新延迟导致配置不生效调试的时候这个问题特别头疼。1.3 两条路怎么选我的习惯是全都接。因为需求往往不是“二选一”而是“都要”。做渠道归因的时候大多数第三方归因 SDK 本身用的是 URL Scheme 那套因为它的跳转链更短——直接一个协议就能拉起。而微信朋友圈、Safari 里点击链接拉起 App苹果更推荐 Universal Links。到了某些聚合广告平台那里还坚持用 Scheme 来唤醒。所以实际工程里两条路都要走。URL Scheme 负责响应各种自定义协议Universal Links 负责响应标准网页链接。关键在于两条路线进到原生层之后统一整理成一个规范格式再投给 Unity。这就是我们常说的“统一入口设计”。不要搞两套处理方法否则后面 Unity 侧的逻辑会炸裂。2. Unity 工程的桥接层设计与参数协议2.1 原生层要做什么一旦决定“两条路都接”原生层的职责就很明确了监听 URL Scheme 和 Universal Links 的回调。把回调的原始来源Scheme 还是 Universal Link和完整 URL 包装成一个统一结构体。判断当前 Unity 是否已经就绪。把包装好的内容投递给 Unity 侧。理清职责之后你会发现这活儿的核心其实不是“调起”而是“投递的时机管理”。因为 Unity 的运行时代和原生层是独立的你不能保证 App 被唤起的时候 Unity 引擎已经跑起来了。尤其是冷启动场景App 是被 Deep Link 直接拉起来的此时 Unity 还在初始化你要是直接调用 UnitySendMessage消息会发不出去。2.2 从原生注入 Unity 的三条常规路径Unity 暴露给原生层做通信的接口非常有限常规有三条第一条是UnitySendMessage。这是 Unity 官方提供的最简单方式接收方场景里必须存在指定的 GameObject 和挂在它上面的脚本方法。缺点是如果那个 GameObject 在场景加载完成后才被创建或者脚本被禁用消息照样无效。高版本 Unity 开启 IL2CPP 代码裁剪后还会出现方法被裁掉的情况。第二条是丢PlayerPrefs。原生层把 Deep Link 字符串写到PlayerPrefs里Unity 侧在启动流程里主动去读。这法子笨但非常可靠因为PlayerPrefs本质是写 plist 文件不存在“引擎没起来”的情况。缺点是要处理写和读之间的同步问题而且只适合传递简单字符串。第三条是走 SDK 初始化回调。如果你接了某种渠道 SDKSDK 的初始化流程里有一个“等待唤起参数”的原生接口原生层先把参数传给 SDK等 Unity 初始化完成后从 SDK 那边主动拉取。这种是最稳的但前提是你确实用了这种带队列机制的 SDK。这三种方式我在项目里不是只选一个而是组合使用UnitySendMessage负责“热启动时的实时投递”PlayerPrefs负责“冷启动时的兜底存储”。这样两套互补不会因为单一方案失效导致参数彻底丢失。2.3 参数协议设计一次把话说清这个环节是最容易被忽略但最重要的。Deep Link 本身是 URL 格式比如mygame://open?pageactivityid12345fromwechat https://yourdomain.com/open?pageactivityid12345fromwechat到了 Unity 侧业务层想知道的是打开的是哪个页面、页面 ID 是什么、从哪里跳过来的。你完全可以让业务层自己去 parse 这个 URL但那样做的结果就是每个用 Deep Link 的模块都要写一遍解析代码这就是麻烦的开始。我推荐的做法是在原生层就完成解析把结果转成一个固定格式的 JSON 字符串投给 Unity。规则是解析出源类型urlscheme还是universal、完整路径、查询参数。举个例子{ source: universal, originalUrl: https://yourdomain.com/open?pageactivityid12345fromwechat, path: /open, page: activity, id: 12345, from: wechat }原生层解析好Unity 侧拿到 JSON 直接左键鼠标一把梭。这样做的好处有俩第一业务代码不用关心 URL 编码、特殊字符、参数截断这些细节第二如果以后要接 AndroidAndroid 那边也套同样的 JSON 协议Unity 层完全不用改。URL 里的参数一定要做 URLDecode。我就遇到过%E5%95%86%E5%93%81这种中文没解码直接传到 UI 层变成一坨乱码的尴尬情况。3. 冷启动与热启动状态机才是核心3.1 为什么必须区分冷启动和热启动冷启动指 App 已经完全退出被 Deep Link 直接拉起热启动指 App 已经在后台或者前台运行被 Deep Link 从后台唤回前台。两者的区别决定了投递方式。热启动时 Unity 引擎已经是活的你可以立刻把参数传过去冷启动时 Unity 引擎可能还在黑屏阶段收到参数的脚本还没加载你传了也白传。另一个隐藏问题是时序。iOS 的回调发生在 App 进入主循环前还是后、Unity 的Awake和OnSceneLoaded什么时候触发这些顺序完全是散的。你要是把“等收到 Deep Link 参数再初始化业务模块”的逻辑写反了就会出现业务模块已经初始化完成但参数还没到、或者参数到了但 UI 还没创建这类问题。3.2 缓冲区的实现与生命周期既然存在“参数先到业务后到”的情况那就得在 Unity 侧塞一个缓冲区。我习惯在项目里加一个DeepLinkManager单例启动时自建一个 pending 队列public class DeepLinkManager { private static Queuestring _pendingLinks new Queuestring(); private static bool _initialized false; public static void OnDeepLinkReceived(string jsonPayload) { if (!_initialized) { _pendingLinks.Enqueue(jsonPayload); } else { ProcessLink(jsonPayload); } } public static void LateInit() { _initialized true; while (_pendingLinks.Count 0) { ProcessLink(_pendingLinks.Dequeue()); } } }这里面的LateInit要在什么时机调用我的做法是在游戏主入口流程里比如LoginScene加载完成并初始化完基础 UI 框架之后调用。因为这个阶段业务模块已经具备处理参数的能力了把缓冲的参数交出去是安全的。请特别注意这个缓冲队列千万不要清空得太早。某些业务逻辑可能只是暂存了参数并没有立刻使用如果你在一次循环里 pad 完了还把它清掉后面其他模块再读就没了。3.3 一次唤醒的完整链路我直接把一次完整链路写出来让大家感受下玩家在微信里点了一个链接https://yourdomain.com/open?pageeventid88。iOS 系统拦截到这个链接检查 Associated Domains 配置确认命中你的 App于是唤起你的 App。你的 App 冷启动原生 AppDelegate 的didFinishLaunchingWithOptions里带了一个launchOptions[UIApplicationLaunchOptionsURLKey]这表示用户是从 Universal Link 冷启动进来的。此时 Unity 还没起来原生层把这个 URL 解析成 JSON然后做两件事先用PlayerPrefs保存一份再尝试调UnitySendMessage投递。因为你工程里 Unity 接收方的 GameObject 还没创建所以UnitySendMessage大概率失败但没关系JSON 已经备份到PlayerPrefs了不丢数据。Unity 场景加载完游戏主入口跑起来DeepLinkManager.LateInit被调用。它会先读PlayerPrefs里备份的 JSON再接着检查缓冲队列最终把参数交给业务模块。业务模块拿到参数之后弹出“你要进入活动页 88 吗”的弹窗或者直接自动跳转。而热启动的情况就简单得多Unity 一直活着原生层调用UnitySendMessage直接进DeepLinkManager.OnDeepLinkReceived_initialized为 true参数当场被消费掉。4. 常见问题与排障实录4.1 UnitySendMessage 为什么偶尔投递失败这是我最常被问到的问题没有之一。UnitySendMessage有它的天然限制接收方必须是场景中实际存在的 GameObject方法必须是重载 MonoBehaviour 实例的方法而且方法名要和方法保持一致。高版本 Unity 在 IL2CPP 开 Managed Stripping 时会把一些没被引用或者被判定为“未被 C# 调用”的方法给裁掉而这恰恰是原生层调用的方法——它是原生调用不是 C# 调用因此编译器不知道这个方法是入口点。解法是在被调用的方法上增加[UnityEngine.Scripting.Preserve]特性或者在 Strip 设置里排除这个类。using UnityEngine.Scripting; public class DeepLinkHandler : MonoBehaviour { [Preserve] public void OnDeepLinkReceived(string jsonPayload) { DeepLinkManager.OnDeepLinkReceived(jsonPayload); } }如果你在调试的时候发现UnitySendMessage一直调不通另一个原因是 GameObject 的名称对不上。场景里那个挂脚本的对象可能不叫DeepLinkHandler名字在 Awake 里被改名了原生层那边还是用的旧名字。这坑太隐蔽了我上次排查这个问题的经验是先在 C# 侧挂一个 Debug 日志输出再在原生层调通后会看到 Unity 日志打出来由此反向确认名称匹配。4.2 Universal Links 为什么在调试时死活不生效Universal Links 配置不生效的原因非常多我在实际项目里踩过的就有开发者后台那个Associated Domains能力没开、provisioning profile 没重新下载、AASA 文件 JSON 格式不对、HTTPS 证书用了不受信任的私有 CA、域名被 CDN 缓存了旧 AASA。调试时排查起来相当恼人。我自己总结了一套排查顺序。先看这个 AASA 文件在 Safari 里能不能直接访问到确认路径和 JSON 都对再用一个手势验证从备忘录里输入这个链接长按如果弹出了“在“你的 App”中打开”说明系统已经关联上了之后在 Xcode 的 Device 面板里查看 console 日志能看到系统关于 Universal Link 匹配的原始日志这才是最靠谱的调试信息。注意在同一个设备上iOS 只信任 app 安装时或者 App 启动时加载过的 AASA。如果你改了 AASA 文件即使服务器上已经更新设备上也得重装 App 或者重启才能重新拉取。调试时一定要有耐心。4.3 参数里的中文字符变成乱码URL 里头带中文或者特殊字符极容易出现乱码。比如用户在活动页的标题是“签到有礼”运营把链接拼成https://yourdomain.com/open?title签到有礼到 Unity 侧一解析变成了%E7%AD%BE%E5%88%B0%E6%9C%89%E7%A4%BC有的接口没做 decode 就展示到 UI 上直接乱成一团。正确的姿势是在原生层做完整 URL 解析时对每个查询参数都调用removingPercentEncoding或者stringByRemovingPercentEncoding。如果拼接方自行做了二次编码你还需要先处理一下。如果参数里本身带或者这种直接 “字符串拼接 URL” 的做法本来就是错的应该用 URLComponents 去拼让系统帮你做好编码。4.4 参数没有在第一次启动时完整传给业务这种“时有时无”的 Bug 最致命因为它不是必现的需要多次冷启才能复现。通常的根因有三种。第一种是当时用按内存时序投递但 Unity 启动时异步加载某个场景还没完成参数处理模块跑在场景加载之前。这时候你用LateInit兜底就能解决也就是我上面说到的队列缓冲。第二种是重登录场景。比如游戏登录结束后场景被重新加载而DeepLinkManager还在旧的单例里新场景没法访问到它。这种情况我会建议把DeepLinkManager放到一个持久化的对象身上比如挂在启动场景里并设置DontDestroyOnLoad。第三种是参数被“消费”了就清理但下游业务模块是多点监听A 模块消费完清空了B 模块后面又去读结果读了个空。这种情况要在协议层面区分——有些参数是一次性消费有些参数是全局共享全局共享的要存到单独的位置不要跟一次性队列混在一起。5. 实战调试工具与方法论5.1 三类实用的调试发起点开发调试时最烦的就是没法方便地模拟 Deep Link。我的做法是准备三个入口第一个是 Safari 模拟。直接用 Safari 打开你的 Universal Link 链接系统会跳转。注意 iOS 13 以后如果用户没开启 Universal Link 对应的 App 开关系统会直接在 Safari 里打开网页这个开关在设置里面可以找到检查一下。第二个是备忘录模拟。把链接粘在备忘录里长按点击“在“你的 App”中打开”。这个很适合验证 AASA 是否被 App 当前安装版本信任。第三个是 Xcode 的 URL Scheme 触发。在真机或者模拟器上用xcrun simctl openurl booted mygame://open?pagetest这个命令直接唤起模拟器的 Scheme 注册。真机上可以在 Safari 地址栏输入你的 scheme或者用网页里的a hrefmygame://...来触发。实际开发中我还会在原生层加一个手动触发的测试按钮直接在 App 内伪造一条 Deep Link 消息投递给 Unity这样连系统解析都跳过专注验证 Unity 侧的处理逻辑。这个入口对单元测试特别有用。5.2 如何打点日志来定位时序问题Deep Link 的时序问题排查极其依赖日志。我在原生层和 Unity 层的出入点都打了带时间戳的日志格式约定为[DL][原生][进入回调]、[DL][原生][发出投递]、[DL][Unity][收到消息]这样的统一前缀。在 Unity 侧用Application.consoleLogTags不同版本 API 可能不同一般直接 Debug.Log打时间戳。通过比对两边的日志能瞬间看到“原生投递时 Unity 侧有没有活着”。要是发现 Unity 侧一次性收到好几条排队的参数说明缓冲队列生效了流程没问题也不用管。另外一个调试技巧在编辑器里也能测 Deep Link。Unity 编辑器运行期间我通常会在 Inspector 上搞一个调试面板手动粘贴 URL 触发DeepLinkManager.OnDeepLinkReceived。这样无需真机就能验证业务模块对参数的处理逻辑但话要说在前头这种验证阻止不了原生回调那些坑只适合业务层联调。5.3 上线前的配置核对清单这部分是我个人长期形成的核对表每次提审前都过一遍避免上线后出问题找不到原因开发者后台 Associated Domains 能力是否已经开通provisioning profile 是否已经更新并且包含新的 entitlementXcode 工程 Associated Domains 里 Domain 拼写是否正确服务器上 AASA 文件路径是否可公开访问JSON 是否合法AASA 里的 appID 是否正确Team ID 加 Bundle IDApp 的 URL Scheme 是否在 Info.plist 里注册冲突情况是否排查过冷启动路径下 PlayerPrefs 兜底是否正常写入Unity 侧[Preserve]是否加在接收方法上特殊字符中文、、、%在模拟链路中是否显示正确这张清单我打印过好几次每次排查问题时都会先对着清单逐项排除。AASA 文件的“路径匹配优先级”也是坑点之一。假设你的详情配置里既有精确路径又有通配符iOS 会按照 AASA 文件里给出的顺序匹配一旦前面的匹配规则挡了后面的就会导致某些路径跳不了。反正我见过不少项目被这个问题折腾后来干脆只保留一套精确规则。6. 一点经验总结API 调用谁都会Google 一下五分钟能懂但这些方案真正用起来坑全在时序、状态管理和配置细节里。尤其是冷启动状态下的参数投递如果不做缓冲区别的实现了也是纸糊的。我做这个功能的时候前前后后改了三版第一版只接了 URL Scheme发现运营在微信里发不了链接第二版接了 Universal Links 但没做冷启动缓冲区结果活动页参数经常丢第三版完善了统一 JSON 协议和字节存储兜底之后才稳定下来。最后把 Android 那边的 Deep Link 也套了同一套协议Unity 业务层几乎零改动地接上了。如果你当前正被 Deep Link 唤醒问题卡住我建议先别急着查代码先把“冷启动、热启动”这张状态图在纸上画出来理清“谁先执行、谁等谁”再动手写代码。这个思路能帮你在接入之前就避免掉大部分时序雷区。最后再分享一个小技巧调试 Universal Links 时可以把https://yourdomain.com/.well-known/apple-app-site-association的访问记录和服务端日志打开这样你能确认是“设备没来拉”还是“服务器发错了”。这比你在 Xcode 里反复猜要高效得多。
返回列表