Unity 手游 iOS Deep Link 唤醒全流程:从 URL Scheme / Universal Links 到 C# 层参数投递
做手游运营或者用户增长的同学应该都有同感:一条短信、一个分享卡片、一个广告点击,用户点下去之后能不能直接落到游戏里的指定界面,直接决定了活动转化率能差出好几个点。这里的核心链路就是 iOS 上的 Deep Link(深度链接)——用 URL Scheme 或者 Universal Links 把用户从外部拉进 App,再把携带的参数一路送到游戏逻辑层。这事在 Unity 手游里尤其容易翻车,因为多了一层原生和 C# 的桥接,参数一丢就是一场线上事故。这篇文章把我做 Unity iOS 手游 Deep Link 全流程的配置、踩坑、参数投递方案一次性讲透,适合正在接邀请活动、分享回流、广告归因的 Unity 客户端开发同学参考,也适合刚接触 iOS 原生桥接的朋友当一份实操手册。
先说结论:整个链路拆开看其实就三段。第一段是 iOS 系统层面怎么识别链接并唤醒 App,这是 URL Scheme 和 Universal Links 的配置问题。第二段是 Unity 引擎和原生层之间的接力,系统回调把 URL 交到哪、 Unity 什么时机能拿到它。第三段才是真正考验工程能力的地方——冷启动、热启动、场景没加载完、参数特殊字符,任何一个环节处理不到位,都会导致用户明明点进来了却卡在登录页,还查不到任何报错。
1. 方案选型:URL Scheme 和 Universal Links 怎么选,能不能只配一个
很多刚接 Deep Link 的同学上来就问:现在 Universal Links 是不是已经全面替代 URL Scheme 了?我可以直接说:目前主流项目基本都是两者并存,URL Scheme 做兜底,Universal Links 做主力。原因不复杂,两个机制解决的问题有交叉但不完全一样,放在一起用才是最优解。
1.1 URL Scheme 的工作机制和适用场景
URL Scheme 是最古老也最直接的方式。你在 Info.plist 里注册一个自定义协议,比如mygame://,然后 iOS 系统在用户打开mygame://open?room=123这样的链接时,会直接唤起注册了这个协议的游戏 App。整个过程系统会弹一个确认框,询问是否用该 App 打开,这是它体验上最大的短板,但也正因为有明确的系统确认,URL Scheme 被第三方 App 劫持的概率是可控的。
我在项目里通常把 URL Scheme 用在两个地方:一个是 H5 页面发短信或者扫码场景下的兜底跳转,因为 Universal Links 偶尔会被外部 App 内嵌浏览器拦截,Scheme 是最后一道保险;另一个是跳转到系统设置页、或者和其他已安装 App 之间做定向通信,这种场景只有 Scheme 能做。URL Scheme 的格式也很灵活,参数可以放在 path 上,也可以放在 query 上,iOS 不会做任何校验,到了 App 里你想怎么解析都行。
1.2 Universal Links 的机制、AASA 文件和关联域名
Universal Links 是 Apple 从 iOS 9 开始推的标准方案,它的核心是:你在自己的 HTTPS 域名下放一个apple-app-site-association文件(一般简称 AASA),文件里声明哪些 path 对应哪个 App 的 bundle ID,然后在 Xcode 的 Associated Domains 里配置相同的域名。配置好之后,用户点击https://example.com/openGame?room=123这类链接,如果手机上装了对应 App,系统会直接拉起且不弹确认框;如果没装,这个链接就在 Safari 里正常打开你的网页,不会报错。
这里要注意一个关键点:AASA 文件必须通过 HTTPS 访问,且不能有重定向,返回值必须带application/json的 Content-Type。文件内容核心就是一个 applinks 字典,里面 details 数组标识哪个 App 响应哪些 path 规则。很多第一次配置的同学在这里翻车:文件确实放在根目录了,但没有处理移动端 Safari 的缓存,改了配置之后手机上一两天还是不走 Universal Links,实际上 iOS 对 AASA 的缓存非常激进,最稳妥的验证方法我后面单独说。
1.3 实际项目里的选型建议和兜底逻辑
我的习惯是 Universal Links 和 URL Scheme 全量配置,但业务逻辑必须优先走 Universal Links。原因很实际:Universal Links 没有弹窗,用户点击链接到进入游戏的过程干净顺滑,体验最好;而且 Universal Links 在 iOS 的 App 内嵌浏览器、系统短信、邮件里都能正常工作,不会被某些大流量 App 的 webview 劫持。URL Scheme 就留在后面当 Plan B,比如某些第三方 SDK 内部跳转、扫码落地页,或者用户手机系统版本太旧不支持 Universal Links 时,拿来做降级处理。
从 iOS 13 开始,Apple 其实也收紧了一部分 Scheme 的权限,比如限制第三方 App 唤醒系统设置页的能力,但对普通游戏自建 Scheme 影响不大。个人建议:如果你的游戏需要面向外部投放渠道做大量落地页跳转,Universal Links 一定要作为线上主链路来维护,因为投放系统很多都要求使用标准的 HTTPS 链接来做点击归因,URL Scheme 在部分渠道的数据回传上是有兼容问题的。
2. 工程配置:Xcode 原生侧和 Unity 导出工程的联动
方案定下来之后就是配置落地。这一步的难点不在于信息量大,而在于很多 Unity 工程是交给自动化打包机出包的,手动改 Xcode 工程一次两次可以,每周出包的时候就非常痛苦。所以我们要把原生的配置和 Unity 打包后的自动化修改结合起来做。
2.1 URL Scheme 在 Info.plist 里的配置方法和坑
URL Scheme 的配置位置在Info.plist的CFBundleURLTypes数组。一个常见的配置项长这样:CFBundleURLName写一个标识符,CFBundleURLSchemes数组里写你的 scheme 名。一个 App 可以注册多个 scheme,但我不建议这么做,因为 iOS 的 scheme 匹配是全局的,注册得越多,被其他 App 误唤起或者你误唤起别人的概率就越大,保持一个唯一 scheme 就够用了。
如果你是手动出包,直接在 Xcode 的 Info 标签页里加就行。但 Unity 项目我强烈建议用PostProcessBuild自动注入,脚本里使用PlistDocument读取导出的Info.plist,然后添加字典到CFBundleURLTypes。这里有个容易忽略的细节:PostProcessBuild的执行时机要选择BuildTarget.iOS的后期处理阶段,因为 Unity 导出 Xcode 工程之后已经生成了 plist,但还没有进行签名,此时修改 plist 是生效的。如果脚本写错执行时机,改的是临时文件,打包机上根本不会生效。
2.2 Universal Links 的关联域名配置与 AASA 文件细节
Universal Links 在 Xcode 里的配置在 Signing & Capabilities 面板,需要添加 Associated Domains 能力,然后填applinks:example.com。这里有个很常见的误区:很多人只填了域名,没注意主域名和子域名的关系。AASA 文件的获取是以你填写的域名为准,如果你配置的是applinks:open.example.com,那系统会去https://open.example.com/apple-app-site-association拉取文件,而不是example.com根域。所以统一管理域名和 AASA 文件的部署路径很重要。
再强调一次 AASA 文件本身的关键字段:
appID必须写成TeamID.BundleID的格式,中间是点,不是别的符号。paths数组支持精确匹配、通配符和排除符,但规则比正则简单得多,通配符只有*和?,而且不能嵌套使用。- 文件最大不能超过 50MB,但实际建议控制在 64KB 以内,因为 iOS 会做大小校验,超大文件直接判定无效。正常游戏用不到那么多 path,把规则配精确就行。
比较稳妥的做法是 AASA 文件同时放到域名根目录和.well-known目录,因为 iOS 有两个请求路径,双保险能应对不同 iOS 版本的请求差异。放置完成后,用 Safari 直接访问这个文件,如果浏览器能打开且显示 JSON,说明基本部署没问题。
2.3 Unity 导出工程里怎么自动注入 Universal Links 配置
Unity 导出工程默认没有打开 Associated Domains 能力,而且 Xcode 的 Capability 配置本质上是修改工程的 project.pbxproj 文件。在PostProcessBuild里操作 pbxproj 是比较繁琐的,好在社区里有成熟的工具库让人可以直接修改工程配置,比如通过加载 PBXProject 对象,AddCapability 或者手动往 Entitlements 文件加 key。我个人的习惯是:用脚本生成 Entitlements 文件,并且把com.apple.developer.associated-domains数组写入域名,同时要修改工程的 CODE_SIGN_ENTITLEMENTS 设置指向这个文件。
这套逻辑如果纯手写很痛苦,但好在 Unity 的 iOS 打包本身就会处理签名相关配置,只要你脚本在PostProcessBuildAttribute里正确执行,就不会和 Unity 原生的签名工程冲突。还有一点记得处理 bitcode 的影响,不过现在 iOS 打包已经基本不用 bitcode,新项目不需要考虑,老项目的话还是建议手动验证一下整个导出工程能不能正常归档。
2.4 iOS 13 后的 SceneDelegate 陷阱和缓存问题
配置做到这里,很多人以为就完事了,但我必须专门提一个版本差异问题。iOS 13 之后如果 App 支持 SceneDelegate 生命周期,那么某些回调方法不会走到AppDelegate,而是会走到SceneDelegate的scene:openURLContexts:。很多从老项目迁移过来的游戏,代码还在AppDelegate的openURL里,结果线上用户从 iOS 13 开始就发现 Deep Link 部分失效,查了几天才发现是生命周期变了。
如果你用的是 Unity 官方导出的原生工程,它默认没有启用 SceneDelegate,所以这个坑对大部分 Unity 游戏来说不会触发。但只要你接入了某些第三方登录、广告 SDK,这些 SDK 可能会自动引入 Scene 生命周期支持,这时候就不得不处理这个分支。我的建议是:原生侧统一封装一个 DeepLinkHandler 单例,从AppDelegate和SceneDelegate两边都往它里面投递 URL,谁先收到都无所谓,最终由它统一转发给 Unity。
另一个高频坑就是 AASA 的缓存。iOS 对 AASA 的缓存通常是最少几小时、最多可能一两天。开发测试的时候改了文件想立刻验证,最好的方法是:App 没启动时,用 Safari 打开你的 Universal Link,然后等拉起 App 后,杀掉 App,再修改 AASA 重新测试;如果一直不生效,可以把手机上的 App 删除重装,或者用设置里的"关闭 App"来强制刷新,但这不是 100% 有效。经验之谈:测试 AASA 修改,最好保留一台专门的测试机,不装其他竞品 App,减少变量。
3. Unity C# 层接参:从原生回调到游戏内参数分发
原生配置全部到位,系统能正常拉起 App 了,接下来就是最核心的 C# 层接参。这一块我说实话,是最容易出问题、也最考验基本功的部分,因为涉及到 iOS 原生和 Unity 的时序问题。用户的 App 可能根本没启动,可能已经启动在后台,可能正在看战斗结算动画——不同状态下,系统把参数给你的时机和形式完全不一样。
3.1 冷启动、热启动和后台恢复的区别
先明确三个概念。冷启动:App 进程不存在,用户点击链接后系统拉起进程,此时 Deep Link 参数包含在进程启动参数里。热启动:App 进程已经在后台存活,用户点击链接后系统直接唤起已有进程,参数通过系统回调方法传入,走不到启动参数。后台恢复:App 进程刚被系统杀掉但还残留在后台切换列表中,重新拉起时相当于冷启动但速度极快,参数来源和冷启动一致。
在 iOS 原生层面,冷启动时 AppDelegate 的didFinishLaunchingWithOptions里能拿到UIApplicationLaunchOptionsURLKey,Universal Links 在冷启动时也可能通过continueUserActivity回调抵达,但不会出现在didFinishLaunchingWithOptions里。热启动时,URL Scheme 走application:openURL:options:,Universal Links 走application:continueUserActivity:restorationHandler:。所以原生侧必须同时实现这些方法,并且在方法实现里统一把 URL 组装成标准字符串,转发到一个共享入口,而不是各自处理各自的。
3.2 Unity 官方提供的 deepLinkActivated 事件与原理
Unity 从很早就提供了Application.deepLinkActivated事件,但很多同学不知道的是,这个事件在 iOS 上的触发时机比原生回调晚一拍。原因很简单,官方实现是在原生侧收到 Deep Link 之后,把参数通过 UnitySendMessage 发给一个固定的 GameObject,再由引擎层转发给 C# 侧。这个过程跨越了原生和托管两个环境,中间必然有时序差。
而且要注意,deepLinkActivated只在有新的 Deep Link 到达时触发。如果 App 已经起来,你注册事件之前收到的链接,它是不会补发给你一个"历史事件"的。所以更稳妥的方式不是只依赖这个事件,而是用"当前 URL + 事件通知"的组合。我封装的时候一般会额外保存一份latestDeepLinkURL,不管是冷启动还是热启动,只要原生层拿到了 URL,就立刻同步给 C# 层存到一个静态属性里。等游戏主流程启动完毕,再从这个静态属性里取参数去消费。这种做法比纯粹等事件要稳得多,因为事件可能会丢,但静态值不会丢。
3.3 从原生到 C# 的可靠投递:缓存优先,事件辅助
这里讲一下我最终落地的投递方案。原生侧持有一个NSString类型的缓存变量和一组待回调的 block,当系统把 URL 传进来时,原生先把 URL 保存进缓存,同时判断当前 Unity 是否已经 ready。判断方式可以是 Unity 官方接口的UnityFramework是否有效,或者你自己在 C# 侧 Awake 之后调用一个原生方法标记 Ready 状态。如果 Native 拿到 URL 时 Unity 还不 ready,就先存着;等 C# 侧主动调用原生查询方法时,原生把缓存的 URL 返回。
C# 侧的逻辑是:在游戏启动入口注册deepLinkActivated事件,同时马上调用原生查询接口拿一次缓存值,两边可能拿到同一个 URL,要去重处理。去重的标准不能只看 URL 字符串是否相等,还要看时间戳或序号,因为同一用户连续点击两次同一个链接是真实存在的,你不能当成重复请求丢弃。原生侧每转发一次 URL,就递增一个自增序号,C# 侧用"URL + 序号"二元组去重,这样既不会漏事件,也不会把同一链接的二次点击误判成重复。
3.4 参数解析与协议约定:URLDecode 和参数命名
参数到达 C# 层后,第一件事是解码。iOS 在传递 URL 时不会替你解码 query 里的内容,而 H5 侧拼参数时经常会把中文、特殊字符做 URLEncode,所以 C# 层必须先用UnityWebRequest.UnEscapeURL或Uri.UnescapeDataString处理一遍整串 query,再去逐字段解析。这里有个很典型的坑:如果你先按&分割再解码,那某个参数值里如果包含被编码过的&(比如%26),先分割就会把值切断。正确顺序是:先对 query 整体做解码,再按&分割。但整体解码也会出问题,如果参数里有未编码的空格,系统可能把空格自动转成+,解码之后要处理+还原成空格的问题。
协议命名上,我的建议是 Game 内部统一定义一套 Deep Link 协议格式,比如scheme://open?scene=lobby&uid=10086&token=abc,解析层只负责把顶层参数转成字典,再交给业务层去决定跳转。不要把业务逻辑写在协议解析器里,否则以后加一个页面就要改一次解析器。解析完参数之后,还要注意参数是否过期的问题,链接里如果带了时间戳,要在业务层判断有效期,防止老链接反复刷奖励。我们线上就吃过这个亏:分享链接没加过期时间,结果有玩家把链接存下来反复点,一个活动奖励领了几十次。
4. 实操中的状态机设计与避坑实录
接 Deep Link 最怕的不是功能做不出来,而是各种时序问题在测试的时候没暴露,上了线上才炸。我把自己在实际项目中遇到过的典型问题整理成状态机和排查清单,按这个思路去设计,可以少走很多弯路。
4.1 冷启动时序:场景没加载完参数就来了怎么办
冷启动最麻烦的情况是:用户点击了一个邀请链接拉起 App,但此时 Unity 引擎刚刚初始化,游戏场景还在加载 Loading 界面,你这个时候去执行跳转指令,场景里的 UI 都还没实例化,跳转必然失败。我一开始的做法是直接在参数到达时执行跳转,结果线上反馈一批用户卡在初始场景,后来日志一看,都是因为跳转时目标场景尚未加载。
正确的处理方式是引入一个延迟队列。C# 层拿到 Deep Link 参数后,不要立刻消费,而是先存到PendingDeepLink结构里,等到你的游戏主场景跑完初始化流程、主相机和 UI 根节点都就绪后,再去消费这个参数。判断"就绪"的标准可以是你的全局游戏状态机切到了主界面状态,或者某个IsMainReady标志位变为 true。如果参数到达时游戏已经 ready,那就直接消费;如果没有 ready,就排队等待 ready 信号。这个队列要支持先进先出,因为用户在外部可能连续点击了多个链接,最后一个到达的链接往往是最新鲜的操作,业务上应该优先处理最新参数。所以队列可以不设长度,每次新参数到达时覆盖掉上一个未消费参数即可,或者用堆栈结构,取栈顶最新值。
4.2 热启动场景:App 在后台时链接来了,场景切换要小心
热启动时游戏进程还在,UI 状态完整,看起来没什么难度,但实际有另一个坑:App 从后台被拉起时,正在显示的可能是战斗场景、结算界面、商店页面,你一上来就按 Deep Link 参数切场景,会打断玩家当前操作。比如玩家正打 Boss 打了一半,突然点了个广告链接跳回游戏,如果直接按参数跳转到活动页,玩家的对局就没了,这种体验非常伤留存。
所以热启动的消费逻辑应该是:如果游戏正在战斗状态,先把 Deep Link 参数暂存,弹一个确认框提示玩家"有一个活动入口要进入,是否现在前往",确认后再执行跳转。在技术上,这要求在游戏状态机里暴露一个"当前是否可以切场景"的接口,消费 Deep Link 前先检查这个接口。不能只看场景名,因为战斗场景里还有结算流程,可能处于不可打断的阶段。这种细节如果不在设计阶段考虑,上线后一定会收到玩家投诉。
4.3 原生层回调和 C# 事件收不到的双重保险
我踩过的最深的一个坑是:iOS 原生层明明已经收到了 URL,但 C# 侧始终收不到deepLinkActivated。后来发现原因是我们工程里同时有 Unity 官方的 DeepLink 处理逻辑,又有自己接的原生桥接,两边通过 UnitySendMessage 各自发消息,结果其中一个把 Unity 侧接收消息的 GameObject 销毁了,另一个就再也发不进去了。类似这种"回调被干扰"的问题非常隐蔽,而且只在特定包体上出现。
为了避免这种问题,我将所有原生到 C# 的消息统一走一个固定的常驻 GameObject,这个对象在游戏启动时创建,挂一个 DontDestroyOnLoad,并注册接收所有 native 消息的接口。原生侧发送的 UnitySendMessage 全部指向这个对象的方法,不要在多个地方各自发。同时,C# 侧在注册事件前主动向原生要一次缓存 URL,即使事件通道被外力打断,也能靠这次主动查询救回来。实际上,这套"被动收 + 主动拉"的双通道机制上线以后,Deep Link 丢失率几乎降到了零,我觉得是非常值得借鉴的防御式设计。
4.4 常见问题排查速查表
我把实战中最常出现的几个问题整理成一个排查表,开发的时候直接对照着查。这张表也是我每次带新人做 Deep Link 接入时必发的资料。
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 点击链接无反应,没有任何拉起动作 | 关联域名/AASA 未生效,或 URL Scheme 协议头写错 | 使用 Safari 直接打开链接看是否报错;检查 AASA 文件是否可访问且 JSON 格式正确 |
| 单独测试 Scheme 能拉起,但从 H5 点击不拉起 | H5 侧使用了 Universal Link,但 AASA 没匹配到 path 规则 | 对照 AASA 里的 paths 与实际链接 path 是否完全匹配,注意大小写 |
| 链接拉起 App 但参数为空 | 冷启动时读取时机太早,或 URL 编码/解码处理顺序错误 | 在原生层打断点或输出日志,确认系统回调里的 URL 是否完整;再确认 C# 侧解析前是否先整体解码 |
| 同一个链接被处理两次 | 热启动事件与冷启动缓存互相重复 | 使用原生层自增序号 + C# 侧去重字典过滤 |
| 部分 iOS 系统版本收不到回调 | SceneDelegate 与 AppDelegate 双生命周期问题 | 检查工程是否启用了 Scene 生命周期,两端都要实现回调且统一转发到单例 |
| 参数中的中文/特殊字符变成乱码 | 只做了分段解析,没有先整体 URLDecode | 修改解析顺序:整体解码 → 分割字段 → 再次处理+号为空格 |
| 游戏内已安装但 Universal Links 打开后跳转到了网页 | AASA 缓存或 App 关联域名配置未生效 | 删除重装 App;确认 TeamID.BundleID 完全正确;确认域名支持 HTTPS 且无重定向 |
5. 测试与上线前检查:可复现的验证步骤
开发完功能不等于能上线,Deep Link 这种链路长、跨端交互多的功能,必须有一套完整的测试方案和上线检查清单。我每次提测前都会按下面的顺序过一遍,确保万无一失。
5.1 本地调试的三种模拟方式和日志输出
第一种是 URL Scheme 的本地调试,在 Safari 地址栏直接输入mygame://open?scene=lobby&uid=123,回车之后系统会弹窗询问是否打开对应 App,点击确认就能拉起,这是最快捷的验证方式。注意 Safari 有时会尝试把 scheme 当成搜索词处理,需要在地址栏完整输入并敲回车,不能省略冒号。第二种是 Universal Links 的调试,需要你的域名和 AASA 文件已经部署到正式环境或测试环境,然后在备忘录里输入完整链接,长按点击或直接点击,观察是否拉起。第三种是外部 App 跳转测试,用系统自带的 Safari 打开一个包含了 Deep Link 的网页,或者用微信/QQ 的聊天窗口发送链接,模拟真实用户场景。
调试期建议在原生侧加一条基于 NSLog 的输出,把每次系统回调的方法名、URL 完整字符串、当前时间都打出来。C# 侧也加一个自定义日志,把deepLinkActivated的参数、是否 ready、队列状态都打印到日志文件。这样两端日志合起来一对,问题基本能定位到具体环节。
5.2 Universal Links 的线上真机验证方法
线上验证 Universal Links,最标准的方法是直接访问你这个域名的 AASA 文件地址,确认 JSON 内容正确后,用真机 Safari 打开一条匹配 path 的链接。如果系统拉起 App,说明整条链路是通的。如果没拉起而是展示网页,接下来逐个排查:先看 AASA 的 appID 是不是当前的 TeamID.BundleID,再看 paths 是否匹配,然后看是不是缓存问题。缓存问题的终极验证手段是:把 App 删除重装,重新点链接,如果恢复,说明是缓存导致;如果仍然不行,那就是配置问题。
更深入一点,你还可以在 Mac 上用curl模拟 AASA 文件的请求头,查看Content-Type是否为application/json,同时校验文件是否会被重定向。有些 CDN 服务默认给 json 文件加跳转参数,这会导致 AASA 校验失败。如果你的域名走了 CDN,AASA 文件的缓存策略最好设置成不缓存或短缓存,尽量降低 iOS 拉到陈旧配置的概率。
5.3 iOS 14 之后的隐私提示和用户体验细节
iOS 14 之后系统对 App 读取剪贴板会弹隐私提示,如果你的落地页或 H5 侧在跳转前把参数暂存到剪贴板、等 App 启动后再读取,就会触发这个弹窗,很影响体验。所以 Deep Link 参数永远不要依赖剪贴板传递,要走正规 URL 参数通道。另外,在 App 被拉起的过程中,如果游戏启动时间超过两三秒,用户可能会以为没反应,最好在启动阶段展示一个启动页,收到 Deep Link 参数后不要立刻藏掉启动页,等业务消费完成再切到目标界面,这样视觉上更顺滑。
如果你的游戏使用了 ATT 隐私弹窗,还要注意不要在 Deep Link 拉起后的首帧就弹出 ATT,此时用户可能还没反应过来,被弹窗打断之后流失率很高。合理的做法是优先展示落地页内容,等用户做出有效操作之后再弹授权。这不是 Deep Link 本身的问题,但属于接入后最容易伤害转化率的一个交互细节。
5.4 打包与线上监控的配合
Deep Link 的验证一定要在正式包(Release 模式)和提审包两种包体上分别测。Release 模式的日志会被裁剪,所以线上监控不能依赖日志,要在 C# 层把 Deep Link 接收事件和消费结果上报到数据平台。比如用户点击链接、App 拉起到启动完成、参数解析成功、跳转成功,这四个关键节点至少各埋一个事件。后面排查用户问题的时候,有埋点就能精确定位是系统没拉起,还是拉起后解析失败,还是跳转被业务逻辑拦截。
提审包还有个特殊问题:App Store 审核正是以 URL Scheme 和 Universal Links 为重点审核项之一。如果你的 App 注册了 scheme,提审时审核员会在 Safari 输入 scheme 尝试唤起,如果你没处理好导致唤起后闪退或白屏,是有被拒风险的。所以提审前一定要模拟审核员的路径:冷启动状态下点 scheme 和 Universal Link,确保 App 正常进入游戏主界面,不闪退、不卡在加载页。这个流程我建议写进自动化测试的 checklist,每次提审都过一遍。
6. 最终落地体会与进阶建议
整套流程走下来,我自己最深的体会是:Deep Link 的配置复杂度不在"知道几个 API",而在于每一层之间都有时序耦合。原生层不知道 Unity 引擎什么时候 ready,Unity 引擎不知道游戏业务什么时候可以安全跳转,业务层又不知道外部来的参数是不是新的。所以只要有一个环节用"简单直接"的方式处理,就一定会出线上问题。
把这些经验总结成一句话:原生层只负责"不丢参数",C# 层只负责"时序安全",业务层只负责"消费参数",三个角色各司其职。原生层用缓存变量保证任何时序下参数都不丢;C# 层用 ready 标志和待消费队列保证参数不会在错误时机被使用;业务层用统一入口保证每个参数只被处理一次。按这个思路去实现,不管未来 iOS 版本怎么改、业务需求怎么加,Deep Link 这一块都不会成为你反复修 Bug 的地方。
最后再分享一个小技巧:如果你的游戏以后要接多家广告平台归因 SDK,建议 Deep Link 的统一入口设计成"可以串接多个消费者"的形式,也就是一个 Deep Link 参数到达后,先让归因 SDK 消费一遍,再让业务跳转消费一遍,不要互相拦截。我见过一个项目,因为广告归因 SDK 把链接参数当成自己的归因数据消耗掉了,导致游戏内邀请活动的参数永远接不到,排查了很久才发现是两个模块在抢同一个数据源。入口和数据源分离,是这个环节减少未来冲突最重要的设计决策。