Unity 手游 iOS Deep Link 唤醒全流程:从 URL Scheme / Universal Links 到 C# 层参数投递

0 阅读14分钟

分享回流、广告归因、活动页跳转、客服工单——只要你的项目里挂了其中任何一个需求,就绕不开 Deep Link。它听起来是个"配一下就行"的活儿:iOS 上注册个 scheme,或者丢一个 json 文件到域名根目录,然后等回调进来就完事。

现实是,Unity 手游的 Deep Link 通常以"半个好"的状态上线:冷启动能进,热启动没反应;自定义 scheme 能跳,https 链接跳不进来;开发机全对,测试机全挂。而这些现象的根因往往不在你自己的代码里,而在 UIKit 生命周期、Unity 的 UIScene 适配、以及 Apple CDN 的抓取策略上。

这篇文章按"配置 → 时序 → 落点 → 投递 → 排错"的顺序,把这条链路拆开讲清楚,代码可以直接抄。

一、两条唤醒路径:配置差异与安全边界

URL Scheme:零校验的"先到先得"

自定义 URL Scheme 通过 Info.plistCFBundleURLTypes(具体是 CFBundleURLSchemes 数组)声明。Unity 侧更省事:Player Settings > iOS > Other > Configuration > Supported URL schemes,在 Element 0 填进去即可。

问题在于它的归属模型。只要不是 Apple 保留的 scheme(httpmailtotel 等),任何 App 都能注册同一个,且没有任何校验机制。Apple 官方文档的表述被多方引用:"如果多个第三方 App 注册了同一个 URL scheme,目前没有流程可以决定哪个 App 会被授予该 scheme。" 也就是说行为未定义,实际取决于安装顺序,恶意 App 可以抢先接管,甚至反过来导致正版 App 装不上。

OWASP MASWE-0029 把这个风险归成三类:Deep Link 里携带的 token / 个人信息被截获、认证授权被绕过(截获登录或改密链接导致账号接管)、未净化参数流入解释器或 WebView 导致任意代码执行。最容易出事的恰恰是"改密""邮箱验证""邀请""magic link"这类流程——它们比完整的 OAuth 更脆弱,因为 App 往往直接把"链接本身"当成身份证明。典型脆弱写法是只校验 scheme 不校验 host/path,例如:

myapp://oauth/callback?token=xxx

任何注册了 myapp 的 App 都能收到这条消息。缓解方向很明确:改用 Universal Links(https + 域名归属 + AASA,别的 App 抢不走),对其余 Deep Link 的入参做白名单校验,并且不要用链接传递长效密钥。

Universal Links:真正的门槛在服务端

Universal Links 需要在 Xcode 里给 Target 打开 Associated Domains 能力,加入 applinks:yourdomain.com,然后由服务端提供 AASA 文件。因为依赖外部网站,Unity 无法在 Editor 内配置,必须走 Xcode 工程或 PostProcessBuild 脚本注入。

AASA 的硬性要求(来自 Apple TN3155):

  • 文件名必须是无 .json 后缀的 apple-app-site-association,放在 https://<domain>/.well-known/apple-app-site-association,域名根目录是兜底位置
  • 仅 HTTPS,不能有任何重定向
  • Content-Type 必须是 application/json
  • 文件体积必须小于 128 KB
  • iOS 14 起设备不再自己拉 AASA,改由 Apple CDN 抓取并缓存:App 安装时立即从 CDN 下载一份,之后大约每周检查一次更新,且没有任何直接失效 CDN 缓存的手段——想让测试机拿到新 AASA 只能重装 App

排查时不要看自己服务器的文件,直接看 Apple 实际给你的副本:

https://app-site-association.cdn-apple.com/a/v1/<yourdomain.com>

Xcode 直连调试有个 developer 模式,在 Associated Domains 里给域名加 ?mode=developer,可以绕过 CDN 直拉源站,但只对 development 签名的包生效,TestFlight / Ad Hoc / App Store 包无效。

请添加图片描述

一个值得记住的真实故障:Apple 开发者论坛 thread 815853 里记录过一例,源站 AASA 返回 HTTP 200 的合法 JSON,Content-Type 正确、无重定向、SSL 有效,但 Apple CDN 返回 {"cause":"invalid character '<' looking for beginning of value"}< 说明 CDN 拿到的是 HTML 而不是 JSON。诊断结论是服务器 / WAF 拦截了 Apple 爬虫的 User-Agent(AASA-Bot/1.0)和 / 或 Apple 的 IP 段,返回了 403 或验证挑战页。修复方式是在防火墙里为该路径放行这个 UA 与 IP 段,改完之后还要等数小时才生效。

一句话写进排查表:一个连浏览器都能正常打开的 AASA 文件,只要服务端屏蔽了 Apple CDN 的 User-Agent,Universal Link 依然会断。

AASA 匹配规则:顺序决定成败

iOS 13 引入的现代格式是 appIDs 数组 + components 数组,旧格式是单数 appID + paths 数组。两套格式不能混用,混用行为未定义。iOS 13+ 只要出现 componentspaths 键就被整个忽略(要兼容 iOS 12 及以下才保留 paths 兜底)。

现代格式长这样:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCDE12345.com.example.game"],
        "components": [
          { "/": "/products/preview/*", "exclude": true },
          { "/": "/shop/*", "?": { "chan": "?" }, "comment": "带来源参数的分享回流" },
          { "/": "/products/*" },
          { "/": "/share/*", "caseSensitive": false }
        ]
      }
    ]
  }
}

三个易错点:

  1. components 会覆盖 paths,别指望两套并存。
  2. 排除用 exclude: true,等价于旧格式的 NOT 关键字,但 NOTcomponents 格式下已不再支持。
  3. 顺序决定成败:iOS 取第一条命中的规则,所以排除规则必须排在通配规则上面。上例中 /products/preview/*exclude 如果写在 /products/* 之后,就永远不生效。

components 每一项可以同时约束 path/)、query?)、fragment#),query 支持字典值做精确匹配,例如 {"articleNumber": "????", "articleName": "?*"}。WWDC20 Session 10098 解释了为什么要换这套格式:老式 * / ? 路径规则下,两种语言码 × 两种地区码 × 四个产品就膨胀出约 180 万个 pattern、文件超过 27 MB,而 AASA 硬上限只有 128 KB。

二、两条时序:冷启动与热启动的落点完全不同

这是绝大多数 Deep Link bug 的来源,值得单独拎出来。

冷启动:App 没在运行,进程从零开始。系统把 URL 塞进 application:didFinishLaunchingWithOptions:launchOptions(key 是 UIApplicationLaunchOptionsURLKey),或者在 scene 场景下塞进 connectionOptions.URLContexts / connectionOptions.userActivities这个回调发生在 Unity 运行时初始化之前,此时首个场景还没加载,C# 侧连 MonoBehaviour 都还没跑起来。

热启动:App 在后台,进程是活的。回调走 application:openURL:options:application:continueUserActivity:restorationHandler:(scene 场景下是 scene:openURLContexts: / scene:continueUserActivity:),进的是一个已经初始化完成、可能还停留在某个 UI 状态的进程。

Unity 官方对这个划分是明确的:Application.absoluteURL 保存的是"应用未运行、被 deep link 拉起时"的 URL(冷启动);Application.deepLinkActivated 是"应用已在运行时"收到的事件(热启动),事件参数带 URL,同时 absoluteURL 也会刷新。官方 ProcessDeepLinkMngr 示例的骨架就是在 Awake() 里订阅事件,紧接着立刻判断 if (!string.IsNullOrEmpty(Application.absoluteURL)) 手动调一次回调——这正是冷启动那条路径的补丁。

请添加图片描述

三、iOS 13+ SceneDelegate 引入后的历史坑

Unity 从 2022.3.72f1 / 6000.0.68f1 起接入 UIScene 生命周期,用新的 UnityScene.mm 充当 SceneDelegate,并在 Info.plist 里默认写入 UIApplicationSceneManifest。UIKit 生命周期改由 UIScene 接管之后,AppDelegate 上的 application:openURL:options:continueUserActivity 就不再是唯一入口了。

这里有一个代码级根因值得记录:UnityScene.mm 实现了 scene:openURLContexts: 并处理了 connectionOptions.URLContexts(所以自定义 scheme 是好的),但完全没有实现 scene:continueUserActivity:,也没有处理 connectionOptions.userActivities。结果是 https 的 Universal Link 既不触发 Application.deepLinkActivatedApplication.absoluteURL 也不被设置。

典型现象:App 未运行时冷启动能进(走 launchOptions 那条老路),App 已在后台时点击链接毫无反应。 Unity 工程师在讨论帖里承认过:"universal deep links require quite a set up... it was missed during UI Scene implementation, and when fixing deep links only custom url schemes were fixed."

官方分了两个 issue 修:

  • UUM-135497(修自定义 URL scheme):落地于 2022.3.75f1、6000.0.71f1、6000.3.12f1、6000.4.0b12、6000.5.0a9、6000.6.0a1
  • UUM-140746(修 Universal Link,并把 AbsoluteURL 的赋值时机提前到 Awake() 可读):含 UIScene + Universal Link 完整支持的推荐版本为 Unity 6.6 = 6000.6.0a5、6.5 = 6000.5.0f1、6.4 = 6000.4.7f1、6.3 LTS = 6000.3.16f1、6.0 LTS = 6000.0.75f1、2022 xLTS = 2022.3.76f1
版本线自定义 scheme 修复Universal Link + UIScene 完整支持
2022.3 LTS2022.3.75f12022.3.76f1
Unity 6.0 LTS6000.0.71f16000.0.75f1
Unity 6.3 LTS6000.3.12f16000.3.16f1
Unity 6.46000.4.0b126000.4.7f1
Unity 6.56000.5.0a96000.5.0f1
Unity 6.66000.6.0a16000.6.0a5

低于这些版本有两个 workaround:一是删掉 Info.plist 里的 UIApplicationSceneManifest,退回老 AppDelegate 生命周期——这是治标,UIScene 后续会被 Apple 强制;二是手工给 UnityScene.mm 打补丁。

如果你的项目需要自己接管回调,接法有两种。一是子类化 UnityAppController,用 IMPL_APP_CONTROLLER_SUBCLASS 宏注册:

// MyAppController.mm
#import "UnityAppController.h"
@interface MyAppController : UnityAppController @end

IMPL_APP_CONTROLLER_SUBCLASS(MyAppController)

@implementation MyAppController
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options {
    [MyDeepLinkBridge stash:url.absoluteString];
    return [super application:app openURL:url options:options];
}
@end

二是完全自定义 AppDelegate,成本更高,一般只在需要接管 UIScene 配置时用。注意:UIScene 接入后,上面这些 AppDelegate 方法可能根本不被调用,此时要改成实现 scene:openURLContexts:scene:continueUserActivity:

// UnityScene 接入后需要补的方法
- (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts {
    UIOpenURLContext *ctx = URLContexts.anyObject;
    if (ctx) [MyDeepLinkBridge stash:ctx.URL.absoluteString];
}

- (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity {
    if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
        [MyDeepLinkBridge stash:userActivity.webpageURL.absoluteString];
    }
}

四、参数投递:为什么"静态缓存 + 就绪轮询"比 UnitySendMessage 可靠

Unity 手册对 iOS 原生回调的限定很硬:

  • UnitySendMessage 三个参数是目标 GameObject 名、脚本方法名、消息字符串
  • 原生侧只能调用签名形如 void MethodName(string message) 的方法,没有别的重载
  • 调用是异步的,有约一帧延迟
  • 多个 GameObject 同名时会造成冲突

再加上 Unity 主线程与原生线程不同步,绝大多数 Unity API 只能在主线程调用,原生回调必须 marshal 回主线程;常见失败模式就是 GameObject 名或方法名写错导致消息静默丢失(没有报错)。

把这几条串起来看冷启动:回调发生在 Unity 运行时和首个场景就绪之前,这时 UnitySendMessage 找不到目标 GameObject,消息直接丢弃。这就是为什么"原生侧直接投递"在冷启动路径上不可靠。

正确做法是原生侧先把 URL 静态缓存下来,C# 首个场景起来之后再主动取走,并配去重与超时。

原生侧(DeepLinkBridge.mm):

#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>

static NSString *g_pendingURL = nil;
static BOOL g_consumed = YES;

@implementation MyDeepLinkBridge
+ (void)stash:(NSString *)url {
    if (url.length == 0) return;
    g_pendingURL = [url copy];
    g_consumed = NO;   // 新的一条,重新置为未消费
}
@end

extern "C" {
    // 返回的指针在下次 stash 前保持有效,C# 侧立即 Marshal 成 string 即可
    const char *_DeepLink_Peek(void) {
        if (g_consumed || g_pendingURL.length == 0) return NULL;
        return [g_pendingURL UTF8String];
    }
    void _DeepLink_Consume(void) { g_consumed = YES; }
}

C# 侧(DeepLinkBootstrap.cs):

using System;
using System.Collections;
using System.Runtime.InteropServices;
using UnityEngine;

public class DeepLinkBootstrap : MonoBehaviour
{
#if UNITY_IOS && !UNITY_EDITOR
    [DllImport("__Internal")] static extern IntPtr _DeepLink_Peek();
    [DllImport("__Internal")] static extern void     _DeepLink_Consume();
#endif

    const float TimeoutSeconds = 10f;
    static string _lastHandled;
    static DeepLinkBootstrap _instance;

    void Awake()
    {
        if (_instance != null) { Destroy(gameObject); return; }
        _instance = this;
        DontDestroyOnLoad(gameObject);
        // 热启动路径:进程已就绪,事件直接进来
        Application.deepLinkActivated += OnDeepLink;
    }

    void OnDestroy()
    {
        Application.deepLinkActivated -= OnDeepLink;
        if (_instance == this) _instance = null;
    }

    IEnumerator Start()
    {
        // 冷启动路径:轮询原生缓存,直到拿到或超时
        float elapsed = 0f;
        while (elapsed < TimeoutSeconds)
        {
            if (TryConsumePending()) yield break;
            elapsed += Time.unscaledDeltaTime;
            yield return null;
        }
        Debug.LogWarning("[DeepLink] 等待原生缓存的 URL 超时,本次不投递");
    }

    bool TryConsumePending()
    {
#if UNITY_IOS && !UNITY_EDITOR
        IntPtr p = _DeepLink_Peek();
        if (p == IntPtr.Zero) return false;
        string url = Marshal.PtrToStringAnsi(p);
        if (string.IsNullOrEmpty(url)) return false;
        _DeepLink_Consume();
        OnDeepLink(url);
        return true;
#else
        return false;
#endif
    }

    void OnDeepLink(string url)
    {
        // 去重:热启动事件与冷启动轮询可能拿到同一条
        if (url == _lastHandled) return;
        _lastHandled = url;

        Debug.Log($"[DeepLink] 收到跳转:{url}");
        var uri = new Uri(url);
        // TODO: 按 uri.Host / uri.AbsolutePath / Query 路由到对应界面
    }
}

几个设计点值得说明。超时是必须的:原生侧没收到任何东西时,轮询不能无限跑下去。去重也是必须的:热启动事件和冷启动轮询可能拿到同一条 URL,尤其在 App 被杀死又立刻被拉起的情况下。unscaledDeltaTime:首场景加载时可能有 loading 动画把 timeScale 调成 0,用缩放时间会永远等不到超时。

五、不同来源的实测差异

微信内置浏览器是最麻烦的一条,属于"双失效"。微信会主动拦截 scheme:// 跳转,未进白名单的开发者域名无法在微信内用 H5 拉起 App,而这份白名单并未对外公开。iOS 侧更严:微信内嵌 WebView 在 iOS 14+ 使用 SFSafariViewController,该系统容器主动屏蔽所有 URL Scheme 跳转,不支持 location.href 触发式唤端,iOS 17+ 连 iframe 注入 scheme 也会被静默拦截。同时 Universal Links 在微信内也常被屏蔽或配置失效。

官方合规方案是微信开放标签 wx-open-launch-app,但有明确门槛:必须是已认证服务号的 JS 接口安全域名、必须真机才能渲染、文字链无法拉起、微信版本需 ≥ 8.0.36、必须正确调用 wx.config 并在 wx.ready() 回调内初始化组件(否则组件降级成普通 div),且通过"复制网页地址"形成的蓝色外链打开的页面不能使用外跳 App 功能。还有一条实测差异:有用户反馈扫码、收藏、分享卡片进入可以跳转,但直接点链接进入的 H5 不能跳,Android 尤其明显。

兜底策略是引导用户点右上角"在浏览器中打开",或在开放标签失败时降级到 intent:// 再兜底下载页。注意跳转必须由真实用户点击触发,不能靠 setTimeout 自动跳。

QQ 内置浏览器的拦截策略与微信类似但白名单尺度不同;Safari 是最"守规矩"的,Universal Links 与自定义 scheme 都能按预期工作,是排查时最干净的对照组;短信 / iMessage 里的链接通常直接进默认浏览器,Universal Link 表现正常。

六、真机验证手段与排查表

命令行触发(模拟器):

xcrun simctl openurl booted "https://example.com/product/123"

命中则打开 App,未命中则回落 Safari。同一个命令也可以用来在模拟器 Safari 里打开 AASA 地址,验证可达性。

核心验证点在 swcd 进程(负责 associated-domain 注册)。连真机、开 Console.app、删除并重装 App,过滤 swcd,正常应看到:

swcd(CoreUtils) <Notice>: Added service 'applinks', appID 'xxx', domain 'xxx'

出现 Domain not associated with appNo app for URL 就是配置问题;如果 swcd 日志里什么都没有,最可能的原因是 Associated Domains entitlement 根本没配上。还能看到 AASA 下载动作日志,例如 Beginning data task AASA-... { domain: example.com, bytes: 0, route: cdn }

不装 Console.app 时可以用命令行流式抓日志:

xcrun simctl spawn booted log stream --level debug --predicate 'process == "MyApp"'

请添加图片描述

两个坑要提前知道:SWCErrorDomain Code=1701 "Failed to get associated domain data from ManagedConfiguration framework"非企业受管设备上的正常噪声,别当故障排查;模拟器上 Universal Link 历史上多次出现"真机正常、模拟器不正常",可尝试清模拟器数据、切换 Wi-Fi、重启或换模拟器版本,Apple 工程师更推荐看真机 sysdiagnose 里的 swcutil.txt(见 TN3155)。

现象优先怀疑验证方式
冷启动进 App,热启动无反应Unity 版本低于 UUM-140746 修复线查上文版本表,升级或打补丁
自定义 scheme 跳不动CFBundleURLSchemes 未写或写错检查 Info.plist / Player Settings
Universal Link 完全无反应Associated Domains entitlement 缺失Console.app 过滤 swcd,无日志即未配置
开发机正常,TestFlight 不正常?mode=developer 只对 development 签名生效换正式签名验证 + 查 CDN 副本
AASA 源站正常但仍失败WAF 拦截了 AASA-Bot/1.0直接访问 app-site-association.cdn-apple.com/a/v1/<domain>
改完 AASA 不生效Apple CDN 缓存不可主动失效卸载重装 App
微信内点击无反应未使用 wx-open-launch-app 或被 SFSafariViewController 屏蔽引导"在浏览器中打开"

七、结论与实施顺序

如果只能记三件事:

  1. 先升版本再写代码。Unity 2022.3.76f1 / 6000.0.75f1 及以上的完整支持版本,能省掉一整套 UnityScene.mm 打补丁的工作。UIScene 是 Apple 在推的方向,靠删 UIApplicationSceneManifest 退回老生命周期只是缓兵之计。
  2. 冷启动那条路径必须走"静态缓存 + 就绪轮询",不要指望 UnitySendMessage 在 Unity 运行时起来之前把消息送进去。去重和超时是配套的必需品,不是可选项。
  3. 服务端与客户端要一起排。Universal Links 的故障大概率不在你的 C# 代码里,而在 AASA 的重定向、Content-Type、128KB 体积、CDN 缓存、以及 WAF 对 AASA-Bot 的态度上。app-site-association.cdn-apple.com 那条 URL 应该加进你的书签栏。

至于安全,URL Scheme 只要还挂在项目里,就要默认它是可被劫持的:不放长效 token、不把"能打开链接"当作身份证明、所有入参做白名单校验。真要承载敏感流程,用 Universal Links,并且把 AASA 的匹配规则写严——用 exclude: true 把不该进 App 的路径挡在外面,比在客户端里判断要可靠得多。

参考链接