深入 Android Privacy Sandbox 全链路:从 SDK Runtime 进程隔离到 Protected Audience 再营销的隐私保护架构

当设备标识符不再是「免费的午餐」

2023 年某次版本迭代中,广告 SDK 上报的数据突然少了一大截。排查后发现,部分 Android 14 设备上 ANDROID_ID 返回了空字符串——用户关闭了跨应用追踪,Privacy Sandbox 的 getTopics() 取代了传统的设备标识符获取方式。

GAID(Google Advertising ID)不能再当作归因和投放的基础依赖,广告基建需要从 SDK 的进程模型到竞价逻辑整体重新设计。我所在的团队花了近 3 个月完成适配,这篇文章记录其中的技术决策。

SDK Runtime:把广告 SDK 关进「沙箱」

传统模式下,第三方广告 SDK 以依赖库形式集成到宿主 APK,和主应用运行在同一进程,可以访问应用的所有权限:SharedPreferences、文件系统、甚至反射调用宿主组件。

SDK Runtime 改变了这个模型:广告代码运行在独立进程中,通过声明式 API 与宿主通信。安全边界从「信任第三方代码」变成「只允许声明式调用」。

进程隔离的实现

Manifest 中声明配置入口:

<!-- AndroidManifest.xml -->
<application>
    <property
        android:name="android.adservices.AD_SERVICES_CONFIG"
        android:resource="@xml/ad_services_config" />
</application>
<!-- res/xml/ad_services_config.xml -->
<ad-services-config>
    <custom-audiences>
        <sdk runtimeEnabled="true"
            android:name="com.example.adtech" />
    </custom-audiences>
</ad-services-config>

SDK 开发者以 SandboxedSdkProvider 作为入口分发能力:

class MyAdSdkProvider : SandboxedSdkProvider() {
    override fun onLoadSdk(params: Bundle): SandboxedSdk {
        val controller = AdController.build(requireContext())
        return SandboxedSdk(IBinderWrapper(controller.asBinder()))
    }
}

宿主通过 SdkSandboxManager.loadSdk() 加载 SDK,两者通过 Binder 跨进程通信。但这并非普通的 Binder IPC——SDK Runtime 进程没有网络权限不能读写外部存储不能访问宿主内存空间

踩过的一个坑:SandboxedSdkProvider 里的 context 不是 Application Context,不能启动 Activity 或注册广播。如果你的 SDK 用了隐式依赖 Application 初始化的三方库,大概率崩溃。

Protected Audience:端侧如何替代「云端画像」

Protected Audience(原 FLEDGE)的核心思路:用户兴趣数据不出设备,竞价在端侧完成

从加入受众到端侧竞价

val buyer = AdTechIdentifier.fromString("com.example.adtech")
val customAudience = CustomAudience.Builder()
    .setBuyer(buyer)
    .setName("sports-enthusiasts")
    .setDailyUpdateUri(adsUri)          // 后台更新竞价数据
    .setAds(listOf(
        AdData.Builder()
            .setRenderUri(renderUri)
            .setMetadata("{ \"bid\": 0.5 }")
            .build()
    ))
    .setActivationTime(Instant.now())
    .setExpirationTime(Instant.now().plus(30, ChronoUnit.DAYS))
    .build()

adServicesClient.joinCustomAudience(customAudience)

joinCustomAudience 将用户加入「体育爱好者」这个受众。系统触发竞价时,setDailyUpdateUri() 指向的后台负责下发广告素材和竞价参数。对接时遇到一个问题:系统对 dailyUpdate 的缓存硬编码为 24 小时,动态调整定价需要提前一天部署,运维节奏要跟着改。

竞价脚本与决策逻辑

竞价逻辑不在客户端代码里写死,而是通过 JavaScript 脚本注入到 WebView 沙箱中执行:

// bidding_logic.js - 在独立 WebView 沙箱中执行
function generateBid(ad, auction_signals, per_buyer_signals,
                      trusted_bidding_signals, browser_signals) {
    const bidFloor = parseFloat(ad.metadata.bid);
    const budgetRemaining = browser_signals.original_budget /
        browser_signals.original_bid_count;

    if (budgetRemaining < bidFloor) return { bid: -1 };

    return {
        bid: bidFloor,
        render: ad.renderUri,
        adComponents: [ad.componentUri]
    };
}

function reportResult(auctionConfig, browserSignals) {
    sendReportTo(browserSignals.reportingUrls.reportingUrl +
        "&bid=" + browserSignals.bid +
        "&win=" + browserSignals.winningBid);
}

脚本运行在与宿主完全隔离的 WebView 沙箱中,能访问的信息仅限于 browser_signals 传入的字段——看不到已安装应用列表、联系人等隐私数据。

脚本的灰度策略

竞价逻辑跑在端侧,没法像服务端那样随时 hotfix。我设计的方案:每次 dailyUpdateUri 返回数据时带上脚本版本号,客户端按版本号缓存,新版本覆盖旧版本。要回滚就让后台返回旧版本号。

归因报告:不用 Device ID 追踪转化

没有了 GAID,Privacy Sandbox 提供了 Attribution Reporting API。归因分两步:广告展示时注册 Source,转化时注册 Trigger,系统异步匹配生成报告。

// 注册 Source:广告被看到了
val source = RegisterSourceRequest.Builder()
    .setAttributionSource(
        AttributionSource.Builder()
            .setRegistrant(AdTechIdentifier.fromString("com.advertiser"))
            .build())
    .setWebDestination(Uri.parse("https://advertiser.com"))
    .setAppDestination(Uri.parse("android-app://com.advertiser"))
    .setExpiry(30, TimeUnit.DAYS)
    .build()
measurementClient.registerSource(source)
// 注册 Trigger:用户完成了购买
val trigger = RegisterTriggerRequest.Builder()
    .setAttributionSource(/* 同上 */)
    .setEventTriggers(listOf(
        EventTrigger.Builder()
            .setTriggerData(1)      // 0=浏览, 1=购买, 2=注册
            .setTriggerPriority(100)
            .setDedupKey(10001L)
            .build()
    ))
    .build()
measurementClient.registerTrigger(trigger)

报告类型分两种:事件级报告(Event-Level)触发即上报含粗略转化数据;聚合报告(Aggregate)加密后延迟发送,用于统计分析。

实际开发中容易踩的一个限制:triggerData 值域只有 0-7(3 个 bit),别指望用它传递自定义数据。更多维度的转化信息得靠聚合报告的 histogram 贡献值实现。

工程迁移的落地建议

SDK 重构粒度。SDK Runtime 要求广告逻辑独立运行,我把竞价引擎、受众管理、归因上报拆成三个独立 Module。好处是各自独立更新测试,代价是跨 Module 状态同步需要宿主中转。体量不大的 SDK 放一个 Module 就够了。

测试环境搭建。Privacy Sandbox API 在模拟器上行为与真机不同——selectAds() 经常返回空结果,因为系统需要真实端侧数据训练兴趣模型。最终我搭建了真机测试集群,CI 中加入了集成测试用例。

如果现在开始规划迁移,建议按这个顺序推进:先搞 Attribution Reporting(改动最小,立竿见影),然后是 Custom Audience 管理,最后再动 SDK Runtime 进程隔离。SDK Runtime 对现有架构冲击最大,但它从根本上解决了第三方代码的安全信任问题——这是 Privacy Sandbox 架构里我最认可的部分。