ATT 追踪弹窗不出现:排查与修复

ATTrackingManager.requestTrackingAuthorization 不弹 UI 直接返回 notDetermined 或 denied,多因 App 状态、设备总开关、Info.plist 或调用位置。2026 年 6 月核实的修复方案。

你在 App 首次启动时调 ATTrackingManager.requestTrackingAuthorization,但系统弹窗根本没出现。completion handler 立即拿到 .denied.notDetermined,IDFA 仍是全 0,归因 SDK(AppsFlyer、Adjust、Branch、Meta)一直把安装报成 unattributed。有时本地真机能弹,TestFlight 或正式版上又消失。

先试最快的修复: 最常见的原因是在 App 进入前台之前就调了这个 API。iOS 只在 UIApplication.shared.applicationState == .active 时才弹窗——这条规则从 iOS 15 起就强制执行,iOS 18 依然如此。如果你在 didFinishLaunchingWithOptions 里调 requestTrackingAuthorization,此时 App 还是 .inactive,iOS 会静默丢弃请求并返回 .notDetermined。把调用挪到 sceneDidBecomeActive(SwiftUI 里放到首屏的 .task)并加一小段延迟,再检查设备级跟踪总开关。这两步就能解决绝大多数工单。

任何可见症状——不弹窗、没问就被拒、IDFA 全 0——都能追溯到下面某一类。API 本身诚实可预测,只要你知道往哪看。

你属于哪一类?

你看到的现象最可能的原因跳转
返回 .notDetermined,无 UI.active 之前调用,或延迟 / 并发弹窗问题原因 3、8
立即返回 .denied,无 UI设备总开关关、Info.plist key 缺失、或已回答过原因 1、2、6
返回 .restricted儿童账号、MDM 或屏幕使用时间原因 4、5
弹窗出现了但 IDFA 仍是 0设备总开关关,或 SDK 在 ATT 完成前读了 IDFA原因 2、7
真机能弹,扩展 / 小组件里从不弹从 App 扩展里调用原因 9

常见原因

按工单出现频率排序。

1. NSUserTrackingUsageDescription 缺失或为空

没这个 Info.plist key 时 iOS 不弹窗,handler 直接返回 .denied。空字符串同等待遇。

如何识别grep NSUserTrackingUsageDescription Info.plist 没输出,或值为 ""。Console:“Cannot request tracking authorization without a value for NSUserTrackingUsageDescription.”

2. 设备级跟踪总开关关着,或根本打不开

如果 设置 → 隐私与安全性 → 跟踪 → 允许 App 请求跟踪着的,没有任何 App 能弹窗,requestTrackingAuthorization 直接返回 .denied,不显示 UI。这个总开关对符合条件的账号默认是开的,但不少用户会主动关掉,而且在某些账号上它根本打不开。

根据苹果当前的支持文档(截至 2026 年 6 月),出现以下任一情况时该开关不可用、弹窗也永远不会出现:

  • Apple Account 是儿童账号,或按出生年份属于未满 18 岁的用户(年龄阈值因国家 / 地区而异)。
  • Apple Account 由教育或企业机构管理(受管理的 Apple 账户)。
  • 设备处于受管理状态,且使用了限制跟踪的配置描述文件。
  • Apple Account 是最近 3 天内创建的。

如何识别:在出问题的设备上按上述路径检查。主开关关着或变灰时,每个 App 的 requestTrackingAuthorization 都会返回 .denied(受管理 / 儿童账号则返回 .restricted),且无 UI。权威清单见苹果的 If an app asks to track your activity

3. App 还没进入前台 / active 状态就调用了

这是头号原因。从 iOS 15 起(iOS 18 仍然如此),requestTrackingAuthorization 只在 applicationState == .active 时才弹窗。在 application(_:didFinishLaunchingWithOptions:) 里调太早了,applicationDidBecomeActive(或 sceneDidBecomeActive)触发前 App 都是 .inactive,iOS 会丢掉这个请求。

如何识别:你在 AppDelegate 的 didFinishLaunchingWithOptions 里调 ATT。handler 拿到 .notDetermined,UI 没出现。在调用处打断点并打印 UIApplication.shared.applicationState——如果不是 .active,这就是你的 bug。

4. 儿童账号或未满 18 岁的 Apple Account

苹果规定未满 18 岁(阈值因地区而异)的用户和儿童账号不会被显示 ATT 弹窗。该开关变灰,requestTrackingAuthorization 返回 .restricted(不是 .denied),无 UI。

如何识别:测试设备的 Apple Account 是儿童 / 家庭受管理账号。trackingAuthorizationStatus 返回 .restricted。换成成人、未受管理的账号弹窗就出现。

5. MDM 或屏幕使用时间禁用了跟踪

企业 MDM profile 或屏幕使用时间内容限制可以全局禁用跟踪。屏幕使用时间 → 内容和隐私访问限制 → 允许更改 → 跟踪,“不允许”会静默拒绝所有 ATT 请求。

如何识别:设备在 MDM 管控中,或屏幕使用时间限制启用了。设置 → 屏幕使用时间 → 内容和隐私访问限制显示跟踪被锁。

6. 本次安装里已经回答过弹窗

每次安装 ATT 只弹一次。如果你(或别的测试者)已经回答过,之后再调,保存的答案就一直生效。trackingAuthorizationStatus 返回上一次的决定,handler 不带 UI 就触发。

如何识别:在 requestTrackingAuthorization 之前 ATTrackingManager.trackingAuthorizationStatus != .notDetermined。答案就是上次给的。

7. SDK 在 ATT 之前已经初始化并读了 IDFA

某些归因 SDK 在 init 时就读 IDFA。如果你在 SDK init 之后才调 requestTrackingAuthorization,SDK 已经缓存了全 0 的 IDFA。弹窗可能照常出现,但下游归因已经废了。

如何识别:用户同意后 SDK 报告的 IDFA 仍为 0。重装 App 并确保 ATT 在 SDK init 前完成就能修复。

8. 屏幕上已经有另一个权限弹窗

iOS 一次只显示一个系统权限弹窗,且不会把 ATT 排队到别的弹窗后面。如果你在通知、定位、相机或 App Store 登录弹窗还在(或正在消失)的时候调 requestTrackingAuthorization,iOS 会丢掉这次 ATT 请求,handler 返回 .notDetermined,无 UI。这就是为什么 ATT 单独测时”正常”,可一旦放进堆叠多个弹窗的真实 onboarding 流程里就消失。

如何识别:单独测 ATT 时弹窗出现,但紧跟在另一个权限请求之后就不出现。逐个串行申请权限,等上一个的 completion handler 回来再调下一个。

9. 从 App 扩展或小组件里调用

requestTrackingAuthorization 只在主 App 进程里弹窗。从 App 扩展、小组件(widget)或 App Clip 里调用不会显示 UI,会直接返回、不弹窗。

如何识别:调用写在了非主 App 的 target 里(Today 小组件、share 扩展、通知 service 等)。把请求挪进主 App,并把需要 IDFA 的功能放在它之后。

开始之前

  • 在真机上测。模拟器返回 mock 值,对 ATT 不是可靠信号。
  • 记录 iOS 版本(ATT 要求 iOS 14.5+;.active 状态规则从 iOS 15 起适用)。
  • 先检查调用位置(是不是 .active?)和设备级跟踪总开关。大多数”弹窗不出现”的工单都在这两处之一解决。

需要收集的信息

  • grep -A1 NSUserTrackingUsageDescription Info.plist 的输出(或对应 build setting 值)。
  • 调用 requestTrackingAuthorization 之前 ATTrackingManager.trackingAuthorizationStatus 的当前值。
  • requestTrackingAuthorization 的具体调用位置(文件 + 行),以及它跑在哪个生命周期方法里。
  • 测试设备的 iOS 版本。
  • 设置 → 隐私与安全性 → 跟踪 → 允许 App 请求跟踪的状态。
  • 设备是否在企业 MDM 中,或者是不是儿童 / 家庭账号。

修复步骤

步骤 1:确认 Info.plist key 写了一段真正的描述

<key>NSUserTrackingUsageDescription</key>
<string>Your data helps us show you relevant ads and measure ad performance.</string>

App Review 强制要求具体。“We need tracking”会被拒;要说明用户能看到的好处。每种支持的语言都通过 InfoPlist.strings 本地化。

步骤 2:确认设备级跟踪允许

测试设备:

设置 → 隐私与安全性 → 跟踪 → 允许 App 请求跟踪 = 开

关着时所有 App 的弹窗都不会出现。在你的 TestFlight 测试说明里告诉测试者:这个开关必须开才能测 ATT。

步骤 3:只在 App .active 时调 ATT

把调用从 didFinishLaunchingWithOptions 挪到进入前台之后才跑的方法:

import AppTrackingTransparency
import AdSupport

func requestTrackingPermission() {
    if #available(iOS 14.5, *) {
        ATTrackingManager.requestTrackingAuthorization { status in
            switch status {
            case .authorized:
                let idfa = ASIdentifierManager.shared().advertisingIdentifier
                AnalyticsSDK.shared.setIDFA(idfa.uuidString)
            case .denied, .restricted, .notDetermined:
                AnalyticsSDK.shared.setIDFA(nil)
            @unknown default:
                AnalyticsSDK.shared.setIDFA(nil)
            }
        }
    }
}

sceneDidBecomeActive(SwiftUI:在根视图 .onAppear 里加一小段延迟)或者一个明确的首次启动 onboarding 页面里触发。

步骤 4:紧贴启动后调的话加一小段延迟

如果在 App 刚进入 active、scene 还没完全稳定的最初一瞬间就请求,iOS 偶尔会静默吞掉弹窗。加一小段延迟:

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
    requestTrackingPermission()
}

SwiftUI .task 里:

.task {
    try? await Task.sleep(nanoseconds: 500_000_000)
    requestTrackingPermission()
}

步骤 5:调整 SDK init 相对于 ATT 的顺序

推荐模式:ATT 完成后再收 IDFA,再传给 SDK:

ATTrackingManager.requestTrackingAuthorization { status in
    let idfa: String? = (status == .authorized)
        ? ASIdentifierManager.shared().advertisingIdentifier.uuidString
        : nil
    AppsFlyerLib.shared().advertisingIdentifier = idfa
    AppsFlyerLib.shared().start()
    Adjust.appDidLaunch(adjustConfig)
}

如果 SDK 必须在 App 启动时 init(比如要处理 deeplink),用 SDK 自己的 delayInitForATT / waitForATTUserAuthorization 配置。AppsFlyer 和 Adjust 都提供这种选项。

步骤 6:用 pre-prompt 提升同意率

截至 2026 年 6 月,按 Adjust 的 2025 年基准,ATT 同意率(被显示弹窗的用户中允许跟踪的比例)行业整体在 30-35% 左右,AppsFlyer 的样本里部分地区和品类要明显更高。设计良好的 pre-prompt 页面——系统弹窗前由你自己 UI 解释价值交换——能可靠地多拿几个百分点,因为它只把系统弹窗显示给大概率会同意的用户:

NavigationView {
    VStack(spacing: 16) {
        Image(systemName: "person.crop.circle.badge.checkmark")
            .font(.system(size: 64))
        Text("Help us improve your experience")
            .font(.title)
        Text("Allow tracking to see personalized content and support free features.")
        Button("Continue") {
            showSystemPrompt = true
        }
    }
}
.alert(isPresented: $showSystemPrompt) {
    // request actual ATT here on dismiss
}

Pre-prompt 是你的销售环节,系统弹窗才是用户做决定的瞬间。

步骤 7:处理”已经回答过”的情况

调用 requestTrackingAuthorization 之前先检查状态:

let status = ATTrackingManager.trackingAuthorizationStatus
switch status {
case .notDetermined:
    requestTrackingPermission()
case .denied, .restricted:
    // Send user to Settings if you want to give a second chance
    if let url = URL(string: UIApplication.openSettingsURLString) {
        UIApplication.shared.open(url)
    }
case .authorized:
    break  // already approved, nothing to do
@unknown default: break
}

已经拒过又想重测的测试者,需要删 App 重装(唯一能重置那个 App ATT 状态的办法)。

步骤 8:权限一次只申请一个

iOS 不会给系统权限弹窗排队,所以绝不要把 ATT 和别的弹窗同时弹。用 completion handler 把它们串起来,让 ATT 要么排在最前,要么等所有其他弹窗都处理完之后再弹:

UNUserNotificationCenter.current()
    .requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in
        DispatchQueue.main.async {
            // 通知已处理完;现在在主线程申请 ATT。
            requestTrackingPermission()
        }
    }

如果你的 onboarding 还有定位、相机或通知弹窗,等每一个的 completion handler 回来再弹下一个。两个弹窗在同一个 run loop 里触发,其中一个(往往就是 ATT)会被静默丢弃。

验证

  • iOS 14.5+ 真机、设备级总开关开、未曾回答过的状态下,App 在 .active 时启动会看到系统 ATT 弹窗。
  • 调用前 ATTrackingManager.trackingAuthorizationStatus 读到 .notDetermined,调用后读到对应的值。
  • 同意后状态变为 .authorized,能拿到非 0 IDFA。
  • 拒绝后状态变为 .denied,IDFA 为全 0(00000000-0000-0000-0000-000000000000)。
  • 再次启动不重复弹,状态返回上次保存的值。
  • 归因 SDK 后台能看到 authorized 安装上报真实 IDFA。

长期预防

  • CI 中对 NSUserTrackingUsageDescription 缺失硬失败;弹窗对它的缺失是静默的,所以要显式检查。
  • 把设备级总开关写进测试者说明;每个 TestFlight 邀请都要提一句。
  • 做一个 QA 用的内部诊断页,打印 trackingAuthorizationStatusadvertisingIdentifier,能省下大量”ATT 到底有没有工作?“的调试时间。
  • 对 pre-prompt 文案做 A/B 测试;措辞的变化能让同意率波动几个百分点。
  • 把 SDK init 放在明确命名的”after-ATT”代码路径里;永远不要在 didFinishLaunchingWithOptions 里直接调用需要 IDFA 的 SDK。
  • iOS 大版本升级时复测一遍;.active 状态要求是 iOS 15 引入的、一直延续到 iOS 18,但苹果在各版本里都以不显眼的方式收紧过 ATT 执行。

常见坑

  • 在自己的 pre-prompt 之前先弹系统弹窗。系统弹窗只有一次机会,pre-prompt 必须先。
  • 在一个 modal 里调 ATT,另一个 presentation 正在飞行时 iOS 有时会忽略请求。从根 scene 状态调。
  • 把弹窗描述本地化时把”跟踪”这个词改成”个性化”之类委婉说法,App Review 会拒,“tracking”是苹果的官方用词。
  • .denied 之后通过反复调 requestTrackingAuthorization 想重问。一旦拒掉只能让用户去设置里改,或重装 App。
  • .notDetermined 当成”用户拒绝了”,这只意味着弹窗根本没出现过。要查根因,不要默认当 .denied
  • 以为模拟器的 ATT 行为和真机一致。必须真机复测。隐私层面 reviewer 还会检查的回应策略,可参考App 因 5.1.1 隐私条款被拒

FAQ

Q:不实现 ATT 会被苹果拒吗?

只有当你用 IDFA 或跨 App / 站点跟踪而又没弹这个弹窗才会被拒。如果你完全不跟踪、只用第一方数据,你完全不需要调 ATT,而且不需要时不应该调,那属于误导用户。

Q:用户同意了,IDFA 还是全 0,为什么?

两个常见原因:(1) 你在 ATT 完成前就收了 IDFA,SDK 缓存了 0;(2) 设备级”允许 App 请求跟踪”是关着的,这种情况下同意也返回 authorized,但 IDFA 仍是 0。苹果用这种方式强制设备级总开关。

Q:.notDetermined.denied.restricted 有什么区别?

.notDetermined 表示弹窗从未显示过,所以这是你调 API 之前应该看到的值;如果调用之后还看到它,说明弹窗被静默丢弃了(状态不是 .active、并发弹窗、扩展等)。.denied 表示用户或设备总开关说了不。.restricted 表示系统因账号 / 设备原因(儿童账号、MDM、屏幕使用时间)拦下了弹窗,用户也改不了。把 .restricted 当成”无法询问”,而不是”用户拒绝了”。

Q:用 SKAdNetwork 或 AdAttributionKit 能绕开 ATT 吗?

两者都是苹果提供的保护隐私的归因通道,都不需要 ATT,给出的是聚合、有延迟的安装归因。AdAttributionKit 是较新的框架,对新安装取代了 SKAdNetwork,而 SKAdNetwork 仍受支持。如果是基于 IDFA 的个人级归因,ATT 仍是必需。

Q:测试者同意之后删了 App 重装,弹窗会再出现吗?

会。删 App 会重置该 App 在该设备上的 ATT 状态。在设置里重置 Apple ID 的广告标识符也一样。

Q:TestFlight 算不算 ATT 的”首次启动”?

算。TestFlight 上的首次安装会弹窗。同一个 CFBundleIdentifier 的后续构建除非用户卸载否则不再弹。

相关阅读

标签: #排查 #ios #att #idfa #privacy