你好,我是朱濤。這是「沉思錄」的第三篇文章。
今天我們來扒一下 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 應用的體驗才會慢慢好起來。
這是為什麼呢?