App Review 依据指南 2.1 拒绝了我的 iOS 应用。审核备注说,审核人员在测试这个构建版本时,找不到 App Tracking Transparency(以下简称 ATT)权限请求弹窗。
弹窗在我自己的 iPhone 上一切正常。每次启动都出现。只是在他们那台设备上不出现。
排查下来,元凶是 ATT 这两个 API 属性。单独看任何一条都容易忽略,合在一起却极其坑人。它们合起来会产出一个 bug:在快的设备上完全看不见,在慢的设备上完全可以复现。你的测试设备很快。审核人员的设备不一定。
这篇文章讲三件事:根本原因、我上线的修复方案,以及一份会让弹窗静默不出的其他原因清单。
解释一切的两个事实
1. iOS 只在应用处于活跃状态时弹 ATT 弹窗
Apple 对 requestTrackingAuthorization(completionHandler:) 的文档里,针对 iOS 15 及以后版本写着:
“只有当应用状态是 UIApplicationStateActive 时,调用这个 API 才会弹窗。”
这个状态是 UIApplication.State.active,不是单纯的“在前台”,也不是“代码在运行”。在启动过程中,有一段窗口期,你的 JS 和 UI 已经在跑了,但应用仍然处于 inactive 状态。比如启动页消失到一半、首次渲染进行中、模态转场正在进入或退出。在这个窗口里调用 API,iOS 会拒绝弹窗。
2. 当 iOS 拒绝弹窗时,你拿不到任何错误
你拿回来的是 notDetermined(在 expo-tracking-transparency 里叫 undetermined)。这和用户还没回答时拿到的值一模一样。
没有“我弹不出来”的信号。没有抛出的异常。没有 presented: false 这个标志位。单看返回值,“用户还没决定”和“iOS 静默地把你的请求吞掉了”根本无法区分。
这就是陷阱所在。这个 API 看起来像是成功了。
我犯下的 bug
把问题简化到本质,就是这样:
// 在启动阶段调用,此时启动页还在消失的过程中。
const { status } = await requestTrackingPermissionsAsync();
const granted = status === 'granted';
nonPersonalizedOnly = !granted; // 然后就没有然后了,这个进程从此不再询问
两个错误叠在一起。
-
请求得太早。调用发生在应用进入
active之前,所以 iOS 有时会直接跳过弹窗。 -
把
undetermined当成最终答案。任何不是granted的结果都被归入“未授权”,缓存在整个进程生命周期里,从此不再重试。
单看任何一条都还能撑过去。合在一起就变成:如果第一次尝试错过了时机,那么这一次安装就再也看不见弹窗。不只是这一次启动,后续启动里同样的竞态还会重复发生。
在我的手机上,启动够快,调用落地时应用通常已经 active。在审核那台设备上则没有。这个时间差,就是“上架成功了”和“被拒了”的全部距离。
关键信号:如果你明确发起了授权请求,status 却返回 undetermined,那不是用户在拒绝。那是 iOS 压根没问。
修复一:等待 active
不要在定时器里请求,也不要设个“两秒后再请求”然后指望它碰运气。要观察应用的真实状态。
import { AppState } from 'react-native';
const ACTIVE_WAIT_TIMEOUT_MS = 10_000;
function waitUntilActive(): Promise<void> {
if (AppState.currentState === 'active') {
return Promise.resolve();
}
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
subscription.remove();
clearTimeout(timer);
resolve();
};
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') finish();
});
// 绝不要让这个 Promise 无限挂起。ATT 不能阻塞启动流程的其余部分。
const timer = setTimeout(finish, ACTIVE_WAIT_TIMEOUT_MS);
// 补上这种情况:从上面那次检查到监听器挂载之间,应用已经切到 active。
if (AppState.currentState === 'active') finish();
});
}
两个值得偷走的细节。
订阅之后再复查一次。读取 AppState.currentState 和注册监听器之间,存在一个真实的空档。如果状态转换正好发生在这个空档里,你会一直等一个已经触发过的事件。这是那种在生产环境一周出现一次、在你自己桌上永远不出现的竞态。
超时机制。一个能无限挂起的权限辅助函数,最后一定会无限挂起,还会把你的广告 SDK 初始化(更糟的话,是你的启动页)一起拖下水。到点就 resolve,让调用方继续走。
修复二:遇到 undetermined 就重试,而不是放弃
既然“没弹出来”和“没被回答”看起来一模一样,唯一能把它们区分的办法,就是再试一次,看看结果有没有变化。
import {
getTrackingPermissionsAsync,
isAvailable as isTrackingApiAvailable,
requestTrackingPermissionsAsync,
} from 'expo-tracking-transparency';
/** 每次尝试之间的间隔。数组长度等于最大尝试次数。 */
const ATT_ATTEMPT_DELAYS_MS = [600, 1_500, 3_000, 5_000, 8_000] as const;
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
async function ensureTrackingConsent(): Promise<boolean> {
// Android / iOS 14:库会返回与 granted 等价的结果。没什么要问的。
if (!isTrackingApiAvailable()) return true;
// 已经回答过了(本次安装,或者上一次启动)?那 iOS 永远不会再弹,
// 再请求毫无意义。
const { status: current } = await getTrackingPermissionsAsync();
if (current !== 'undetermined') return current === 'granted';
for (const settleMs of ATT_ATTEMPT_DELAYS_MS) {
await waitUntilActive();
await delay(settleMs);
// 等待期间我们可能又掉回了 inactive。现在请求只会浪费一次尝试,
// 而且什么都弹不出来。
if (AppState.currentState !== 'active') continue;
const { status } = await requestTrackingPermissionsAsync();
if (status !== 'undetermined') {
return status === 'granted'; // 弹窗出现了,用户做了回答。
}
// 依然是 undetermined:说明它根本没被弹出来。退避一下,再试一次。
}
return false;
}
为什么用递增间隔,而不是固定间隔:那些压制弹窗的情况(转场动画、屏幕上还有另一个权限弹窗、系统在启动后正在安定下来)都会自行结束,但时间尺度无法预测。第一次尝试短一些,让健康的启动快速弹出;后面几次拉长,让慢设备最终也能等到。
不要狂轰滥炸。在一个紧凑循环里反复请求权限,本身就是一个失败模式。其他权限弹窗(通知、定位)在屏幕上停留期间,会把你的应用切进 inactive 状态。所以在通知弹窗期间连打一串重试请求,就是五次注定落空的尝试。
还有一点值得注意:整个辅助函数应该每个进程只跑一次,把同一个 Promise 交给所有调用方。两个并发的 ATT 请求并不会排队,第二个会被直接丢弃。
let trackingConsent: Promise<boolean> | null = null;
export function getTrackingConsent(): Promise<boolean> {
trackingConsent ??= ensureTrackingConsent();
return trackingConsent;
}
而且不要在展示广告之前 await 它。先用非个性化模式启动广告,让授权结果到达时自己去翻转标志位。一个挡住你第一次广告请求的重试循环,就是一个让你损失第一次广告曝光的重试循环。
修复三:给用户一个手动触发入口
总会有一些环境,自动请求就是会落空。所以我在设置页加了一行,叫“广告跟踪设置”,点击时直接调用 requestTrackingPermissionsAsync()。
除了对用户的价值,这件事值得做还有两个原因。
-
它给了 App Review 一条确定的路径去看到弹窗,不依赖启动时机。你可以在审核备注里写清楚。
-
一旦用户已经回答过,iOS 就不会再弹了。所以这一行应该检测到这种状态,然后跳转到系统设置,而不是静默地什么都不做。在 iOS 上,
Linking.openSettings()能处理这个。
const status = await getTrackingStatus();
if (status === 'undetermined') {
await requestTrackingPermissionsAsync();
} else {
await Linking.openSettings(); // 已经回答过了,只有系统还能改这个状态
}
同样的两条规则:原生 Swift 版
我上面上线的是 React Native 版本。这一段请当作原理的转译,而不是我跑过的生产代码。
import AppTrackingTransparency
import UIKit
final class TrackingRequester {
private var observer: NSObjectProtocol?
func requestWhenActive() {
guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
guard UIApplication.shared.applicationState == .active else {
observer = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
if let observer = self.observer {
NotificationCenter.default.removeObserver(observer)
self.observer = nil
}
self.requestWhenActive()
}
return
}
ATTrackingManager.requestTrackingAuthorization { status in
// 这里的 status == .notDetermined,意味着弹窗从未出现。
// 应该安排下一次尝试,而不是把它记录成一次拒绝。
}
}
}
同样的两条规则:只在 .active 状态请求,以及绝不要因为查到 .notDetermined 就把它当成一个决定。
弹窗不会出现的其他原因
在你重写状态处理之前,先把这些排除掉。其中有几条,会让一个写得正确的实现看起来像是坏的。
用户已经回答过了。iOS 会记住这个选择,一直到本次安装结束。按照 Expo 的文档,除非删除并重装应用,否则它不会再弹。这是“我的修复不生效”的头号原因,因为那台设备上你早就点过一次按钮了。
系统级的跟踪请求被关闭。设置、隐私与安全、跟踪,如果“允许 App 请求跟踪”被关掉,那么任何应用都永远弹不出弹窗。你的 API 调用会返回 denied 或 undetermined,什么东西都不显示。
还有另一个权限弹窗悬而未决。Apple 的文档写明,当另一个权限请求正在等待用户时,ATT 弹窗不会显示。如果你在启动时同时触发通知、定位和 ATT,它们并不会礼貌地排队。要串行:请求一个,然后在上一个的完成回调里再请求下一个。
你是从应用扩展(App Extension)里调用的。Apple 的文档写明,通过应用扩展发起的调用不会弹窗。
Info.plist 里缺少 NSUserTrackingUsageDescription。没有这个字符串,就没有弹窗。在 Expo 里,这要写在 app.json 的 ios.infoPlist 下(或者通过 expo-tracking-transparency 的配置插件来配)。
iOS 13 及更早版本,或者非 iOS 平台。没有 ATT 框架。要用可用性检查做保护,这样这条路径看起来才不像是一个失败。
模拟器。行为跟真机不一样。在下结论前,先在真机上验证。
重置方法,方便你真的测试修复
因为这个回答是跟着安装走的,所以测试需要一些纪律。
删除并重装应用。这是针对应用内状态最可靠的重置方式。
在设置里把“允许 App 请求跟踪”关掉再打开,也会影响行为。这是刻意复现“什么都不出现”状态最快的办法。
要复现原来的 bug 而不是修复后的行为,就人为拖慢进入 active 的速度。把请求放在一处重量级的同步启动路径后面,或者在你手里最老的设备上测试。快的硬件会掩盖这个缺陷。
我发给 App Review 的材料
单靠改代码并不足以让它过审,证据才是。
一份真机录屏,从冷启动开始,一直到弹窗出现,没有剪辑。
审核备注里写明确步骤:启动应用,在首页等待,弹窗出现;或者走设置、广告跟踪设置、点击。
一句话说明这版相比上一版改了什么。
第三次提交,通过了。
一句诚实的话:Apple 文档确实写了 active 状态的要求,但 Apple 并没有把“遇到 notDetermined 就重试”当成官方认可的对策。那一部分是在我的代码路径上让它通过的关键。请把它当作一份实测报告,而不是一份规范。
彩蛋:如果你做订阅,另一次被拒
因为这是同一次过审之旅,而且又是另一个“就是不知道”的问题。我第一次被拒,是依据指南 3.1.2:自动续费订阅没有在应用的元数据里提供一个能用的服务条款(EULA)链接。
我的应用内付费墙里本来就有链接。缺的,是商店侧的元数据。
可靠的修法:把 EULA 链接直接写进应用描述(App Description),无论你是用标准 EULA 还是自定义的。App Store Connect 的应用信息页面确实有许可协议这一栏,但如果你选的是标准 EULA,那里并没有 URL 输入框可填。所以描述文本,才是审核人员真正能点到链接的地方。
Apple 的订阅页面写得很明白:你的应用和你的 App Store 元数据都必须包含服务条款和隐私政策的链接,没有任何附加条件。标准 EULA 的地址:
https://www.apple.com/legal/internet-services/itunes/dev/stdeula/
这只是一个元数据改动,不需要重新构建。知道的话十分钟搞定,不知道的话折腾两天。
背景:这是给什么做的
这个应用叫 QuesToDo,一款面向 iPhone 的离线优先、RPG 风的待办应用。完成一个任务,赚取经验值,让像素风角色升级。我单枪匹马用 23 天做完,用 AI 智能体(Claude Code)负责实现,我负责设计决策和真机验证。技术栈是 Expo SDK 54、React Native 0.81、严格模式 TypeScript。数据库用 expo-sqlite,做的是追加式迁移,从 v1 一路做到 v9。25 个测试套件共 366 个测试,14,451 行实现代码。所有数据都留在设备本地,没有后端。
上线那一侧没有数字可报。它于 2026 年 8 月 7 日上线,我没有下载量、用户数或收入值得拿出来写。这篇讲的是审核过程,不是增长数据。
如果你想看看:QuesToDo 在 App Store 上架(免费,含应用内购买)。
太长不看
-
iOS 只在应用处于
active状态时才弹 ATT 弹窗。要等它。观察AppState或didBecomeActiveNotification,别用定时器去猜。 -
当 iOS 拒绝弹窗时,API 会返回
notDetermined,而且不带任何错误。请求之后返回的notDetermined,并不是一次拒绝。要用退避重试它。 -
每个进程只请求一次,别把它跟其他权限弹窗叠在一起,也别让广告 SDK 阻塞在结果上。
-
在设置里加一个手动触发入口,然后录屏给 App Review 看。
附:原作者留下的日文摘要(中译)
ATT 弹窗“只在审核环境不出现”的问题,其原因和应对。
原因是两个事实的组合。
-
iOS 的 ATT 弹窗,只有当应用处于
UIApplicationStateActive时才会显示。启动刚结束时(启动页关闭中、首次渲染中、模态转场中)应用常常还是inactive,这时候发起请求也不会被提示。 -
当提示被跳过时,API 不会返回错误。它不显示弹窗,直接返回
notDetermined(Expo 里是undetermined)。这和“用户还没回答”时的值相同,所以单看返回值,无法区分“没弹出来”和“还没回答”。
原来我的代码在启动后立刻发起请求,把返回的 undetermined 解释成“被拒绝了”,然后在进程内不再请求。我手头的 iPhone 启动够快,碰巧赶上了 active 状态。只差这一点,结果审核时就弹不出来了。
对策是三件套。
(1) 订阅 AppState,等到 active 再请求(要带上订阅前那次状态转换的复查,以及防止永久 pending 的超时)。
(2) 把 undetermined 当作“提示失败”而不是“拒绝”,用递增间隔重试(600 毫秒、1.5 秒、3 秒、5 秒、8 秒)。其他权限弹窗显示期间应用会变成 inactive,所以不要连打权限请求。
(3) 在设置页放一个手动触发入口。已经回答过的设备上,iOS 不会弹窗,这种时候用 Linking.openSettings() 跳转到设置应用。
弹窗不出的原因还有别的:已经回答过(只有删除重装才能重置)、“允许 App 请求跟踪”被关闭、有其他权限弹窗在等待处理、从 App Extension 调用、NSUserTrackingUsageDescription 没配置。修之前务必先排查。
审核时我附上了真机录屏(启动到弹窗出现)和写明操作步骤的审核备注。第三次才通过。另外,“应该重试 notDetermined”并不是 Apple 白纸黑字写明的,这是我这边环境里这么修就通了,一个基于实操经验的说法。
顺带一提,如果做订阅,要在应用描述(App Description)里直接写 EULA 链接(指南 3.1.2)。只在应用内付费墙里放链接不够,商店侧的元数据也需要。选标准 EULA 时 App Store Connect 没有 URL 输入框,写到描述里最稳妥。这个修复只改元数据,不需要重新构建。
除了 ATT,还讲了 SQLite 追加上迁移和时间的参数注入的日文版文章在 Qiita 上有。
来源
原文标题:Why your App Tracking Transparency prompt doesn't show up (and how it got my app rejected)
作者:ninomaeDev
平台:DEV Community
原文链接:https://dev.to/ninomaedev/why-your-app-tracking-transparency-prompt-doesnt-show-up-and-how-it-got-my-app-rejected-oob
相关链接
QuesToDo 在 App Store:https://apps.apple.com/app/questodo/id6794613228
作者日文版文章(Qiita):https://qiita.com/ninomaeDev/items/d56129c46c227d09f6cc
标准 EULA 地址:https://www.apple.com/legal/internet-services/itunes/dev/stdeula/
相关阅读:
AI 工具越多,工作流反而越慢:我最后留下的 Agent 工具栈

