Baseline Profiles have been covered before, but we wanted to go beyond theory. After a year of building a high-traffic sports app at STRV, we saw firsthand how much impact they can have. This article shares real lessons, real results… and maybe even some inspiration for your own projects.
Quick Intro to the App
The app is the official native Android app for a major sports organization. Launched in February 2025, it delivers fixtures, standings, stats, videos, news and more. It has complex UIs built on multiple reactive data sources.
Architecture Note:
The app relies on internally developed SDKs handling authentication, content delivery and stats aggregation. These preload large amounts of data at startup, increasing launch performance pressure — making Baseline Profiles especially valuable.
Technical Dive/Recap
Let’s walk through what happens behind the scenes when generating Baseline Profiles — and how they’re used afterwards.
AOT vs. JIT Compilation
Baseline Profiles are an optimization tool for Android apps that take advantage of AOT (ahead-of-time) compilation for defined user flows. This avoids the JIT (just-in-time) compilation delays that would otherwise affect performance.
You might be wondering:
“I already built and compiled the app on my machine, and I’m distributing it as an APK or bundle — so why are we talking about compilation again?”
Good question.
When you build the app, you compile your code into DEX (Dalvik Executable) bytecode, which is packaged into the APK or Android App Bundle. But DEX bytecode isn’t native machine code — the device’s processor can’t run it directly.
To bridge that gap, the system translates DEX bytecode into native machine code at install time or runtime, using one of two methods:
Without Baseline Profiles, JIT with code profiling is the primary mechanism to optimize performance, meaning you get slower initial launches as the system figures things out on the fly.
Note: JIT with code profiling was introduced to save storage space and speed up application/system updates.
Reference (Android Developers):
“In Android 7.0, we've added a Just in Time (JIT) compiler with code profiling to ART, which lets it constantly improve the performance of Android apps as they run. The JIT compiler complements ART's current Ahead of Time (AOT) compiler and helps improve runtime performance, save storage space, and speed up app updates and system updates.”
(More here.)
So yes, Android already does optimization — but we want it from the very first launch. First impressions matter.
Baseline Profile = Guided AOT
Baseline Profiles are essentially guided AOT compilation. You define a user flow (journey), similar to a scripted UI test, marking the paths most crucial to your app’s experience. The Baseline Profile generator then tracks the classes and methods used during that journey and saves them to baseline-prof.txt
.
At install time, the system compiles those classes/methods into native machine code. That way, when the user hits those paths, there’s no need for JIT — and no JIT delay.
Generating a Baseline Profile
Setup of the Generator: rule.collect { }
In the snippet below, you can see how we defined the user flow used to collect the Baseline Profile.
@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generateBaselineProfile() = rule.collect(
packageName = TARGET_PACKAGE,
maxIterations = 15, // default value
stableIterations = 3, // default value
) {
startApplicationJourney()
homeToMatchDetailJourney()
matchDetailToClubDetailJourney()
}
}
Example: homeToMatchDetailJourney()
Here’s a simplified example of a typical user journey we used in our benchmarks — scrolling through the home screen, opening a detail screen and interacting with content, all while ensuring real data loads in time.
fun MacrobenchmarkScope.homeToMatchDetailJourney() {
val homeScreenScrollArea = By.res("home_scroll_area")
val scheduledMatch = By.res("scheduled_match")
val matchDetailScrollArea = By.res("match_detail_scroll_area")
with(device) {
// Simulate user scrolling on the home screen
waitFor(homeScreenScrollArea) {
it.scroll(Direction.DOWN, 0.8f)
it.scroll(Direction.UP, 0.8f)
}
// Click on a scheduled match item to open detail screen
waitForAndClick(
selector = scheduledItem,
timeout = 60_000, // Allow extra time for real server data to load
)
// Simulate user scrolling on the match detail screen
waitFor(matchDetailScrollArea) {
it.scroll(Direction.DOWN, 0.8f)
it.scroll(Direction.UP, 0.8f)
}
}
}
Custom Journey for BaselineProfileGenerator
For the custom journey, we decided to navigate through these screens:
- HomeScreen (start destination after authentication)
- MatchDetailScreen
- ClubDetailScreen
On each, we perform scrolling actions to simulate real user behavior.
Note: When triggering Baseline Profile generation in CI, we had to fake authentication, since the entire app is gated behind login.
Result: baseline-prof.txt
The baseline-prof.txt
file is generated in the src/main/ directory and contains a list of all classes and methods executed during the defined user flow. In our case, the file ended up with around 75,000 entries — many referencing familiar methods and classes from our codebase.
It’s essentially a snapshot of what the runtime touches, giving a clear picture of what will be optimized.
Example (yes, it’s intentionally messy):
HSPLandroidx\/activity\/ComponentActivity;-\>getLifecycle\(\)Landroidx\/lifecycle\/Lifecycle;
HSPLandroidx\/activity\/ComponentActivity;-\>getOnBackPressedDispatcher\(\)Landroidx\/activity\/OnBackPressedDispatcher;
HSPLandroidx\/activity\/ComponentActivity;-\>getSavedStateRegistry\(\)Landroidx\/savedstate\/SavedStateRegistry;
HSPLandroidx\/activity\/ComponentActivity;-\>getViewModelStore\(\)Landroidx\/lifecycle\/ViewModelStore;
HSPLandroidx\/activity\/ComponentActivity;-\>initializeViewTreeOwners\(\)V
HSPLandroidx\/activity\/ComponentActivity;-\>onCreate\(Landroid\/os\/Bundle;\)V
HSPLandroidx\/activity\/ComponentActivity;-\>registerForActivityResult\(Landroidx\/activity\/result\/contract\/ActivityResultContract;Landroidx\/activity\/result\/ActivityResultCallback;\)Landroidx\/activity\/result\/ActivityResultLauncher;
This file is then packaged into the app’s APK or app bundle.
Tip: You can spot key methods like ComponentActivity.onCreate
, meaning they’ll be precompiled.
Comparing baseline-prof.txt
Files
It’s possible to create several Baseline Profile generation test methods, each producing its own baseline-prof.txt
— and then merge them.
While comparing these files can help highlight redundant or missing code paths, it’s often time-consuming. At minimum, you can check (for your own peace of mind) that the expected methods/classes in the data layer show up in the file and will be precompiled.
Using a Baseline Profile
Comparing Installed App Package Size
Here’s what we saw when comparing the installed app package sizes on an Android device:
- With Baseline Profiles (home → match → club): 198MB
- With Baseline Profiles (home → match): 198MB
- Without Baseline Profiles: 176MB
- Forced full AOT: 387MB (for the curious)
While the app size increases with Baseline Profiles, the performance gains often outweigh the trade-off — especially for apps with complex UIs or critical user flows. (And no, we don’t want full AOT because of the reasons mentioned earlier.)
Comparing baseline-prof.txt
with Shorter/Longer Journeys
When we compared the generated baseline-prof.txt
covering only Home → MatchDetail vs. Home → MatchDetail → ClubDetail, the file sizes were basically the same.
Why? Because a lot of code is reused — for example, club data is often preloaded on earlier screens. So even though we navigate deeper in the journey, many of the methods and classes have already been touched on earlier.
That said, the generated profiles still differ. You could spend time analyzing those ~75k-line files, but we decided to stick with the longer journey to ensure at least three NavDestinations
were covered.
Custom Journey Benchmarks
Without Baseline Profiles:
CopyEdit
frameCount (min/median/max): 5,146 / 5,267 / 5,838
frameDurationCpuMs (P50/P90/P95/P99): 13.4 / 20.3 / 28.4 / 35.6
frameOverrunMs (P50/P90/P95/P99): 1.4 / 12.5 / 16.3 / 54.5
With Baseline Profiles:
CopyEdit
frameCount (min/median/max): 5,577 / 5,682 / 5,745
frameDurationCpuMs (P50/P90/P95/P99): 7.2 / 15.8 / 17.5 / 32.1
frameOverrunMs (P50/P90/P95/P99): -6.8 / 2.2 / 10.2 / 56.2
We saw a clear reduction in frameDurationCpuMs
with Baseline Profiles, especially at the P50 level — meaning smoother animations and interactions.
The negative P50 value in frameOverrunMs
suggests more efficient frame rendering, where frames were completing faster than the 16.67ms target for 60 FPS.
Benchmark definitions (for clarity):
frameDurationCpuMs
: Total CPU time (UI + RenderThread) to produce a single frame. Lower is better — less CPU work per frame.PXX
: Indicates that XX% of frames took this time or less.frameOverrunMs
: How much a frame missed its rendering deadline by (in milliseconds). Negative values = faster-than-deadline.
Startup Benchmarks
With Baseline Profiles:
timeToInitialDisplayMs (min/median/max): 1,136.7 / 1,380.4 / 1,688.8
Without Baseline Profiles:
CopyEdittimeToInitialDisplayMs (min/median/max): 1,342.1 / 1,414.5 / 1,791.1
Baseline Profiles noticeably improved startup times, with a lower median timeToInitialDisplayMs
— a clear win.
Baselines on CI
Why Automate?
We didn’t want to waste time generating baseline profiles locally when we could automate the process.
How It Works
On Codemagic, we trigger the Generate Baseline Profiles
workflow manually (this could be set to run on every main branch change or biweekly — but for now, manual fits our workflow).
From a black-box view:
- Input → select the branch and workflow to trigger
- Magic happens → Codemagic runs everything
- Output → a new pull request on GitLab, with an updated
baseline-prof.txt
file as the only change
Known Challenges
Forced Login
The app has a mandatory login screen (client requirement), so you need to be authenticated or you’re stuck at the Auth screen.
Solution: Use a FakeUserService
to simulate a logged-in user during baseline builds, with only minimal user data.
Third-Party Services
We have multiple third-party integrations (analytics, notifications, feature flags, remote config, etc.). These can break things during generation.
Solution: Write fake implementations and swap them in via dependency injection (DI). This required refactoring some direct service calls into interfaces with at least two implementations:
- Real (for production)
- Fake (for baseline profiles + UI tests)
Example: Remote Config
We enforce a minimum supported Android version due to forced updates. Locally, if you don’t set the version in local.properties
, it defaults to 1.0.0
— which is lower than the required minimum, causing the build to hang on an “Update needed” screen.
Workarounds:
- Write a Fake Remote Config that returns a minimum version lower than
1.0.0
- (Less clean) Disable the forced update for baseline profile builds
Scheduled Matches Problem
Our journey starts on the Home screen, selecting the second scheduled match → MatchDetail → ClubDetail. But only certain matches allow navigation to ClubDetail (i.e., between supported league clubs).
Solution: Filter for matches where both home and away clubs have available ClubDetail screens — done in CI or via a specific build variant.
Script + Environment Insights
Build machine
- Needs to be Linux (Mac M2 machines can’t run Android emulators on Codemagic due to lack of Apple-Android support)
Credentials setup
GITLAB_TOKEN
: auth token for creating merge requestsGITLAB_PROJECT_ID
: static project ID from GitLab
These are defined in Codemagic’s environment settings.
CI Script Steps (Overview)
- Set up Android SDK location (boilerplate from Codemagic docs).
- Install GPG (for decrypting secrets).
- Extract secrets (API keys, etc.).
- Clear unused Gradle Managed Devices (to keep the build environment clean).
- Generate Baseline Profile (
./gradlew :app-mobile:generateBaselineProfile
) with flags to minimize retained snapshots and suppress emulator UI. - Prepare shared CI variables (timestamp, branch name).
- Create and push change. (Configure Git, check out a new branch, add
baseline-prof.txt
if it exists, commit, and push.) - Create merge request via GitLab REST API.
Example YAML snippet
generate_baseline_profiles:
name: Generate Baselines Profiles
max_build_duration: 120
instance_type: linux
environment:
groups:
- sportsapp_credentials
- gitlab_credentials
scripts:
- *set_android_sdk_location
- *install_gpg
- *extract_sportsapp_secrets
- name: Clear unused Gradle Managed Devices
script: |
./gradlew cleanManagedDevices --unused-only
- name: Generate Baseline Profile
script: |
set -e
./gradlew :app-mobile:generateBaselineProfile \
-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile \
-Pandroid.experimental.testOptions.managedDevices.emulator.show=false \
-Pandroid.experimental.testOptions.managedDevices.snapshot.maxRetainedSnapshots=0
- name: Prepare shared CI variables
script: |
TIMESTAMP=$(date +%Y-%m-%d_%H-%M)
BRANCH_NAME="ci/baselines_profiles"
echo "TIMESTAMP=${TIMESTAMP}" >> $CM_ENV
echo "BRANCH_NAME=${BRANCH_NAME}" >> $CM_ENV
- name: Create and Push Change
script: |
git config user.email "ci-baseline-profile@strv.com"
git config user.name "CI Baseline Profile"
git checkout -b "$BRANCH_NAME"
if [ -f app-mobile/src/main/generated/baselineProfiles/baseline-prof.txt ]; then
git add app-mobile/src/main/generated/baselineProfiles/baseline-prof.txt
fi
git commit -m "chore: generate baseline profiles ${TIMESTAMP}"
git remote set-url origin https://oauth2:${GITLAB_TOKEN}@gitlab.com/your-org/your-repo.git
git push -u origin "$BRANCH_NAME"
- name: Create Merge Request
script: |
curl --request POST "https://gitlab.com/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests" \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--form "source_branch=$BRANCH_NAME" \
--form "target_branch=main" \
--form "title=CI: baselines update [$TIMESTAMP]"
And that’s it! Once the workflow runs successfully, you’ll see a new merge request in your GitLab repo, created automatically from CI.
Final Note
Baseline Profiles aren’t magic, but they make a real difference. For apps with complex user journeys and heavy data use, they’re one of the best (and often overlooked) ways to improve performance right from the start.
Any questions or want to swap notes? Reach out to us at hello@strv.com.