你好,我是朱涛。这是「沉思录」的第三篇文章。
今天我们来扒一下 Baseline Profiles 的底层原理。
正文
今年 Google I/O 大会上,Android 官方强推了一把 Baseline Profile,不仅在 Android、Jetpack 的主题演讲里有提到了它,就连 Jetpack Compose、Android Studio 相关的主题里也有它的身影。
第一眼,我就被它给惊艳
到了!动辄 30%、40%
的启动优化成绩,还是一个通用的解决方案,真的很牛逼了!而且 App 越复杂,提升明显!说实话,刚开始我甚至有点不太相信。
国内能用吗?
在官方介绍 Baseline Profile 的时候,放了一张这样的图,貌似 Google Play Service 在中间扮演着重要的角色。
Google Play??我心里顿时就凉了半截。完了!这么牛逼的东西,国内不能用吗? 吓得我赶紧找来了文档,仔细看了一遍 Baseline Profile 的用法以及原理,这才放下心来:
国内能用 Baseline Profiles,只是 Cloud Profiles 不可用而已。
为了保险起见,我也在 Twitter 上找了 Google 工程师,对方也证实了我的想法。
那就没毛病了!学起来!
底层原理
其实吧,Baseline Profile 并不是一个新的东西。而且它也不是一个 Jetpack Library,它只是存在于 Android 系统的一个文件。
这里,我们要从 Android 系统的发展说起。
对于 Android 5.0、6.0 系统来说,我们的代码会在安装期间进行全量 AOT 编译。虽然 AOT 的性能更高,但它会带来额外的问题:应用安装时间大大增加、磁盘占用更加大。
对于 Android 7.0+ 系统来说,Android 支持 JIT、AOT 并存的混合编译模式。在这些高版本的系统当中,ART 虚拟机会在运行时统计到应用的热点代码,存放在/data/misc/profiles/cur/0/包名/primary.prof
这个路径下。ART 虚拟机会针对这些热点代码进行 AOT 编译,这种方式要比全量 AOT 编译灵活很多。
看到这里,你是不是已经猜到了 Baseline Profile 的底层原理了呢?
不难发现,对吧?由于 ART 虚拟机需要执行一段时间以后,才能统计出热点代码,而且由于每个用户的使用场景、时长不一样,最终统计出来的热点代码也不一定是最优的。
Google 的思路其实也很简单:让开发者把热点代码提前统计好,然后跟着代码一起打到 APK 当中,然后将对应的规则存到/data/misc/profiles/cur/0/
这个目录下即可。总的来说,就是分成两步:1. 统计热点代码的规则;2. 将规则存到特定目录下。
统计热点代码
Baseline Profile 其实就是一个文件,它里面会记录我们应用的热点代码,最终被放在 APK 的 assets/dexopt/baseline.prof
目录下。有了它,ART 虚拟机就可以进行相应的 AOT 编译了。
虽然,我们也可以往 Baseline Profile 当中手动添加对应的方法,但 Google 更加推荐我们使用 Jetpack 当中的 Macrobenchmark。它是 Android 里的一个性能优化库,借助这个库,我们可以:生成Baseline Profile文件
。
@ExperimentalBaselineProfilesApi@RunWith(AndroidJUnit4::class)class BaselineProfileGenerator { @get:Rule val baselineProfileRule = BaselineProfileRule() @Test fun startup() = baselineProfileRule.collectBaselineProfile(packageName = "com.example.app") { pressHome() // This block defines the app's critical user journey. Here we are interested in // optimizing for app startup. But you can also navigate and scroll // through your most important UI. startActivityAndWait() }}
唯一需要注意的,就是我们需要在 root 过后的 AOSP 9.0+ 的系统上才能采集到热点代码的信息。最终,Macrobenchmark 会把统计到的热点代码信息放到文件里。
/storage/emulated/0/Android/media/package.name.SampleStartupBenchmark_startup-baseline-prof.txt
我们拿到这个统计的文件,将其重命名为baseline-prof.txt
,放到工程里去即可。
写入 baseline.prof
经过前面的分析,我们知道,baseline.prof 需要写入到系统特定的目录下,才能够引导 AOT 编译。这一点又是如何做到的呢?
这时候,我们需要用到另一个 Jetpack Library:ProfileInstaller。从它的名字,我们就能看出,它的功能就是:将 APK 当中的 baseline.prof 写入到系统目录下。
它的用法也很简单:
implementation "androidx.profileinstaller:profileinstaller:1.2.0-beta02"
引入依赖,这没什么好说的,常规操作。然后就是初始化设置。
<provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" android:exported="false" tools:node="merge"> <meta-data android:name="androidx.profileinstaller.ProfileInstallerInitializer" tools:node="remove" /></provider>
可以看到,它是通过集成 Androidx.startup 库,实现的初始化,用的是 Content Provider 的思路,也是常规操作了。我们来分析一下源代码吧!
总的来说,ProfileInstaller 的代码结构很简单:
通过前面 XML 的分析,我们知道,ProfileInstallerInitializer
肯定是功能的入口,我们来看它的逻辑。
public class ProfileInstallerInitializer implements Initializer<ProfileInstallerInitializer.Result> { private static final int DELAY_MS = 5_000; @NonNull @Override public Result create(@NonNull Context context) { if (Build.VERSION.SDK_INT < ProfileVersion.MIN_SUPPORTED_SDK) { // 小于 7.0 的系统没必要执行 return new Result(); } // 延迟 5 秒,写入 profile 文件 delayAfterFirstFrame(context.getApplicationContext()); return new Result(); } }}
接着,我们来看看 Delay 是如何实现的:
@RequiresApi(16)void delayAfterFirstFrame(@NonNull Context appContext) { // 从第一帧开始算,延迟 5 秒 Choreographer16Impl.postFrameCallback(() -> installAfterDelay(appContext));}void installAfterDelay(@NonNull Context appContext) { Handler handler; if (Build.VERSION.SDK_INT >= 28) { handler = Handler28Impl.createAsync(Looper.getMainLooper()); } else { handler = new Handler(Looper.getMainLooper()); } Random random = new Random(); int extra = random.nextInt(Math.max(DELAY_MS / 5, 1)); // Handler 实现 delay handler.postDelayed(() -> writeInBackground(appContext), DELAY_MS + extra);}
可以看到,为了避免 Profile 的写入影响到 App 的正常执行,这里延迟了 5 秒左右。最终,会执行writeInBackground()
,进行真正的写入操作。
private static void writeInBackground(@NonNull Context context) { Executor executor = new ThreadPoolExecutor( /* corePoolSize = */0, /* maximumPoolSize = */1, /* keepAliveTime = */0, /* unit = */TimeUnit.MILLISECONDS, /* workQueue = */new LinkedBlockingQueue<>() ); executor.execute(() -> ProfileInstaller.writeProfile(context));}
这里,程序会创建一个线程数量为 1 的线程池,然后将执行流程交给 ProfileInstaller,进行 Profile 文件的写入。
static void writeProfile( @NonNull Context context, @NonNull Executor executor, @NonNull DiagnosticsCallback diagnostics, boolean forceWriteProfile) { Context appContext = context.getApplicationContext(); String packageName = appContext.getPackageName(); ApplicationInfo appInfo = appContext.getApplicationInfo(); AssetManager assetManager = appContext.getAssets(); String apkName = new File(appInfo.sourceDir).getName(); PackageManager packageManager = context.getPackageManager(); PackageInfo packageInfo; try { packageInfo = packageManager.getPackageInfo(packageName, 0); } catch (PackageManager.NameNotFoundException e) { diagnostics.onResultReceived(RESULT_IO_EXCEPTION, e); return; } File filesDir = context.getFilesDir(); // 判断是否要写入 if (forceWriteProfile || !hasAlreadyWrittenProfileForThisInstall(packageInfo, filesDir, diagnostics)) { transcodeAndWrite(assetManager, packageName, packageInfo, filesDir, apkName, executor, diagnostics); }}
writeProfile()
的主要逻辑就是判断当前是否要强制写入 Profile 文件(正常情况是不强制的),以及之前是否已经写入过了。之后,程序会执行transcodeAndWrite()
方法,也就是转码并写入
。
终于到关键逻辑了!我们来看看它的逻辑。
private static void transcodeAndWrite( @NonNull AssetManager assets, @NonNull String packageName, @NonNull PackageInfo packageInfo, @NonNull File filesDir, @NonNull String apkName, @NonNull Executor executor, @NonNull DiagnosticsCallback diagnostics) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { result(executor, diagnostics, ProfileInstaller.RESULT_UNSUPPORTED_ART_VERSION, null); return; } File curProfile = new File(new File(PROFILE_BASE_DIR, packageName), PROFILE_FILE); DeviceProfileWriter deviceProfileWriter = new DeviceProfileWriter(assets, executor, diagnostics, apkName, PROFILE_SOURCE_LOCATION, PROFILE_META_LOCATION, curProfile); // 是否具备写入权限 if (!deviceProfileWriter.deviceAllowsProfileInstallerAotWrites()) { return; /* nothing else to do here */ } boolean success = deviceProfileWriter.read() .transcodeIfNeeded() .write(); if (success) { noteProfileWrittenFor(packageInfo, filesDir); }}public boolean deviceAllowsProfileInstallerAotWrites() { if (mDesiredVersion == null) { result(ProfileInstaller.RESULT_UNSUPPORTED_ART_VERSION, Build.VERSION.SDK_INT); return false; } if (!mCurProfile.canWrite()) { // 某些厂商可能不允许写入 Profile 文件 result(ProfileInstaller.RESULT_NOT_WRITABLE, null); return false; } mDeviceSupportsAotProfile = true; return true;}
从上面的注释,我们可以看到,transcodeAndWrite()
主要还是在判断当前设备是否支持写入 Profile 文件,如果支持才会继续。
至此,我们整个 Baseline Profile 的技术方案就分析完了!
注意事项
在研究 Baseline Profiles 的过程中,我也发现了一些小细节,可能需要大家额外留意。
第一,由于 Android 手机有许多的厂商,每个厂商会对系统进行一些定制化,也许某些厂商会封死 Profile 文件的写入权限。即使这个方案无需 Google Play,但国内支持写入 Profile 的手机具体占多大的比例,我目前还没有数据,欢迎大家在使用了 Baseline Profile 以后来向我反馈。
第二,如何衡量 Baseline Profile 带来的性能提升?这一点, Macrobenchmark 也提供了相关的能力,具体可以看这个官方文档的链接。
第三,Debug 编译的 App,是不会进行 AOT 编译的,因此它的性能会比 release 低不少。
第四,baseline-prof.txt
放的位置很关键,它必须跟AndroidManifest.xml
是同级目录下。
第五,Baseline Profile 必须使用 AGP 7.1.0-alpha05
及以上的版本,7.3.0-beta01
及以上对 App Bundle、多 Dex 应用的支持会更好。
第六,baseline-prof.txt
文件大小不得超过 1.5M,且,其中定义的规则不能太宽泛,否则可能反而降低性能。
一个有趣的故事
这个故事具体的来源是谁,我忘了,反正是某个 Google 工程师说的。关于,Baseline Profile 是如何诞生的。
其实,它跟 Jetpack Compose 还有一些渊源。Compose 由于它的底层原理,它的核心代码是会频率调用的,因此对性能要求非常高。
在 Google 内部研发 Jetpack Compose 的过程中,他们发现:Compose 应用在初次安装、启动的阶段,会非常的卡!等到应用使用一段时间后,Compose 应用的体验才会慢慢好起来。
这是为什么呢?
你肯定能猜到,对吧?没错!因为 ART 默认情况下,并没有把 Compose 的核心代码进行 AOT 编译,而是 JIT 执行。这就要命了,像 Compose 底层的 Snapshot 系统、Slot Table,都是热点代码,短时间内会被频繁调用,JIT 根本无法满足 Compose 的性能要求。
怎么办呢?当然是 Baseline Profile 啦!其实,这套方案,早在 2021 年就被率先引入 Jetpack Compose 当中。今年 2022 年的 Google I/O 大会上,才被官方拿出来大力推广。
感谢 Android 团队,让我们开发者拥有了一个新的角度,来优化应用的性能。
OK,感谢你的阅读,咱们下周……额……我也不知道啥时候能写出下一篇,总之,下次再见!
原文:https://juejin.cn/post/7104230480391864356