ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

iOS 14 后 IDFA 获取、ATT 授权与归因降级实践

iOS 14 后 IDFA 获取、ATT 授权与归因降级实践 线上投放的数据对不上很多人的第一反应是怀疑渠道作弊第二反应是怀疑归因逻辑写错了最后才回头去看代码里那行advertisingIdentifier。我经手的项目里iOS 14.5 之后最典型的一幕是安卓侧一天几十万新增iOS 侧只有零头客服还反馈新用户装完就弹一个看不懂的框。问题的根子不在渠道而在IDFAIdentifier for Advertisers广告标识符的获取方式被彻底改写了——它从随时可读的一个系统属性变成了必须先拿到用户授权、否则返回一串全零 UUID的东西。这篇内容我会把 iOS 14 之后 IDFA 获取的完整链路拆开讲授权文案怎么写、状态机怎么判、弹窗什么时候请求、拿不到的时候用什么降级、代码怎么封装、联调期哪些坑最容易翻车。不管你是刚接手广告 SDK 对接的 iOS 新人还是正在为长期归因体系做架构的老手都能在这里找到可以直接抄的部分。我会尽量给到能跑通的代码和能解释清楚的为什么而不是只丢一段 API 文档。1. IDFA 从随手可读到授权后才有效中间发生了什么1.1 IDFA 的原始定位设备级广告标识不是用户 ID先把概念拉直。IDFA 是系统给每一台 iOS 设备分配的一个 UUID 格式字符串同一个开发者账号下、同一台设备上所有 App 读到的值是一致的。这个跨 App 一致的特性正是广告归因能成立的基础用户在 A 应用里看到广告、点了下载装上了 B 应用B 应用启动时读到的 IDFA 和广告平台记录的那一个是同一个归因链条就接上了。但它从来就不是用户 ID。用户可以重置它系统也可能在特定条件下改变它同一台设备在不同时间点读到的值不保证永远相同。我在早期项目里见过有人把 IDFA 当主键存进用户表做跨设备打通的美梦结果用户在设置里动一下开关整张表的映射关系全废。所以第一件要记住的事是IDFA 是当前时刻的广告标识快照不是永久用户身份。它适合做归因、做频次控制、做投放回传不适合做账号体系的主键。1.2 ATT 把读取行为拆成了技术可读与合规可用两件事iOS 14 引入的App Tracking TransparencyATT应用跟踪透明度框架本质上是把一件原本混在一起的事情拆成了两层。第一层是技术层ASIdentifierManager.shared().advertisingIdentifier这个属性依然存在你依然能调用它编译器不会报错运行时也不会崩溃。第二层是合规层如果用户没有授权跟踪这个属性返回的是一串固定的全零 UUID而不是你期望的设备标识。也就是说系统没有用抛异常或者拒绝访问来阻止你它用的是给你一个看起来合法、但明显无意义的值来让你自己意识到这条路走不通了。这个设计非常聪明也非常容易让人踩坑。我见过太多代码是这样的读到 IDFA判断非空就上报。全零 UUID 当然非空于是整条链路都在拿一串00000000-0000-0000-0000-000000000000做归因后台看板上同一个用户的活跃度高得离谱。所以判断逻辑必须从是否为空升级成是否为全零这是最低限度的改造。1.3 全零 UUID它不是报错而是被设计出来的返回值全零 UUID 出现的场景比很多人想的要多整理成表格会清楚一些场景授权状态读到的 IDFA备注用户从未被询问过notDetermined全零需要主动发起请求用户在弹窗里点了要求 App 不跟踪denied全零不会再弹第二次系统级允许 App 请求跟踪开关被关闭denied全零弹窗根本不会出现用户已授权authorized正常 UUID可用于归因回传设备开启了家长控制等限制restricted全零无法通过应用内手段改变用户重启设备后尚未解锁视情况全零启动早期读取容易拿到空值这张表里最容易被误判的是第四条和第二条。很多同学发现新装用户状态直接就是 denied以为是代码写错了其实是用户在系统设置里把允许 App 请求跟踪这个全局开关关掉了。全局开关关闭时requestTrackingAuthorization不会被调用弹窗直接回调 denied——系统认为用户已经表达过不想被跟踪的意愿了不需要再问一遍。注意全零 UUID 是一个合法字符串长度、格式都和真 IDFA 一模一样。任何非空即有效的判断都会静默失效务必做字符串比对。2. 授权链路的三个关键节点文案、状态查询、弹窗时机2.1 NSUserTrackingUsageDescription 不是摆设它决定审核能不能过只要你的 App 链接了AppTrackingTransparency.framework或者调用了requestTrackingAuthorization就必须在Info.plist里配置NSUserTrackingUsageDescription。这个字段就是弹窗里那句允许某某 App跟踪您在其他公司的 App 和网站上的活动吗下面的说明文字。写这句话有讲究。它会被审核人员逐字读也会被用户逐字读。我见过的被拒案例里文案问题占了一大半典型的错误写法有这么几类我们需要您的 IDFA 用于广告投放——太技术化用户看不懂IDFA是什么审核也容易认为你只是想要标识符。为了给您提供更好的服务——太空等于没说属于典型的模板话术。不授权将无法使用本应用——这是明确违规苹果不允许把核心功能与追踪授权绑定。比较稳妥的写法是把用途和用户获益讲清楚比如用于统计广告带来的安装效果帮助我们判断哪些推广渠道值得继续投入不会用于识别您的个人身份。这句话做了三件事说清用途广告效果统计、说清边界不识别身份、没有威胁用户。长度控制在两句话以内不要写成小作文。2.2 trackingAuthorizationStatus 的四种状态与分支处理ATTrackingManager.trackingAuthorizationStatus返回的是一个枚举四个值的处理方式完全不同import AppTrackingTransparency switch ATTrackingManager.trackingAuthorizationStatus { case .notDetermined: // 用户还没被问过这是唯一可以主动请求的时机 requestTracking() case .authorized: // 已授权可以直接读取有效 IDFA readIDFA() case .denied: // 用户拒绝或系统级开关关闭只能走降级方案 fallbackToIDFV() case .restricted: // 受系统策略限制应用内无法改变直接降级 fallbackToIDFV() unknown default: fallbackToIDFV() }这里有个细节值得单独拎出来说只有.notDetermined才值得调用请求接口。在已经 denied 或 authorized 的状态下再调requestTrackingAuthorization弹窗不会出现回调会带着当前状态立刻返回。有些同学为了确保拿到在每次冷启动都调一次请求结果是白跑一遍异步流程还多了一次无意义的状态读取。更糟的是如果调用发生在 App 尚未进入 active 状态的时候有些系统版本上会出现回调迟迟不来的现象把启动流程堵住。2.3 弹窗时机的选择在 active 状态下请求成功率最高这是我在多个项目里反复验证过的一条经验requestTrackingAuthorization的调用时机直接决定弹窗能不能正常出现。最理想的时机是 App 已经完成首屏渲染、处于active状态之后。具体落地时我一般放在首屏viewDidAppear之后的短暂延迟里或者监听sceneDidBecomeActive通知确保界面已经真正呈现给用户。为什么不放在application(_:didFinishLaunchingWithOptions:)里因为在这个阶段App 还没进入前台活跃状态系统认为现在不适合打断用户弹窗可能被吞掉而回调也不会按预期返回。另一个容易被忽略的点是弹窗要有上下文。用户刚打开 App 就被一个陌生的追踪弹窗糊脸拒绝率会高得吓人。比较成熟的做法是在真正需要追踪能力的业务动作发生前再请求。比如用户进入首页、看到活动页面或者完成一次关键浏览行为后这时用户对 App 有了一点认知授权率通常能提升一截。我实测过的最优位置是首屏展示后、首次触发广告相关上报之前既保证了数据的完整性也不至于让用户一脸茫然。还有一类做法是在系统弹窗之前先弹一个自制的说明页用大白话解释为什么要问你这个问题用户点继续之后再触发系统弹窗。这种做法本身是允许的但红线很清楚自制说明页不能模仿系统弹窗的样式不能诱导用户必须点允许也不能把同意按钮做得比不同意大得多、显眼得多。踩过这条线的开发者审核被打回的不少。3. 拿不到 IDFA 之后的降级设计IDFV、SKAdNetwork 与 AdServices3.1 IDFV 作为同一开发者账号下的稳定锚点identifierForVendorIDFV是另一条路。它同样是一个 UUID但它和 IDFA 的定位完全不同IDFV 是同一厂商维度的标识同一个开发者账号下的所有 App 读到同一个值卸载该厂商所有 App 之后再重装这个值会变。它不需要用户授权随时可读。这两者的差异可以用一张表说清楚维度IDFAIDFV是否需要授权需要iOS 14不需要跨厂商范围全设备一致仅同一开发者账号下一致可否用于第三方归因授权后可以不可以第三方拿不到同一值卸载重装后是否变化可能变化该厂商 App 全卸载后变化典型用途广告归因、投放回传自家多应用间打通、风控辅助降级方案的核心思路是用 IDFV 在自家服务端建立设备档案再把服务端档案 ID 作为后续上报的主键。这样即使拿不到 IDFA自家应用内的行为分析、频次统计、留存计算依然能跑通。但要清楚它的局限——第三方广告平台没法用 IDFV 做归因因为广告平台根本不知道你 App 的 IDFV 是什么这条链是断的。3.2 SKAdNetwork不依赖 IDFA 的官方归因通道苹果给出的官方答案是SKAdNetwork。它把归因做成了一个隐私保护中间人模型广告平台通过这个框架给设备打上匿名标记用户安装 App 后系统在满足条件时把转化信息回传给广告平台和开发者全程不暴露设备标识。接入层面的关键动作有这么几个。首先要在Info.plist里用SKAdNetworkItems声明你合作的广告平台标识其次在转化发生时调用更新转化值的方法。iOS 16.1 之后这套机制升级到了支持粗粒度转化值 多次回传的版本回传窗口从固定的 24 小时变成了可配置的多档窗口能承载的信息更多了。SKAdNetwork 的短板也很明显数据是延迟的、聚合的拿不到用户级明细转化值只有有限的取值空间。它解决的是这次安装大概从哪来的问题解决不了这个用户后续行为链路的问题。所以现实中的做法通常是 IDFA 和 SKAdNetwork 双轨并行能拿到 IDFA 的用户走精细归因拿不到的用户靠 SKAdNetwork 和统计模型兜底。3.3 AdServices 与 ASA 归因令牌如果你投的是苹果应用商店搜索广告还有一条专门通道AdServices框架。iOS 14.3 之后可以通过AAAttribution.attributionToken()拿到归因令牌把这个令牌交给服务端去换取该次安装对应的广告信息。这个接口不需要用户授权追踪属于苹果自家广告体系内的一环接入成本很低。import AdServices if #available(iOS 14.3, *) { do { let token try AAAttribution.attributionToken() // 把 token 上送给服务端由服务端向苹果接口换取归因结果 uploadAttributionToken(token) } catch { // 常见原因是网络异常或系统接口暂时不可用重试即可 print(attribution token 获取失败: \(error)) } }要注意的是这个令牌是一次性的、有时效的服务端拿到后要尽快去兑换不要存起来慢慢用。而且它只覆盖苹果自有广告渠道其他第三方平台用不了。3.4 哪些场景能降级哪些不能降级不是随便找个替代品糊上去要先想清楚业务到底需要什么。我一般用这样一个判断框架需要跨平台归因判断某个第三方广告带来的安装——只能用 IDFA 或 SKAdNetworkIDFV 帮不上忙。需要自家多应用间的账号打通——IDFV 完全够用而且更稳定。需要行为统计与留存分析——服务端生成的自有设备 ID 最合适甚至比 IDFA 更好因为它不受用户授权波动影响。需要反作弊与频次控制——IDFV 加设备指纹类特征组合比单一 IDFA 更可靠。我见过一个典型翻车案例某团队在拿不到 IDFA 之后直接把 IDFV 填进了原本给 IDFA 准备的上报字段结果广告后台把所有用户都算成了新设备报表彻底失真。字段名换了语义没换这是最容易犯的错。稳妥的做法是在数据协议层就把两个字段分开让下游明确知道这一条记录用的是哪种标识。4. 一份可以直接抄的 IDFA 获取封装4.1 接口设计同步快照 异步请求直接在主流程里裸调requestTrackingAuthorization会带来三个问题调用点分散、状态判断重复、并发调用时可能拿到不一致的结果。我习惯把它封装成一个单例服务对外只暴露两类接口同步快照立刻返回当前已知的最优标识可能是缓存的 IDFA也可能是降级的 IDFV用于日志、埋点这类不介意精度的场景。异步获取返回一个回调保证在授权流程结束后给出最终结果用于广告回传这类不能出错的场景。enum DeviceIdentifier { case idfa(String) case idfv(String) case none } protocol IDFAServiceProtocol { /// 同步获取当前可用的标识快照 func currentIdentifier() - DeviceIdentifier /// 异步请求授权并返回最终标识 func fetchIdentifier(completion: escaping (DeviceIdentifier) - Void) }4.2 核心实现代码import AdSupport import AppTrackingTransparency final class IDFAService: IDFAServiceProtocol { static let shared IDFAService() private init() {} private let zeroUUID 00000000-0000-0000-0000-000000000000 private var cachedIDFA: String? private var isRequesting false private var pendingCompletions: [(DeviceIdentifier) - Void] [] // MARK: - 同步快照 func currentIdentifier() - DeviceIdentifier { if let cached cachedIDFA, cached ! zeroUUID { return .idfa(cached) } if let idfv UIDevice.current.identifierForVendor?.uuidString { return .idfv(idfv) } return .none } // MARK: - 异步获取 func fetchIdentifier(completion: escaping (DeviceIdentifier) - Void) { // 已经有可用结果直接返回避免重复请求 if let cached cachedIDFA, cached ! zeroUUID { completion(.idfa(cached)) return } // 已经有请求在飞挂起等待避免并发重复弹窗 if isRequesting { pendingCompletions.append(completion) return } // 系统版本低于 14走老逻辑 guard #available(iOS 14, *) else { completion(legacyIdentifier()) return } handleTrackingRequest(completion: completion) } available(iOS 14, *) private func handleTrackingRequest(completion: escaping (DeviceIdentifier) - Void) { switch ATTrackingManager.trackingAuthorizationStatus { case .authorized: completion(readAndCacheIDFA()) case .denied, .restricted: completion(currentIdentifier()) case .notDetermined: isRequesting true // 必须在主线程调用且建议在 App 已进入活跃状态后触发 ATTrackingManager.requestTrackingAuthorization { [weak self] status in guard let self self else { return } // 回调不保证在主线程切回主线程再处理状态与回调分发 DispatchQueue.main.async { self.isRequesting false let result: DeviceIdentifier if status .authorized { result self.readAndCacheIDFA() } else { result self.currentIdentifier() } completion(result) self.pendingCompletions.forEach { $0(result) } self.pendingCompletions.removeAll() } } unknown default: completion(currentIdentifier()) } } private func readAndCacheIDFA() - DeviceIdentifier { let idfa ASIdentifierManager.shared().advertisingIdentifier.uuidString guard idfa ! zeroUUID else { return currentIdentifier() } cachedIDFA idfa return .idfa(idfa) } /// iOS 13 及以下用 isAdvertisingTrackingEnabled 判断限制广告跟踪开关 private func legacyIdentifier() - DeviceIdentifier { let manager ASIdentifierManager.shared() guard manager.isAdvertisingTrackingEnabled else { return currentIdentifier() } let idfa manager.advertisingIdentifier.uuidString guard idfa ! zeroUUID else { return currentIdentifier() } cachedIDFA idfa return .idfa(idfa) } }这段代码里有几个点是我刻意为之的值得展开说。第一zeroUUID的比对是硬性的。没有这一步整个封装就等于没写。判断顺序也要注意先判空再判全零因为某些异常情况下拿到的可能是空字符串。第二isRequesting加挂起队列。广告回传、埋点上报、统计 SDK 三处代码可能同时要求获取 IDFA如果不做并发控制就会出现多次调用请求接口。虽然系统对重复请求有保护但多次回调会让上层的状态管理变乱尤其是多线程环境下的竞态。挂起队列的写法简单效果稳定。第三回调必须切回主线程。requestTrackingAuthorization的完成回调不保证在哪个线程执行如果上层拿它去刷新 UI很容易出现主线程检查器的警告甚至偶发崩溃。统一在主线程分发能让调用方的使用体验一致。第四缓存策略要有意识。我在currentIdentifier()里做了缓存读取但缓存的生命周期需要想清楚用户在 App 使用过程中跑到设置里改了权限缓存就会和真实状态不一致。我的处理方式是监听UIApplication.didBecomeActiveNotification每次回到前台重新校验一次授权状态如果不一致就清掉缓存。4.3 缓存、超时与并发调用的处理细节缓存这块有个折中方案值得推荐内存缓存 每次回前台校验。把 IDFA 存在内存里不落磁盘——落磁盘会带来额外的合规风险一旦审核人员发现你把广告标识写进了本地文件解释起来很麻烦。回到前台时重新读一次trackingAuthorizationStatus如果发现变成了 denied就清空缓存并把上层标识切换成 IDFV。超时方面虽然回调理论上一定会来但线上确实出现过回调延迟十几秒的反馈。我的建议是在上层加一个软超时超过 5 秒还没拿到结果先用当前快照IDFV 或空走一遍上报流程等真正的回调来了再补一次修正上报。这样做的好处是主流程不会因为授权弹窗卡住代价是同一台设备可能产生两条记录需要服务端做去重。提示不要把 IDFA 写进UserDefaults、文件或 Keychain。需要跨启动使用的话交给服务端保存本地只保留你自己的匿名 ID。5. 联调期的行为差异与踩坑排查5.1 模拟器、真机、TestFlight 三者的差异这是最容易让新人困惑的地方我直接列成表环境弹窗是否出现IDFA 表现注意事项Xcode 模拟器可能出现通常拿不到有意义的真实值不要在模拟器上验证归因只验证流程分支真机 Debug 包会出现授权后返回正常 UUID最接近线上首选联调环境TestFlight 包会出现授权后返回正常 UUID与线上行为基本一致适合回归线上正式包会出现授权后返回正常 UUID注意全局开关关闭的用户会直接 denied模拟器的坑在于它可以跑通授权流程但返回的标识值可能并不具备跨设备唯一性用它去和广告后台核对数据永远不会对上。我的习惯是模拟器只用来验证四种状态分支有没有走对真机才用来验证值是不是真的能对上。还有一个测试上的细节授权状态一旦确定就只能在设置里改或者把 App 卸载重装才会回到notDetermined。这意味着你想反复测试弹窗就得反复重装。更麻烦的是如果系统级的允许 App 请求跟踪开关被关掉了卸载重装也没用状态依然是 denied。所以测试前先确认这个开关是打开的能省下不少时间。5.2 弹窗死活不出现的几种真实原因排查这类问题我一般按下面的顺序走一遍效率最高检查系统全局开关。设置 → 隐私与安全性 → 跟踪 → 允许 App 请求跟踪。关闭状态下无论代码怎么写都不会弹。确认状态是不是 notDetermined。如果之前已经弹过并被拒绝状态就是 denied不会再有第二次机会只能卸载重装。确认 Info.plist 里的描述字段存在且非空。缺失这个字段请求可能直接失败而且不会有明显报错。检查调用时机。在didFinishLaunching或 App 还没进入 active 状态时调用弹窗大概率不出现。挪到首屏出现之后。检查是不是在后台线程调用。某些系统版本上非主线程调用会导致行为异常。检查有没有多个 SDK 抢占。接了多个第三方 SDK 时谁先调用谁拿到弹窗其他 SDK 的回调会带着当前状态直接返回。这种情况要统一入口避免各家 SDK 各弹各的。第 6 条是我在集成第三方广告 SDK 时踩过的坑接入三个 SDK每个都自己调一次请求接口结果只有一个弹窗另外两个 SDK 收到 denied 就开始走降级数据口径全乱了。后来改成所有 SDK 都从我这里的封装取标识不允许自己调请求接口问题才消失。统一收口是多方 SDK 集成的第一原则。5.3 上架前的自查清单提交前我会过一遍这几个点能避开绝大多数审核和线上问题Info.plist里NSUserTrackingUsageDescription存在文案说明了具体用途没有威胁性表述。拒绝授权后App 的核心功能完全可用。任何不授权就限制使用的逻辑都要删掉。隐私清单里如实勾选了用于追踪的数据类型和实际采集行为一致。上报的 IDFA 字段和 IDFV 字段是分开的下游能区分。全零 UUID 的过滤逻辑在客户端和服务端都有不依赖单侧。自制的前置说明页没有模仿系统弹窗样式没有把拒绝选项做得难以发现。授权状态在回到前台时有重新校验。最后这一条经常被漏掉。用户在 App 运行期间去设置里改了权限如果不重新校验App 会在整个会话里都拿着已经失效的标识继续上报数据错得很隐蔽。加一个didBecomeActive的监听成本极低收益很大。注意无论业务多着急都不要尝试用设备指纹多条硬件信息拼合成一个稳定标识去替代 IDFA。这种做法在苹果的规则里属于明令禁止的行为被发现的后果比拿不到 IDFA 严重得多。我在实际项目里最后落地的方案其实就三句话授权拿得到就用 IDFA 做精细归因拿不到就用 IDFV 加服务端档案兜住自家分析跨平台的归因缺口交给 SKAdNetwork 和统计模型去补。这套组合跑下来iOS 侧的归因覆盖率能回到一个可接受的水平比死磕单一标识要踏实得多。真正需要花时间打磨的不是那句请求授权的代码而是拿不到之后怎么办的那一整套设计。
返回列表