Skip to content

Commit

Permalink
Add baseline profile for optimizing app startup (#80)
Browse files Browse the repository at this point in the history
* Add baseline profile for startup journey & benchmark it

* Generate baseline profiles on CI on releasing a new version
  • Loading branch information
mr3y-the-programmer authored Aug 12, 2024
1 parent d7a94f3 commit dfd6cff
Show file tree
Hide file tree
Showing 14 changed files with 25,657 additions and 0 deletions.
31 changes: 31 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ env:
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
contents: write

Expand Down Expand Up @@ -57,6 +58,36 @@ jobs:
KEYSTORE_KEY_PASSWORD: ${{ secrets.STORE_KEY_PASSWORD }}
KEYSTORE_STORE_PASSWORD: ${{ secrets.STORE_KEY_PASSWORD }}

- name: Enable KVM group perms (for baseline profiles generation)
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls /dev/kvm
- name: Install GMD image for baseline profile generation
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager "system-images;android-34;aosp;x86_64"

- name: Accept Android licenses
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true

- name: Generate Updated baseline profiles
run: ./gradlew :app:generateReleaseBaselineProfile
-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
-Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
-Pandroid.experimental.androidTest.numManagedDeviceShards=1
-Pandroid.experimental.testOptions.managedDevices.maxConcurrentDevices=1

- name: Commit and push baseline profiles changes (if any).
if: ${{ github.ref == 'refs/heads/main' }}
uses: EndBug/add-and-commit@v9
with:
author_name: GitHub Actions
author_email: [email protected]
message: Update baseline profiles
push: true

- name: Build & Publish Release (.aab) bundle to Play console
run: ./gradlew bundleRelease publishReleaseBundle
env:
Expand Down
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ plugins {
alias(libs.plugins.appversioning)
alias(libs.plugins.play.publisher)
alias(libs.plugins.roborazzi)
alias(libs.plugins.baselineprofile)
}

android {
Expand Down Expand Up @@ -142,6 +143,8 @@ dependencies {
implementation(libs.firebase.crashlytics, excludeAndroidxDataStore)

implementation(libs.datastore.pref)
implementation(libs.androidx.profileinstaller)
"baselineProfile"(projects.baselineprofile)

ksp(libs.hilt.compiler)
implementation(libs.hilt.runtime)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.mr3y.podcaster.ui.screens

import androidx.activity.compose.ReportDrawnWhen
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
Expand Down Expand Up @@ -61,6 +62,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mr3y.podcaster.BuildConfig
import com.mr3y.podcaster.LocalStrings
import com.mr3y.podcaster.core.model.CurrentlyPlayingEpisode
import com.mr3y.podcaster.core.model.Episode
Expand Down Expand Up @@ -110,6 +112,12 @@ fun SubscriptionsScreen(
) {
val subscriptionsState by viewModel.state.collectAsStateWithLifecycle()
val currentlyPlayingEpisode by appState.currentlyPlayingEpisode.collectAsStateWithLifecycle()

if (!BuildConfig.DEBUG) {
ReportDrawnWhen {
!subscriptionsState.isEpisodesLoading && !subscriptionsState.isSubscriptionsLoading
}
}
SubscriptionsScreen(
state = subscriptionsState,
onPodcastClick = onPodcastClick,
Expand Down
25,354 changes: 25,354 additions & 0 deletions app/src/release/generated/baselineProfiles/baseline-prof.txt

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions baselineProfile/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import com.android.build.api.dsl.ManagedVirtualDevice

plugins {
alias(libs.plugins.podcaster.android.test.lib)
alias(libs.plugins.baselineprofile)
}

android {
namespace = "com.mr3y.podcaster.baselineprofile"

targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true

// This code creates the gradle managed device used to generate baseline profiles.
// To use GMD please invoke generation through the command line:
// ./gradlew :app:generateBaselineProfile
testOptions.managedDevices.devices {
create<ManagedVirtualDevice>("pixel6Api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp"
}
}
}

// This is the configuration block for the Baseline Profile plugin.
// You can specify to run the generators on a managed devices or connected devices.
baselineProfile {
managedDevices += "pixel6Api34"
useConnectedDevices = false
}

dependencies {
implementation(libs.androidx.test.ext.junit)
implementation(libs.espresso.core)
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
}

androidComponents {
onVariants { v ->
val artifactsLoader = v.artifacts.getBuiltArtifactsLoader()
v.instrumentationRunnerArguments.put(
"targetAppId",
v.testedApks.map { artifactsLoader.load(it)?.applicationId }
)
}
}
1 change: 1 addition & 0 deletions baselineProfile/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest />
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.mr3y.podcaster.baselineprofile

import android.Manifest
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.TIRAMISU
import androidx.benchmark.macro.MacrobenchmarkScope

internal fun MacrobenchmarkScope.allowNotifications() {
if (SDK_INT >= TIRAMISU) {
val command = "pm grant $packageName ${Manifest.permission.POST_NOTIFICATIONS}"
device.executeShellCommand(command)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.mr3y.podcaster.baselineprofile.startup

import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.mr3y.podcaster.baselineprofile.allowNotifications
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* This test class generates a basic startup baseline profile for the target package.
*
* We recommend you start with this but add important user flows to the profile to improve their performance.
* Refer to the [baseline profile documentation](https://d.android.com/topic/performance/baselineprofiles)
* for more information.
*
* You can run the generator with the "Generate Baseline Profile" run configuration in Android Studio or
* the equivalent `generateBaselineProfile` gradle task:
* ```
* ./gradlew :app:generateReleaseBaselineProfile
* ```
* The run configuration runs the Gradle task and applies filtering to run only the generators.
*
* Check [documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args)
* for more information about available instrumentation arguments.
*
* After you run the generator, you can verify the improvements running the [StartupBenchmarks] benchmark.
*
* When using this class to generate a baseline profile, only API 33+ or rooted API 28+ are supported.
*
* The minimum required version of androidx.benchmark to generate a baseline profile is 1.2.0.
**/
@RunWith(AndroidJUnit4::class)
@LargeTest
class StartupBaselineProfile {

@get:Rule
val rule = BaselineProfileRule()

@Test
fun generate() {
// The application id for the running build variant is read from the instrumentation arguments.
rule.collect(
packageName = InstrumentationRegistry.getArguments().getString("targetAppId")
?: throw Exception("targetAppId not passed as instrumentation runner arg"),

// See: https://d.android.com/topic/performance/baselineprofiles/dex-layout-optimizations
// Disable startup profiles for now as they increase the final app size by almost ~24%
includeInStartupProfile = false
) {
// This block defines the app's startup user journey. Here we are interested in
// optimizing for app startup. But you can also navigate and scroll through your most important UI.
pressHome()
startActivityAndWait()
allowNotifications()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.mr3y.podcaster.baselineprofile.startup

import androidx.benchmark.macro.BaselineProfileMode
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.mr3y.podcaster.baselineprofile.allowNotifications
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* This test class benchmarks the speed of app startup.
* Run this benchmark to verify how effective a Baseline Profile is.
* It does this by comparing [CompilationMode.None], which represents the app with no Baseline
* Profiles optimizations, and [CompilationMode.Partial], which uses Baseline Profiles.
*
* Run this benchmark to see startup measurements and captured system traces for verifying
* the effectiveness of your Baseline Profiles. You can run it directly from Android
* Studio as an instrumentation test, or run all benchmarks for a variant, for example benchmarkRelease,
* with this Gradle task:
* ```
* ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest
* ```
*
* You should run the benchmarks on a physical device, not an Android emulator, because the
* emulator doesn't represent real world performance and shares system resources with its host.
*
* For more information, see the [Macrobenchmark documentation](https://d.android.com/macrobenchmark#create-macrobenchmark)
* and the [instrumentation arguments documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args).
**/
@RunWith(AndroidJUnit4::class)
@LargeTest
class StartupBenchmarks {

@get:Rule
val rule = MacrobenchmarkRule()

@Test
fun startupCompilationNone() = benchmark(CompilationMode.None())

@Test
fun startupPartialCompilationBaselineProfiles() =
benchmark(CompilationMode.Partial(BaselineProfileMode.Require))

@Test
fun startupFullyPrecompiledBaselineProfiles() = benchmark(CompilationMode.Full())

private fun benchmark(compilationMode: CompilationMode) {
// The application id for the running build variant is read from the instrumentation arguments.
rule.measureRepeated(
packageName = InstrumentationRegistry.getArguments().getString("targetAppId")
?: throw Exception("targetAppId not passed as instrumentation runner arg"),
metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode,
startupMode = StartupMode.COLD,
iterations = 15,
setupBlock = {
pressHome()
},
measureBlock = {
// The app is fully drawn when Activity.reportFullyDrawn is called.
// For Jetpack Compose, you can use ReportDrawn, ReportDrawnWhen and ReportDrawnAfter
// from the AndroidX Activity library.

// Check the UiAutomator documentation for more information on how to
// interact with the app.
// https://d.android.com/training/testing/other-components/ui-automator
startActivityAndWait()
allowNotifications()
}
)
}
}
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ plugins {
alias(libs.plugins.google.services) apply false
alias(libs.plugins.crashlytics) apply false
alias(libs.plugins.aboutlibraries) apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.baselineprofile) apply false
}
true // Needed to make the Suppress annotation work for the plugins block
4 changes: 4 additions & 0 deletions convention-plugins/plugins/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ gradlePlugin {
id = "podcaster.android.library"
implementationClass = "com.mr3y.podcaster.gradle.AndroidLibraryConventionPlugin"
}
register("androidTestLibrary") {
id = "podcaster.android.test.library"
implementationClass = "com.mr3y.podcaster.gradle.AndroidTestConventionPlugin"
}
register("androidComposeLibrary") {
id = "podcaster.android.compose.library"
implementationClass = "com.mr3y.podcaster.gradle.AndroidComposeLibraryConventionPlugin"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.mr3y.podcaster.gradle

import com.android.build.api.dsl.TestExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.getByType

class AndroidTestConventionPlugin : Plugin<Project> {

override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.test")
pluginManager.apply("org.jetbrains.kotlin.android")
pluginManager.apply("org.jlleitschuh.gradle.ktlint")

val extension = extensions.getByType<TestExtension>()
configureAndroidTestExtension(extension)
}
}

private fun Project.configureAndroidTestExtension(
testExtension: TestExtension
) {
testExtension.apply {
compileSdk = libs.findVersion("compileSdk").get().toString().toInt()

defaultConfig {
minSdk = 28 // Generating baseline profiles isn't supported on devices running Android API 27 and lower.
targetSdk = libs.findVersion("targetSdk").get().toString().toInt()

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}

configureKotlin()
configureKtlint()
}
}
10 changes: 10 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ datastore = "1.1.1"
aboutlibraries = "11.2.2"
appversioning = "1.3.2"
gpp = "3.10.1"
uiautomator = "2.3.0"
benchmarkMacroJunit4 = "1.2.4"
baselineprofile = "1.2.4"
profileinstaller = "1.3.1"

[libraries]
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
Expand Down Expand Up @@ -129,6 +133,9 @@ datastore-pref = { group = "androidx.datastore", name = "datastore-preferences",
aboutlibraries-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
xmlutil-core = { group = "io.github.pdvrieze.xmlutil", name = "core", version.ref = "xmlutil" }
xmlutil-serialization = { group = "io.github.pdvrieze.xmlutil", name = "serialization", version.ref = "xmlutil" }
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }

# Convention plugins dependencies
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
Expand All @@ -155,8 +162,11 @@ play-publisher = { id = "com.github.triplet.play", version.ref = "gpp" }
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
podcaster-android-app = { id = "podcaster.android.application", version = "unspecified" }
podcaster-android-lib = { id = "podcaster.android.library", version = "unspecified" }
podcaster-android-test-lib = { id = "podcaster.android.test.library", version = "unspecified" }
podcaster-compose-android-lib = { id = "podcaster.android.compose.library", version = "unspecified" }
podcaster-jvm-lib = { id = "podcaster.jvm.library", version = "unspecified" }
android-test = { id = "com.android.test", version.ref = "agp" }
baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }

[bundles]
androidx-core = [
Expand Down
Loading

0 comments on commit dfd6cff

Please sign in to comment.