> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mobileboost.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Prepare builds for performance testing

> Set up your iOS and Android CI so performance reports can name the functions that got slower

A performance report compares your pull request build with the build it merges
into, on the same real phone. With the build file alone, the report shows how
long startup and each flow took, the operating system's own startup phases, and
what changed in your network requests.

To also see **which functions got slower**, for example
`AnalyticsClient.<init>: 6 ms -> 188 ms`, MobileBoost records sampled CPU
profiles while your app runs and needs two things from your CI to read them:

| | Android | iOS |
| - | - | - |
| Lets us profile the release build | `<profileable android:shell="true" />` in the manifest of the build you upload | Nothing. MobileBoost re-signs the build for its devices, which allows profiling |
| Turns addresses into function names | The R8 `mapping.txt` of that exact build | The dSYM files of that exact build |

Both are a one-time change to your build setup plus one upload step. Most teams
already produce the symbol files for Google Play or a crash reporter.

## What every performance build needs

* **Release-like.** Build with the same compiler optimisation, minification and
  SDKs as production. A debug build is several times slower in places your users
  never see, so its numbers would mislead you.
* **The same setup for both sides.** The base build (your `main` branch) and the
  head build (the pull request) have to come from the same build type and flags,
  or the report measures your build configuration instead of your change. Build
  `main` with the same job on every merge so a base build always exists.
* **Its own symbol files.** Symbols only match the build they were produced
  with. Upload the mapping file or dSYMs from the same CI job as the build,
  never from an earlier one.
* **The backend your tests already use.** Performance runs use the same
  environment as your functional tests. Recorded responses are replayed for the
  mocked network runs, and the live runs talk to that backend.

## Android

**1. Add a perf build type**

Copy your release build type, so the performance build is optimised and
minified exactly like production, but sign it with your debug key so CI does
not need the upload key.

```kotlin app/build.gradle.kts theme={null}
android {
    buildTypes {
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
        }
        create("perf") {
            initWith(getByName("release"))
            signingConfig = signingConfigs.getByName("debug")
            // Library modules have no "perf" build type: use their release.
            matchingFallbacks += listOf("release")
        }
    }
}
```

If your release build type sets `isDebuggable = true`, leave it out of
`perf`. A debuggable build runs slower than what your users get, so its
numbers would not be representative.

**2. Make only the perf build profileable**

Create a manifest that only the `perf` build type merges in. Your production
release stays unchanged.

```xml app/src/perf/AndroidManifest.xml theme={null}
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application>
        <profileable
            android:shell="true"
            tools:targetApi="29" />
    </application>
</manifest>
```

`profileable` lets profiling tools sample a release build without the
overhead of a debug build. It takes effect on Android 10 (API 29) and later,
which covers every MobileBoost device.

**3. Keep the R8 mapping file**

R8 renames your classes and methods in release builds, so a profile shows
names like `a.b.c()` until it is mapped back. The mapping is written next to
the build:

```text theme={null}
app/build/outputs/mapping/perf/mapping.txt
```

Keep obfuscation on in `perf`, as in production. Turning it off with
`-dontobfuscate` saves the upload step but also changes the code you are
measuring.

**4. Ship your Baseline Profile with the build**

If your app has a Baseline Profile, add the Profile Installer library so the
profile is applied when MobileBoost installs the APK directly. Google Play
applies it for your users; a direct install does not, and startup would
look slower than it is for them.

```kotlin app/build.gradle.kts theme={null}
dependencies {
    implementation("androidx.profileinstaller:profileinstaller:1.4.1")
}
```

**5. Build an APK**

MobileBoost installs APKs. Build the perf variant:

```bash theme={null}
./gradlew :app:assemblePerf
# -> app/build/outputs/apk/perf/app-perf.apk
```

If your pipeline only produces an App Bundle, turn it into one universal APK
with `bundletool`:

```bash theme={null}
bundletool build-apks --mode=universal \
  --bundle=app/build/outputs/bundle/perf/app-perf.aab \
  --output=app-perf.apks
unzip -p app-perf.apks universal.apk > app-perf.apk
```

**6. Check the build before uploading**

`apkanalyzer` ships with the Android SDK command-line tools:

```bash theme={null}
# Must print: false
apkanalyzer manifest debuggable app/build/outputs/apk/perf/app-perf.apk

# Must show the profileable element with shell="true"
apkanalyzer manifest print app/build/outputs/apk/perf/app-perf.apk | grep -A2 profileable
```

### GitHub Actions for Android

Run the same job on pull requests and on every merge to `main`, so each pull
request has a base build to compare against.

```yaml .github/workflows/mobileboost-perf-android.yml theme={null}
name: MobileBoost performance build (Android)

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  perf-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"

      - uses: gradle/actions/setup-gradle@v4

      - name: Build the perf variant
        run: ./gradlew :app:assemblePerf

      - name: Check the build is profileable and not debuggable
        run: |
          APKANALYZER="$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer"
          APK=app/build/outputs/apk/perf/app-perf.apk
          test "$($APKANALYZER manifest debuggable "$APK")" = "false"
          $APKANALYZER manifest print "$APK" | grep -q 'android:shell="true"'

      - uses: MobileBoostHQ/actions/upload-build@v1
        with:
          api-key: ${{ secrets.MOBILEBOOST_API_KEY }}
          organisation-id: ${{ vars.MOBILEBOOST_ORG_ID }}
          build-path: app/build/outputs/apk/perf/app-perf.apk
          # The symbols of this exact build.
          symbols-path: app/build/outputs/mapping/perf/mapping.txt
          # Which commit this is and which one it merges into.
          metadata: >-
            {"purpose": "performance",
             "commit": "${{ github.event.pull_request.head.sha || github.sha }}",
             "branch": "${{ github.head_ref || github.ref_name }}",
             "baseCommit": "${{ github.event.pull_request.base.sha }}"}
```

## iOS

**1. Build the Release configuration for a device**

Profile the Release configuration, or a `Perf` configuration duplicated from
it (**Project** > **Info** > **Configurations** > **+** > **Duplicate
"Release" Configuration**). Use a separate configuration only if you need to
switch something off for performance runs, see
[What changes after re-signing](#what-changes-after-re-signing).

The build must be for a real device (`iphoneos`). Simulator builds run on the
Mac's CPU and say nothing about phone performance.

**2. Produce dSYMs for that configuration**

In the target's **Build Settings**, set **Debug Information Format** to
**DWARF with dSYM File** for the configuration you upload. New projects
already do this for Release; check targets that were added later, and
frameworks you build from source.

```text theme={null}
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym
```

Stripping the app binary is fine, and it is what production does: the dSYM
keeps the symbols. For binary frameworks from vendors, symbols exist only if
the vendor ships dSYMs. Their code then shows as an address with the
framework's name, which still tells you whose code it is.

**3. Archive and export an IPA**

Use the export method your CI already uses for internal testers (Ad Hoc,
called Release Testing in Xcode 15 and later, or Development). MobileBoost
re-signs the app for its own devices, so the export method does not change
what is measured.

```bash theme={null}
xcodebuild archive \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  -archivePath build/App.xcarchive

xcodebuild -exportArchive \
  -archivePath build/App.xcarchive \
  -exportOptionsPlist ExportOptions.plist \
  -exportPath build/export
```

**4. Collect the dSYMs from the archive**

The archive holds the dSYMs of the app and of every embedded framework built
with it:

```bash theme={null}
cd build/App.xcarchive && zip -r ../../dSYMs.zip dSYMs && cd -
```

**5. Check that the dSYM matches the app**

Each build has its own UUID, and a dSYM only works for the binary with the
same one:

```bash theme={null}
dwarfdump --uuid build/App.xcarchive/Products/Applications/App.app/App
dwarfdump --uuid build/App.xcarchive/dSYMs/App.app.dSYM
```

Both commands must print the same UUID.

### What changes after re-signing

MobileBoost re-signs your IPA with its own team so it installs on MobileBoost
devices and can be profiled. Re-signing keeps the app identifier, the team
identifier, `get-task-allow` and your keychain access groups, and drops every
other entitlement. Plan your performance build around that:

* **Push notifications, App Groups, associated domains and other capabilities
  are not available.** Flows that depend on them will not complete on a
  performance run.

* **Integrity checks that verify your own signature must be off.** Jailbreak
  detection, code-signature checks or a check of the team ID make a re-signed
  build quit or behave differently. Switch them off in a `Perf` configuration
  with a compilation condition:

  ```text theme={null}
  SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PERF_BUILD
  ```

  ```swift theme={null}
  #if !PERF_BUILD
  IntegrityGuard.verifySignature()
  #endif
  ```

* **Keep your other SDKs as in production.** Analytics, crash reporting and
  feature flag SDKs are part of what your users wait for, and measuring them is
  the point.

### GitHub Actions for iOS

```yaml .github/workflows/mobileboost-perf-ios.yml theme={null}
name: MobileBoost performance build (iOS)

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  perf-build:
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v4

      # Your existing steps that install the signing certificate and
      # provisioning profile used for internal builds.

      - name: Archive the Release configuration
        run: |
          xcodebuild archive \
            -workspace App.xcworkspace \
            -scheme App \
            -configuration Release \
            -destination 'generic/platform=iOS' \
            -archivePath build/App.xcarchive

      - name: Export the IPA
        run: |
          xcodebuild -exportArchive \
            -archivePath build/App.xcarchive \
            -exportOptionsPlist ExportOptions.plist \
            -exportPath build/export

      - name: Collect the dSYMs and check they match the app
        run: |
          APP_UUID=$(dwarfdump --uuid build/App.xcarchive/Products/Applications/App.app/App | awk '{print $2}' | sort)
          DSYM_UUID=$(dwarfdump --uuid build/App.xcarchive/dSYMs/App.app.dSYM | awk '{print $2}' | sort)
          test "$APP_UUID" = "$DSYM_UUID"
          (cd build/App.xcarchive && zip -qr ../../dSYMs.zip dSYMs)

      - uses: MobileBoostHQ/actions/upload-build@v1
        with:
          api-key: ${{ secrets.MOBILEBOOST_API_KEY }}
          organisation-id: ${{ vars.MOBILEBOOST_ORG_ID }}
          build-path: build/export/*.ipa
          # The symbols of this exact build.
          symbols-path: dSYMs.zip
          # Which commit this is and which one it merges into.
          metadata: >-
            {"purpose": "performance",
             "commit": "${{ github.event.pull_request.head.sha || github.sha }}",
             "branch": "${{ github.head_ref || github.ref_name }}",
             "baseCommit": "${{ github.event.pull_request.base.sha }}"}
```

## How MobileBoost handles your symbol files

Mapping files and dSYMs describe the structure of your code. MobileBoost uses
them only to put function names on the profiles of the build they came with,
and keeps them with that build.

## Checklist

| Check | Android | iOS |
| - | - | - |
| Optimised like production | `perf` build type copied from `release`, minified | Release configuration, device build |
| Not debuggable | `apkanalyzer manifest debuggable` prints `false` | Release configuration |
| Profileable | `<profileable android:shell="true" />` in `src/perf` | Given by re-signing |
| Symbols of this exact build | `mapping/perf/mapping.txt` from the same job | dSYMs from the same archive, UUIDs match |
| Base build for every pull request | Same job runs on merges to `main` | Same job runs on merges to `main` |
| Works after re-signing | Not applicable | Integrity checks off, no flow needs push or App Groups |

## Exact timings for your own milestones

With the setup above, flows end when the screen stops changing and profiles are
sampled. To measure exactly from and to the points you choose, mark them in your
code: `reportFullyDrawn()` or `trace("name") { ... }` on Android, and
`os_signpost` on iOS. The report then shows those spans by name.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.