> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/quality-delivery/automated-apk-ipa-size-tracking.md).

# Tracking APK/IPA Size in CI

How I measure exact bytes for release APKs and IPAs, block regressions with budgets, and retain metric artifacts when GitLab CI fails

## Outcome

I build release APKs and IPAs on a schedule, measure the artifacts in CI, and return a non-zero exit code when their size exceeds a budget. However, I do not use one ambiguous `appSize` field for everything.

The size of a local APK or IPA, store download size, and installed footprint are different metrics:

```
Flutter release build
    │
    ├── APK file bytes ────────► CI artifact gate
    │
    ├── IPA file bytes ────────► CI artifact gate
    │
    ├── AAB ─► Google Play ────► device-specific download/install size
    │
    └── iOS archive/export
          └── App Thinning ────► compressed/uncompressed variant size
```

The CI artifact gate provides early feedback before upload. Store metrics represent delivery closer to the user but appear at a different layer. I keep separate names and baselines for each series.

My gate checks in this order:

```
release build succeeded?
    └── exactly one artifact?
          └── non-empty file?
                └── measure exact integer bytes
                      ├── write metric JSON
                      ├── compare absolute budget
                      ├── compare baseline delta
                      └── upload evidence even on failure
```

If no artifact exists or a glob matches multiple files, the job fails before measurement. I do not silently select the first file or turn missing output into size `0`.

## Problem

### “App size” is not one metric

For Android:

```
local APK file bytes
    ≠ AAB upload bytes
    ≠ optimized APK bytes per device
    ≠ compressed download size
    ≠ installed size
```

Google Play uses an App Bundle to generate and deliver APKs optimized for a device's ABI, density, and language. A local arm64 APK remains a useful regression signal, but it is not every user's download size.

For iOS:

```
exported/upload IPA bytes
    ≠ thinned IPA variant bytes
    ≠ App Store compressed download size
    ≠ installed app size
```

Apple explicitly says that an upload IPA is unsuitable for accurately measuring download and installation size. An Xcode App Size Report or App Store Connect provides the size of thinned variants.

I name metrics after the exact artifact and contract:

* `apk_file_bytes_android_arm64`.
* `ipa_file_bytes_app_store_export`.
* `play_download_bytes_<device-profile>`.
* `ios_thinned_compressed_bytes_<variant>`.

I do not compare an APK with an IPA to decide which platform is “lighter.” Packaging, compression, native runtimes, and store processing differ.

### Debug builds do not represent release size

Debug builds contain overhead for hot reload, debugging, and source-level tooling. I measure only artifacts built in release mode with the intended flavor and target.

The build contract is part of the metric:

| Input                       | Example contract                                |
| --------------------------- | ----------------------------------------------- |
| Build mode                  | `release`                                       |
| Flavor                      | `<release-flavor>`                              |
| Android target              | `android-arm64` APK                             |
| iOS export                  | App Store IPA export                            |
| Flutter/toolchain           | Version pinned in CI                            |
| Shrink/minify/symbol policy | Recorded in metadata or the baseline definition |

If I switch from APK to AAB, single ABI to universal, change a minify flag, or change the iOS export method, I create or annotate a new baseline. I do not call every build-configuration difference a code optimization or regression.

### `du -sh` is a poor source for trends

This command is convenient for manual inspection:

```bash
du -sh "$artifact"
```

However, `du` reports disk blocks occupied by the file. `-h` converts the result to a rounded K/M/G string. Parsing that string and naming it MB creates several problems:

* Regressions smaller than the rounding step disappear.
* Disk allocation does not always equal file length.
* K/M/G can use a binary scale while a parser converts with `1000`.
* Locale and tool implementation can change output.
* Float and unit parsing make the gate unnecessarily complex.

I use exact file bytes as the source of truth:

```bash
wc -c < "$artifact"
```

MiB is only a display value:

```
size_mib = size_bytes / 1024 / 1024
```

Thresholds and deltas use integer bytes. Logs explicitly say `MiB` when using `1024²`, rather than the ambiguous `MB`.

### A glob can hide multiple artifacts

This pattern does not define cardinality:

```
build/app/outputs/flutter-apk/*.apk
```

It behaves as expected when the build produces one file. If output changes to multiple ABIs or flavors, or stale artifacts remain, a script might measure only the first glob element.

I define this contract:

```
0 artifacts  → fail
1 artifact   → measure
>1 artifacts → fail or emit a separate metric for each artifact
```

A split-per-ABI build does not have “one APK size.” It has one metric per ABI, with the target recorded in metadata.

### A JSON string is not a good metric schema

Minimal output like this is insufficient for trend analysis:

```json
{
  "platform": "android",
  "appSize": "42.1",
  "commitHash": "abcdef1",
  "branch": "release/example"
}
```

`appSize` is a string with no unit. `platform` does not describe the artifact kind, flavor, target ABI, or build mode. If JSON is assembled by string interpolation, a branch containing a quote or backslash can also make the document invalid.

I use a versioned schema with numeric bytes:

```json
{
  "schemaVersion": 1,
  "metric": "artifact_file_size",
  "platform": "android",
  "artifactKind": "apk",
  "buildMode": "release",
  "flavor": "production",
  "target": "android-arm64",
  "sizeBytes": 123456,
  "limitBytes": 150000,
  "status": "passed",
  "commit": "abcdef1",
  "ref": "release/example"
}
```

These numbers are illustrative fixtures, not the source application's size or thresholds.

### An absolute budget does not detect every regression

An absolute ceiling answers:

```
Does the current artifact exceed the total budget?
```

It does not answer:

```
How much did the artifact grow compared with the previous release or main baseline?
```

For example, an artifact can grow substantially and still remain below the ceiling. The absolute gate stays green even though the change deserves review.

I separate two policies:

| Policy          | Question                                                | Failure action           |
| --------------- | ------------------------------------------------------- | ------------------------ |
| Absolute budget | Does the complete artifact exceed its limit?            | Fail                     |
| Delta budget    | How much did it grow relative to a comparable baseline? | Fail or require approval |

The baseline must have the same platform, artifact kind, flavor, ABI or export contract, and build mode. An arm64 APK cannot be compared with an AAB; an upload IPA cannot be compared with a thinned variant.

### Metrics can disappear precisely when the threshold fails

I always write metric JSON before exiting based on a threshold. The local workspace therefore retains evidence when the gate turns red.

GitLab uploads artifacts only for successful jobs by default, however. If the script exits with `1`, the metric that was just written might not be preserved:

```
write metric JSON
    → threshold exceeded
    → exit 1
    → job failed
    → default on_success artifact is skipped
```

I use `artifacts: when: always` and declare only the path each platform job creates. The Android job does not pretend to own iOS output, and vice versa.

## Solution

### Pin the release build contract

Android single-ABI APK:

```bash
flutter build apk \
  --release \
  --flavor "$RELEASE_FLAVOR" \
  --target-platform android-arm64 \
  --target lib/main.dart
```

iOS exported IPA:

```bash
flutter build ipa \
  --release \
  --flavor "$RELEASE_FLAVOR" \
  --target lib/main.dart \
  --export-options-plist ios/ExportOptions.plist
```

Signing material comes from protected CI configuration. The size script does not print a keystore, certificate, profile, team ID, or secret into logs or JSON.

I build from a clean state according to the chosen policy. A clean build is useful only when every run follows the same rule; if one baseline uses a cache and the current run does not, metadata must expose that difference.

The Android command above measures one arm64 APK. If production releases use AAB, I add an `aab_file_bytes` metric and Play delivery metrics instead of renaming the APK series.

The iOS command measures an exported IPA. Apple's App Thinning report is a separate output handled later.

### Require exactly one artifact

The script explicitly uses Bash:

```bash
#!/usr/bin/env bash
set -euo pipefail

find_one_artifact() {
  local directory="$1"
  local pattern="$2"
  local matches=()

  while IFS= read -r -d '' file; do
    matches+=("$file")
  done < <(find "$directory" -maxdepth 1 -type f -name "$pattern" -print0)

  if (( ${#matches[@]} != 1 )); then
    echo "Expected exactly one $pattern artifact; found ${#matches[@]}" >&2
    return 1
  fi

  printf '%s\n' "${matches[0]}"
}
```

I do not use a `/bin/sh` shebang when a script depends on arrays, process substitution, or `[[ ... ]]`.

Resolve the path by platform:

```bash
case "$platform" in
  android)
    artifact="$(find_one_artifact \
      build/app/outputs/flutter-apk '*.apk')"
    artifact_kind="apk"
    target="android-arm64"
    ;;
  ios)
    artifact="$(find_one_artifact build/ios/ipa '*.ipa')"
    artifact_kind="ipa"
    target="app-store-export"
    ;;
  *)
    echo "Unsupported platform: $platform" >&2
    exit 64
    ;;
esac
```

Exit `64` distinguishes usage or configuration errors from threshold regressions. A team can use a different code; what matters is that logs and automation can read the failure class.

### Measure exact integer bytes

```bash
size_bytes="$(wc -c < "$artifact" | tr -d '[:space:]')"

[[ "$size_bytes" =~ ^[0-9]+$ ]] || {
  echo "Invalid byte count for $artifact" >&2
  exit 1
}

(( size_bytes > 0 )) || {
  echo "Artifact is empty: $artifact" >&2
  exit 1
}
```

`wc -c` returns file length in bytes without rounding. A CI image can use `stat` when GNU or BSD behavior is pinned, but macOS and Linux use different flags.

The derived display value is:

```bash
size_mib="$(awk -v bytes="$size_bytes" \
  'BEGIN { printf "%.2f", bytes / 1024 / 1024 }')"

printf 'Artifact size: %s bytes (%s MiB)\n' \
  "$size_bytes" "$size_mib"
```

The gate never parses the `MiB` string back into a number.

### Validate thresholds before comparison

Thresholds come from reviewed configuration:

```bash
case "$platform" in
  android) limit_bytes="$ANDROID_APK_LIMIT_BYTES" ;;
  ios) limit_bytes="$IOS_IPA_LIMIT_BYTES" ;;
esac

[[ "$limit_bytes" =~ ^[0-9]+$ ]] || {
  echo "Invalid size limit for $platform" >&2
  exit 1
}

(( limit_bytes > 0 )) || {
  echo "Size limit must be positive" >&2
  exit 1
}
```

Only then do I determine status:

```bash
if (( size_bytes > limit_bytes )); then
  status="failed"
  exit_code=1
else
  status="passed"
  exit_code=0
fi
```

I treat a value exactly equal to the threshold as passing because the condition uses `>`. If the product wants a different inclusive ceiling, tests must state the boundary explicitly.

### Generate JSON with a serializer

I use `jq -n` to escape metadata and retain numeric types:

```bash
mkdir -p build/metrics
metric_path="build/metrics/${platform}_app_size.json"

jq -n \
  --arg metric "artifact_file_size" \
  --arg platform "$platform" \
  --arg artifactKind "$artifact_kind" \
  --arg buildMode "release" \
  --arg flavor "$RELEASE_FLAVOR" \
  --arg target "$target" \
  --arg commit "$CI_COMMIT_SHA" \
  --arg ref "$CI_COMMIT_REF_NAME" \
  --arg status "$status" \
  --argjson sizeBytes "$size_bytes" \
  --argjson limitBytes "$limit_bytes" '
  {
    schemaVersion: 1,
    metric: $metric,
    platform: $platform,
    artifactKind: $artifactKind,
    buildMode: $buildMode,
    flavor: $flavor,
    target: $target,
    sizeBytes: $sizeBytes,
    limitBytes: $limitBytes,
    status: $status,
    commit: $commit,
    ref: $ref
  }
' > "$metric_path"

exit "$exit_code"
```

The metric is written before `exit`. A branch or ref containing a slash, quote, or Unicode still becomes valid JSON.

I do not include an absolute artifact path if it exposes runner topology. A relative path or artifact kind is usually enough.

### Add a delta gate without comparing the wrong baseline

Delta calculation:

```
delta_bytes = current_size_bytes - baseline_size_bytes
delta_percent = delta_bytes / baseline_size_bytes × 100
```

I display both bytes and percentage. The blocking policy has one owner and one clear rule, such as an absolute delta or a combination of conditions.

Before comparison, the baseline selector must match:

```
platform
artifactKind
buildMode
flavor
target / ABI / export contract
schemaVersion
```

If no comparable baseline exists, the job reports `no comparable baseline`. Depending on policy, this can be a non-blocking first observation or a configuration failure; it is never treated as delta `0`.

A baseline can come from:

* The latest successful main or release artifact.
* Object storage containing versioned JSON.
* A metrics or time-series backend.
* The GitLab artifact API.

The source I inspected currently has only an absolute budget. Delta and history are additional improvements, not existing behavior.

### Retain artifacts when the gate fails

Each platform job owns its own path:

```yaml
.size_test:
  stage: test
  rules:
    - if: '$JOB_TYPE == "daily app size check"'

daily_android_size:
  extends: .size_test
  script:
    - make check-android-size
  artifacts:
    when: always
    expire_in: <retention-window>
    paths:
      - build/metrics/android_app_size.json
      - build/size-analysis/android/

daily_ios_size:
  extends: .size_test
  script:
    - make check-ios-size
  artifacts:
    when: always
    expire_in: <retention-window>
    paths:
      - build/metrics/ios_app_size.json
      - build/size-analysis/ios/
```

I repeat `artifacts` in the concrete jobs so path ownership is obvious instead of depending on nested-map inheritance that reviewers must remember.

I do not upload release APKs or IPAs until artifact-access policy is approved. Metric JSON and analyze-size reports are generally enough for the code example.

The separate GitLab CI article owns the broader structure, caching, and artifact lifecycle:

{% content-ref url="/pages/iXaQgqQ1eqF71OtJywau" %}
[GitLab CI for a Flutter Monorepo](/flutter/my-flutter/quality-delivery/gitlab-ci-flutter-monorepo.md)
{% endcontent-ref %}

### Investigate the breakdown with `--analyze-size`

Flutter supports size analysis for release builds:

```bash
flutter build apk \
  --release \
  --target-platform android-arm64 \
  --analyze-size \
  --code-size-directory build/size-analysis/android
```

Android size analysis requires one ABI. The DevTools App Size tool can open the JSON output and break down:

* Dart AOT snapshot.
* Native libraries.
* Assets and fonts.
* Package and resource contributions.

`--analyze-size` does not replace the total artifact byte gate. It answers “which part grew?” after the gate or trend detects that “the total grew.”

The Flutter tool also supports iOS size analysis and uses arm64 symbols for analysis according to the current SDK help. I recheck the CLI when upgrading Flutter because options and output can change.

### Track Android store delivery separately

If the app ships through Google Play as an AAB, Play generates optimized APKs by device. I track at least:

* AAB upload artifact bytes.
* Estimated or observed download size for a standard device profile.
* Installed size if the product needs a storage budget.

A local arm64 APK does not cover density and language splits or every device. It is a separate CI regression series.

When a gate increases, I inspect:

* Native `.so` files and ABIs.
* Duplicate or oversized assets and fonts.
* Resource shrinking and minification policy.
* New packages.
* AAB delivery breakdown in Play Console.

### Track iOS thinned variants separately

Apple recommends the Xcode App Size Report to estimate download and installation size during development. The report contains compressed and uncompressed size per variant.

The flow is:

```
Xcode archive
    → export all compatible device variants
    → App Thinning Size Report.txt
    ├── compressed size   ≈ download size estimate
    └── uncompressed size ≈ installed app estimate
```

App Store Connect provides more accurate numbers after store processing. TestFlight and upload IPAs can contain overhead that is absent from the final store app.

I do not gate a thinned variant with the IPA file's threshold. The two metrics have separate series and budgets.

The Fastlane article owns signing and export context; this article owns size semantics and gates.

{% content-ref url="/pages/AfcP6VFNqQRoYmvOMT1Z" %}
[Fastlane iOS, Code Signing, and TestFlight](/flutter/my-flutter/quality-delivery/fastlane-ios-code-signing-testflight.md)
{% endcontent-ref %}

### Read failures at the correct layer

| Signal                                        | Common cause                                     | Response                                               |
| --------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------ |
| Build or signing failed                       | No release artifact was created                  | Fix build or signing; do not call it a size regression |
| No artifact                                   | Output path or build command is wrong            | Fail the precondition                                  |
| Multiple artifacts                            | Stale output, split ABI, or multiple flavors     | Clean or fix the contract, or emit separate metrics    |
| Empty artifact                                | Build or copy failed                             | Fail before threshold comparison                       |
| Invalid byte count or type                    | Tool, parser, or configuration error             | Fail schema or configuration validation                |
| Absolute budget failed                        | Total artifact exceeded its ceiling              | Open the breakdown and review the change               |
| Delta budget failed                           | Regression against a comparable baseline         | Analyze package, asset, and config deltas              |
| No comparable baseline                        | New contract or missing history                  | Create a reviewed baseline; do not use delta `0`       |
| Metric JSON invalid                           | String interpolation or schema error             | Use a serializer and parsing tests                     |
| Job failed without a metric                   | Artifact upload is success-only or path is wrong | Use `when: always` and fix path ownership              |
| Local APK is stable while Play size increases | Store splitting or delivery profile changed      | Inspect the separate AAB and Play metric               |
| IPA is stable while a variant increases       | App Thinning or store processing differs         | Inspect the size report and App Store Connect          |

### Size-gate review checklist

```
Build contract
□ Release mode, flavor, and target/export method
□ Flutter/native toolchain version
□ Shrink/minify/symbol policy

Artifact
□ Exactly one non-empty file
□ Exact integer bytes
□ Separate metric names for APK/IPA/AAB/variants

Metadata
□ schemaVersion and numeric sizeBytes
□ commit/ref/platform/artifactKind/buildMode/flavor/target
□ JSON produced by a serializer

Gate
□ Absolute budget has a unit and owner
□ Delta compares only a compatible baseline
□ Exact-threshold boundary is tested
□ Metric is written before exit

CI
□ Job schedule matches the intended rule
□ Artifact path belongs to the correct platform job
□ when: always
□ No signing material or secrets are uploaded

Conclusion
□ Do not call APK/IPA size store download or install size
□ Do not compare Android directly with iOS
□ Do not loosen a threshold merely to make the pipeline green
```

### Trade-offs and limitations

* Exact bytes are more stable than `du -h`, but still describe only the selected artifact.
* A single-ABI APK is easy to gate but does not represent every Play delivery variant.
* An IPA file gate gives fast feedback but does not represent App Thinning.
* An absolute budget is simple but can miss regressions far below the ceiling.
* A delta gate is more sensitive but needs a trustworthy baseline store.
* Percentage adds context but exaggerates small apps; byte delta lacks context for large apps.
* `--analyze-size` helps investigation but adds build time and storage.
* Clean builds reduce stale output but increase CI time.
* `when: always` preserves evidence but increases artifact-retention cost.
* Size reduction must not trade away crash symbols, security, or feature correctness without review.

### Verification performed

In the source snapshot I inspected:

* Flutter is pinned to `3.41.2`, with Dart `3.11.0`.
* Two Make targets build an arm64 release APK and a release IPA.
* Both targets clean before building and call one shared size script.
* The script uses `du -sh`, handles K/M/G suffixes, writes four JSON fields, and has a platform-specific threshold.
* Missing APK and missing IPA both return exit `1` when run directly.
* Two GitLab jobs appear only when `JOB_TYPE` matches the scheduled app-size check.
* The artifact template uses the default success-only behavior and declares both platform paths.
* No unit or fixture tests cover the size script.
* The local snapshot contains no APK, IPA, or metric JSON.

I ran shell syntax checks, parsed the GitLab YAML, dry-ran Make for both platforms, and exercised the missing-artifact path. I did not run a real release build because Android and iOS require signing state and build dependencies, so no size value or threshold result is published.

### Related article

Startup and scrolling benchmarks also need scheduled jobs, metadata, and fail-closed artifact parsing. That article owns performance metrics; this one owns binary-size semantics.

{% content-ref url="/pages/HtGuGjHl7HTq3EkFWITw" %}
[Benchmarking Startup and Scrolling Performance](/flutter/my-flutter/quality-delivery/startup-scrolling-performance-benchmark.md)
{% endcontent-ref %}

### Verified versions

* Flutter: `3.41.2`.
* Dart: `3.11.0`.
* Android output: release APK targeting `android-arm64`.
* iOS output: release IPA export.
* CI: GitLab scheduled or variable-selected jobs.
* Platform scope: Android and iOS.

### References

* [Flutter — Measuring your app's size](https://docs.flutter.dev/perf/app-size)
* [Android — Reduce your app size](https://developer.android.com/topic/performance/reduce-apk-size)
* [Apple — Reducing your app's size](https://developer.apple.com/documentation/Xcode/reducing-your-app-s-size)
* [Apple — View builds and metadata](https://developer.apple.com/help/app-store-connect/manage-builds/view-builds-and-metadata)
* [GitLab — Job artifact upload conditions](https://docs.gitlab.com/ci/jobs/job_artifacts/#with-upload-conditions)
* [GitLab — Job `rules`](https://docs.gitlab.com/ci/jobs/job_rules/)

## Conclusion

APKs, IPAs, store downloads, and installed footprints are different metrics. I start with a clear release build contract, measure exact bytes for exactly one artifact, and name the series by platform, artifact kind, flavor, and target.

A trustworthy gate fails closed before threshold comparison. Missing files, empty files, multiple outputs, invalid metadata, and incomparable baselines are failure or configuration states that require action; they never become `0` or pass.

The absolute budget protects the ceiling. The delta budget detects regressions early. `--analyze-size` identifies the growing component, while Play and App Store metrics show what users receive after store processing. Keeping these layers separate makes the size dashboard meaningful and avoids optimizing a number that does not represent the real experience.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
