Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ plugins {
id 'kotlin-parcelize'
id 'kotlinx-serialization'
alias(libs.plugins.compose.compiler)
alias(libs.plugins.baselineprofile)
}

// Copy the signing.properties.sample file to ~/.sign/signing.properties and adjust the values.
Expand Down Expand Up @@ -82,6 +83,12 @@ android {
maybeCreate(name).res.srcDirs += 'src/extra/res'
}

// The build types created by the Baseline Profile plugin are release builds, so give them the release-only sources.
['nonMinifiedRelease', 'benchmarkRelease'].forEach { name ->
maybeCreate(name).java.srcDirs += 'src/release/java'
maybeCreate(name).kotlin.srcDirs += 'src/release/java'
}

maybeCreate('androidTest').assets.srcDirs += files("$projectDir/schemas".toString())
}

Expand Down Expand Up @@ -162,6 +169,17 @@ ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}

// See baselineprofile/README.md for how the Baseline and Startup Profiles are generated and maintained.
baselineProfile {
// Profiles are generated from a single flavor (alpha) and shared by all flavors, since the code is the same.
mergeIntoMain = true
saveInSrc = true
// Generating requires a device, so it must be run explicitly instead of during every release build.
automaticGenerationDuringBuild = false
// Uses the startup profile to put the classes needed at startup into the primary DEX file.
dexLayoutOptimization = true
}

apply from: '../gradle/src/ktlint.gradle'

dependencies {
Expand Down Expand Up @@ -256,6 +274,11 @@ dependencies {
debugImplementation libs.leakcanary.android
implementation libs.plumber.android

// Baseline and Startup Profiles: profileinstaller applies the profile on devices that don't
// get it from the Play Store, and the baselineprofile module generates the profile.
implementation libs.androidx.profileinstaller
baselineProfile project(':baselineprofile')

testImplementation libs.junit
testImplementation libs.mockk
testImplementation libs.robolectric
Expand Down
37,826 changes: 37,826 additions & 0 deletions app/src/main/generated/baselineProfiles/baseline-prof.txt

Large diffs are not rendered by default.

30,319 changes: 30,319 additions & 0 deletions app/src/main/generated/baselineProfiles/startup-prof.txt

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions baselineprofile/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Baseline Profile and Startup Profile

This module generates the profiles that make the app start and run faster. The generated files are
committed to the app module:

- `app/src/main/generated/baselineProfiles/baseline-prof.txt`: the **Baseline Profile**. It lists
the classes and methods used on startup and on the most common journeys (browsing the feed,
reading an article).
- `app/src/main/generated/baselineProfiles/startup-prof.txt`: the **Startup Profile**. It lists only
the classes and methods used during a cold start into the home feed.

## Why they help

Our code ships as DEX bytecode. Without a profile, a freshly installed or updated app runs mostly in
ART's interpreter and JIT, and is only compiled ahead of time once the device has collected enough
usage data, which can take days. Until then, startup and first-run jank are noticeably worse.

- The **Baseline Profile** is packaged into the APK/AAB (as `assets/dexopt/baseline.prof`). The Play
Store and `profileinstaller` use it to AOT-compile the listed code at install time, so it's fast
from the very first launch.
- The **Startup Profile** is used at build time by R8 (`dexLayoutOptimization = true` in
`app/build.gradle`). R8 puts the startup classes into the primary `classes.dex` file and orders
them, so startup touches fewer DEX files and fewer memory pages.

Neither profile changes behavior: if a profile is missing or out of date, the app still works, just
with less of a speed benefit. Rules for classes that no longer exist are ignored.

## How to regenerate

Do this periodically (e.g. before each release, or after significant changes to startup, the home
feed, or the article screen), and commit the updated files.

1. Run:
```
./gradlew :app:generateBaselineProfile
```
This builds the `alphaNonMinifiedRelease` variant, boots a dedicated headless emulator (the
`pixel6Api35` Gradle Managed Device, defined in `baselineprofile/build.gradle.kts`), runs
`BaselineProfileGenerator` on it, and copies the result into
`app/src/main/generated/baselineProfiles/`. The same profile is used by all flavors. The first run
downloads the API 35 system image if you don't already have it. The whole run takes a few minutes.
2. Check that both files were actually updated. If the app couldn't be installed, or no device was
available, the task can still report `BUILD SUCCESSFUL` without generating anything (look for
"No baseline profile rules were generated" in the output).
3. Review the diff (it's expected to be large and noisy) and commit it.

To generate on a device or emulator that's already running instead, add `-PgenerateOnConnectedDevice`.
That device must run **API 33+** (or be rooted on API 28+), be set to **English**, be online, and have
enough free storage for the app.

## How to verify the benefit

`StartupBenchmarks` measures cold startup with no precompilation versus with the Baseline Profile.
Run it on a **physical device** (emulator timings aren't representative):
```
./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest \
-P android.testInstrumentationRunnerArguments.class=org.wikipedia.baselineprofile.StartupBenchmarks
```
Compare `timeToInitialDisplayMs` and `timeToFullDisplayMs` between the two tests in the output, or in
`baselineprofile/build/outputs/connected_android_test_additional_output/`.

To check that a release build contains the profile, look for `assets/dexopt/baseline.prof` in the
APK, or in the AAB under `BUNDLE-METADATA/com.android.tools.build.profiles/`.

## How to maintain the journeys

The journeys live in `AppJourneys.kt`, and are run by `BaselineProfileGenerator`:

- `startup()` uses `includeInStartupProfile = true`, so its code goes into both profiles. Keep it
limited to what happens on a normal cold start; anything else in the startup profile pushes
real startup classes out of the primary DEX file.
- `browseAndRead()` covers the most common journeys after startup, and only goes into the Baseline
Profile. This is the place to add other important journeys (e.g. search, or the Places map).

The journeys find UI elements with UI Automator, by English text, content description, or view ID.
If a generation run fails with a timeout, the UI has probably changed; update the selectors in
`AppJourneys.kt`. `skipOnboarding()` gets past the first-run screens before collection starts, so
that onboarding code isn't treated as part of startup.
71 changes: 71 additions & 0 deletions baselineprofile/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

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

android {
namespace = "org.wikipedia.baselineprofile"
compileSdk = 37

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

defaultConfig {
// Generating a profile requires API 33+, or API 28+ on a rooted device/emulator.
minSdk = 28
targetSdk = 37
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

// Profiles are generated against the alpha flavor only, and merged into the app's main source set.
missingDimensionStrategy("default", "alpha")
}

targetProjectPath = ":app"

testOptions.managedDevices.localDevices {
create("pixel6Api35") {
device = "Pixel 6"
apiLevel = 35
systemImageSource = "google_apis_playstore"
}
}
}

kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}

// By default, profiles are generated on a dedicated Gradle Managed Device, so that the results don't depend on the
// state of a developer's device. Pass -PgenerateOnConnectedDevice to use a connected device or emulator instead.
val generateOnConnectedDevice = providers.gradleProperty("generateOnConnectedDevice").isPresent

baselineProfile {
useConnectedDevices = generateOnConnectedDevice
if (!generateOnConnectedDevice) {
managedDevices += "pixel6Api35"
}
}

apply(from = "../gradle/src/ktlint.gradle")

dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
}

androidComponents {
onVariants { variant ->
val artifactsLoader = variant.artifacts.getBuiltArtifactsLoader()
variant.instrumentationRunnerArguments.put(
"targetAppId",
variant.testedApks.map { artifactsLoader.load(it)?.applicationId }
)
}
}
2 changes: 2 additions & 0 deletions baselineprofile/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package org.wikipedia.baselineprofile

import android.content.Intent
import android.net.Uri
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import java.util.regex.Pattern

// These journeys look for English UI strings, so the device should be set to English.
private const val UI_TIMEOUT_MILLIS = 10_000L
private const val FEED_TIMEOUT_MILLIS = 30_000L
private const val MAX_ONBOARDING_SCREENS = 5
private val FEED_CARD_HEADERS = Pattern.compile("Featured article|Top read|In the news|On this day|Picture of the day")
private const val ARTICLE_URL = "https://en.wikipedia.org/wiki/Earth"

val targetPackageName: String
get() = InstrumentationRegistry.getArguments().getString("targetAppId")
?: throw IllegalStateException("targetAppId not passed as an instrumentation runner argument")

/**
* Gets a freshly installed app past the first-run onboarding and personalization screens, so that
* subsequent launches go straight to the home feed like they do for returning users. This runs
* outside of profile collection so that onboarding code isn't part of the startup profile.
*/
fun skipOnboarding(packageName: String) {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
device.pressHome()
// Launcher intent filters have no DEFAULT category, so the launcher component must be resolved before starting it.
val launcherComponent = device.executeShellCommand(
"cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER $packageName"
).lines().last { it.isNotBlank() }.trim()
device.executeShellCommand("am start -W -n $launcherComponent")

if (device.wait(Until.hasObject(By.desc("Forward")), UI_TIMEOUT_MILLIS)) {
repeat(MAX_ONBOARDING_SCREENS) {
device.findObject(By.desc("Forward"))?.click() ?: return@repeat
device.waitForIdle()
}
device.wait(Until.findObject(By.text("Skip")), UI_TIMEOUT_MILLIS)?.click()
device.wait(Until.hasObject(By.text(FEED_CARD_HEADERS)), FEED_TIMEOUT_MILLIS)
}
device.executeShellCommand("am force-stop $packageName")
}

fun MacrobenchmarkScope.waitForHomeFeed() {
check(device.wait(Until.hasObject(By.text(FEED_CARD_HEADERS)), FEED_TIMEOUT_MILLIS)) {
"Home feed did not load. Make sure the device is online and onboarding was skipped."
}
}

fun MacrobenchmarkScope.scrollHomeFeed() {
val feed = device.findObject(By.scrollable(true).hasDescendant(By.text(FEED_CARD_HEADERS))) ?: return
feed.setGestureMargin(device.displayWidth / 5)
repeat(3) {
feed.fling(Direction.DOWN)
device.waitForIdle()
}
}

fun MacrobenchmarkScope.readArticle() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ARTICLE_URL)).setPackage(packageName)
startActivityAndWait(intent)
val webView = device.wait(Until.findObject(By.res(packageName, "page_web_view")), UI_TIMEOUT_MILLIS) ?: return
device.waitForIdle()
webView.setGestureMargin(device.displayWidth / 5)
repeat(3) {
webView.fling(Direction.DOWN)
device.waitForIdle()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.wikipedia.baselineprofile

import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Generates the app's Baseline Profile and Startup Profile. Run with:
*
* ./gradlew :app:generateBaselineProfile
*
* The results are written to app/src/main/generated/baselineProfiles/, and should be committed.
* See baselineprofile/README.md for details.
*/
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {

@get:Rule
val rule = BaselineProfileRule()

@Before
fun setUp() {
skipOnboarding(targetPackageName)
}

/**
* The cold-start path of a returning user: launching the app into the home feed. The code
* exercised here goes into the startup profile (used for DEX layout) as well as the baseline profile.
*/
@Test
fun startup() = rule.collect(
packageName = targetPackageName,
includeInStartupProfile = true
) {
pressHome()
startActivityAndWait()
waitForHomeFeed()
}

/**
* The most common journeys after startup. These are added to the baseline profile only,
* so that they're precompiled, without crowding the startup classes out of the primary DEX file.
*/
@Test
fun browseAndRead() = rule.collect(
packageName = targetPackageName,
includeInStartupProfile = false
) {
pressHome()
startActivityAndWait()
waitForHomeFeed()
scrollHomeFeed()
readArticle()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package org.wikipedia.baselineprofile

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 org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Measures cold startup time with and without the Baseline Profile, to verify that it helps.
* Run on a physical device for meaningful numbers:
*
* ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest \
* -P android.testInstrumentationRunnerArguments.class=org.wikipedia.baselineprofile.StartupBenchmarks
*/
@RunWith(AndroidJUnit4::class)
class StartupBenchmarks {

@get:Rule
val rule = MacrobenchmarkRule()

@Before
fun setUp() {
skipOnboarding(targetPackageName)
}

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

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

private fun benchmark(compilationMode: CompilationMode) = rule.measureRepeated(
packageName = targetPackageName,
metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode,
startupMode = StartupMode.COLD,
iterations = 10,
setupBlock = { pressHome() }
) {
startActivityAndWait()
waitForHomeFeed()
}
}
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.baselineprofile) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp.plugin) apply false
alias(libs.plugins.gms.plugin) apply false
Expand Down
Loading
Loading