10.1 ATT、IDFA 与 SKAdNetwork

ATT 不是弹窗,是一台状态机:四种授权状态决定 IDFA 能不能用、能不能再弹、走不走聚合回传。本节把 iOS 归因链路整理成一份可机读的清单(状态 × 用途 × 回传字段 schema),用 D 层校验器把「链路决策」算出来,并用 P 层 TypeScript 内核跨语言对拍,证明 6 位转化值的位掩码编解码逐位一致。

站内那篇出海合规与税务风险清单 给的是法律与表单口径——哪些地区要什么资质、资金怎么走。本节不碰那些,只做一件更工程的事:把 iOS 归因链路接对。而在站内另一个相邻主题上,卷四 4.2 已经算过「隐私新政后你还能看见多少」的可见度模型。本节与它明确分工:卷四讲归因窗口、匹配率与量化损失这条「算可见度」的线;本节讲 iOS 侧的链路本身——ATT 状态机怎么流转、IDFA 在哪个状态才拿得到、SKAdNetwork 回传字段长什么样、6 位转化值该怎么定义。

「链路」不是一条线,是一台状态机

很多人把 ATT 理解成一个「同意 / 不同意」的弹窗。工程上它不是弹窗,是一台只有四个状态的状态机,而每个状态同时决定三件事:还能不能拿到 IDFA、还能不能再弹、未授权侧走不走聚合回传。这三件事互相耦合——比如 restricted(系统受限,常见于家长控制)既拿不到 IDFA 也不能弹,但它仍然走聚合回传。把状态、用途、回传三张表分开看,链路就会漏掉分支。

状态能拿 IDFA能再弹窗走聚合回传
not_determined 未决定否是否
denied 已拒绝否否是
authorized 已授权是否是
restricted 系统受限否否是

追踪用途也要单独门控

第二个容易漏的点:状态允许 ≠ 用途允许。同一个 denied 状态,「归因测量」通常还能靠聚合数据做,而「个性化广告」必须已授权才合法。所以链路决策是状态与用途的笛卡尔积,不是只看状态:

用途需要已授权未授权时的处置
归因测量 measurement否可走聚合回传
产品分析 analytics否可走聚合回传
个性化推荐 personalization是禁用
个性化广告 ads是禁用

回传字段是合同,转化值是接口

SKAdNetwork 的回传不是「把安装明细发给你」,而是一组合法字段 + 一个整数转化值。字段表本身就是一份合同:每个字段有位宽与合法区间(conversion_value 是 6 位、上限 63;source_identifier 是 4 位十进制)。你要做的是把「应用内发生了什么」编码进那个整数。本节用的编码是位掩码——每个里程碑占一个比特位,达成即置位,cv = OR(1 << bit)。这样 6 位能同时表达 6 个独立里程碑,比「按收入分桶」更能保留「到底做了哪几件事」的信息。

D 层:链路清单与 schema 校验

下面这份是 D 层配置 config/g10_att_link.json,把状态机、用途门控、回传字段 schema、里程碑位与场景全部写成可机读的整数表:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "ATT / IDFA / SKAdNetwork 链路清单。口径是「公开可观察机制」的建模假设(观察时点 2026-10),不是任一平台后台或归因服务商实测。attStates:idfaAvailable=该状态能否拿到 IDFA;canPrompt=该状态能否再次请求授权;skanPath=该状态是否走聚合回传。purposes:needsConsent=该用途是否必须已授权。skanFields:bits=字段位宽(0 表示非数值字段);min/max=合法取值区间。milestones:bit=里程碑占用的转化值比特位。coarseBands:粗粒度档位按 popcount 落带。真实同意率 / 回传门槛 / 平台版本需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "attStates": [
    { "id": "not_determined", "label": "未决定", "idfaAvailable": 0, "canPrompt": 1, "skanPath": 0 },
    { "id": "denied", "label": "已拒绝", "idfaAvailable": 0, "canPrompt": 0, "skanPath": 1 },
    { "id": "authorized", "label": "已授权", "idfaAvailable": 1, "canPrompt": 0, "skanPath": 1 },
    { "id": "restricted", "label": "系统受限", "idfaAvailable": 0, "canPrompt": 0, "skanPath": 1 }
  ],
  "purposes": [
    { "id": "measurement", "label": "归因测量", "needsConsent": 0 },
    { "id": "analytics", "label": "产品分析", "needsConsent": 0 },
    { "id": "personalization", "label": "个性化推荐", "needsConsent": 1 },
    { "id": "ads", "label": "个性化广告", "needsConsent": 1 }
  ],
  "skanFields": [
    { "name": "version", "bits": 0, "min": 0, "max": 0 },
    { "name": "conversion_value", "bits": 6, "min": 0, "max": 63 },
    { "name": "coarse_conversion_value", "bits": 2, "min": 0, "max": 3 },
    { "name": "source_identifier", "bits": 14, "min": 1000, "max": 9999 },
    { "name": "campaign_id", "bits": 64, "min": 0, "max": 0 }
  ],
  "milestones": [
    { "id": "install", "bit": 0, "revenueDeciFen": 0 },
    { "id": "tutorial", "bit": 1, "revenueDeciFen": 0 },
    { "id": "d1_return", "bit": 2, "revenueDeciFen": 0 },
    { "id": "first_ad", "bit": 3, "revenueDeciFen": 0 },
    { "id": "first_pay", "bit": 4, "revenueDeciFen": 600 },
    { "id": "level10", "bit": 5, "revenueDeciFen": 0 }
  ],
  "coarseBands": [
    { "id": "low", "minPopcount": 0, "maxPopcount": 1 },
    { "id": "medium", "minPopcount": 2, "maxPopcount": 3 },
    { "id": "high", "minPopcount": 4, "maxPopcount": 6 }
  ],
  "scenarios": [
    { "id": "s1", "state": "not_determined", "purpose": "measurement", "milestones": ["install", "tutorial", "d1_return"] },
    { "id": "s2", "state": "denied", "purpose": "ads", "milestones": ["install", "tutorial"] },
    { "id": "s3", "state": "authorized", "purpose": "measurement", "milestones": ["install", "tutorial", "d1_return", "first_ad", "first_pay"] },
    { "id": "s4", "state": "restricted", "purpose": "personalization", "milestones": ["install"] },
    { "id": "s5", "state": "authorized", "purpose": "ads", "milestones": ["install", "tutorial", "d1_return", "first_ad", "first_pay", "level10"] }
  ]
}

下面这段是 D 层校验脚本 sim/g10_att_link.py。它读上面那份配置,把「状态 × 用途」解析成链路决策,把里程碑编码成转化值并反解回里程碑列表证明可逆,再对字段表做 schema 校验:

#!/usr/bin/env python3
# file: sim/g10_att_link.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g10_att_link.py --config config/ --out out/
"""ATT / IDFA / SKAdNetwork 链路的清单解析与 schema 校验(D 层:清单结构 / lint)。

读 config/g10_att_link.json,把「ATT 状态 × 追踪用途」解析成链路决策:
  idfa = state.idfaAvailable;prompt = state.canPrompt;skan = state.skanPath
  allowed = purpose.needsConsent == 0 or idfa == 1;link = idfa / skan / none
  encode: cv = OR(1 << bit),coarse = 按 popcount 落带;decode 把 cv 解回里程碑
  schema_ok: bits>0 的字段必须满足 max < 2^bits
产出 out/g10_att_link.json + .csv。全整数、无浮点、无随机。
口径:ATT 状态机与回传字段是「公开可观察机制」的建模假设(观察时点 2026-10);真实
同意率 / 回传门槛 / 平台版本需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations

import argparse
import csv
import json
import os


def resolve(state, purpose):
    idfa, prompt, skan = state["idfaAvailable"], state["canPrompt"], state["skanPath"]
    allowed = 1 if (purpose["needsConsent"] == 0 or idfa == 1) else 0
    return {"idfa": idfa, "prompt": prompt, "skan": skan, "allowed": allowed,
            "link": "idfa" if idfa else ("skan" if skan else "none")}


def encode(cfg, achieved):
    bit_of = {m["id"]: m["bit"] for m in cfg["milestones"]}
    cv = 0
    for mid in achieved:
        cv |= 1 << bit_of[mid]
    pc = bin(cv).count("1")
    band = next(b["id"] for b in cfg["coarseBands"] if b["minPopcount"] <= pc <= b["maxPopcount"])
    return {"cv": cv, "popcount": pc, "coarse": band}


def decode(cfg, cv):
    return [m["id"] for m in cfg["milestones"] if (cv >> m["bit"]) & 1]


def schema_ok(fields):
    return 0 if any(f["bits"] > 0 and f["max"] >= (1 << f["bits"]) for f in fields) else 1


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    with open(os.path.join(a.config, "g10_att_link.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    states = {s["id"]: s for s in cfg["attStates"]}
    purposes = {p["id"]: p for p in cfg["purposes"]}
    rows, parts = [], []
    for s in cfg["scenarios"]:
        r = resolve(states[s["state"]], purposes[s["purpose"]])
        e = encode(cfg, s["milestones"])
        rows.append({"id": s["id"], "state": s["state"], "purpose": s["purpose"], **r, **e,
                     "roundTrip": 1 if decode(cfg, e["cv"]) == list(s["milestones"]) else 0})
        parts.append(f'{s["id"]}:{s["state"]}:{s["purpose"]}:{r["idfa"]}:{r["prompt"]}:'
                     f'{r["skan"]}:{r["allowed"]}:{r["link"]}:{e["cv"]}:{e["coarse"]}')
    ok, bits = schema_ok(cfg["skanFields"]), sum(f["bits"] for f in cfg["skanFields"])
    cnt = lambda key: sum(1 for r in rows if r["link"] == key)  # noqa: E731
    digest = (f'fields={len(cfg["skanFields"])},bits={bits},schemaOk={ok},idfaPath={cnt("idfa")},'
              f'skanPath={cnt("skan")},nonePath={cnt("none")},'
              f'blocked={sum(1 for r in rows if r["allowed"] == 0)},'
              f'roundTrip={sum(1 for r in rows if r["roundTrip"] == 1)};' + ";".join(parts))
    os.makedirs(a.out, exist_ok=True)
    cols = ("id", "state", "purpose", "idfa", "prompt", "skan", "allowed", "link", "cv", "coarse", "roundTrip")
    with open(os.path.join(a.out, "g10_att_link.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(cols)
        for r in rows:
            w.writerow([r[c] for c in cols])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "fieldCount": len(cfg["skanFields"]), "totalBits": bits, "schemaOk": ok,
           "scenarios": rows, "digest": digest, "csv": os.path.join(a.out, "g10_att_link.csv")}
    with open(os.path.join(a.out, "g10_att_link.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "schemaOk": ok, "totalBits": bits,
                      "scenarios": [{"id": r["id"], "link": r["link"], "cv": r["cv"],
                                     "coarse": r["coarse"], "allowed": r["allowed"]} for r in rows]},
                     ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑 /opt/homebrew/bin/python3 sim/g10_att_link.py --config config/ --out out/,五个场景的链路决策如下(同机相对量级,绝对数值不可移植):

场景状态用途IDFA可弹窗聚合用途放行链路转化值粗档
s1not_determinedmeasurement0101none7medium
s2deniedads0010skan3medium
s3authorizedmeasurement1011idfa31high
s4restrictedpersonalization0010skan1low
s5authorizedads1011idfa63high

两处 0 就是「用途门控」拦下来的:s2 是未授权用户想做个性化广告、s4 是受限用户想做个性化推荐,两条链路都只能降级到聚合,不能按用户级投放。而 s3 / s5 拿到 IDFA,走的是精确归因。字段表 schema 校验通过(schemaOk=1,五个字段合计 bits=86),roundTrip=5 说明五个场景的位掩码编码全部可逆。

P 层内核:跨语言对拍,证明编码没写错

下面这段是 P 层 TypeScript 内核 core/src/g10/att_link.ts,复算同一套链路决策与位掩码编解码。它读同一份 config/g10_att_link.json,产出和 Python 逐字符一致的摘要。注意位宽校验在 TS 侧必须用 BigInt——campaign_id 是 64 位,1 << 64 在普通 number 下会静默溢出成 0,schema 校验会假通过:

// file: core/src/g10/att_link.ts
// tier: P
// verify: node --test 'core/test/g10_*.test.ts'
// ATT/IDFA/SKAdNetwork 链路内核:与 sim/g10_att_link.py 逐位等价的对偶实现(全整数、无浮点、
// 无随机、只用可擦除语法;位宽校验用 BigInt,因 campaign_id 是 64 位)。

export interface AttState {
  readonly id: string; readonly label: string; readonly idfaAvailable: number;
  readonly canPrompt: number; readonly skanPath: number;
}
export interface Purpose { readonly id: string; readonly label: string; readonly needsConsent: number }
export interface SkanField { readonly name: string; readonly bits: number; readonly min: number; readonly max: number }
export interface Milestone { readonly id: string; readonly bit: number; readonly revenueDeciFen: number }
export interface CoarseBand { readonly id: string; readonly minPopcount: number; readonly maxPopcount: number }
export interface Scenario {
  readonly id: string; readonly state: string; readonly purpose: string; readonly milestones: readonly string[];
}
export interface LinkConfig {
  readonly version: number; readonly game: string; readonly attStates: readonly AttState[];
  readonly purposes: readonly Purpose[]; readonly skanFields: readonly SkanField[];
  readonly milestones: readonly Milestone[]; readonly coarseBands: readonly CoarseBand[];
  readonly scenarios: readonly Scenario[];
}
export interface Decision {
  readonly idfa: number; readonly prompt: number; readonly skan: number;
  readonly allowed: number; readonly link: string;
}
export interface Encoded { readonly cv: number; readonly popcount: number; readonly coarse: string }
export interface ScenarioRow extends Decision, Encoded {
  readonly id: string; readonly state: string; readonly purpose: string; readonly roundTrip: number;
}

export function resolve(state: AttState, purpose: Purpose): Decision {
  const idfa = state.idfaAvailable, prompt = state.canPrompt, skan = state.skanPath;
  const allowed = purpose.needsConsent === 0 || idfa === 1 ? 1 : 0;
  const link = idfa ? "idfa" : skan ? "skan" : "none";
  return { idfa, prompt, skan, allowed, link };
}

/** 把已达成里程碑集合编码成转化值位掩码与粗粒度档位。 */
export function encode(cfg: LinkConfig, achieved: readonly string[]): Encoded {
  const bitOf = new Map(cfg.milestones.map((m) => [m.id, m.bit]));
  let cv = 0;
  for (const mid of achieved) cv |= 1 << (bitOf.get(mid) ?? 0);
  let popcount = 0;
  for (let v = cv; v > 0; v >>= 1) popcount += v & 1;
  const band = cfg.coarseBands.find((b) => popcount >= b.minPopcount && popcount <= b.maxPopcount)!;
  return { cv, popcount, coarse: band.id };
}

export function decode(cfg: LinkConfig, cv: number): string[] {
  return cfg.milestones.filter((m) => ((cv >> m.bit) & 1) === 1).map((m) => m.id);
}

export function schemaOk(fields: readonly SkanField[]): number {
  for (const f of fields) {
    if (f.bits > 0 && BigInt(f.max) >= 1n << BigInt(f.bits)) return 0;
  }
  return 1;
}

export function rows(cfg: LinkConfig): ScenarioRow[] {
  const st = new Map(cfg.attStates.map((s) => [s.id, s]));
  const pu = new Map(cfg.purposes.map((p) => [p.id, p]));
  return cfg.scenarios.map((s) => {
    const d = resolve(st.get(s.state)!, pu.get(s.purpose)!);
    const e = encode(cfg, s.milestones);
    const back = decode(cfg, e.cv);
    const rt = back.length === s.milestones.length && back.every((v, i) => v === s.milestones[i]);
    return { id: s.id, state: s.state, purpose: s.purpose, ...d, ...e, roundTrip: rt ? 1 : 0 };
  });
}

/** 跨语言对拍摘要:与 sim/g10_att_link.py 的 digest 逐字符一致。 */
export function digest(cfg: LinkConfig): string {
  const rs = rows(cfg);
  const ok = schemaOk(cfg.skanFields);
  const totalBits = cfg.skanFields.reduce((a, f) => a + f.bits, 0);
  const nIdfa = rs.filter((r) => r.link === "idfa").length;
  const nSkan = rs.filter((r) => r.link === "skan").length;
  const nNone = rs.filter((r) => r.link === "none").length;
  const nBlocked = rs.filter((r) => r.allowed === 0).length;
  const nRound = rs.filter((r) => r.roundTrip === 1).length;
  const head = `fields=${cfg.skanFields.length},bits=${totalBits},schemaOk=${ok},`
    + `idfaPath=${nIdfa},skanPath=${nSkan},nonePath=${nNone},blocked=${nBlocked},roundTrip=${nRound};`;
  return head + rs.map((r) => `${r.id}:${r.state}:${r.purpose}:${r.idfa}:${r.prompt}:`
    + `${r.skan}:${r.allowed}:${r.link}:${r.cv}:${r.coarse}`).join(";");
}

配套测试 core/test/g10_att_link.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算结果逐字符相等,并顺手验状态机门控、用途门控、位掩码可逆与 64 位边界。在本机跑 node --test 'core/test/g10_*.test.ts',本节 6 个用例全过:

✔ 跨语言一致:TS 链路摘要 == Python 金标准
✔ ATT 状态机:只有 authorized 有 IDFA,只有 not_determined 能弹窗
✔ 用途同意门控:需要同意的用途在未授权时被禁用
✔ 转化值位掩码:编码 / 解码可逆
✔ 字段表 schema:6 位转化值上限 63,64 位字段不溢出
✔ 场景表:两条 IDFA 路径、两条聚合路径、一条无路径
ℹ tests 6 / pass 6 / fail 0(本节 6 个;该 glob 连同 10.3 的 5 个共 11 个)

两套独立实现给出同一个摘要(截取首段):fields=5,bits=86,schemaOk=1,idfaPath=2,skanPath=2,nonePath=1,blocked=2,roundTrip=5;s1:not_determined:measurement:0:1:0:1:none:7:medium;s2:denied:ads:0:0:1:0:skan:3:medium;…。注意 TS 侧若用普通 number 做 1 << bits,位移计数按 32 取模,1 << 64 会静默等于 1,64 位字段的上限校验等于形同虚设——所以那里必须用 BigInt。

X 层:真实同意率与回传门槛不可复现

必须把边界划清。上面算的是模型侧:给定状态与用途,链路决策是什么、转化值怎么编码。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的 ATT 同意率(随地区、品类、弹窗时机剧烈变化,且平台不允许诱导);
  • 真实的 SKAdNetwork 回传门槛与延迟(隐私阈值、回传窗口、转化值更新规则由平台定义);
  • 真实的字段版本与位宽(平台会随版本调整字段集,本节按观察时点 2026-10 冻结)。

工程上的正确做法依旧是把模型层与真实数据层分开:模型层回答「用途门控会不会把这条链路拦下来」,真实数据层用来在投放后反标定。这也是为什么链路决策必须与埋点口径一一对应——口径对不上,反标定就无从谈起。

自检表

检查项合格标准
状态机完整四个 ATT 状态都有分支,不把 restricted 与 denied 混为一谈
用途单独门控需要已授权的用途在未授权时显式禁用,不靠默认值
转化值定义位掩码可逆(编码后能解回里程碑),不是只报一个数
字段 schema位宽与上限显式校验,64 位字段用 BigInt 不溢出
跨语言一致Python 与 TS 摘要逐字符相同
边界清晰真实同意率与回传门槛标 X,不写成「实测」

把状态机与字段 schema 冻结下来,你才不会把「未授权用户的聚合回传」当成全量数据去优化。下一节处理链路断了之后的事:没有 IDFA,还能靠什么把渠道贡献估出来。

阅读导航:上一节:9.3 同意管理与数据出境 · 下一节:10.2 无 IDFA 时代的归因替代 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制