尧图精选

Unity手游iOS Deep Link实战:从URL Scheme到Universal Links的完整接入

🕒 发布时间:2026/10/1 23:22:56 📁 来源:尧图网络
很多做Unity手游的朋友第一次被要求搞定Deep Link往往不是出于技术热情而是被发行或运营拉进会议买量渠道说“我们SDK已经接了归因回调需要你们配合”活动运营说“H5页面要一键拉起App还要自动跳到签到页”市场同事说“邀请链接分享出去好友点进来得回到同一个房间不然裂变活动没法玩”。这些诉求剥掉包装之后底子是同一道题把一条外部链接里的参数安全地送进Unity的C#层并且在冷启动、热启动、新安装三种状态下都能对上号。我自己在几款Unity手游里完整接入过iOS端Deep Link从URL Scheme做到Universal Links中间踩了不少坑尤其是冷启动时序和参数投递这部分。下面按照实际趟过的流程来写先讲清业务诉求和触发场景再对比两条链路的取舍然后给出iOS原生层拦截、C#层接收、归因平台对接的完整方案最后附上真机自测清单。文章面向Unity开发同学也涉及iOS原生配合涉及代码的部分我会把两侧的实现都贴出来并把容易翻车的地方重点标出。1. 先搞清楚Deep Link到底在解决谁的什么事1.1 三条业务线共用同一套链路Deep Link在手游里最常见的三个业务来源是买量归因、邀请裂变和活动拉活。买量归因的场景是用户点了广告落地页引导下载装完后App启动时需要告诉归因平台“这个用户是从哪条广告进来的”平台会拼一个带渠道ID和广告ID的链接。邀请裂变则是老用户生成带房间号或者邀请人ID的分享链接好友点开链接后唤起AppApp要直接进入指定房间或者给邀请双方发奖励。活动拉活相对简单运营在短信、Push或者公众号文章里挂一个链接用户点击后App要从后台回到前台并弹出对应活动页。这三条线表面看着各自独立实际上就是一个数据管道外部链接进App解析出参数派发给对应业务模块。所以不要为每条业务线各做一套解析逻辑。统一做一个Deep Link入口参数按标准格式传递后续无论加多少推广场景都只是在这个入口上增加分发规则而不是再造一条链路。1.2 冷启动、热启动、新安装三类场景参数投递的难点不在解析而在启动状态的区分。冷启动是App进程不存在用户点击链接后系统把进程拉起来这种情况下didFinishLaunching阶段就能拿到链接但Unity引擎可能还没初始化完。热启动是App进程还在只是退到了后台用户点击链接后App回到前台这种情况原生层和Unity层都已经就绪投递相对简单。新安装则是最特殊的一种用户点链接时App还没装系统先跳App Store下载安装完成后启动App此时原始链接已经被系统丢掉拿不到任何参数。真正能拿到参数的方案叫Deferred Deep Link本质是归因SDK根据设备指纹反查点击记录再在激活时把还原出的参数交给业务侧。这一点很多刚接触的同学会误解以为自己监听一下启动回调就能拿到下载前的链接实测会发现什么都没有。1.3 没有Deep Link时的现场还原用一个邀请活动来感受差异。运营在活动后台生成邀请链接https://game.com/invite?room1024uid8888。用户A把这条链接发到微信里用户B点开浏览器展示落地页页面上一个“在App中打开”按钮。B点击按钮iOS弹窗询问是否允许打开App确认后游戏启动了却停在首页B还得自己跑到活动入口手动输入房间号1024才能找到A。有Deep Link的版本是另一个结果B点链接App启动后读取room1024直接拉起房间界面。转化率差距是肉眼可见的。所以Deep Link在这个语境下不是单纯的技术功能它是活动运营和买量素材能不能落地的底座。2. URL Scheme与Universal Links两条路线的技术权衡2.1 URL Scheme接入最快的自定义协议URL Scheme是iOS从早期就支持的唤醒方式本质是App注册一个自定义协议比如mygame://room?room1024。系统检测到这个协议时会把链接交给注册了这个协议的App。接入成本非常低在Info.plist的CFBundleURLTypes里声明Scheme然后在AppDelegate的openURL回调里处理即可不依赖域名和服务器。局限也很明显。第一如果App没安装系统无法处理这个自定义协议用户会看到“无法打开网页”的错误提示。第二iOS弹窗“是否打开App”多了一步操作转化流程上多一次打断。第三Scheme是全局的理论上存在冲突风险例如几个App都注册了mygame系统弹窗会让用户在多个App里选一个。2.2 Universal Links无弹窗的域名唤起Universal Links是iOS 9之后推出的方案思路是用一个普通HTTPS网址绑定App。用户在Safari或支持Universal Links的WebView里点击https://game.com/open?room1024系统检查域名根目录下的apple-app-site-association文件通常叫AASA部署在/.well-known/路径确认这个域名所有权归属于当前App就直接唤起App没有中间确认弹窗。这套方案的好处很明显未被安装时点击链接会正常打开网页网页可以承接下载引导而不是报错域名是唯一的不存在Scheme冲突系统从iOS 9开始支持覆盖面足够。代价是需要一个HTTPS域名、配置Associated Domains能力、服务端放置AASA文件并且AASA生效还有缓存延迟调试时经常要把App删掉重装才能让系统重新拉取配置文件。2.3 双通道并行是更稳妥的做法实际项目里我不会只选一条路。Universal Links作为主通道解决体验问题URL Scheme保留作为兜底处理一些老版本、部分受限WebView环境。两条链路在原生层统一转成同一套参数结构C#侧只面对一个入口不去区分到底哪条路进来的。有一个点需要特别提醒iOS 13之后如果App启用了Scene生命周期打开链接的回调位置和旧的AppDelegate方法有差异会在scene(_:openURLContexts:)里触发。这部分见下面原生层章节双通道方案里务必把两处入口都接住否则会出现“热启动一切正常冷启动参数丢失”这种极具迷惑性的Bug。维度URL SchemeUniversal Links接入成本低只改App工程中需域名、证书、AASA拉起体验有系统确认弹窗无弹窗未安装体验报错无法引导下载打开网页可放下载引导冲突风险同Scheme可能冲突域名唯一最低系统版本iOS 2iOS 93. iOS原生层拦截从AppDelegate到Unity的心跳3.1 Info.plist里需要处理的三个位置先说配置。URL Scheme要注册到Info.plist的CFBundleURLTypes里格式大致是这样keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourgame.deeplink/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /arrayUniversal Links对应的则是Associated Domains能力在Xcode的Signing Capabilities里添加applinks:game.com。如果你不确定域名是不是已经生效可以先查一下服务器上AASA文件是否可访问路径是https://game.com/.well-known/apple-app-site-association。文件内容是一个JSON只有真正上线了才能验证。另外如果App内还要判断其他App的Scheme是否存在例如调起微信登录需要在LSApplicationQueriesSchemes里声明对应的Scheme否则canOpenURL永远返回false。3.2 冷热启动两条入口的完整拦截实现iOS侧的核心逻辑放在一个路由类里统一接收URL转出参数JSON再尝试发给Unity。下面这份Swift代码同时处理了冷启动和热启动两条链路import UIKit import UnityFramework main class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) - Bool { // 冷启动App进程被链接拉起 if let url launchOptions?[.url] as? URL { DeepLinkRouter.shared.handle(url: url, isColdStart: true) } else if let activity launchOptions?[.userActivityDictionary]?[ UIApplication.LaunchOptionsKey.userActivity ] as? NSUserActivity, activity.activityType NSUserActivityTypeBrowsingWeb, let url activity.webpageURL { DeepLinkRouter.shared.handle(url: url, isColdStart: true) } return true } func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] [:]) - Bool { // 热启动URL Scheme 拉起 DeepLinkRouter.shared.handle(url: url, isColdStart: false) return true } func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: escaping ([UIUserActivityRestoring]?) - Void) - Bool { // 热启动Universal Links 拉起 if userActivity.activityType NSUserActivityTypeBrowsingWeb, let url userActivity.webpageURL { DeepLinkRouter.shared.handle(url: url, isColdStart: false) } return true } func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) - UISceneConfiguration { // iOS 13 若使用 Scene 生命周期这里会接管链接回调 return UISceneConfiguration(name: Default Configuration, sessionRole: connectingSceneSession.role) } }如果项目正在使用Scene生命周期不要在SceneDelegate里重复处理传入的URL最好都收口到同一个DeepLinkRouter避免同一份参数被分发两次。3.3 路由类里的缓存与投递逻辑DeepLinkRouter的职责是把URL标准化并维护一个待投递的payload。为什么需要缓存理由很直接冷启动时Unity引擎可能还没有初始化完成此刻调用UnitySendMessage没人接收。所以原生侧的做法是先把payload存下来等C#层告知“Unity已就绪”后再补投。class DeepLinkRouter { static let shared DeepLinkRouter() private var pendingPayload: String? private let unityBridgeName DeepLinkBridge private init() {} func handle(url: URL?, isColdStart: Bool) { guard let url url else { return } let payload DeepLinkRouter.normalize(url) pendingPayload payload sendToUnity(payload) } static func normalize(_ url: URL) - String { var params: [String: String] [:] if let components URLComponents(url: url, resolvingAgainstBaseURL: false) { params[scheme] components.scheme ?? params[host] components.host ?? params[path] components.path for item in components.queryItems ?? [] { params[item.name] item.value } } if let data try? JSONSerialization.data(withJSONObject: params), let json String(data: data, encoding: .utf8) { return json } return url.absoluteString } private func sendToUnity(_ payload: String) { guard let unityFramework UnityFramework.getInstance(), unityFramework.appController() ! nil else { return } unityFramework.sendMessageToGO(withName: unityBridgeName, functionName: OnDeepLinkReceived, message: payload) } func flushToUnity() { guard let payload pendingPayload else { return } sendToUnity(payload) pendingPayload nil } }normalize这一步很重要它把URL解析成结构化的JSON而不再是一个裸的URL字符串。比如mygame://room?room1024和https://game.com/open?room1024会被统一成{scheme:mygame,host:room,path:/,room:1024}C#侧不用关心来源只需要解析这一份结构。URL里的中文和特殊字符在queryItems阶段已经被系统解码过避免了C#侧手工处理URL编码时容易出错的问题。4. 原生层到C#层桥接设计决定了参数能不能安全到达4.1 UnitySendMessage的局限与对策Unity官方的跨语言通信接口是UnitySendMessage它有一个硬约束只能传递一个字符串参数且接收方必须挂在某个GameObject上方法必须是public。参数一多就必须做序列化JSON是这里最自然的选择。还有一个更隐蔽的问题如果UnityFramework尚未初始化完成UnitySendMessage会静默失败没有报错没有回调。冷启动时恰好最容易撞上这个窗口。很多团队第一次联调时热启动测试一切正常冷启动测试参数没了就是因为这层时序。抗住这个问题的设计是双保险一方面原生侧缓存payload在C#侧通知就绪后主动补投另一方面C#侧保留一个待处理队列即使因故没收到补投也可在进入游戏场景后主动向原生层拉取最后一次payload。把“推”和“拉”都做了参数才不会丢。4.2 C#侧桥接对象的完整实现C#侧需要一个常驻桥接对象建议放在启动场景里挂一个名为DeepLinkBridge的GameObject上并使用DontDestroyOnLoad保证它在切场景时不销毁。using System.Runtime.InteropServices; using UnityEngine; public class DeepLinkBridge : MonoBehaviour { public static DeepLinkBridge Instance { get; private set; } private string _pendingPayload; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } private void Start() { NativeBridge.NotifyUnityReady(); } public void OnDeepLinkReceived(string payload) { _pendingPayload payload; Debug.Log([DeepLink] 原生投递参数 - payload); DeepLinkDispatcher.Dispatch(payload); } public string PollPendingPayload() { return _pendingPayload; } }NativeBridge这里用DllImport导出iOS侧的一个C函数public static class NativeBridge { #if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void _NotifyUnityReady(); #endif public static void NotifyUnityReady() { #if UNITY_IOS !UNITY_EDITOR _NotifyUnityReady(); #endif } }iOS侧对应实现一个_NotifyUnityReady的C导出再调用Swift类的flush_cdecl(_NotifyUnityReady) func _NotifyUnityReady() { DeepLinkRouter.shared.flushToUnity() }这套模式的好处是C#侧的Start里明确告诉原生层“我这里已经standby”原生层只在这之后补投缓存既不抢占时序也不丢消息。4.3 参数标准化与分发设计C#侧收到JSON之后建议不要直接在桥接类里写业务逻辑而是拆一个DeepLinkDispatcher专门做参数解析和业务分发。解析时用Newtonsoft.Json或者Unity自带的JsonUtility都可以但JsonUtility对字典类型的支持偏弱我这里更推荐Newtonsoft。业务参数的结构要提前和运营约定好例如统一叫scene表示落地场景sceneParam表示场景参数再留一个extra承载渠道回传的补充字段。约定清晰之后每一条新业务线只需要在Dispatcher里新增一个case不用动原生层和桥接层。我在项目里还会额外打印一份收到参数的日志包含时间戳和来源标识排在定位问题时会省很多力气。5. 冷启动时序差参数投递翻车重灾区5.1 为什么冷启动时UnitySendMessage会丢消息把冷启动的过程拆开看用户点击链接系统启动App进程先执行的是AppDelegate的didFinishLaunching此时UnityFramework作为动态库被加载但C#侧的Mono运行时还没有跑起来场景对象也没有创建。如果你在这个时间点调用UnitySendMessage消息发出去后根本没有接收方而且没有任何错误提示。我最早一次踩坑就是在didFinishLaunching里直接调了UnitySendMessage热启动测了十几次都是正常的冷启动十次有八次丢参数查了半天还以为是Swift和C#桥接写错了。后来在原生层打了日志才发现消息比Unity启动完成“早到了”。5.2 缓存配合主动拉取是稳定解处理思路前面已经提到过原生侧在handle里把payload写入pendingPayload然后立刻尝试sendToUnity如果此刻Unity没就绪这个尝试是无效的但pendingPayload已经保留下来。等C#侧的Start触发通过_NotifyUnityReady通知原生侧原生侧再flush。整个链路用代码落地就是原生handle缓存payload尝试投递。引擎就绪C# Start里调用NativeBridge.NotifyUnityReady。原生flush读出pendingPayload调用sendMessageToGO。C#收到进入正常Dispatch流程。有一个细节要注意C#侧的NotifyUnityReady可能会比原生侧已经尝试投递的时间点更早吗不会。C# Start的触发一定意味着Unity初始化已完成这时候原生侧再去投递接收方一定已经注册。所以“缓存主动拉”比“延迟1秒再投”这种拍脑袋方案靠谱得多。5.3 热启动时小心重复消费和连续链接热启动的时序问题是另一个方向如果C#侧既接收原生主动投递又主动拉取pendingPayload那同一份参数就会被处理两次。所以要在桥接层加一个消费标记每次处理完毕把当前payload清空。Dispatch时用一个类似_processedPayloadId的字段记录已经处理过的URL重复消息直接丢弃。连续点击多个链接的场景也需要处理。用户在后台收到两条不同的推送分别带不同的Deep LinkApp回到前台时两条链接都触发了。原生侧需要决定是覆盖还是排队。我一般走覆盖策略取最新的一条因为用户的意图是最新点的那一下。如果业务需要保留多条那原生层要么改成数组缓存要么把每条带上时间戳C#侧自己做合并。这个决策越早做越好不然后续改协议很被动。6. 归因对接时的参数约定与测试链路6.1 归因SDK的Deep Link和你自己解析的区别买量归因场景下很多团队会陷入一个误区既然我已经在自己AppDelegate里解析了URL是不是归因也直接解析广告链接就行了答案是不行。广告平台回传的归因参数依赖于设备指纹、点击时间、App激活记录等多重信息不是冷启动那一刻的URL能完全覆盖的。尤其是用户点击广告之后隔了几天才安装的场景原始点击URL早就无效了。正经的做法是接入Adjust、AppsFlyer或Branch这类归因SDK让SDK自己处理点击与激活之间的映射关系。SDK会在激活后回调带参数的回调URL你再把回调解包转成统一的Deep Link payload。换句话说你的URL Scheme和Universal Links处理的是用户“点击时已经安装/运行中”的即时唤起场景归因SDK处理的是“点击后未安装装完再还原归因参数”的延迟场景两条链路并行。6.2 游戏逻辑层需要的字段结构不管消息来自即时Deep Link还是归因SDK回调最终都建议收敛成同一个C#类字段类型示例说明sourcestringad / invite / push / unknown来源标识scenestringroom / sign / activity落地场景sceneParamstring1024场景参数通常是房间号或活动IDextrastringuserId8888campaignabc透传的扩展参数linkTypestringuniversal / scheme / sdk链路类型timestamplong1697000000客户端收到时间建议把所有入参整理成一个DeepLinkPayload的PlainObject反序列化之后先交给DeepLinkDispatcher由它决定要不要进入游戏后再处理还是立刻弹窗。有些场景要求用户已经登录才能生效比如邀请奖励那参数就不能在启动阶段直接消费而是要存起来等登录成功后再校验归属关系。6.3 真机自测清单测试Deep Link最怕只在模拟器上过一遍iOS模拟器对Universal Links的很多行为与真机不一致AASA拉取、Scheme弹窗、Scene生命周期等问题只有在真机上才暴露得出来。我这里整理了一份每次发版前必须跑一遍的清单Safari里输入并访问Universal Links地址确认无弹窗直接拉起App。杀掉App进程后再试一次Universal Links确认冷启动参数完整到达C#。App切到后台后点击Universal Links确认热启动参数正常且不会被重复处理。通过系统备忘录等工具构造Scheme地址例如mygame://room?room1024测试URL Scheme链路。在未安装App的闲置设备上测试确认Universal Links会打开网页而不是报错。检查AASA文件是否可以被外网访问留意CDN或HTTPS证书变动后配置是否仍然生效。测试阶段如果发现Universal Links没有拉起App优先排查AASA。系统对AASA的缓存很“顽固”改完文件之后通常要删除App重新安装或者等一段时间这个在联调时要提前告诉测试同学省得他们反复提无效Bug。7. 上线后的监控与版本兼容7.1 新旧版本的参数协议兼容手游玩家的版本分布永远是参差的运营不可能等所有用户都升到最新版本再推活动。这就带来协议兼容问题。如果你在2.1版本新增了一个scenelive的直播落地页但老版本客户端不认这个场景解析参数时会走到default分支可能回传一个“落地页缺失”的报错。我的做法是服务端下发的Deep Link地址里带一个v参数标识协议版本客户端在解析时先看版本号不认识的高版本参数走兜底逻辑至少保证不崩溃同时上报一条“协议版本不支持”的日志。运营后台侧也要把这些参数写清楚活动上线前核对一遍客户端最低支持版本防止拉了坑。7.2 用到达率和解析失败率评估健康度Deep Link做完了不是终点。上线后至少要盯两个指标到达率和参数解析失败率。到达率可以理解为“用户点击链接后C#层成功收到参数的次数 / 系统唤起App的次数”。解析失败率则是C#层收到payload但场景参数缺失、格式异常的比例。前者衡量从iOS系统到Unity引擎这一段链路是否稳定后者衡量业务参数约定是否和运营侧对齐。日志上报不用做得很复杂只要在DeepLinkDispatcher收到payload和解析失败时各打一条带版本号、链路类型的记录即可。数据沉淀半个月之后你会非常清楚到底是AASA配置出过问题还是某些渠道的链接参数拼错了。这个监控体系对后续上线新活动的作用很大能帮你把排查时间从小时级缩到分钟级。7.3 把兜底方案做进业务习惯里最后说一点个人体会。Deep Link这个功能链路上任何一个环节出问题都不会让App崩溃而是表现为“静默失效”——用户点了链接App打开了但是停在了首页没人知道上一个环节已经断了。正因为它太安静才需要把监控、日志和缓存兜底做成一种习惯而不是等到运营活动上线当天再来查。我在几个项目里每次接新的业务方不管对方是运营还是市场都会先让他们回答一个问题用户打开App之后你希望他看到的第一个页面是什么如果对方答得清楚那我就知道这个Deep Link应该把参数投递到哪里如果对方犹豫那多半是参数协议还没想好这时候先把链接协议定清楚比急着写代码重要得多。技术链路本身有标准答案而业务参数约定才是决定这个功能到底好不好用的关键。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →