第三方 SDK 性能影响评估与治理实战
治理第三方 SDK,我们先要能随时回答一组问题:发布包里究竟有什么、代码在什么时候执行、消耗了哪些资源、异常由谁触发、出问题后能否快速停止调用或回退版本。一张“可接入/不可接入”的静态名单回答不了这些问题。
源码基线:Android 17 / API 37 /
android-17.0.0_r1;内核内存接口以android17-6.18-2026-06_r6为准。历史版本仅用于解释兼容路径,预览版与更高版本的行为不提前套用。
1. 先定义“SDK”,再讨论数量
治理的第一步是把“SDK”定义清楚:它并不只是一份 AAR。一次商业能力接入可能同时带来:
- 多个 Maven artifact(以 groupId、artifactId 和版本区分的独立发布单元),以及它们继续引入的传递依赖;
- Java/Kotlin 字节码、资源、Manifest 合并项,以及按 ABI(应用二进制接口,如
arm64-v8a)区分的.so; ContentProvider、Service、Receiver、Activity、独立进程和自定义权限;- 初始化代码、线程池、定时任务、网络协议、数据库与本地缓存;
- consumer ProGuard/R8 规则(库随 AAR 交付、构建时并入应用的压缩与混淆规则)以及基线配置文件和注解处理器;
- 服务端开关、控制台配置、数据处理关系与供应商运维能力。
行业里流传的“平均 SDK 数量”“SDK 体积比例”之类的数字,我们只当背景参考:它们往往缺少可核对的样本,怎么统计也没有统一答案——按供应商、业务能力、Maven 发布单元、DEX 包名前缀,还是按最终安装字节计数?同一家供应商可能拆成十几个发布单元,一个基础库也可能被多个 SDK 复用,项目结论落在这种数字上站不住。
更可靠的做法,是我们同时维护两种视图:
- 能力/供应商视图:回答谁提供了什么能力、由谁负责、能否替换。
- 发布制品视图:回答本次 release 中多了哪些代码、资源、组件、权限和原生库。
依赖声明数量与最终体积、包名前缀与运行时责任,相互之间都对不上号;治理对象应当是“某个能力在某个版本发布制品中的完整执行路径”。
2. 建立可核对的 SDK 台账
2.1 从 release 产物反推事实
调试变体和发布变体在混淆、资源压缩、动态特性交付与依赖选择上可能不同,评估一律以准备发布的构建变体(variant)为准。
下面的命令用于检查 release 运行时依赖图,并解释某个模块为什么被选中:
./gradlew :app:dependencies \
--configuration releaseRuntimeClasspath
./gradlew :app:dependencyInsight \
--configuration releaseRuntimeClasspath \
--dependency <artifact-name>
第一条命令给出直接依赖和传递依赖,第二条命令显示版本选择与依赖路径。它们仍然只是构建输入;还要检查最终合并 Manifest、APK/AAB 和动态交付模块。
APK 是可直接安装的应用包,AAB(Android App Bundle)则由应用商店按设备配置生成安装包。Android Studio 的 APK Analyzer 可以比较两个 APK/AAB 的下载大小、文件组成、DEX 内容和最终 Manifest。
每个 SDK 至少要记录这些字段:
| 维度 | 必须记录的事实 |
|---|---|
| 身份 | 能力、供应商、Maven 坐标、精确版本、制品校验值 |
| 代码 | DEX 包、原生库、ABI、Build ID(把原生二进制与符号文件一一对应的标识)、consumer R8 规则 |
| 入口 | Provider、Application、App Startup、显式 API、其他组件 |
| 权限 | 声明权限、运行时权限、特殊访问、组件导出状态 |
| 执行 | 进程、线程/执行器、Job/Alarm/Work、回调与监听器 |
| 数据 | 采集字段、存储位置、网络端点、保留周期、用户同意条件 |
| 可观测性 | Trace(带时间轴的执行跟踪)能力、日志、指标、R8 mapping(反混淆映射文件)、原生符号 |
| 运维 | 负责人、供应商响应时限、禁用开关、回退版本、移除成本 |
Google Play 的第三方 SDK 使用责任说明与Google Play SDK Index都强调:即使问题来自第三方代码,应用开发者仍对发布的应用负责。Google Play SDK Index提供版本采用率、权限、可靠性和供应商消息等风险信号,但覆盖范围有限;本应用的实测与代码审查仍然省不掉。
2.2 版本升级也按新依赖准入
接入评审通过的是某个具体版本,升级之后是否仍可信要重新审。SDK 升级可能改变:
- 自动初始化入口和初始化顺序;
- 新增权限、导出组件或独立进程;
- 传递依赖版本以及 R8 keep 范围;
- 原生库、ABI 和符号化方式;
- 线程、网络、缓存与后台调度策略;
- 数据采集字段和用户同意时机。
每次升级都应保存依赖图、Manifest、APK/AAB 组成和基准结果的差异。供应商说不清变更内容时,就提高小流量发布的准入门槛;版本号本身给不了风险答案。
3. 测量先于归因
3.1 四条基本规则
SDK 评估最常见的误判,是“工具显示了数字,于是数字属于 SDK”。测量前我们先固定四条规则:
- 同场景比较:启动模式、账号状态、网络、数据集、设备温度与电量状态一致。
- 单变量比较:A/B release 产物只改变一个 SDK、一个版本或一个初始化策略。
- 重复采样:保留分布、P50/P95 和异常样本,不用单次结果下结论。P50 是中位数;P95 表示 95% 的样本不超过该值,可用来观察偏慢的一段样本。
- 完整归档:产物、版本、编译模式、Trace、符号、mapping 与实验脚本一并保存。
单 SDK 实验用于判断边际成本,也就是有无该 SDK 或版本变更时的场景差值;全部 SDK 的集成实验用于识别竞争和叠加效应。两个问题,两套实验,各管各的。
3.2 证据强度
| 结论 | 弱证据 | 较强证据 |
|---|---|---|
| 启动变慢来自 SDK | 主线程栈出现 SDK 类 | A/B 产物回归 + Trace 时间片 + 可复现调用路径 |
| 内存增加来自 SDK | heap dump 中有 SDK 包名 | 同场景 A/B + retained path(从 GC Root 到对象的强引用链)/分配栈 + 原生 Build ID |
| 网络由 SDK 发起 | 某域名看起来像供应商 | 请求 ID/调用栈/供应商诊断回调 + 端点和配置核对 |
| Crash 属于 SDK 缺陷 | 顶部帧位于 SDK | SDK 开关或版本对照复现 + 供应商确认/修复 |
| 后台耗电来自 SDK | 看到一个 SDK 线程 | 调度来源、唤醒、CPU 栈、网络与场景差值一致 |
工具只回答“观察到了什么”;要把观察变成归因,我们还得靠对照实验与调用关系。同一进程内,线程、堆、网络连接和系统回调经常由宿主与 SDK 共同参与。
4. 启动:先找自动入口
4.1 Provider 早于 Application.onCreate
看 ActivityThread 的启动路径:普通 bindApplication 启动中,系统先创建 Application,在非 restrictedBackupMode 且存在 Provider 列表时调用 installContentProviders(...),随后通过 Instrumentation.callApplicationOnCreate(...) 调用 Application.onCreate()。Instrumentation 是 Android 用来创建和驱动应用组件的框架入口。因此,SDK 的自动 ContentProvider 初始化发生在应用自己的 Application.onCreate() 之前。
常见主线程成本包括:
- Provider 构造、类加载与静态初始化;
- SharedPreferences、文件、数据库或 PackageManager 查询;
- Binder(Android 跨进程调用机制) 调用、锁等待和同步网络探测;
- 初始化时注册监听器、创建线程池或加载原生库;
- 多个 SDK 争用主线程、磁盘和类加载锁。
这也解释了为什么只给 Application.onCreate() 打点会漏掉早期成本。Provider 优化的完整路径见 ContentProvider 启动优化。

图:固定非受限备份启动路径:Provider和App Startup初始化可能发生在业务Application.onCreate之前。
4.2 用 Macrobenchmark 建立可比较基线
Macrobenchmark 能在应用进程外重复驱动冷、温、热启动,并保存 Perfetto Trace。冷启动从无进程状态开始,温启动保留进程但重新启动 Activity,热启动则把仍在内存中的 Activity 带回前台。
目标应用应使用可 profile 的非 debuggable 发布变体:在 <application> 内声明 <profileable android:shell="true" />,允许本地性能工具访问。profileable 是元素,不是 android:profileable 属性;最终以合并 Manifest 与基准的配置检查为准。我们比较 SDK 版本时,release 配置、设备、启动模式和编译模式都要相同。
下面的测试用于测量带有 Baseline Profile 的冷启动;BaselineProfileMode.Require 会在配置不满足时让测试失败,避免悄悄换成另一种编译条件:
@LargeTest
@RunWith(AndroidJUnit4::class)
class SdkStartupBenchmark {
@get:Rule
val rule = MacrobenchmarkRule()
@Test
fun coldStart() = rule.measureRepeated(
packageName = TARGET_PACKAGE,
metrics = listOf(StartupTimingMetric()),
compilationMode = CompilationMode.Partial(
baselineProfileMode = BaselineProfileMode.Require,
),
startupMode = StartupMode.COLD,
iterations = 10,
setupBlock = {
pressHome()
},
) {
startActivityAndWait()
}
}
测试应对 A/B 产物分别运行,并查看 timeToInitialDisplayMs 与 timeToFullDisplayMs及对应 Trace。迭代十次只是示例,保证不了统计显著性;样本量要按设备噪声和回归幅度来定。
Baseline Profiles可以预编译关键代码路径,SDK 也能随库交付 profile rules。同步磁盘、网络、锁等待这些成本它管不掉,静态初始化语义也保持原样。评估 profile 收益时,仍要固定 CompilationMode.Partial 配置。
对于可控的集成代码,我们用 androidx.tracing 给初始化入口加命名切片——Trace 时间轴上带开始、结束与名称的一段区间。不透明的 Provider 只能依赖系统切片、调用栈或供应商提供的 Trace。切片要把等待时间和结果都圈进来,只包住一次异步任务提交没有意义。
4.3 App Startup 能解决什么
Jetpack App Startup 将多个自动初始化 Provider 合并到一个 InitializationProvider,并通过 Initializer.dependencies() 显式声明依赖顺序。它适合统一入口和减少 Provider 数量,但不会自动把初始化移出主线程:由 Manifest 声明的 Initializer.create() 仍在 Provider 启动阶段执行。
如果某个初始化器与首屏无关,可从 Manifest 中移除对应声明:
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.example.analytics.AnalyticsInitializer"
tools:node="remove" />
</provider>
删除这条元数据后,应用可在用户同意或功能首用时调用 AppInitializer.initializeComponent(...)。App Startup 的公开契约还规定:关闭某个组件(component)的自动初始化,也会关闭其 dependencies() 返回组件的自动初始化;随后手动初始化该组件时,这些依赖会按图一并初始化。
若某个依赖同时被其他 Manifest 声明的 Initializer 引用,它仍可能从另一条路径启动,因此必须检查完整依赖图。

图:手动initializeComponent会按依赖图初始化;App Startup统一入口与顺序,不会自动把create移出主线程。
4.4 按需、延迟与异步初始化
- 按需初始化:功能第一次需要时才启动,通常最节省未使用能力的成本。
- 延迟初始化:在首帧后某个时点启动,成本仍会发生,并可能撞上首屏交互。
- 异步初始化:把允许并发的工作移出主线程,但没有改变后台执行限制、生命周期和依赖时序。
选择策略时先拆分 SDK 工作:
| 工作 | 建议 |
|---|---|
| 首次 API 调用前必须完成的轻量状态 | 保持同步,但设定很小的主线程预算 |
| 用户同意前不得运行的数据能力 | 同意后显式初始化 |
| 只在特定页面使用的能力 | 页面或功能首用时初始化 |
| 可预取且可取消的资源 | 首帧后按场景调度,限制并发和截止时间 |
| 需要持久保证的后台任务 | 按任务契约选择系统调度 API,不靠常驻线程 |
把同步调用包进协程并不会自动安全:代码若仍在 Main dispatcher 运行,主线程成本原样不动;换到后台线程,还可能带来 CPU、I/O 竞争或生命周期泄漏。
4.5 BOOT_COMPLETED 的触发语义
有些 SDK 会同时放 Provider、App Startup 和 BOOT_COMPLETED Receiver。评估这类 SDK 时,我们要把三个信号分开:system_server 进程存在、sys.boot_completed=1、应用 Receiver 执行,它们是三个不同的里程碑。Android 17 的 SystemService 契约里,phase 600 表示系统服务可以启动或绑定第三方应用;ActivityManagerService.finishBooting() 后续进入 phase 1000、设置 sys.boot_completed / dev.bootcomplete,再调用 UserController.onBootComplete(...) 推动用户启动、解锁与相关广播流程;ACTION_BOOT_COMPLETED 的实际发送还取决于用户状态和异步流程。sys.boot_completed=1 出现时用户广播还没处理完,某个 SDK 的 Receiver 是否已执行、是否成功,从这里看不出来。(来源:SystemService、ActivityManagerService、UserController,固定 Android 17 tag。)

图:固定AMS在属性后调用UserController.onBootComplete;属性已置位之后,还要另行确认某SDK Receiver是否成功执行。
落到记录上,三类入口要分开:进程创建时的 Provider / App Startup、业务首用时的显式初始化、开机后由 LOCKED_BOOT_COMPLETED 或 BOOT_COMPLETED 触发的后台工作。第三类还要按用户、Direct Boot 状态、stopped package 状态和广播结果回调核对;只看到全局属性或旧 system_server 日志,还归因不到当前 Framework generation 或当前应用进程。(来源:SystemService、ActivityManagerService、UserController,固定 Android 17 tag。)
观察 BOOT_COMPLETED 型 SDK 时,至少要记录 Receiver 被调起的用户 ID、进程 PID、触发时间、任务 ID 和后续 Job/Work/网络请求;若设备发生 runtime restart,还要确认 phase、property 和广播记录来自同一个 system_server 实例。否则很容易把“系统全局里程碑到了”误写成“SDK 已经完成初始化”。(来源:SystemService、ActivityManagerService、UserController,固定 Android 17 tag。)
5. 内存:同进程没有可靠的“每 SDK PSS”
5.1 先辨认工具的观察对象
dumpsys meminfo、procrank 和 /proc/<pid>/smaps 都以进程或虚拟内存区域为观察对象:
- PSS(Proportional Set Size,比例集大小)把共享物理页按映射进程数分摊,适合进程级比较;
- RSS(Resident Set Size,驻留集大小)统计当前驻留在物理内存中的页,读取成本较低,但共享页会在多个进程重复出现;
smaps描述每个 VMA(Virtual Memory Area,虚拟内存区域)的地址、权限、对应文件与页统计;- Java heap dump 是 Java/Kotlin 托管堆在某一时刻的对象与引用快照;
- heapprofd 是 Perfetto 的原生堆采样器,可按分配调用栈归集 native 分配。
内核接口的定义可核对 Documentation/filesystems/proc.rst。这些工具本身不会把内存归属到某个 SDK:
- 同一进程的 Java heap 与 native 内存分配器被宿主和各 SDK 共用;
- 包名只能定位类的来源,定不了保留责任,宿主自己也可能把对象长期放进缓存;
.so文件映射的 RSS 里既有代码页也有共享干净页,读不出该 SDK 的活跃 native heap;- native 分配可能经过宿主封装、JNI 回调或公共库,必须看符号化调用栈。
所以“按包名汇总 heap dump”“把 .so RSS 当作 SDK 内存”“从 smaps 精确拆分 SDK PSS”这三条做法都只提供线索;精确归因要看下面这套 A/B 内存实验。
5.2 推荐的 A/B 内存实验
一次可审计的内存评估应包含:
- 生成只相差一个 SDK 或一个版本的两个 release 产物。
- 在相同设备档位、冷进程、账号数据和固定脚本下运行相同场景。
- 在稳定点与峰值点重复采集 RSS/PSS、Java heap、native heap 和映射。
- Java 堆先看到 GC Root 的保留路径与 dominator(支配关系):若从根到对象 Y 的每条路径都经过 X,X 才支配 Y;X 的 retained set 是其支配的对象集合。仅仅被 X 引用的“下游对象”可能还有其他根路径,不会随 X 不可达就全部回收。支配树边也不一定是原对象图的直接引用。
- native 堆用带 Build ID 的符号解析 heapprofd 分配栈。
- 把加载映射、活跃分配、线程栈和图形缓冲分开解释。
- 保存每轮原始数据与分布,报告差值、取值范围和离散程度;只有明确统计模型时才报告置信区间。

图:这是对象引用图:A不可达时C仍可经Root2→B到达,因此C不属于A的retained set。
若 SDK 自带有语义的缓存/队列计数器,也应纳入同一时间线。供应商指标可以帮我们解释差异,系统级内存差值还是要自己测。
SDK 使用独立进程时,可以分别观测宿主和子进程,却仍要报告两者的合计用户成本。关于 Java 泄漏的 retained path 分析见 内存泄漏治理,native 分配归因见 Native 内存泄漏的线上分层监控。
5.3 Android 17 内存上限
Android 17 的 所有应用行为变更 引入 App Memory Limits,但只在部分设备启用,限制值由设备配置决定,不存在一个适用于全部 Android 17 设备的固定阈值。
嵌入宿主进程的 SDK 内存会计入宿主进程。若命中限制,历史退出信息在 Android 17 上可能以 REASON_OTHER 出现,并在描述中包含 MemoryLimiter:AnonSwap;不要把更高版本新增的退出 reason 常量提前写进 Android 17 代码。
做准入实验时,我们至少覆盖一个可能启用该机制的低内存设备档位,并验证应用的降级和状态恢复。
6. 后台、CPU、网络与功耗
6.1 后台线程与后台执行资格
嵌入式 SDK 与宿主共享 UID(Linux 用户标识,也是 Android 权限与部分资源统计的边界)以及进程状态、权限和后台限制。把任务提交到线程池、协程或 native 线程(通常由 pthread 创建),并不会取得新的后台执行豁免。
治理时我们要清点这些入口和资源:
- 常驻线程、线程池大小、线程优先级和空闲回收;
- WorkManager、JobScheduler、AlarmManager 与前台服务;
BroadcastReceiver、ContentObserver、生命周期回调和系统监听器;- 长连接、心跳、重试队列、DNS 与 TLS 会话;
- 定位、传感器、音频、蓝牙等受限资源。
需要持久保证的工作,按 持久后台任务指南选择系统 API。调度条件、重试上限与用户可见性的控制权在应用手里,“内部线程实现”不是供应商的免责理由。
6.2 CPU 归因需要调用栈
top 能告诉我们哪个进程或线程在采样窗口里占了 CPU;至于谁安排了这些工作,要看 simpleperf(Android NDK 提供的 CPU 采样与性能计数器工具) 与 Perfetto 的调用栈。调用栈更接近归因依据,但仍受这些条件影响:
- 应用是否可 profile,以及 Java/native 符号是否齐全;
- 异步提交后,执行线程可能只有公共执行器名称;
- 宿主回调 SDK、SDK 回调宿主时,顶部帧常常只是现场,工作发起者在更上游;
- 混淆、内联和 stripped native library(已剥除调试符号的原生库)会降低栈的可读性。
应用自有适配层应给关键任务增加稳定的 Trace 名称、请求 ID 和来源标签;线程与协程泄漏治理见 20.8 线程与协程泄漏治理。
6.3 网络字节与 UID 统计
TrafficStats 的 UID 统计适合观察应用总体网络量,但同 UID 内分不清宿主和多个 SDK。应用 HTTP interceptor(HTTP 客户端的请求拦截器)只覆盖经过该客户端的请求;SDK 的自建客户端、Cronet(Chromium 网络库)、WebView 或 native socket 可能绕开它。
较可靠的组合是:
- 实验环境抓包或受控代理,核对域名、协议、字节量和重试;
- 供应商提供请求标签、诊断回调或网络代理注入点;
- 在适配层传播请求 ID,把业务动作与后台流量对齐;
- 对照关闭 SDK、关闭某功能或更换版本后的场景差值;
- 将上传负载、协议开销、失败重试和长连接心跳分开统计。
网络请求的端到端分层方法见 网络架构与连接管理。
6.4 功耗的场景化测量
请求次数少,耗电未必低:频繁唤醒、差网络下重传、无线电尾部能耗、过密 Alarm、持续定位和音频资源都可能放大成本。我们在固定场景中比较 CPU 时间、唤醒、调度、网络字节与电量指标。
Macrobenchmark PowerMetric 仍带 ExperimentalMetricApi 标记;高精度 power/energy 数据依赖设备支持,而且结果反映的是测量窗口内的系统总消耗,分不出哪个应用或 SDK 独占多少。Perfetto power rails(设备供电轨功耗计数器)有同样的设备与归因限制;读不到的数据要标为缺失,按零处理会把结论偏向乐观。
6.5 Android 17 的相关兼容点
除 App Memory Limits 外,Android 17 还需关注:
- target API 37 后,
MessageQueue使用无锁实现;依赖反射访问其私有字段的 SDK 可能失效。公共Handler/Looper契约没有因此改变。 - Android 17 对所有应用施加了更严格的后台音频交互限制:后台播放、音频焦点和音量操作需要可见 Activity,或一个类型不为
SHORT_SERVICE的前台服务。target API 37 后,后台应用还需让该前台服务具备 while-in-use(由可见界面或用户操作授予的“使用期间”)能力;精确闹钟权限配合USAGE_ALARM仅豁免额外的 WIU 能力要求,仍需满足可见 Activity 或非SHORT_SERVICE前台服务的基本条件。不满足条件时,播放和音量操作会静默失败,音频焦点请求返回AUDIOFOCUS_REQUEST_FAILED。 - 后台调度、前台服务和权限的合规与稳定性责任仍由宿主应用承担。
评估报告要写明 SDK 行为依赖的是公开 API 还是私有实现。Android 17 的目标 SDK 变更可从 target 37 行为变更逐项核对。
7. Crash、ANR(Application Not Responding,应用无响应) 与进程退出归因
7.1 栈顶信息的归因分档
Java 异常栈顶出现 SDK 类,可能是 SDK 缺陷,也可能是宿主传入非法参数、调用顺序错误、线程模型不符或回调状态被破坏。我们把归因分成三档:
- 候选相关:堆栈或线程状态涉及 SDK。
- 实验相关:关闭 SDK、改变版本或改变调用路径后可稳定复现/消失。
- 供应商确认:供应商确认根因,并提供可验证修复或规避方案。
ANR 也一样,光看栈里有没有 SDK 帧远远不够;我们要找的是主线程正在等待什么、锁或 Binder 的另一端是谁、后台线程是否持有资源,以及等待是否由宿主调用时机触发。
7.2 上线前的符号与版本准备
每个 release 必须保存与产物一一对应的:
- R8 mapping 和 retrace 工具版本;mapping 记录混淆前后的名称对应关系,retrace 用它还原 Java/Kotlin 堆栈;
- 未剥离 native library 或可用符号文件;
- ABI、Build ID、SDK 精确版本和制品校验值;
- 上传到监控平台的符号处理结果。
ndk-stack需要与崩溃二进制匹配的符号。闭源 SDK 只提供 stripped .so、供应商又没法按 Build ID 符号化时,native crash 的定位就失去了可靠依据;这项缺口直接记入准入风险,发布前把材料备齐。
聚类键是把疑似同一根因的事件归为一组的字段组合,建议包含异常类型或 signal(如 native crash 的 SIGSEGV)、归一化栈、SDK 版本、ABI、Android 版本、设备、进程和功能路径。归一化可以移除 ASLR 导致的绝对装载地址等易变字段,原始诊断材料仍须保留 Build ID 与 relative PC;native 的模块内偏移有助于区分和符号化不同故障点,一概删掉会丢掉这层信息。聚类规则应明确采用函数、行号或相对 PC 中的哪一层。
Android vitals可能标记“可能与 SDK 有关”的问题,这仍是线索;SDK 供应商可通过 Google Play SDK Console接收聚合信息并上传反混淆文件。
对于没有 Java/Native 崩溃栈的异常退出,应结合 ApplicationExitInfo(系统保存的历史进程退出原因)、系统日志、内存样本和前台状态分析。不要把低内存杀进程、用户停止、系统更新与 SDK crash 混成同一指标。
8. 包体积、R8 与供应链
8.1 体积看最终交付字节
供应商宣称的 AAR 大小,与用户实际下载或安装的增量是两回事:资源压缩、代码复用、ABI 拆分、语言/密度拆分和动态交付都会改变结果。比较同配置 A/B App Bundle,并分别报告:
- 压缩下载增量;
- 安装后 DEX、资源和 native library 增量;
- 各 ABI/密度/语言组合的影响;
- 动态特性是否只在需要时交付。
DEX 方法数可以帮助我们发现代码量变化;字节、启动类加载和运行时成本仍要分别测。
8.2 审查 consumer R8 规则
SDK 携带的宽泛 -keep 规则可能保留无关代码、阻止优化或放大反射面。我们审查时要:
- 查看合并后的 R8 配置;
- 用
-whyareyoukeeping解释关键类为什么保留; - 核对反射、JNI、序列化所需范围;
- 比较规则收窄前后的构建与回归测试;
- 每个 release 单独保存 mapping,禁止跨版本混用。
为缩体积盲删供应商规则是危险动作。收窄规则必须配合功能测试和反混淆验证,必要时让供应商给出可维护的最小规则。
8.3 传递依赖冲突
Gradle 的版本调解可能让一个 SDK 运行在它没有验证过的依赖版本上。编译通过也不说明运行路径兼容。对于关键公共库,应通过 dependencyInsight 找出选择原因,固定可验证版本,并覆盖涉及二进制兼容、资源、序列化与网络协议的集成测试。
供应链记录还应包含来源仓库、制品校验、许可证、SBOM(Software Bill of Materials,软件物料清单)、漏洞响应与下架策略。远程下发未知代码或通用动态修补不是稳妥的 SDK 修复方案;通常应依次考虑关闭能力、回退已验证版本和发布修复包。
9. 准入:阈值来自应用预算
“启动不得增加 X ms、内存不得增加 Y MB”这样的统一阈值并不存在:社交应用、车机、低内存设备与支付确认页的预算各不相同。我们从应用 SLO(Service Level Objective,服务级目标)、重点场景、设备档位和当前余量推导,并为多个 SDK 的合计成本留出预算。
一份可执行的评分表应覆盖:
| 领域 | 评估项 |
|---|---|
| 价值 | 用户能力、业务收益、替代方案、功能负责人 |
| 启动 | 冷/温/热启动 P50/P95、主线程同步时间、Provider 入口 |
| 内存 | 稳态/峰值 RSS/PSS、Java 保留对象与保留大小、native 分配、低内存恢复 |
| 流畅度 | 关键页面帧时间、主线程任务、Binder 与远端渲染 |
| 后台 | CPU、线程、唤醒、调度、网络字节、重试 |
| 稳定性 | Crash、ANR、异常退出、符号与供应商响应 |
| 包体积 | 下载/安装增量、DEX、资源、各 ABI .so |
| 安全隐私 | 权限、组件、端点、数据字段、同意与删除路径 |
| 运维 | 指标、日志、开关、回退、移除时间、事故联系人 |
建议按五个阶段检查:
- 静态检查:依赖、Manifest、权限、组件、R8、native 库和数据清单。
- 隔离 A/B:只改变一个 SDK,测量标准场景的边际成本。
- 集成压力:在全部 SDK 同时工作时检查锁、线程、内存和网络竞争。
- 内测与小流量发布:观察分设备档位、版本、ABI 和进程的回归。
- 持续监控:每次 SDK 或构建工具升级重放基准与差异检查。
“每个 SDK 单独达标”仍可能让总预算超标。准入记录必须同时显示该 SDK 的边际成本和应用剩余预算。
10. 退出能力要在接入时设计
10.1 用应用自己的接口隔离供应商
退出这件事,难在业务代码一旦到处暴露供应商类型,替换和禁用都会变成大规模改造。我们先定义自己的领域接口——由应用控制、只表达业务能力的抽象——供应商 API 留在适配层,由适配层处理同意、初始化、状态和异常。
下面示例采用显式线程约束:在供应商允许的同一个线程上创建并串行调用适配器,不接受同步重入。上层可以投递调用,但本例不提供调度器;start() 必须是同步就绪契约,耗时与线程要求仍要由接入层评估:
interface Analytics {
fun record(event: AnalyticsEvent)
}
class GovernedAnalytics(
private val provider: AnalyticsProvider,
) : Analytics {
// 在供应商允许的线程创建并使用;上层负责把调用投递到这个线程。
private val ownerThread = Thread.currentThread()
private var enabled = false
private var inCall = false
private inline fun onOwner(block: () -> Unit) {
check(Thread.currentThread() === ownerThread)
check(!inCall) { "Synchronous adapter reentry is unsupported" }
inCall = true
try {
block()
} finally {
inCall = false
}
}
fun enableAfterConsent() = onOwner {
if (!enabled) {
provider.start()
enabled = true // 只有同步 start 成功返回才开放调用。
}
}
override fun record(event: AnalyticsEvent) = onOwner {
if (enabled) provider.record(event)
}
fun disable() = onOwner {
enabled = false // 停止操作失败也不重新开放后续 record。
provider.stopIfSupported()
}
}
在上述串行契约内,开关拦住之后的 record(),停止操作只在供应商支持时执行。供应商异常会传播给上层;start() 部分失败后的清理交给供应商契约,避免盲目自动重试;异步初始化要另建 STARTING/READY/FAILED 状态,就绪与否以状态机为准,“提交成功”只代表任务交了出去。这个例子覆盖不到已经进入供应商内部的工作;已经加载的 DEX/.so 会留在进程里,静态初始化、未知监听器或已排队任务的撤销同样要靠 SDK 自己。所以 SDK 必须提供成对的注册/反注册和可重复调用的停止契约。

图:同步示例不假设AtomicBoolean能完成生命周期同步;异步start必须另建STARTING/READY/FAILED状态。
10.2 用双视图测试守住供应商边界
适配层这条线,要靠构建和源码两条线一起守住:Gradle 依赖图能暴露业务模块是否直接依赖供应商 SDK 模块或同级实现模块;Kotlin 源模型能暴露 UiState、领域接口、公共 repository 等签名是否导入或返回供应商 DTO、持久化实体或 Android/框架类型。(工程架构规则示例;依赖视图可依据 Gradle 官方依赖报告,源码侧的签名约束要由项目实际分析器与 CI 用例验证。)
可执行规则应放在普通测试和 CI 中,而不是只靠代码审查记忆。对 SDK 治理来说,最低限度可以检查三类契约:只有适配层可以依赖供应商 artifact;公共接口只暴露应用自有类型;KMP 或共享模块禁止意外导入 Android 框架或供应商实现包。若规则太宽,例如笼统禁止 kotlinx..,会误伤协程、序列化等合法跨平台库;应从少量高信号规则开始,写明例外并跟随架构演进维护。
10.3 远程开关的适用条件
远程配置适合阻止后续初始化、停止新请求或隐藏依赖 SDK 的功能,但要满足:
- 读取开关不依赖待禁用 SDK;
- 冷启动早期即可得到安全默认值;
- 禁用路径不触发无限重试或 crash loop(应用启动后反复崩溃、再次拉起的循环);
- 本地保留上次可信状态并设过期策略;
- 隐私同意撤回能停止采集并处理待上传数据;
- 服务端、客户端和供应商控制台状态可核对。
若 SDK 在进程创建时由 Provider 自动运行,远程开关读取往往已经太晚。高风险 SDK 应改为显式初始化,或至少让 Provider 只做轻量登记,不执行耗时的网络或磁盘操作。
11. 常见 SDK 类型的治理重点
这些类别用来说明检查方式;具体某个供应商实现的结论,仍以精确版本、配置和发布产物为准。
11.1 多通道推送
华为、小米、OPPO、vivo、FCM 等通道通常按设备能力、地区和业务策略选择。治理目标不是让全部通道在每个进程中同时启动:
- 由应用层选择当前有效通道,避免无关通道执行重初始化;
- 统一推送 token 的状态流转,明确生成、更新、失效、账号切换和注销时各自执行什么操作;
- 服务端做消息 ID 去重,客户端记录通道与送达路径;
- 为切换、失败回退和供应商服务不可用设定处理规则;
- 分通道观测初始化、线程、网络、送达率与耗电。
推送到达率,光靠增加心跳或并行初始化是换不来的;任何后台策略都要符合平台限制和用户设置。
11.2 统计、Crash 与错误监控
友盟、Bugly、Sentry 等产品覆盖的能力并不完全相同。常见控制点包括:
- 在用户同意和数据配置满足条件后初始化;
- 统一事件 schema(字段、类型、命名和版本约定)以及采样、批量大小、上传条件和重试;
- 限制 breadcrumb(崩溃前的操作与状态记录)、附件、线程栈与本地队列上限;
- 保留与业务版本匹配的 mapping 和 native symbols;
- 让核心错误观测不依赖可能引发故障的同一执行链。
降低事件采样率只能减少一部分上传与服务端负载,不一定降低初始化、常驻线程或基础缓存成本,仍需分别测量。
11.3 广告与远端内容
穿山甲、优量汇等广告 SDK 的版本与配置变化频繁,我们评估时只认精确版本,供应商名称说明不了问题。建议:
- 只在广告场景临近使用时初始化或预热;
- 预取必须有数量、超时、内存和取消上限;
- 分开测量 SDK 初始化、请求、素材下载、WebView、视频解码,以及把 View 挂入窗口的成本;
- 检查远端进程、Binder、Surface 和宿主页面的帧时间;
- 广告不可用时让页面能快速降级,避免阻塞主流程;
- 将点击、展示和收入校验与性能诊断 ID 对齐,但避免记录敏感内容。
WebView 或视频卡顿不一定由 SDK 主线程代码直接引起;需结合渲染、Binder、网络与媒体缓冲时间线定位。
12. SDK Runtime:只按遗留路径评估
12.1 Android 17 的 SDK sandbox 已不再受支持
Google 在 2025 年 10 月 17 日宣布退役 SDK Runtime 等 Privacy Sandbox 技术,官方功能状态页也把 Android SDK Runtime 标为计划逐步退出。Android 17 / API 37 的 SdkSandboxManager 已废弃,API 文档明确说明 SDK sandbox 不再受支持;AndroidX privacysandbox-sdkruntime 的 1.0.0-alpha19 同样已废弃且不会继续更新。
API 符号、旧设计页或系统服务类仍然存在,只代表兼容代码还在;能力还能否用于新项目,以公开 API 的废弃契约为准。Android 17 AOSP 仍保留 SdkSandboxManagerService 等兼容代码,面向 API 37 的接入决策同样要服从这份契约。新项目不要围绕 runtime-enabled SDK bundle、SdkSandboxManager.loadSdk() 或旧 sandbox 生命周期设计广告架构。
现有产品要按 Android 版本和 SDK 精确版本分流:Android 14–16 上只维护已经发布的遗留能力,并做可用性探测和失败降级;Android 17 上关闭 sandbox 路径,转回受支持的普通嵌入式 SDK 或供应商明确支持的其他进程模型。
12.2 遗留隔离路径仍要计算合计成本
SDK Runtime 架构说明与兼容模式仍可用于理解 Android 14–16 的历史行为。旧 Runtime 路径把 SDK 放进每个应用对应的独立进程,宿主通过 Binder 与它通信,两端没有共享 Java/native heap;兼容路径则把 DEX 载入宿主进程,只用独立 classloader 隔开类名空间。
对仍在维护的遗留版本,独立进程改善了地址空间隔离,也让宿主与 Runtime 进程的内存更容易分别观察;代价包括:
- Runtime 和 SDK 的冷启动;
- 独立进程的固定内存;
- Binder 参数序列化、跨进程往返与回调调度;
- 远端 UI 的 Surface 与帧同步;
- 进程死亡后的状态恢复和重新加载。
旧 Runtime 路径如果仍在运行,我们评估时报告宿主与 Runtime 的逐进程 PSS/RSS;需要合计时说明范围与采样时刻。PSS 按共享页比例分摊,用于进程级比较;RSS 直接相加会重复计算共享驻留页,得到的数与去重后的总物理内存是两回事。并同时测量加载延迟、Binder 回调、远端 UI 帧时间、进程死亡率与恢复成功率。两端没有共享堆;应用商店曾提出的可信分发可能影响下载或磁盘存储,但由此推不出运行时 RAM 会下降。
历史兼容方案的构建流程是:Android Gradle Plugin(AGP)和 Bundletool 构建包含 SDK 的应用变体,SDK Runtime client library 从应用 assets 提取 DEX,再用独立 classloader(类加载器)载入宿主进程。类加载器能降低类名冲突,却不提供进程隔离。
AndroidX 兼容库已经停止更新,所以它只能解释现有发布包的行为;Android 17 的替代方案与新接入路径,都要在受支持的方案里选。
迁移时应验证禁用开关、Manifest 与构建依赖清理、普通 SDK 回退、进程死亡和数据合规路径。每个发布版本都要记录实际运行路径,避免把 Android 14–16 的遗留行为推到 Android 17。
13. 一份可复用的检查清单
接入前
- 明确用户价值、能力负责人、供应商联系人和替代方案。
- 固定坐标、版本、来源、校验值、许可证和数据处理关系。
-
检查 release 依赖图、Manifest、权限、组件、进程、R8 与
.so。 - 要求 Java mapping、native symbols、Build ID 方案和响应时限。
- 设计应用自有接口、同意门、显式初始化、禁用与回退路径。
- 在目标设备档位完成隔离 A/B 与全部 SDK 集成实验。
发布前
- 对比 AAB/APK 的下载、安装、DEX、资源与 ABI 增量。
- 固定编译模式测冷/温/热启动,并保存 Trace。
- 测稳态/峰值内存、CPU、线程、网络、功耗和关键页面帧时间。
- 完成 Crash/ANR/退出符号化与故障注入,即主动制造断网、超时、进程死亡等受控故障。
- 验证远程禁用、供应商不可用、无网、进程死亡和低内存恢复。
- 按版本、设备档位、ABI、进程和功能配置设置小流量观测。
运行中
- SDK 升级触发完整依赖差异和性能回归。
- 对新增权限、组件、端点、线程和调度做自动检查。
- 定期演练禁用、回退、符号化和供应商响应。
- 结合业务价值复查闲置 SDK,移除没有清晰负责人的能力。
- 同时跟踪单 SDK 边际成本和应用总体预算。
小结
SDK 治理的输出应当是一组能被重复执行的实验、制品差异和运行时记录,而不是一份静态名单。当团队能回答“哪段代码在什么条件下运行、成本如何复现、出错后如何停止”时,SDK 才处于可控状态。
参考资料
- AOSP Android 17
ActivityThread - AOSP Android 17
SystemServiceboot phase constants - AOSP Android 17
ActivityManagerService.finishBooting() - AOSP Android 17
UserControllerboot-completed broadcast - AOSP Android 17
SdkSandboxManagerService - Android Common Kernel
android17-6.18-2026-06_r6proc 文档 - Jetpack App Startup
- profileable 元素、Gradle 依赖报告、Eclipse MAT 支配树定义。
- Macrobenchmark 概览
- Macrobenchmark 指标
- Baseline Profiles
- Android 内存管理概览
- Android 17 所有应用行为变更
- Android 17 target 37 行为变更
- Privacy Sandbox 技术退役公告
- Privacy Sandbox 功能状态
- Android 17
SdkSandboxManagerAPI - AndroidX
privacysandbox-sdkruntimerelease notes - SDK Runtime 架构
- SDK Runtime 兼容模式
- Google Play SDK Index
- Google Play SDK Index
- 架构测试为何需要双视图
- Android 系统启动机制(九):system_server 已经运行,为什么还不能说 Android 启动完成?