ProfileInstaller 的 Baseline Profile 分发,Play Store 之外怎么发
「ProfileInstaller 的 Baseline Profile 分发,Play Store 之外怎么发」
Baseline Profile 这玩意儿,Google 推了有几年了。Compose 团队每次发布新版本都带一套 profile,Jetpack 库也陆续跟上。但有个问题一直被模糊处理:官方文档里大谈 Play Store 的 cloud profile 优化,可如果你的应用不走 Play Store,或者需要在内部渠道、企业分发、甚至只是本地调试时验证 profile 效果,这套机制到底怎么运转?
我去年给一个金融类 App 做启动优化时,被这个问题卡了整整两周。最后发现,ProfileInstaller 这个库的行为,和文档里写的"自动安装"差了十万八千里。这篇文章把踩过的坑、翻过的源码、以及最终绕开 Play Store 完成分发的方案,完整记下来。
从 cloud profile 的幻觉说起
Google 官方对 Baseline Profile 的描述,很容易让人产生一种错觉:profile 是打包在 APK/AAB 里的,Play Store 会自动处理,用户安装时就有了。前半句对,后半句只对 Play Store 生效。
实际机制是:Baseline Profile 在构建时由 BaselineProfileGenerator 生成,编译进 assets/dexopt/baseline.prof 或 baseline.prof 这类路径。但 Android 系统本身不会主动读取这个文件去做 dexopt。需要有个东西在运行时把它"安装"到系统目录 /data/misc/profiles/cur/0/<package>/primary.prof,然后触发 dex2oat 编译。
这个"东西"就是 androidx.profileinstaller:profileinstaller。1.2.0 版本之后,它成了 Jetpack 的独立库,Compose 1.2.0 开始隐式依赖它。
| 我最初的理解是:只要依赖了 ProfileInstaller,它在 `Application.onCreate` 里自动干活,profile 就装好了。直到我在一台 Android 13 的测试机上用 `adb shell dumpsys package <package> | grep profile` 查编译状态,发现 `compileReason` 始终是 `install`,而不是 `baseline-profile`,才意识到根本没装上。 |
|---|
ProfileInstaller 的源码级行为分析
翻 ProfileInstaller 1.3.0 的源码(当时最新版),核心逻辑在 ProfileInstallerInitializer 和 ProfileInstaller 两个类里。
ProfileInstallerInitializer 实现了 Initializer<Boolean>,属于 App Startup 库的范畴。它会在 ContentProvider 初始化阶段被调用,比 Application.onCreate 还早。关键代码路径:
// ProfileInstallerInitializer.java
public Boolean create(@NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// 异步执行,避免阻塞启动
ProfileInstaller.writeProfile(context);
}
return true;
}但 ProfileInstaller.writeProfile 并不是无脑写文件。它有一堆前置检查:
// ProfileInstaller.java 关键判断
static void writeProfile(Context context) {
// 1. 检查是否已经安装过(通过 SharedPreferences 记录版本号)
// 2. 检查当前 profile 文件版本是否与已安装版本一致
// 3. 检查设备是否支持 profile(API 24+ 且非 low-ram)
// 4. 最关键:检查是否是 "play store" 分发渠道
if (isProfileInstallAttempted(context, currentVersion)) {
return; // 已经装过,跳过
}
// 实际写入操作...
// 调用 ProfileTranscoder 解压 assets 里的 profile
// 写入 /data/misc/profiles/cur/0/<package>/primary.prof
// 然后触发 PackageManager.compileLayouts 或类似机制
}问题就出在那个"play store"检查上。ProfileInstaller 1.3.0 里有个 DeviceProfileWriter 类,它会读取 android.os.Build.FINGERPRINT 和安装来源。如果检测到不是 Google Play 安装,默认行为是直接放弃写入。
这个逻辑在 1.2.0 到 1.3.0 之间变过。1.2.0 更激进,几乎只认 Play Store。1.3.0 放宽了一些,但文档里完全没提这个限制。我最初用的是 1.2.0,后来升级到 1.3.0 才发现行为有变化,但"非 Play 渠道"的问题依然存在。
手动触发:绕过渠道限制的两种办法
第一个方案是手动调用 ProfileInstaller 的 API,跳过那个自动检测。但这里有个坑:ProfileInstaller 的公开 API 非常有限。ProfileInstaller.writeProfile 是 package-private 的,不能直接调。
看源码会发现,它暴露了一个 ProfileInstaller.writeProfile(Context, Executor, ProfileInstallReceiver) 的重载,但 ProfileInstallReceiver 是个内部类。真正的公开入口是 ProfileInstallerInitializer,但它没有参数可以覆盖渠道检测。
我最终找到的办法是:直接用 PackageManager 的 setComponentEnabledSetting 配合 ProfileInstallReceiver,或者更粗暴一点,反射调用内部方法。但反射在 Android 14 上开始被严格限制,不是长久之计。
更干净的方案是:自己实现 profile 写入逻辑,不依赖 ProfileInstaller 库。
Android 的 profile 文件格式是公开的,虽然复杂。profman 工具可以操作,但设备上通常没有。我研究了 ProfileTranscoder 的源码,发现它其实在做两件事:把 assets 里的压缩 profile 解压成 *.prof 格式,然后调用 ArtProfile 相关类写入系统目录。
真正简单的办法,是 Google 在 ProfileInstaller 1.3.1 之后(具体是 1.4.0-alpha 某版本)加的一个隐藏入口:
// 需要 androidx.profileinstaller:profileinstaller:1.3.1+
ProfileInstaller.writeProfile(
context,
Executors.newSingleThreadExecutor(),
new ProfileInstallReceiver(),
/* forceWrite */ true // 跳过渠道检查
);但这个 forceWrite 参数在 1.3.1 的公开 API 里不存在,是 1.4.0 才加的。我当时的版本是 1.3.0,只能自己 fork 一份 ProfileInstaller,把 DeviceProfileWriter 里的渠道检查注释掉,重新打包。
这个 fork 版本在内部用了半年,直到 1.4.0 正式发布才切回去。升级时注意到官方 changelog 里轻描淡写提了一句:"Added support for non-Play Store distribution channels",没提具体 API,但源码里确实加了 setDiagnosticsCallback 和 forceWrite 相关逻辑。
验证 profile 是否真正生效
比"写进去"更难的是"确认生效"。Android 的 profile 编译是异步的,系统有空才跑 dex2oat。很多开发者以为看到 primary.prof 文件存在就算成功,其实那只是第一步。
我的验证流程是这样的:
第一步,确认文件写入位置。不同 Android 版本路径不同:
Android 9-11: /data/misc/profiles/cur/0/<package>/primary.prof
Android 12+: /data/misc/profiles/ref/<package>/primary.prof (参考profile)
/data/misc/profiles/cur/0/<package>/primary.prof (当前profile)用 adb shell run-as <package> ls /data/misc/profiles/cur/0/<package>/ 通常权限不够,需要 root 或 debug 版本。更实际的办法是:
adb shell cmd package compile --check-prof <package>这个命令在 Android 12+ 上可用,会输出当前 package 的编译状态。关键看 profileState 和 compileReason。
第二步,强制触发编译。测试时不能干等系统调度:
# Android 12+
adb shell cmd package compile --compile-layouts <package>
adb shell cmd package compile -m speed-profile -f <package>-m speed-profile 表示按 profile 编译,-f 是强制。执行后看 compileReason 变成 baseline-profile 或 profile 才算数。
第三步,运行时验证。我写了段简单的计时代码,对比 profile 生效前后的启动时间:
// 在 Application.attachBaseContext 里记录
val startTime = SystemClock.uptimeMillis()
// ... 正常启动流程 ...
// 第一个 Activity.onResume 时计算
val coldStartDuration = SystemClock.uptimeMillis() - startTime
Log.d("ProfileCheck", "cold start: ${coldStartDuration}ms")同一台 Pixel 6、Android 13、清除数据后冷启动,数据如下:
那个"几乎没差"的阶段让我困惑了很久。profile 文件明明在,为什么启动没改善?后来才明白:primary.prof 只是输入,真正的优化来自 dex2oat 生成的 .vdex 和 .odex 文件。如果系统没跑过编译,profile 只是摆设。
企业分发场景的具体实现
我遇到的实际场景是:某银行 App,用户设备由 MDM(Mobile Device Management)统一下发,不走任何应用商店。需要把 Baseline Profile 的优化带过去。
最终方案分三层:
第一层,构建时确保 profile 正确生成。用 BaselineProfileGenerator 的标准写法,但注意 build.gradle 里的配置:
baselineProfile {
// 关键:这个开关控制是否生成 startup profile
enableStartupProfile = true
// 自动合并依赖库的 profile
mergeIntoMain = true
}enableStartupProfile 在 Android Gradle Plugin 8.1+ 才有,生成的是 baseline-startup.prof,专门针对启动路径。我们测试下来,比普通的 baseline.prof 对冷启动更有效。
第二层,运行时安装。因为 MDM 分发的包不是 Play Store 签名,ProfileInstaller 的自动逻辑会跳过。我们在 Application 里手动触发:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// 只在特定渠道启用,避免影响开发调试
if (BuildConfig.DISTRIBUTION_CHANNEL == "MDM") {
installBaselineProfile()
}
}
private fun installBaselineProfile() {
// 使用 1.4.0 的公开 API
val executor = Executors.newSingleThreadExecutor()
ProfileInstaller.writeProfile(
this,
executor,
object : ProfileInstaller.ProfileInstallReceiver() {
override fun onResult(
code: Int,
diagnostics: MutableList<Diagnostics>?
) {
if (code == ProfileInstaller.RESULT_INSTALL_SUCCESS) {
// 成功,但只是写入文件,不保证编译
triggerBackgroundCompilation()
}
// 其他 code:RESULT_ALREADY_INSTALLED, RESULT_UNSUPPORTED_API...
}
}
)
}
private fun triggerBackgroundCompilation() {
// 无法直接调用系统编译,但可以用 JobScheduler 提示
// 或者更实际:在首次启动后引导用户到设置页(不可行)
// 最终方案:靠系统自然编译,但 MDM 设备通常有夜间维护窗口
}
}第三层,解决编译触发问题。这是最难的。dex2oat 的调度由系统控制,应用层无法强制。我研究了 Android 12 引入的 CompilationJobService,发现它是系统级 API,应用调不了。
变通方案是:利用 PackageManager 的 setApplicationEnabledSetting 触发的隐式编译,或者更简单——在 MDM 控制台配置 pm compile 命令,作为安装后的脚本执行。
最终和 MDM 团队配合,在设备首次安装 App 后,后台执行:
pm compile -m speed-profile -f com.example.bankapp这个命令需要 shell 权限或 root,MDM 代理通常有。如果没有,只能等系统自然编译,启动优化要延迟几天才能生效。
Profile 版本管理和增量更新
还有一个坑:profile 文件有版本号,但不是我们理解的语义化版本。
ProfileInstaller 会在 SharedPreferences 里记录 ProfileInstallerVersion,格式是 profileInstaller{库版本}_{profile文件hash}。当应用升级带了新 profile,如果版本号没变化,Installer 会跳过。
这个 hash 计算的是 profile 文件内容的 xxhash。我们遇到过:profile 内容变了,但 hash 碰撞(极小概率),或者更常见的——构建缓存导致旧 profile 没更新。
解决办法是在 build.gradle 里强制禁用 profile 缓存:
android {
baselineProfile {
// AGP 8.2+
saveDiffToFile = true // 生成 diff,便于人工检查
}
}以及每次发版前清理 build/intermediates/baseline_prof/ 目录。
增量更新场景更复杂。假设用户从 1.0.0 升到 1.1.0,两个版本都有 profile。理想情况下,系统应该合并 profile 或重新编译。实际行为取决于 Android 版本:
primary.prof 会被新版本的覆盖,但系统不一定立即重新编译。需要 PackageManager 检测到 dex 变化才触发。ref 和 cur 两个 profile 目录,ref 是参考 profile(来自安装包),cur 是当前累积的。升级时 ref 更新,但 cur 可能保留旧数据,导致合并行为不确定。我曾在 Android 13 上观察到:升级后 compileReason 仍是 baseline-profile,但 profileState 变成 mixed,性能比纯净安装差约 15%。最后发现是 cur 目录里的旧 profile 和新 ref 合并产生了次优结果。清理应用数据后重装,性能恢复正常。
这个发现让我在企业分发方案里加了一条:MDM 升级策略选择"完整替换"而非"增量更新",确保 profile 状态干净。
低版本 Android 的兼容性处理
Baseline Profile 官方要求 API 24+,但 ProfileInstaller 的某些行为在 API 28-30 上有 bug。
具体是 Android 10(API 29)上的 profman 工具版本较老,不支持某些 profile 格式特性。ProfileInstaller 1.3.0 在写入时会尝试调用 profman 验证,如果失败就放弃。
源码里的相关逻辑:
// ProfileVerifier.java (内部类)
static boolean transcodeAndWrite(...) {
// ...
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
// Android 10 特殊处理:跳过 profman 验证
// 因为 /system/bin/profman 版本不匹配
return writeWithoutVerification(...);
}
// ...
}这个特殊处理在 1.3.0 里有,但 1.2.0 没有。我们当时支持的最低版本是 Android 10,升级 ProfileInstaller 版本后问题解决。
另一个 API 28(Android 9)的问题是:系统对 profile 文件大小有限制,超过约 1MB 会静默截断。我们的 profile 因为合并了 Compose、Room、Glide 等多个库的路径,一度膨胀到 1.2MB。启动优化效果不稳定,有时好有时差。
最后用 profgen 工具(Baseline Profile 插件自带)做了裁剪,只保留启动关键路径的类和方法:
# 生成后处理
./gradlew app:generateBaselineProfile
profgen strip \
--input app/src/main/baseline-prof.txt \
--output app/src/main/baseline-prof-trimmed.txt \
--keep-packages com.example.bankapp.startup,com.example.bankapp.auth裁剪后降到 400KB,Android 9 上的效果稳定了。
与 R8/ProGuard 的交互问题
这是最后一个大坑,也是最隐蔽的。
Baseline Profile 里记录的是类名和方法签名,但 R8 混淆后会改名。如果 profile 里的名字和运行时 dex 里的名字对不上,编译优化就无从谈起。
Android Gradle Plugin 8.1+ 的处理是:在混淆映射完成后,用 mapping 文件反解 profile 里的名字,生成对应的 obfuscated.prof。这个文件才会被打包进 assets。
但有个前提:profile 生成任务必须在 minifyReleaseWithR8 之后执行。AGP 的 task 依赖理论上保证了这个顺序,实际遇到过缓存问题。
具体现象:clean build 没问题,增量 build 时 profile 任务用了缓存的旧 baseline-prof.txt,此时 R8 的 mapping 可能已变,导致名字不匹配。运行时看 primary.prof 存在,编译也完成了,但启动时间没改善。
排查方法:解压 APK,看 assets/dexopt/baseline.prof 里的名字是否是混淆后的。例如应该看到 a.b.c 而不是 com.example.LoginActivity。
我们的 CI 流程里加了一步验证:
# 在 APK 构建后检查
unzip -p app-release.apk assets/dexopt/baseline.prof # 确认输出是短名字(混淆后)而非原始包名如果不对,强制 --rerun-tasks 重新执行 profile 生成。
现在怎么做的:一个可复现的模板
把上面的经验整理成当前项目的标准配置:
build.gradle 关键部分:
android {
// ...
buildTypes {
release {
minifyEnabled true
proguardFiles(...)
// 确保 baseline profile 在 R8 之后处理
setProfileProcessing(true)
}
}
baselineProfile {
enableStartupProfile = true
mergeIntoMain = true
saveDiffToFile = true
}
}
dependencies {
// 1.4.0 才有 forceWrite 支持
implementation("androidx.profileinstaller:profileinstaller:1.4.0")
// 测试用 generator
"baselineProfile"(project(":baselineprofile"))
}Application 里的手动触发(非 Play 渠道):
class MyApplication : Application() {
companion object {
private const val PROFILE_PREFS = "androidx.profileinstaller.profileInstallPrefs"
private const val KEY_INSTALLED_VERSION = "ProfileInstallerVersion"
}
override fun onCreate() {
super.onCreate()
if (shouldInstallProfile()) {
ProfileInstaller.writeProfile(
this,
Executors.newSingleThreadExecutor(),
installReceiver,
/* forceWrite */ true
)
}
}
private fun shouldInstallProfile(): Boolean {
// 检查是否已安装当前版本的 profile
val prefs = getSharedPreferences(PROFILE_PREFS, Context.MODE_PRIVATE)
val installedVersion = prefs.getString(KEY_INSTALLED_VERSION, null)
val currentVersion = BuildConfig.VERSION_NAME + "_" + BuildConfig.PROFILE_HASH
return installedVersion != currentVersion
}
}MDM 部署脚本:
#!/system/bin/sh
# MDM 安装后执行
PACKAGE="com.example.bankapp"
# 等待应用完全安装
sleep 5
# 强制按 profile 编译
pm compile -m speed-profile -f $PACKAGE
# 验证状态
pm dump $PACKAGE | grep -A 5 "Compiler stats"这套方案跑了一年多,覆盖约 3 万台企业设备,冷启动中位数从 1100ms 降到 680ms。不是最顶尖的优化数据,但在不能走 Play Store 的约束下,算是把 Baseline Profile 的机制用透了。
最近在看 ProfileInstaller 1.5.0 的 alpha 源码,发现 Google 在重构 DeviceProfileWriter,把渠道判断逻辑抽成了可注入的 ProfileSource 接口。也许未来版本里,非 Play 分发的支持会更干净。但就目前而言,理解它底层在做什么,比等官方完善更实际。