ProfileInstaller 的 Baseline Profile 分发,Play Store 之外怎么发

ProfileInstaller 的 Baseline Profile 分发,Play Store 之外怎么发

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.profbaseline.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 的源码(当时最新版),核心逻辑在 ProfileInstallerInitializerProfileInstaller 两个类里。


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,但它没有参数可以覆盖渠道检测。


我最终找到的办法是:直接用 PackageManagersetComponentEnabledSetting 配合 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,但源码里确实加了 setDiagnosticsCallbackforceWrite 相关逻辑。


验证 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 的编译状态。关键看 profileStatecompileReason


第二步,强制触发编译。测试时不能干等系统调度:


# 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-profileprofile 才算数。


第三步,运行时验证。我写了段简单的计时代码,对比 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(compileReason=install):~890ms
  • profile 已写入但未编译(compileReason=install, profileState=empty):~870ms,几乎没差
  • profile 写入且编译完成(compileReason=baseline-profile):~620ms

  • 那个"几乎没差"的阶段让我困惑了很久。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,应用调不了。


    变通方案是:利用 PackageManagersetApplicationEnabledSetting 触发的隐式编译,或者更简单——在 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 版本:


  • Android 9-11:primary.prof 会被新版本的覆盖,但系统不一定立即重新编译。需要 PackageManager 检测到 dex 变化才触发。
  • Android 12+:有 refcur 两个 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 分发的支持会更干净。但就目前而言,理解它底层在做什么,比等官方完善更实际。

    RenderThread 的异步渲染,UI 线程真的减负了吗 2026-06-23

    评论区