
1. 先理清楚手游的 Deep Link 两个唤醒通道到底差在哪我看过太多 Unity 项目在上线买量或者做社交分享回流的时候才急急忙忙来找 Deep Link 方案。其实也不怪大家做手游客户端的平时注意力都在玩法、UI、性能这些地方Deep Link 属于典型的运营要的时候很急平时完全想不起来的功能。先说我自己的场景。之前接的一个模拟经营类项目运营要做 iOS 买量归因还要做老玩家邀请新玩家的分享回流。分享出去一张卡片卡片上带着邀请人的 ID用户点开之后如果手机上装了游戏就直接唤起进入游戏没装的话就落到 App Store 下载页。听起来很常规对不对但实际上手才发现从 iOS 系统层到 Unity C# 层中间每个环节都有坑尤其冷启动App 被杀了之后通过链接唤起和热启动App 在后台被唤起两条路径的处理方式完全不同。Deep Link 在 iOS 上的核心价值简单说就是给游戏加一根带有参数的直达通道。用户点一个链接不仅能打开你游戏还能立刻知道这个用户是从哪个渠道来的、带着什么参数进来的。比如game123://open?pageactivityid10086其中game123是你的 URL Schemepageactivity是要直达的活动页id10086是渠道或邀请人标识。但 iOS 上能做 Deep Link 的通道有两条很多人一开始直接混在一起用对比项URL SchemeUniversal Links技术本质自定义协议如game123://HTTPS 普通链接 系统校验关联关系是否首次弹窗会弹是否打开确认框直接唤起无弹窗未安装 App 时报错无法打开系统自动在 Safari 打开你的落地页配置复杂度只需改 Info.plist开发者后台、Entitlements、服务器 AASA 三件套参数传递通过 URL 携带 query同样通过 HTTPS URL 携带 query安全性任何 App 都能注册相同 scheme可被抢受 AASA 文件约束可控性强归因识别可拿到 sourceApplication拿不到来源 App只能靠参数自己带我的结论很直接两个通道都要接。Universal Links 做主通道用户体验好、能做未安装降级到网页URL Scheme 做兼容兜底因为国内很多第三方广告平台的点击跳转到现在还是只支持 scheme 拉起。只接一个的话买量归因这条线迟早出问题。2. 工程侧配置Info.plist 与 Associated Domains 的正确姿势2.1 URL Scheme 注册Info.plist 里要写什么在 Unity 工程的 iOS 构建产物里找到Info.plist往CFBundleURLTypes数组里加一项keyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.yourcompany.yourgame/string keyCFBundleURLSchemes/key array stringgame123/string /array /dict /array这里game123就是你给这个 App 定的 URL Scheme。用户访问game123://xxx时系统会优先找 scheme 为game123的已安装 App。Scheme 的命名我有几个习惯尽量短因为广告平台回传的链接都是字符串越短越不容易被截断或转义出错。不要用太通用的词比如game、open全终端不知道多少 App 注册了game://一旦冲突iOS 会弹窗让用户选转化率直接掉一截。如果公司有多个游戏最好统一命名规范比如game123、game456运维和投放那边也好记。顺带一提很多人不知道的一点在 iOS 9 之后系统从 Safari 唤起 URL Scheme 会弹一次确认框这个确认框对买量转化影响非常大。用户明明点了广告结果还要再点一次打开很多人就在这一步流失了。这也是为什么 Universal Links 在 iOS 9 推出之后主流做法马上就切了过去。2.2 Universal Links 三件套开发者后台、Entitlements、AASA 文件Universal Links 配置涉及三层缺一不可我按顺序说第一层Apple Developer 后台开启 Associated Domains在 Certificates, Identifiers Profiles 里找到你的 App ID打开Associated Domains能力保存。这一步不做后面 Xcode 里配置了也白搭。第二层Xcode 工程添加 applinks 域名在 Xcode 的 Signing Capabilities 里加Associated Domains填入applinks:yourdomain.com注意这个域名是你自己的 HTTPS 服务器域名最好单独准备一个短链域名因为 AASA 文件的校验和广告平台回传时都会用到它。第三层服务器放置 AASA 文件在域名根目录放一个apple-app-site-association文件路径要求是https://yourdomain.com/apple-app-site-association内容长这样{ applinks: { apps: [], details: [ { appID: TEAMID.com.yourcompany.yourgame, paths: [*] } ] } }appID的格式是团队ID.BundleIDTeamID在开发者后台能看到。paths里面写的是允许唤起 App 的路径规则*表示所有路径都放行。我之前就因为路径配置吃过亏。AASA 的paths匹配是大小写敏感的而且只能匹配 path 部分。如果你某个活动路径是https://yourdomain.com/act/Summer2024而 AASA 里写的是/act/*那没问题但如果你写成/ACT/*iOS 匹配不上链接就只在 Safari 里打开App 不会被唤起。另外AASA 文件有三个硬性要求必须是 HTTPS 访问而且不能有重定向。中间哪怕跳一次 HTTP 地址iOS 都会直接判定无效。Content-Type 官方没有强制统一但建议服务器返回application/json很多后台服务器默认把文件当 octet-stream 也能用不过我用下来稳妥起见还是显式设置。文件里不能有多余字符严格符合 JSON 格式。之前有一个同事手动在文件末尾多敲了个空行结果真机上整整半小时测不出来最后排查到是 AASA 解析失败。2.3 用 PostProcessBuild 让配置自动落进 Xcode 工程手动改 Info.plist 和 Entitlements 的问题很明显Unity 每次 Build 都会生成新的 Xcode 工程你上一次手动配置的东西全部被覆盖。所以我的项目里都写了PostProcessBuild脚本。核心思路是在构建完成后自动往工程里做三件事写 Info.plist 的 URL Scheme、写 Entitlements 关联域名、把原生 Deep Link 代码文件打进工程。大概框架长这样#if UNITY_IOS using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; using System.IO; public class DeepLinkPostProcess { [PostProcessBuild(100)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target ! BuildTarget.iOS) return; string projectPath PBXProject.GetPBXProjectPath(path); PBXProject project new PBXProject(); project.ReadFromFile(projectPath); string targetGuid project.GetUnityMainTargetGuid(); // 1. Info.plist 注册 URL Scheme string plistPath path /Info.plist; PlistDocument plist new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementArray urlTypes plist.root.CreateArray(CFBundleURLTypes); PlistElementDict urlType urlTypes.AddDict(); urlType.SetString(CFBundleURLName, com.yourcompany.yourgame); PlistElementArray schemes urlType.CreateArray(CFBundleURLSchemes); schemes.AddString(game123); plist.WriteToFile(plistPath); // 2. Entitlements 关联域名 string entitlementsPath path /Unity-iPhone.entitlements; PlistDocument entitlements new PlistDocument(); entitlements.ReadFromFile(entitlementsPath); PlistElementArray associatedDomains entitlements.root.CreateArray(com.apple.developer.associated-domains); associatedDomains.AddString(applinks:yourdomain.com); entitlements.WriteToFile(entitlementsPath); project.AddFile(entitlementsPath, Unity-iPhone.entitlements); project.WriteToFile(projectPath); } } #endif脚本里还应该把DeepLinkAppController.h和.mm文件加进 Xcode 工程并把UnityAppControllerClassName写到 Info.plist 里。这个键是什么、为什么必须加下面详细说。3. 原生层拦截继承 UnityAppController 接管系统回调3.1 为什么必须动 UnityAppControllerUnity 生成的 iOS 工程里真正的 AppDelegate 是UnityAppController。系统收到 Deep Link 之后所有回调都会先走到这个类再被 Unity 内部处理。我们要接自定义逻辑就得在系统回调打到自己游戏逻辑之前把 URL 参数截下来否则等 Unity 内部消化完了你很难在 C# 层拿到完整参数。Unity 官方留了一个扩展点Info.plist 里加一个UnityAppControllerClassName键值写你的自定义类名Unity 启动时会把UnityAppController换成你的子类。这个比手动改main.mm干净得多不会被构建覆盖。我的做法是这样新建一个DeepLinkAppController.h#import UnityAppController.h interface DeepLinkAppController : UnityAppController /// 最近一次收到的深链原始 URL冷启动时会先缓存到这里 property (nonatomic, copy) NSString *pendingDeepLink; /// 清理缓存的深链参数 - (void)clearPendingDeepLink; end对应的.mm文件#import DeepLinkAppController.h #import UIKit/UIKit.h extern C const char* UnityGetCachedDeepLink() { DeepLinkAppController *delegate (DeepLinkAppController *)[UIApplication sharedApplication].delegate; if (delegate.pendingDeepLink.length 0) { return strdup(delegate.pendingDeepLink.UTF8String); } return strdup(); } extern C void UnityClearCachedDeepLink() { DeepLinkAppController *delegate (DeepLinkAppController *)[UIApplication sharedApplication].delegate; delegate.pendingDeepLink nil; } implementation DeepLinkAppController - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSURL *url launchOptions[UIApplicationLaunchOptionsURLKey]; if (url ! nil) { self.pendingDeepLink url.absoluteString; } NSUserActivity *activity launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey]; if (activity ! nil) { NSUserActivity *userActivity activity[UIApplicationLaunchOptionsUserActivityKey]; if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { self.pendingDeepLink userActivity.webpageURL.absoluteString; } } return [super application:application didFinishLaunchingWithOptions:launchOptions]; } - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey, id *)options { if (url ! nil) { self.pendingDeepLink url.absoluteString; [self sendDeepLinkToUnity:url.absoluteString]; } return [super application:app openURL:url options:options]; } - (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring * _Nullable))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { NSURL *url userActivity.webpageURL; if (url ! nil) { self.pendingDeepLink url.absoluteString; [self sendDeepLinkToUnity:url.absoluteString]; } } return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; } - (void)sendDeepLinkToUnity:(NSString *)rawURL { NSDictionary *payload { url: rawURL ?: , ts: ([[NSDate date] timeIntervalSince1970]) }; NSError *error nil; NSData *data [NSJSONSerialization dataWithJSONObject:payload options:0 error:error]; if (error ! nil || data nil) { return; } NSString *json [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, json.UTF8String); } end这段代码你应该已经看出几个关键设计冷启动时只缓存、不推送因为 Unity 引擎还没起来推了也丢热启动App 在后台时收到新链接才立刻通过UnitySendMessage推给 C# 层。3.2 冷启动与热启动的两条路径差异冷启动和热启动的处理逻辑天然不同我给项目里的同事讲的时候喜欢打个比方冷启动相当于你刚要进电影院检票员系统把票URL递给你但你还没坐到座位上这时候喊开始看电影是没用的。你要先把票攥在手里等坐下之后再掏出来看。热启动相当于电影看了一半有人从后门递了张纸条进来新 URL你直接看一眼纸条就知道接下来该干嘛。代码里面对应就是didFinishLaunchingWithOptions里只把 URL 存进pendingDeepLink等 C# 侧启动后主动来取。openURL:options:和continueUserActivity:restorationHandler:里先更新缓存再立刻sendDeepLinkToUnity。这个双通道设计非常关键少了任何一边都会出问题。3.3 参数标准化把 NSURL 整理成 C# 侧方便消费的 JSON原生层拿到的是NSURL我在发给 C# 之前会先组装成一个 JSON 字符串。这样 C# 侧不管是用LitJson、Newtonsoft.Json还是JsonUtility都能直接反序列化成结构体不用自己拼字符串。注意 JSON 序列化的时候我故意没有开NSJSONWritingPrettyPrinted因为格式化之后会多出大量空格和换行经UnitySendMessage传过去后再解析反而容易出问题紧凑格式才是最稳的。参数里面我加了一个ts时间戳用来做重复消息去重。这个后面讲踩坑时细说。4. 把参数送进 C# 层UnitySendMessage 的时序陷阱与双通道方案4.1 UnitySendMessage 的调用约定原生层调 C# 层最常用的就是UnitySendMessage三个参数UnitySendMessage(GameObjectName, MethodName, messageString);对应 C# 侧的要求是名为GameObjectName的 GameObject 必须存在且处于激活状态。MethodName必须是该 GameObject 某个组件上公开的方法。方法签名固定是void MethodName(string param)。GameObject 挂的组件要继承MonoBehaviour。我见过很多人在这里踩坑GameObject 叫DeepLinkManager方法也写在DeepLinkManager.cs里但脚本挂载的 GameObject 在场景里被别的逻辑给 SetActive(false) 了结果原生层怎么调都没反应还不报错。排查起来特别浪费时间。为了规避这个问题我一般不用场景里的 GameObject而是用DontDestroyOnLoad运行时创建常驻节点。这样做还有个附带好处无论从哪个场景被唤起深链处理器都一定存在。4.2 冷启动丢消息问题原生缓存 C# 主动拉取UnitySendMessage 对冷启动有一个致命限制消息发出去时如果 C# 脚本还没准备好消息就丢了。而didFinishLaunchingWithOptions调用的时候Unity 引擎还在启动阶段C# 的Awake压根没执行。所以冷启动这条路我从来不在原生层主动推而是反过来C# 层启动完成后主动向原生层拉取缓存。原生层提供两个导出函数extern C const char* UnityGetCachedDeepLink(); extern C void UnityClearCachedDeepLink();C# 侧对应这样写#if UNITY_IOS !UNITY_EDITOR using System.Runtime.InteropServices; [DllImport(__Internal)] private static extern string UnityGetCachedDeepLink(); [DllImport(__Internal)] private static extern void UnityClearCachedDeepLink(); #endif这里有个细节DllImport的入口点名字要和原生导出函数名完全一致C 混编时注意 extern C否则会被名字改编。C# 侧启动逻辑放在Awake里就行private void Awake() { if (_instance ! null) { Destroy(gameObject); return; } _instance this; DontDestroyOnLoad(gameObject); #if UNITY_IOS !UNITY_EDITOR string cached UnityGetCachedDeepLink(); if (!string.IsNullOrEmpty(cached)) { _pendingQueue.Enqueue(cached); UnityClearCachedDeepLink(); } #endif }拉取后立刻清缓存这步很关键。不然下次冷启动时又会取到上一条旧链接导致用户启动游戏四秒后突然被拉去一个活动页体验极差。4.3 C# 侧 DeepLinkManager队列、防重、分发设计完整的管理器我习惯这么组织using System; using System.Collections.Generic; using UnityEngine; public class DeepLinkManager : MonoBehaviour { public static DeepLinkManager Instance { get; private set; } private readonly Queuestring _pendingQueue new Queuestring(); private readonly HashSetstring _processedCache new HashSetstring(); private void Awake() { if (Instance ! null) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); #if UNITY_IOS !UNITY_EDITOR string cached UnityGetCachedDeepLink(); if (!string.IsNullOrEmpty(cached)) { EnqueueRawLink(cached); UnityClearCachedDeepLink(); } #endif } private void Update() { if (_pendingQueue.Count 0) { return; } while (_pendingQueue.Count 0) { string raw _pendingQueue.Dequeue(); ProcessDeepLink(raw); } } public void OnDeepLinkReceived(string json) { #if UNITY_IOS !UNITY_EDITOR EnqueueRawLink(json); #endif } private void EnqueueRawLink(string raw) { _pendingQueue.Enqueue(raw); } private void ProcessDeepLink(string raw) { // 解析并分发 } }热启动时原生层通过UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, json)推送过来方法名字和 GameObject 名字都要严格匹配。队列的写法保证了不管消息是启动时拉取的、还是运行中推送的都在主线程Update里统一处理不会中途打断正在进行的 UI 操作。5. 场景落地从收到 Deep Link 到完成跳转5.1 参数解析原始 URL 还是解析后的字典原生层虽然已经把完整 URL 包进了 JSON但我不建议在 C# 侧直接string.Split去解析 URL太脆弱了。正确做法是先把整个 URL 拆成结构化对象再进行分发。我常用的结构体public struct DeepLinkPayload { public string RawUrl; public string Scheme; public string Host; public string Path; public Dictionarystring, string Query; public static bool TryParse(string raw, out DeepLinkPayload payload) { payload default; if (string.IsNullOrEmpty(raw)) { return false; } if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri uri)) { return false; } payload.RawUrl raw; payload.Scheme uri.Scheme; payload.Host uri.Host; payload.Path uri.AbsolutePath; payload.Query new Dictionarystring, string(); string query uri.Query.TrimStart(?); foreach (string pair in query.Split()) { if (string.IsNullOrEmpty(pair)) { continue; } string[] kv pair.Split(); if (kv.Length 2) { payload.Query[Uri.UnescapeDataString(kv[0])] Uri.UnescapeDataString(kv[1]); } } return true; } }为什么用Uri.TryCreate而不是手切因为 URL 里的 query 可能包含编码过的中文字符、符号、符号手切很容易出错。Uri类能帮你做好绝大多数字符处理。5.2 分发热点活动页、邀请关系绑定、商城直达拿到结构化的DeepLinkPayload之后我一般会根据 Host 和 Path 做路由分发。拿我那个模拟经营项目举例实际遇到的深链类型包括HostPath携带参数触发逻辑open/activityid活动ID下载完首启后直达活动页面invite/bindcode邀请码绑定邀请关系双方发奖励shop/itemitemId商品ID直达商城某个商品详情C# 侧分发逻辑写成这样private void ProcessDeepLink(string raw) { if (!DeepLinkPayload.TryParse(raw, out DeepLinkPayload payload)) { Debug.LogWarning($DeepLink 解析失败: {raw}); return; } if (payload.Scheme ! game123 payload.Host ! yourdomain.com) { return; } switch (payload.Host payload.Path) { case open/activity: JumpToActivity(payload.Query); break; case invite/bind: BindInviteCode(payload.Query); break; case shop/item: JumpToShopItem(payload.Query); break; default: Debug.Log($未处理的 DeepLink: {raw}); break; } }分发逻辑里有一个点要注意首启时机。很多运营场景要求下载后从桌面点开 App也要临时生效一次。例如用户从广告落地页点了下载装完首次打开游戏希望直接跳到一个特定活动页。这个诉求不是 Deep Link 能单独解决的因为你从桌面点图标启动时根本没有 URL 参数。我的做法是在原生层把首次启动的launchOptions里的 URL 存下来但用户从桌面正常启动时确实没参数所以这个需求实际要配合安装归因 SDK比如 AppsFlyer / Adjust来做延迟深度链接那是另一套体系。这里只讲从链接直接唤起这条路。5.3 测试工具与联调方法Deep Link 联调最怕的就是不知道系统到底有没有唤起 App。我平时用这几个方法组合验证模拟 URL Scheme 唤起真机或模拟器都可以在 Safari 地址栏输入game123://open?pageactivityid10086回车后会弹窗确认是否打开 App确认后应该能进游戏并触发深链逻辑。命令行也可以用xcrun simctl openurl booted game123://open?pageactivityid10086模拟器上这样测试比手动输入快得多。模拟 Universal Links 唤起Universal Links 不好直接在地址栏输入测试因为系统可能先走 Safari 网络加载。我一般准备一个测试 HTML 页面页面上放一个a hrefhttps://yourdomain.com/open?pageactivityid10086链接在 Safari 里点这个链接。如果 AASA 生效且 App 已安装会直接唤起如果没生效会留在 Safari 打开你的落地页。Xcode 断点确认在continueUserActivity和openURL方法里打断点能明确看到走了哪个回调、URL 是什么。这比在 C# 侧打日志定位更快因为可以直接看原生层拿到的原始参数。AASA 生效检查Universal Links 配置完最让人头疼的就是不知道 AASA 文件有没有被 Apple 正确抓取。可以访问https://app-site-association.cdn-apple.com/a/v1/yourdomain.com这个地址返回的就是 Apple CDN 抓取到的 AASA 内容。如果这里能正常返回你的配置那说明 Apple 已经收录了如果这里返回失败真机测试基本不可能通过。6. 实战踩坑记录参数乱码、重复回调、首启时序6.1 中文参数在 JSON 投递时的编码翻车这个坑我印象太深了。邀请码参数里带用户昵称用户昵称是中文比如code张伟123。原生层从NSURL里取到的absoluteString中文其实是 URL 编码过的长这样code%E5%BC%A0%E4%BC%9F123。理论上这没毛病C# 侧Uri.UnescapeDataString能还原。问题出在另一种场景如果投放后台给的链接里中文参数没有被百分号编码而是直接裸的中文NSURL照样能创建出来absoluteString也是正常中文。这时候组装 JSON 用的NSJSONSerialization输出的 JSON 字符串是 UTF-8 编码的UnitySendMessage传参时我用的json.UTF8StringC# 侧拿到手应该是正常的中文。但我有一次在模拟器上调C# 侧打日志看到中文全变成了乱码。排查了半天最后发现是原生层多了一步操作有人把NSString转NSData时用了NSASCIIStringEncoding中文字符根本存不进 ASCII全被替换成了?。所以记住一个原则深链参数统一走 UTF-8任何编码转换都别用 ASCII 相关 API。6.2 热启动下重复收到同一条链接Universal Links 和 URL Scheme 同时接好之后出现了这么个情况用户在 Safari 里点 Universal Links 唤起 App热启动进到游戏但深链事件被触发了两次。最开始我以为是系统回调重复后来打断点才发现continueUserActivity被调用了一次但因为我在里面调用[super application:continueUserActivity...]Unity 内部又对同一条 Universal Link 做了一次处理导致 C# 层被通知了两次。解决方案分两层原生层在continueUserActivity里先检查userActivity.activityType确认是NSUserActivityTypeBrowsingWeb再处理。C# 侧加上一个去重机制。我是在DeepLinkPayload里加一个由raw ts生成的指纹在处理前判断_processedCache里有没有同样的指纹。其实还有更简单的做法如果两次事件到达时间非常接近比如 500ms 以内后到的直接忽略。但这种方法治标不治本用了指纹去重之后才彻底安静。6.3 首次启动时序DidFinish 里的缓存有效期再强调一次这个时序问题因为很多人最终都会栽到这里。如果你在didFinishLaunchingWithOptions里拿到 URL 后立刻UnitySendMessage绝大多数情况是消息发出去石沉大海。Unity 引擎此时还在加载脚本你的DeepLinkManager还没创建。我的处理方案是前文说的双通道但还有一个补充细节C# 侧主动拉取缓存之后要立刻UnityClearCachedDeepLink()。这个我之前提到过但这里想再展开说一个坑——如果你拉了缓存不清理下一次冷启动用户杀进程后从桌面图标启动时会把上一次的历史深链又处理一遍。用户上了游戏啥也没干莫名其妙被拉到之前某个活动页里非常尴尬。6.4 真机从 Safari 跳转时系统弹窗的坑这是 Universal Links 特有的体验问题。iOS 13 之前从 Safari 唤起 Universal Links 时右上角会有一个小广告栏提示打开用户点了之后才进 App。iOS 13 之后苹果改了逻辑大多数场景是直接唤起不用再点一次。但注意如果你的 AASA 文件匹配不上或者用户在 Safari 里已经对该域名主动打开了网页iOS 很可能把后续的点击都当成普通网页浏览不再唤起 App。遇到这种情况最直接的排查路径还是回到https://app-site-association.cdn-apple.com/a/v1/yourdomain.com去确认 AASA如果 CDN 返回正常就再检查 Entitlements 文件是否真的被 Xcode 工程引用了。我遇到过Unity-iPhone.entitlements文件加了但工程里CODE_SIGN_ENTITLEMENTS没指向它导致签名的时候完全没带上相关能力。线上买量归因这块Deep Link 只是链路的一部分。就算你 Universal Links 和 URL Scheme 都配置好了广告平台的数据回传还涉及服务端激活匹配、苹果的隐私限制这些不在这篇文章范围里。但客户端这一层从配置到原生拦截再到 C# 投递走通之后至少不会在拉新和回流环节掉链子。我现在的习惯是每次 iOS 版本提测前都要跑一遍深链自测清单URL Scheme 冷启动、URL Scheme 热启动、Universal Links 冷启动、Universal Links 热启动、参数中文编码、重复回调检测。六项全过才敢提测。这套流程固定下来之后深链基本上成了最省心的模块。