动手点关注
干货不迷路
背景
在Android上,Java/Kotlin代码会编译为DEX字节码,在运行期由虚拟机解释执行。但是,字节码解释执行的速度比较慢。所以,通常虚拟机会在解释模式基础上做一些必要的优化。
在Android 5,Google采用的策略是在应用安装期间对APP的全量DEX进行AOT优化。AOT优化(Ahead of time),就是在APP运行前就把DEX字节码编译成本地机器码。虽然运行效率相比DEX解释执行有了大幅提高,但由于是全量AOT,就会导致用户需要等待较长的时间才能打开应用,对于磁盘空间的占用也急剧增大。
于是,为了避免过早的资源占用,从Android 7开始便不再进行全量AOT,而是JIT+AOT的混合编译模式。JIT(Just in time),就是即时优化,也就是在APP运行过程中,实时地把DEX字节码编译成本地机器码。具体方式是,在APP运行时分析运行过的热代码,然后在设备空闲时触发AOT,在下次运行前预编译热代码,提升后续APP运行效率。
但是热代码代码收集需要比较长周期,在APP升级覆盖安装之后,原有的预编译的热代码失效,需要再走一遍运行时分析、空闲时AOT的流程。在单周迭代的研发模式下问题尤为明显。
因此,从Android 9 开始,Google推出了Cloud Profiles技术。它的原理是,在部分先安装APK的用户手机上,Google Play Store收集到热点代码,然后上传到云端并聚合。这样,对于后面安装的用户,Play Store会下发热点代码配置进行预编译,这些用户就不需要进行运行时分析,大大提前了优化时机。不过,这个收集聚合下发过程需要几天时间,大部分用户还是没法享受到这个优化。
最终,在2022年Google推出了 Baseline Profiles (https://developer.android.com/topic/performance/baselineprofiles/overview?hl=zh-cn)技术。它允许开发者内置自己定义的热点代码配置文件。在APP安装期间,系统提前预编译热点代码,大幅提升APP运行效率。
不过,Google官方的Baseline Profiles存在以下局限性:
Baseline Profile 需要使用 AGP 7 及以上的版本,公司内各大APP的版本都还比较低,短期内并不可用
安装时优化依赖Google Play,国内无法使用
为此,我们开发了一套定制化的Baseline Profiles优化方案,可以适用于全版本AGP。同时通过与国内主流厂商合作,推进支持了安装时优化生效。
方案探索与实现
我们先来看一下官方Baseline Profile安装时优化的流程:
这里面主要包含3个步骤:
热点方法收集,通过本地运行设备或者人工配置,得到可读格式的基准配置文本文件(baseline-prof.txt)
编译期处理,将基准配置文本文件转换成二进制文件,打包至apk内(baseline.prof和baseline.profm),另外Google Play服务端还会将云端profile与baseline.prof聚合处理。
安装时,系统会解析apk内的baseline.prof二进制文件,根据版本号,做一些转换后,提前预编译指定的热点代码为机器码。
热点方法收集
官方文档(https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile)提到使用Jetpack Macrobenchmark库(https://developer.android.com/macrobenchmark) 和 BaselineProfileRule
自动收集热点方法。通过在Android Studio中引入Benchmark module,需要编写相应的Rule触发打包、测试等流程。
从下面源码可以看到,最终是通过profman命令可以收集到app运行过程中的热点方法。
private fun profmanGetProfileRules(apkPath: String, pathOptions: List<String>): String {
// When compiling with CompilationMode.SpeedProfile, ART stores the profile in one of
// 2 locations. The `ref` profile path, or the `current` path.
// The `current` path is eventually merged into the `ref` path after background dexopt.
val profiles = pathOptions.mapNotNull { currentPath ->
Log.d(TAG, "Using profile location: $currentPath")
val profile = Shell.executeScriptCaptureStdout(
"profman --dump-classes-and-methods --profile-file=$currentPath --apk=$apkPath"
)
profile.ifBlank { null }
}
...
return builder.toString()
}
所以,我们可以绕过Macrobenchmark库,直接使用profman命令,减少自动化接入成本。具体命令如下:
adb shell profman --dump-classes-and-methods \
--profile-file=/data/misc/profiles/cur/0/com.ss.android.article.video/primary.prof \
--apk=/data/app/com.ss.android.article.video-Ctzj32dufeuXB8KOhAqdGg==/base.apk \
> baseline-prof.txt
生成的baseline-prof.txt文件内容如下:
PLcom/bytedance/apm/perf/b/f;->a(Lcom/bytedance/apm/perf/b/f;)Ljava/lang/String;
PLcom/bytedance/bdp/bdpbase/ipc/n$a;->a()Lcom/bytedance/bdp/bdpbase/ipc/n;
HSPLorg/android/spdy/SoInstallMgrSdk;->initSo(Ljava/lang/String;I)Z
HSPLorg/android/spdy/SpdyAgent;->InvlidCharJudge([B[B)V
Lanet/channel/e/a$b;
Lcom/bytedance/alliance/services/impl/c;
...
这些规则采用两种形式,分别指明方法和类。方法的规则如下所示:
[FLAGS][CLASS_DESCRIPTOR]->[METHOD_SIGNATURE]
FLAGS表示 H
、S
和 P
中的一个或多个字符,用于指示相应方法在启动类型方面应标记为 Hot
、Startup
还是 Post Startup
:
带有
H
标记表示相应方法是一种“热”方法,这意味着相应方法在应用的整个生命周期内会被调用多次。带有
S
标记表示相应方法在启动时被调用。带有
P
标记表示相应方法是与启动无关的热方法。
类的规则,则是直接指明类签名即可:
[CLASS_DESCRIPTOR]
不过这里是可读的文本格式,后续还需要进一步转为二进制才可以被系统识别。
另外,release包导出的是混淆后的符号,需要根据mapping文件再做一次反混淆才能使用。
编译期处理
在得到base.apk的基准配置文本文件(baseline-prof.txt)之后还不够,一些库里面
(比如androidx的库里https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:recyclerview/recyclerview/src/main/baseline-prof.txt)
也会自带baseline-prof.txt文件。所以,我们还需要把这些子library内附带的baseline-prof.txt取出来,与base.apk的配置一起合并成完整的基准配置文本文件。
接下来,我们需要把完整的配置文件转换成baseline.prof二进制文件。具体是由AGP 7.x内的 CompileArtProfileTask.kt
实现的 :
/**
* Task that transforms a human readable art profile into a binary form version that can be shipped
* inside an APK or a Bundle.
*/
abstract class CompileArtProfileTask: NonIncrementalTask() {
...
abstract class CompileArtProfileWorkAction:
ProfileAwareWorkAction<CompileArtProfileWorkAction.Parameters>() {
override fun run() {
val diagnostics = Diagnostics {
error -> throw RuntimeException("Error parsing baseline-prof.txt : $error")
}
val humanReadableProfile = HumanReadableProfile(
parameters.mergedArtProfile.get().asFile,
diagnostics
) ?: throw RuntimeException(
"Merged ${SdkConstants.FN_ART_PROFILE} cannot be parsed successfully."
)
val supplier = DexFileNameSupplier()
val artProfile = ArtProfile(
humanReadableProfile,
if (parameters.obfuscationMappingFile.isPresent) {
ObfuscationMap(parameters.obfuscationMappingFile.get().asFile)
} else {
ObfuscationMap.Empty
},
//need to rename dex files with sequential numbers the same way [DexIncrementalRenameManager] does
parameters.dexFolders.asFileTree.files.sortedWith(DexFileComparator()).map {
DexFile(it.inputStream(), supplier.get())
}
)
// the P compiler is always used, the server side will transcode if necessary.
parameters.binaryArtProfileOutputFile.get().asFile.outputStream().use {
artProfile.save(it, ArtProfileSerializer.V0_1_0_P)
}
// create the metadata.
parameters.binaryArtProfileMetadataOutputFile.get().asFile.outputStream().use {
artProfile.save(it, ArtProfileSerializer.METADATA_0_0_2)
}
}
}
这里的核心逻辑就是做了以下3件事:
读取baseline-prof.txt基准配置文本文件,下文用HumanReadableProfile表示
将HumanReadableProfile、proguard mapping文件、dex文件作为输入传给ArtProfile
由ArtProfile生成特定版本格式的baseline.prof二进制文件
ArtProfile类是在profgen子工程内实现的,其中有两个关键的方法:
构造方法:读取HumanReadableProfile、proguard mapping文件、dex文件作为参数,构造ArtProfile实例
save()方法:输出指定版本格式的baseline.prof二进制文件
参考链接:
https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:profgen/profgen/src/