以下文章来源于朱涛的自习室 ,作者朱涛
Google 认证的 Android & Kotlin 开发者专家 ,极客时间专栏《Kotlin 编程第一课》作者。执着于:用「简单的方式」解释「复杂的概念」。
本文原作者: 朱涛,原文发布于: 朱涛的自习室
今天我们来扒一下 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 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 当中手动添加对应的方法,但 Google 更加推荐我们使用 Jetpack 当中的 Macrobenchmark。它是 Android 里的一个性能优化库,借助这个库,我们可以: 生成 Baseline Profile 文件。
class BaselineProfileGenerator {val baselineProfileRule = BaselineProfileRule()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
经过前面的分析,我们知道,baseline.prof 需要写入到系统特定的目录下,才能够引导 AOT 编译。这一点又是如何做到的呢?
这时候,我们需要用到另一个 Jetpack Library: ProfileInstaller。从它的名字,我们就能看出,它的功能就是: 将 APK 当中的 baseline.prof 写入到系统目录下。
implementation "androidx.profileinstaller:profileinstaller:1.2.0-beta02"引入依赖,这没什么好说的,常规操作。然后就是初始化设置。
<providerandroid: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 ProfileInstallerInitializerimplements Initializer<ProfileInstallerInitializer.Result> {private static final int DELAY_MS = 5_000;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 是如何实现的:
(16)void delayAfterFirstFrame( Context appContext) {// 从第一帧开始算,延迟 5 秒Choreographer16Impl.postFrameCallback(() -> installAfterDelay(appContext));}void installAfterDelay( 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 实现 delayhandler.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(Context context,Executor executor,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(AssetManager assets,String packageName,PackageInfo packageInfo,File filesDir,String apkName,Executor executor,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 的技术方案就分析完了!
注意事项
第一,由于 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 团队,让我们开发者拥有了一个新的角度,来优化应用的性能。
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。