你好,我是朱涛。这是「沉思录」的第三篇文章。
今天我们来扒一下 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 虚拟机会在运行时统计到应用的热点代码,存放在
这个路径下。ART 虚拟机会针对这些热点代码进行 AOT 编译,这种方式要比全量 AOT 编译灵活很多。/data/misc/profiles/cur/0/包名/primary.prof
看到这里,你是不是已经猜到了 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.
唯一需要注意的,就是我们需要在 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 {
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 {
// 从第一帧开始算,延迟 5 秒
Choreographer16Impl.postFrameCallback(() -> installAfterDelay(appContext));
}
void installAfterDelay(@NonNull {
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
可以看到,为了避免 Profile 的写入影响到 App 的正常执行,这里延迟了 5 秒左右。最终,会执行
writeInBackground()
,进行真正的写入操作。
private static void writeInBackground(@NonNull {
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 {
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
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 {
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
及以上对 App Bundle、多 Dex 应用的支持会更好。7.3.0-beta01
- 第六,
文件大小不得超过 1.5M,且,其中定义的规则不能太宽泛,否则可能反而降低性能。baseline-prof.txt
一个有趣的故事
这个故事具体的来源是谁,我忘了,反正是某个 Google 工程师说的。关于,Baseline Profile 是如何诞生的。
其实,它跟 Jetpack Compose 还有一些渊源。Compose 由于它的底层原理,它的核心代码是会频率调用的,因此对性能要求非常高。
在 Google 内部研发 Jetpack Compose 的过程中,他们发现:Compose 应用在初次安装、启动的阶段,会非常的卡!等到应用使用一段时间后,Compose 应用的体验才会慢慢好起来。
这是为什么呢?