1. 为什么手游必须做 Deep Link:先想清楚你打通的是哪一条链路
做 Unity 手游 iOS 端的同学,迟早都会碰上 Deep Link 这个需求——最常见的一幕是:玩家在 Safari 或聊天软件里点了一个带参数的链接,如果手机上装了游戏,App 直接被唤醒并自动跳到对应活动页面;如果没装,则引导去 App Store 下载。整个过程背后就是 URL Scheme 和 Universal Links 这两套机制在起作用,而 Unity C# 层要能拿到那个"参数",链路才算真正打通。
先说一个真实的场景。我们有一款卡牌手游,运营搞了"邀请回流"活动:老玩家分享一条带邀请码和角色 ID 的链接,新玩家点开链接安装游戏后,首次启动就能在主界面自动弹出"你已经被 XX 大佬邀请,领取豪华礼包"的弹窗。如果没有 Deep Link,新玩家装完游戏打开后只是一张白纸,运营活动就断了一半。而要把这条链路做通,涉及的可不是写几行代码那么简单:iOS 原生要配置 Scheme 和 Universal Links,Unity 与原生之间要建一座桥,C# 侧还得处理冷启动、热启动、参数延迟投递这些边界情况。这篇文章就按照我实际接入过的一条完整流水线来讲,从原生配置一路写到 C# 侧的业务分发。
1.1 手游场景里 Deep Link 解决的真实问题
手游业务里 Deep Link 无处不用的,但需求通常集中在三类:
- 拉新和回流:分享链接携带邀请码、来源渠道 ID,安装后自动绑定邀请关系,并弹出对应奖励。
- 跨页面直达:客服工单里给一个"打开礼包商店"的链接,玩家点一下,游戏启动后直接打开商店页而不是让玩家自己摸索。
- 社区和投放转化:广告平台、内容平台上点某个内容位,App 打开后直接进到对应的活动页面,方便做归因统计。
这三类需求本质上都要求同一件事:外部链接能不能把一段"意图"完整、可靠地传进游戏进程。这就涉及到两个层面的工作:一个是 iOS 系统层面如何让 App 被正确地唤起并把 URL 交给你的代码;另一个是 Unity 的 C# 层如何在合适的时机拿到这段 URL 并解析分发。缺了任何一层,Deep Link 就是断的。
我在最早接手这个需求时踩过一个很典型的坑:原生层已经能在 Xcode 里打印出完整的 URL 字符串了,但游戏里就是触发不了任何回调。后来排查半天,发现是 C# 侧用来接收消息的 GameObject 还没创建,原生层发过去的消息直接丢了。这其实就是本文要重点讲的一类问题——跨层通信的时序,它比配置本身更容易翻车。
1.2 URL Scheme 和 Universal Links 的底层差异
在 iOS 上,Deep Link 有两种主流实现:古老的 URL Scheme 和 iOS 9 之后推出的 Universal Links。很多新人以为它们只是"两种配置方式",其实底层逻辑完全不同。
URL Scheme 的本质是向系统注册一个自定义协议,比如mygame://。当 Safari 或任何地方点击了mygame://open?page=shop这样的链接,iOS 会检查是否有 App 注册了mygame这个协议,有就唤起,没有就报错。它的优点是配置简单,兼容老系统;缺点是体验粗糙——如果没装 App,iOS 会弹一个"无法打开网页,因为网址无效"的 Safari 报错页,而且从 iOS 9 起系统会弹确认框"是否要打开 XXX App",多一步确认就容易流失用户。
Universal Links 则是另一种思路:它不走协议,而是走 HTTPS 域名。你在 Apple 开发者后台关联一个域名(比如dl.yourgame.com),并在域名根目录放一个签名文件apple-app-site-association。当用户点击https://dl.yourgame.com/invite?id=123时,iOS 先从网络上拉取这个域名下的 AASA 文件,确认该域名被你的 App 关联,然后直接唤起 App,并把完整 URL 交给 App。如果没装 App,则直接在当前浏览器里打开这个网址,你可以顺势放一个引导下载的页面。换句话说,Universal Links 把一个"自定义协议的报错体验"变成了"正常网页体验",并且没有那个二次确认弹窗。
从商业角度,Universal Links 也是广告归因最通用的标准,很多第三方统计 SDK(比如 AppsFlyer、Adjust)的做基础能力都是建立在 Universal Links 上的。所以现在新项目我一般建议:Universal Links 作为主链路,URL Scheme 作为保底兼容。
1.3 完整链路长什么样:从链接到 C# 回调
理解整条链路最好用一条时间线来想:
- 玩家点击某个 HTTPS 链接或 Scheme 链接。
- iOS 系统根据域名或协议判断这个链接属于哪个 App,唤起它。
- 被唤起后,原生 AppDelegate 收到一个 URL 字符串。
- 原生层把 URL 先暂存起来,并在合适的时机转交给 Unity 的 C# 层。
- C# 层解析 URL 参数,根据参数路由到具体业务模块(弹窗、页面、绑定邀请等)。
这五步里,第 1、2 步由 iOS 系统完成,第 3、4 步是原生桥接,第 5 步是 C# 层工作。大多数人以为难点在第 2 步的配置,其实不是——真正的坑集中在第 4 步,因为 Unity 的 C# 层启动时机和原生层的生命周期并不是严格同步的。
2. iOS 原生侧配置:Scheme 注册和 Universal Links 关联域名的完整姿势
不管桥接代码怎么写,前提都是 iOS 原生侧先把"唤醒"这个动作配置好。这一块如果不熟,很容易在 Xcode 和开发者后台之间来回横跳浪费时间。我把两种方案的配置步骤和验证方法完整列一遍。
2.1 URL Scheme 在 Info.plist 中的注册细节
URL Scheme 的配置位置是 Xcode 工程里的Info.plist,核心是CFBundleURLTypes数组。一个最小可用配置长这样:
<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLName</key> <string>com.yourcompany.yourgame</string> <key>CFBundleURLSchemes</key> <array> <string>mygame</string> </array> </dict> </array>这里有两个容易被人忽视的点:
第一,CFBundleURLName只是一个标识符,不参与匹配,但建议和 Bundle ID 保持关联,方便后续排查。CFBundleURLSchemes才是真正的协议名,可以配置多个,比如你可能有mygame和mygamedebug两个 Scheme,一个生产用,一个开发用。正式发布时,生产 App 的 Scheme 不要和别的应用冲突——一旦冲突,iOS 的唤起规则会变得不可预期,你的链接可能被别的 App“劫走”。
第二,Scheme 命名要短但要有辨识度。有些团队图省事直接用game这种单词,风险很大。我见过一个项目因为 Scheme 叫app,在测试机上和某个系统级行为纠缠,半天排查不出来。建议用公司名缩写 + 产品名,比如cyxhs这种。
配置好之后,在 Xcode 里直接 Build 到真机,用 Safari 输入mygame://test就能看到唤起效果——如果弹了"是否打开"的确认框,说明 Scheme 注册成功。
2.2 Universal Links 的关联域名和 AASA 文件
Universal Links 的配置分两端:一端在 Xcode 工程里,另一端在你控制的服务器上。
Xcode 这边,在 Target 的Signing & Capabilities里添加Associated Domains能力,然后加入applinks:dl.yourgame.com。注意前缀必须是applinks:,后面不带https://,域名也不能带路径。开发阶段如果用的是重签名调试包,域名不变,但 Apple 开发者后台的 App ID 要和 Bundle ID 匹配,并且 Associated Domains 能力要处于开启状态。
服务器那端,要在一个固定位置放一个 JSON 文件apple-app-site-association(没有扩展名),放在域名的根目录或.well-known目录下都可以,但必须是 HTTPS 可访问。内容格式类似:
{ "applinks": { "details": [ { "appID": "TEAMID.com.yourcompany.yourgame", "paths": [ "/invite/*", "/event/*", "NOT /admin/*" ] } ] } }其中的appID必须写成TeamID + "." + Bundle ID的完整形式,TeamID 在 Apple 开发者后台的 Membership 页面可以找到。paths数组用来声明哪些路径下的链接应该唤起 App,支持通配符,也支持NOT前缀做排除。
一个经常踩的坑:很多人把 AASA 文件当成普通的 HTML 或带缩进的 JSON 丢上去,但 iOS 对 AASA 的解析非常严格,只要 JSON 格式不合法或者 Content-Type 不对,就会静默失败,链接就只会打开网页而唤起不了 App。实际部署时建议确认一下 HTTP 返回的Content-Type是application/json或application/pkcs7-mime,不要让服务器套了一层无关的 HTML 模板。
2.3 配置完怎么验证:两分钟自检法
配置是否生效,不需要等到打包真机才能验证,两步就能排查:
第一步,在 Safari 地址栏输入https://dl.yourgame.com/apple-app-site-association,直接看浏览器返回的内容。如果返回的不是上面那种 JSON 格式,而是 HTML 错误页,说明服务器路径或内容是错的。
第二步,在 Safari 里输入你配置的某个链接,比如https://dl.yourgame.com/invite?code=abc。如果 App 被唤起,说明 AASA 拉取和匹配都成功了。如果只是打开网页,那要分两种情况检查:看是不是第一次配置(iOS 会缓存 AASA,有时候要重启手机或者几小时后再生效),以及检查paths里的路径是否真的覆盖了这条链接。注意paths匹配包含 query string 吗?不包含——paths匹配的是路径部分,?后面的 query 不影响匹配,query 依然会作为完整 URL 的一部分传给 App。也就是说,你完全可以在一条paths下用不同的 query 参数做不同业务。
还有一个隐蔽问题:同一个 App 如果同时注册了 Scheme 和 Universal Links,点击一个 Universal Links 链接时,如果 App 已经安装,系统会优先走 Universal Links 唤起;如果未安装,就会在 Safari 中打开。而如果这个网页恰好又包含了跳转到mygame://的位置,那么才会触发 Scheme。所以不要让网页上的按钮强制跳 Scheme,否则兜底体验就坏了。
3. 原生到 Unity 的桥接层:别在原生端把参数"吃完"
配置做完,iOS 系统会把 Deep Link 以 URL 字符串的形式交给原生层。接下来最关键的问题来了:怎么把这段字符串安全地送进 Unity C# 层。
我见过不少刚接触 Unity 的 iOS 原生同学,最容易犯的一个错误是:在 Objective-C 的application:openURL:options:方法里拿着 URL 直接写日志、直接弹窗,确认"能收到"之后就觉得完事了。收是收到了,但 Unity 游戏进程里根本不知道这件事。你必须明确一点:Unity 手游的"大脑"在 C# 层,原生层只是外围的搬运工。
3.1 为什么要有一层独立的桥接代码
原生层和 Unity C# 层之间没有共享内存,也没有直接调用的魔法,唯一标准的方式是:
- C# 通过 DllImport 声明原生函数,调用原生方法;
- 原生回调 C# 时,使用 Unity 提供的
UnitySendMessage,向指定 GameObject 发送一条消息。
基于这个机制,我建议在 iOS 原生工程里建一个专门的 Deep Link 桥接类,它只做三件事:接收 URL、暂存 URL、向 C# 层推送 URL。为什么不直接在AppDelegate里写?因为AppDelegate在 Unity 工程里通常是被 Unity 框架接管的核心类,频繁改动容易和引擎升级后的自动生成代码冲突,也不利于多人协作。用一个独立原生类,逻辑隔离,后续调试也方便。
一个典型的桥接类大概长这样(Objective-C 版本):
// DeepLinkBridge.h #import <Foundation/Foundation.h> @interface DeepLinkBridge : NSObject + (instancetype)sharedInstance; - (void)handleDeepLink:(NSURL *)url; - (NSString *)takePendingDeepLink; - (void)clearPendingDeepLink; @end// DeepLinkBridge.m #import "DeepLinkBridge.h" static NSString * const kPendingDeepLinkKey = @"PendingDeepLink"; @implementation DeepLinkBridge { NSString *_pendingDeepLink; } + (instancetype)sharedInstance { static DeepLinkBridge *instance; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [[DeepLinkBridge alloc] init]; }); return instance; } - (void)handleDeepLink:(NSURL *)url { if (url == nil) return; _pendingDeepLink = url.absoluteString; // TODO: 调用 UnitySendMessage 实时推送 } - (NSString *)takePendingDeepLink { NSString *link = _pendingDeepLink; _pendingDeepLink = nil; return link; } - (void)clearPendingDeepLink { _pendingDeepLink = nil; } @end这个类的精妙之处在于takePendingDeepLink——它是一个"拉取"接口,C# 侧可以随时主动问原生层"你有没有存着一个 Deep Link?",拿完即清空。配合实时推送,就能覆盖住各种时序问题。
3.2 冷启动与热启动的调用入口
iOS 收到 Deep Link 有两个场景,二者处理入口完全不同:
冷启动:App 还没运行,用户点链接直接拉起进程。此时 URL 藏在一个叫launchOptions的字典里,需要在AppDelegate的didFinishLaunchingWithOptions中读取:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSURL *url = launchOptions[UIApplicationLaunchOptionsURLKey]; if (url) { [[DeepLinkBridge sharedInstance] handleDeepLink:url]; } return YES; }注意,这里有一个非常隐晦的点:冷启动时,didFinishLaunchingWithOptions执行的时候 Unity 引擎可能还在初始化,场景还没加载完。如果你在这个时刻立刻调用UnitySendMessage,几乎可以确定消息会丢失,因为目标 GameObject 可能还不存在。所以冷启动场景,原生层最好的做法是只存不推——先存到自己的变量里,等 C# 层主动来取。
热启动:App 已经在后台或前台运行,用户再次点击一个链接。此时系统接入点是:
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options { [[DeepLinkBridge sharedInstance] handleDeepLink:url]; return YES; }热启动时 Unity 大概率已经就绪,所以可以既存也推。这个"既存也推"很重要——实时推给 C# 层处理,同时存一份兜底,防止 C# 侧恰好处于某个重载场景的间隙。
3.3 UnitySendMessage 的时机坑和线程问题
UnitySendMessage是原生层向 C# 层发消息的入口,签名如下:
extern void UnitySendMessage(const char *obj, const char *method, const char *msg);它有三个参数:GameObject 的名称、要调用的方法名、要传递的字符串。使用时有两类限制:
第一,目标 GameObject 必须存在于当前场景中。这就是为什么冷启动时直接发消息会丢——如果场景还没加载,或者 GameObject 不在启动场景里,消息就静默消失了。对付这个问题,通用的做法是只把 URL 存下来,等 C# 层主动拉取。
第二,UnitySendMessage必须在 Unity 主线程调用。iOS 的 Deep Link 回调一般就在主线程,问题不大,但如果你在某个 SDK 的回调里收到 URL、那回调又是 GCD 的后台队列,就必须先切到主线程再调用:
dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage("DeepLinkManager", "OnNativeDeepLink", cString); });我见过一个真实线上事故:某广告 SDK 在后台队列里回调了成功安装信息,间接触发了一次UnitySendMessage,结果 Unity 侧的消息接收方法收到了"跨线程调用 UIKit/Unity API"的崩溃堆栈。虽然崩溃点和 Deep Link 不直接相关,但这类时序问题排查起来极其烧脑。所以我的建议是:任何原生到 Unity 的字符串传递,统一在主线程做,宁可加一层 dispatch,也不能省这个保证。
另外,传给UnitySendMessage的msg参数是一个 C 字符串,也就是不能直接传 Objective-C 的NSString。需要走 UTF-8 转换:
const char *cString = [url.absoluteString cStringUsingEncoding:NSUTF8StringEncoding]; UnitySendMessage("DeepLinkManager", "OnNativeDeepLink", cString);如果 URL 里带了中文参数(比如活动名),千万别用NSASCIIStringEncoding强行转,否则中文直接变成?,C# 侧收到的是残缺字符串。
4. C# 层参数接收与业务分发:让深链参数安全地找到归宿
原生层的桥接代码写完,接下来就是 Unity C# 侧的活了。这一层最常见的坑不是"收不到参数",而是"收到了但不知道该什么时候处理"。实战中,我强烈建议 C# 侧用一个独立的DeepLinkManager单例或挂在一个常驻 GameObject 上,职责只有一个:接收原生层传来的 URL 字符串,解析成结构化数据,再分发给对应业务模块。
4.1 延迟初始化队列:解决"系统还没准备好"的问题
Unity 手游的场景结构通常很复杂:启动场景可能只是展示 Logo,真正的核心业务系统(玩家数据、配置表、主界面)要等异步加载完成后才就绪。这时候如果 Deep Link 参数已经到了,但主界面还没创建,你直接拿着参数去跳转,大概率会得到一堆空引用异常。
标准解法是做一个延迟初始化队列。逻辑不复杂:
public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } private readonly Queue<string> _pendingLinks = new Queue<string>(); private bool _isReady; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } private void Start() { // 先拉一次冷启动时暂存的 Deep Link #if UNITY_IOS && !UNITY_EDITOR string coldStartLink = DeepLinkBridge.PopPendingLink(); if (!string.IsNullOrEmpty(coldStartLink)) EnqueueDeepLink(coldStartLink); #endif } public void SetReady() { _isReady = true; FlushQueue(); } public void EnqueueDeepLink(string rawLink) { _pendingLinks.Enqueue(rawLink); FlushQueue(); } private void FlushQueue() { if (!_isReady) return; while (_pendingLinks.Count > 0) { string rawLink = _pendingLinks.Dequeue(); HandleDeepLink(rawLink); } } }这段代码有几个核心设计:
DontDestroyOnLoad保证了管理器的生命周期贯穿整个游戏进程,不会因为切场景被销毁。EnqueueDeepLink是原生层消息和自身拉取共同汇聚的入口,所有 URL 都先进队列。SetReady由业务层在"核心系统初始化完成"的时机调用,之后队列才开始真正消费。
这样,不管 Deep Link 在冷启动的哪个阶段到达,只要进队列了就不会丢,只会在合适的时机被消费。
4.2 参数契约:用 JSON 而不是用魔法字符串
拿到 URL 之后,怎么解析?常见做法有两种:一种是直接解析 query string,比如?page=shop&itemId=123;另一种是让服务端把参数打包成 JSON,以deeplink参数的形式塞在 query 里。
我个人强烈推荐后者。直接解析 query string 看起来简单,但维护成本很高:业务方每次新增字段,你都要改一遍 C# 解析代码;参数多的时候,URL 编码解码头大;更坑的是某些分享平台会自动对 URL 做重写,参数顺序变化或者被转义后,你按位置解析就废了。
推荐的做法是:链接格式统一为https://dl.yourgame.com/invite,核心参数是一个 JSON 串:
https://dl.yourgame.com/invite?deeplink=%7B%22type%22%3A%22invite%22%2C%22code%22%3A%22ABC123%22%2C%22from%22%3A%22ios_share%22%7DC# 侧解析时,先把整个 URL 拿到,然后用Uri解析出 query,再取deeplink字段的值做Uri.UnescapeDataString,之后交给 JSON 库解析:
private void HandleDeepLink(string rawLink) { if (string.IsNullOrEmpty(rawLink)) return; try { var uri = new Uri(rawLink); var query = System.Web.HttpUtility.ParseQueryString(uri.Query); string payload = query["deeplink"]; if (string.IsNullOrEmpty(payload)) return; payload = Uri.UnescapeDataString(payload); var data = JsonUtility.FromJson<DeepLinkPayload>(payload); Route(data); } catch (Exception e) { Debug.LogError($"[DeepLink] parse failed: {rawLink}, error={e}"); } }要注意一个技术细节:Unity 自带的JsonUtility有个很讨厌的限制——它不支持Dictionary<string, object>,也不支持顶部是数组的 JSON。如果你的 Deep Link 参数结构稍灵活一点,直接上JsonUtility会炸。我更建议用Newtonsoft.Json(Json.NET),Unity 官方包管理器中可以直接装com.unity.nuget.newtonsoft-json。它的JObject.Parse写起来舒服得多,也支持复杂的嵌套结构。
4.3 分发到具体业务模块的骨架
解析出结构化对象后,最后一步是路由分发。这个路由不要写得过于复杂,早期用一个switch就够了,等业务多了再考虑配表驱动:
private void Route(DeepLinkPayload data) { switch (data.type) { case "invite": InviteModule.Handle(data.code, data.from); break; case "activity": ActivityModule.Open(data.activityId); break; case "shop": ShopModule.OpenItem(data.itemId); break; default: Debug.LogWarning($"[DeepLink] unknown type: {data.type}"); break; } }分发时有一个非常重要的原则:各个业务模块在接到 Deep Link 时,也必须自己做"我是否准备好"的检查,不要依赖 DeepLinkManager 的全局就绪标志就是一切。因为主界面可能是分步加载的,活动页面模块可能还在拉配置表。更稳妥的做法是:DeepLinkManager 负责任务"送达",业务模块接收后如果发现前置资源未就绪,再把指令缓存在自己模块内部。
做过多个项目之后,我的体会是:Deep Link 分发类似一条河流——上游(原生层)不要试图决定水浇到哪块田,它只负责把水送到总闸;总闸(DeepLinkManager)保证水流不流失;真正打开哪条水渠,是各个农田(业务模块)自己说了算。这个分层看着多,实际能省下大量定位问题的时间。
5. 上线后最容易翻车的几个坑:亲测排错清单
配置和代码都写完,不代表这条路就永远通畅。上线之后真正的挑战在那些"为什么明明配置了却打不开"的疑难杂症。下面这些坑都是我实际排过的,按频率从高到低列出来,希望能给你省几晚上的排查时间。
5.1 微信内打不开、Safari 打开没反应的处理顺序
如果测试同学报"分享到微信、从微信点链接打不开游戏",先别怀疑是 Universal Links 配置错了。这个现象十有八九是因为:微信内置浏览器对 Universal Links 的默认行为不是立即唤起 App,而是优先当作普通网页加载。这属于第三方 App 内置浏览器的策略问题,不是你能完全控制的。
能做的事有两件:第一,让服务端在网页里通过 JS 检测当前浏览器环境,如果是微信内置浏览器,做一些引导动作(比如让用户右上角选择"在 Safari 中打开");第二,在网页里做一次二次跳转——如果检测到 App 未安装就引导去 App Store 下载,如果已安装就尝试唤起。这套"网页承接 + 二次唤起"逻辑通常由 H5 或服务端来配合,客户端这边能做的只是确保 Universal Links 的 AASA 配置无误。
如果Safari 里直接打开链接也没反应,优先级是这样的排查顺序:
- 先看
https://dl.yourgame.com/apple-app-site-association能不能正常访问,JSON 是否正确。 - 确认
appID的 TeamID 和 Bundle ID 是否和当前安装包的生产签名一致。 - 确认 Xcode 工程里 Associated Domains 是否添加并且域名拼写无误。
- 用
xcrun simctl openurl booted <url>或者真机 Safari 打开;如果开发环境已经反复改过 AASA,最好重启手机后再测,iOS 对 AASA 的缓存相当顽固。
5.2 调试利器:Safari 检查器、Console 日志和 AASA 探测
Deep Link 的排查经常让人感觉"无从下手",因为系统层面的匹配失败没有任何直接报错。几个工具用下来最有效:
- Safari 开发者工具:Mac 上 Safari 的"开发"菜单里可以对真机开启网页检查器。当你的 Universal Links 链接在 Safari 中打开时,能看到网页加载和跳转行为,甚至能确认它是否被当作普通网页处理。
- Console.app 的系统日志:iOS 真机连上 Mac,打开 Console,过滤
swcagent或associated-domains相关进程。当 iOS 尝试解析 Universal Links 时,这里经常会出现关于 AASA 文件拉取成功的日志,是定位"域名关联是否生效"的最直接证据。 - AASA 探测网站:把域名填进去,能直接显示服务器返回的 AASA 内容和你配置的 paths 是否合法。这比手动拼 JSON 省事得多。
另外一个实战技巧:在原生桥接类里加一个本地的 DEBUG 标记。调试包打开时,把收到的 URL 同时写进本地日志文件,下次复现问题时直接查看。不要只在线上日志里打点,很多真机问题根本轮不到看线上日志。
5.3 渠道归因、App Store 跳转和版本兼容的注意事项
最后是几个和产品、发行相关的坑,技术本身没问题,但运营配置错了照样翻车。
关于未安装 App 的用户。Universal Links 最优雅的一点是,未安装时直接打开你的网页。但很多运营想要的是"点链接直接跳 App Store"。这时候不要让客户端去实现,而是让服务端这个网页做跳转。常见的做法是网页里用itms-apps://或 App Store 的 Smart App Banner 引导。一个容易踩的坑是:在网页里既放 Smart App Banner 又写了一段"自动跳转 App Store"的 JS,结果 iOS 上触发两次跳转,体验很怪。选一种就好。
关于版本兼容。URL Scheme 是 iOS 一路沿用到今天的老机制,基本不用担心兼容性。Universal Links 虽然 iOS 9 就推出了,但后续系统版本对它的细节行为一直有小调整。比如 iOS 13 之后,UIApplicationDelegate的回调方法签名虽然不变,但系统对"用户是否允许 App 被链接唤起"的处理更严格。实测中有一个结论可以放心:Universal Links 唤起 App 不会出现 URL Scheme 那个"是否打开"的系统弹窗,这本来就是它的设计优势,别因为测试机弹了一次就以为配置错了——那大概率是 Scheme 路径被触发了。
关于渠道归因。如果你接了广告归因 SDK,一定要注意:不要自己在 Deep Link 里重复埋一套渠道参数,然后又让归因 SDK 也读一遍同一个 URL。实际项目里这两套逻辑经常打架,特别是"冷启动 + 归因 SDK 初始化比较慢 + Deep Link 参数已经被 C# 侧消费掉"这种时序组合,很容易造成归因数据缺失。我的建议是:Deep Link 参数首先完整保留给业务使用,归因 SDK 如果需要原始 URL,提供一个镜像通道给它,不要在业务侧"读一次、抹一次"。
这套链路从最外层链接到 C# 层分发,我每次接新项目都要从头走一遍,踩过的坑上面基本都写到了。如果只让我留一条经验,那就是:把参数契约定清楚了再动手写任何一端代码。链接里放什么、JSON 的字段名是什么、冷启动和热启动分别走哪条路、C# 侧什么时候算"就绪"——这些问题在设计阶段含糊半小时,后面多花的时间可能是十倍的量级。尤其是原生和 Unity 两侧往往不是同一个人维护,把接口签名和时序约定写清楚,后面协作会省掉大量来回拉扯的沟通成本。希望对正在接 iOS Deep Link 的 Unity 手游团队有参考价值。