Skip to main content
mobile8. Februar 202610 Min. Lesezeit

Android-Deployments im Google Play Store automatisieren

Automatisierte Android-Builds und Deployments einrichten — von der Gradle-Konfiguration bis zur Play-Store-Veröffentlichung über CI/CD.

androidgradlecicd
Android-Deployments im Google Play Store automatisieren

Eine Android-App im Play Store zu veröffentlichen klingt, als sollte es einfach sein — ein APK erstellen, hochladen, fertig. In der Praxis umfasst der Prozess Signierungskonfigurationen, Build-Varianten, Bundle-Formate, Play-Store-Tracks, stufenweise Rollouts und genug Gradle-Konfiguration, um jeden an seiner Berufswahl zweifeln zu lassen. Nachdem ich Android-Deployments in mehreren Projekten automatisiert habe, einschließlich Flutter-Apps, die sowohl iOS- als auch Android-Pipelines benötigen, habe ich ein Setup, das alles vom Commit bis zum Produktions-Rollout ohne manuellen Eingriff handhabt.

Das Android-Build-System

Bevor Sie irgendetwas automatisieren, brauchen Sie ein solides Verständnis davon, wie Android-Builds funktionieren. Anders als bei iOS, wo Xcode den Großteil der Komplexität hinter einer GUI verbirgt, ist das Android-Build-System vollständig Gradle-basiert und durch Code konfiguriert. Das ist tatsächlich ein Vorteil für die Automatisierung — alles ist explizit und versionskontrolliert.

Gradle-Konfiguration für Release-Builds

Ihre android/app/build.gradle (oder build.gradle.kts, wenn Sie auf Kotlin DSL migriert haben) ist die zentrale Konfigurationsdatei. Für Release-Builds müssen Sie Signierung, Minifizierung und Optimierung konfigurieren.

android {
    compileSdkVersion 35

    defaultConfig {
        applicationId "com.yourcompany.yourapp"
        minSdkVersion 24
        targetSdkVersion 35
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
    }

    signingConfigs {
        release {
            keyAlias System.getenv("KEY_ALIAS") ?: properties["keyAlias"]
            keyPassword System.getenv("KEY_PASSWORD") ?: properties["keyPassword"]
            storeFile file(System.getenv("KEYSTORE_PATH") ?: properties["keystorePath"] ?: "keystore.jks")
            storePassword System.getenv("STORE_PASSWORD") ?: properties["storePassword"]
        }
    }

    buildTypes {
        release {
            signingConfig signingConfigs.release
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

Die Signierungskonfiguration liest zuerst aus Umgebungsvariablen und fällt dann auf eine lokale Properties-Datei zurück. Dieses Muster ermöglicht es Entwicklern, Builds lokal mit einer key.properties-Datei zu signieren, während CI Umgebungsvariablen verwendet — dieselbe Gradle-Datei funktioniert in beiden Kontexten ohne Modifikation.

Die lokale Properties-Datei

Für die lokale Entwicklung erstellen Sie eine key.properties-Datei in Ihrem android/-Verzeichnis (und fügen Sie sie sofort zu .gitignore hinzu):

storePassword=your_store_password
keyPassword=your_key_password
keyAlias=your_key_alias
keystorePath=../keys/release-keystore.jks

Dann referenzieren Sie sie in Ihrer build.gradle:

def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}

Signierungskonfiguration

Die Android-App-Signierung ist einfacher als die iOS-Code-Signierung, birgt aber ein dauerhaftes Risiko: Wenn Sie Ihren Upload-Key verlieren, verlieren Sie die Fähigkeit, Ihre App zu aktualisieren. Google hat Play App Signing eingeführt, um dies zu mildern, und ich empfehle dringend, es zu verwenden.

Play App Signing

Mit aktiviertem Play App Signing hält Google den tatsächlichen Verteilungsschlüssel. Sie signieren Ihre Uploads mit einem Upload-Key, und Google re-signiert mit dem Verteilungsschlüssel, bevor er an die Nutzer ausgeliefert wird. Die Vorteile:

  • Wiederherstellung bei Key-Verlust: Wenn Sie Ihren Upload-Key verlieren, kann Google ihn zurücksetzen. Ohne Play App Signing bedeutet der Verlust Ihres Keys die Erstellung eines neuen App-Eintrags.
  • Kleinere APKs: Google kann die App für jede Gerätekonfiguration mit dem Verteilungsschlüssel optimieren.
  • Key-Rotation: Sie können Ihren Upload-Key rotieren, ohne installierte Nutzer zu beeinflussen.

Aktivieren Sie es in der Play Console unter Einrichtung > App-Signierung. Für neue Apps ist es standardmäßig aktiviert.

Einen Keystore generieren

Wenn Sie einen neuen Keystore erstellen müssen:

keytool -genkey -v \
  -keystore release-keystore.jks \
  -keyalg RSA \
  -keysize 2048 \
  -validity 10000 \
  -alias your_key_alias

Bewahren Sie diese Keystore-Datei sicher auf. Für CI codiere ich sie base64 und speichere sie als GitHub Actions Secret:

base64 -i release-keystore.jks | pbcopy

Dann decodiere ich sie im CI-Workflow vor dem Build:

- name: Decode keystore
  run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks

AAB vs APK

Google verlangt jetzt Android App Bundles (AAB) für alle neuen Apps im Play Store. Der Unterschied ist relevant für Ihre Build-Pipeline.

APK (Android Package) ist eine einzelne Datei, die alles für alle Gerätekonfigurationen enthält. Sie ist größer, aber universell — jedes Android-Gerät kann jedes APK installieren. APKs sind weiterhin nützlich für die interne Distribution, das Testen auf physischen Geräten und die Distribution außerhalb des Play Stores.

AAB (Android App Bundle) enthält den gesamten Code und die Ressourcen, lässt aber Google Play optimierte APKs für jedes Gerät generieren. Ein Nutzer mit einem Pixel-Telefon lädt nur die Ressourcen herunter, die er braucht, nicht die Assets für jede Bildschirmdichte und CPU-Architektur. Bundles sind typischerweise 15-30% kleiner als universelle APKs.

Für Ihre CI-Pipeline bauen Sie beides:

# For Play Store
flutter build appbundle --release

# For internal testing / direct installation
flutter build apk --release --split-per-abi

Das --split-per-abi-Flag generiert separate APKs für jede CPU-Architektur (arm64-v8a, armeabi-v7a, x86_64), was die Dateigröße für direkte Distribution reduziert.

Fastlane Supply für den Play Store

Fastlanes supply-Action handhabt Play-Store-Uploads und Metadaten-Management, ähnlich dem, was deliver für iOS tut.

Supply einrichten

Zunächst benötigen Sie ein Google Play-Dienstkonto mit den richtigen Berechtigungen.

  1. Gehen Sie zur Google Cloud Console und erstellen Sie ein Dienstkonto.
  2. Laden Sie die JSON-Schlüsseldatei herunter.
  3. Gehen Sie in der Play Console zu Einstellungen > API-Zugang und verknüpfen Sie das Dienstkonto.
  4. Gewähren Sie ihm "Release-Manager"-Berechtigungen für Ihre App.
# android/fastlane/Appfile
json_key_file(ENV["PLAY_STORE_JSON_KEY"] || "path/to/service-account.json")
package_name("com.yourcompany.yourapp")

Einfacher Upload

default_platform(:android)

platform :android do
  desc "Deploy to Play Store Internal Testing"
  lane :internal do
    gradle(
      task: "bundle",
      build_type: "Release",
      project_dir: "./"
    )

    supply(
      track: "internal",
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end
end

Für Flutter-Projekte bevorzuge ich es, den Flutter-Build-Befehl direkt auszuführen statt den Gradle-Task über Fastlane zu verwenden, weil Flutters Build-Prozess Dart-Kompilierungsschritte umfasst, die Gradle allein nicht korrekt handhabt:

lane :internal do
  Dir.chdir("..") do
    sh("flutter build appbundle --release")
  end

  supply(
    track: "internal",
    aab: "../build/app/outputs/bundle/release/app-release.aab",
    skip_upload_metadata: true,
    skip_upload_images: true,
    skip_upload_screenshots: true
  )
end

Play-Store-Tracks

Der Play Store hat vier Tracks, und zu verstehen, wann welcher zu verwenden ist, ist wichtig für Ihre Release-Strategie.

Internes Testing

  • Bis zu 100 Tester.
  • Keine Überprüfung erforderlich — Builds sind fast sofort verfügbar.
  • Am besten für: Entwicklungsteam-Tests, QA-Zyklen, Builds vor der Beförderung prüfen.

Geschlossenes Testing (Alpha)

  • Unbegrenzte Tester, aber Sie verwalten die Liste.
  • Erfordert eine kurze Überprüfung durch Google (normalerweise einige Stunden).
  • Am besten für: Beta-Programme mit eingeladenen Nutzern, Kunden-Previews.

Offenes Testing (Beta)

  • Jeder kann über den Play-Store-Eintrag beitreten.
  • Erfordert eine Überprüfung.
  • Am besten für: Öffentliche Beta-Programme, Feedback vor einer breiten Veröffentlichung sammeln.

Produktion

  • Für alle Nutzer verfügbar.
  • Vollständige Überprüfung erforderlich.
  • Unterstützt stufenweise Rollouts.

Zwischen Tracks befördern

Fastlane macht die Track-Beförderung unkompliziert:

lane :promote_to_beta do
  supply(
    track: "internal",
    track_promote_to: "beta",
    skip_upload_changelogs: false,
    skip_upload_metadata: true,
    skip_upload_images: true,
    skip_upload_screenshots: true
  )
end

lane :promote_to_production do
  supply(
    track: "beta",
    track_promote_to: "production",
    rollout: "0.1",  # 10% staged rollout
    skip_upload_changelogs: false,
    skip_upload_metadata: true,
    skip_upload_images: true,
    skip_upload_screenshots: true
  )
end

Stufenweise Rollouts

Stufenweise Rollouts sind einer der größten Vorteile von Android gegenüber iOS. Sie können für einen Prozentsatz der Nutzer veröffentlichen und schrittweise erhöhen, wobei Sie Absturzraten und Nutzerfeedback in jeder Phase überwachen.

lane :staged_rollout do |options|
  percentage = options[:percentage] || "0.1"

  supply(
    track: "production",
    rollout: percentage,
    aab: "../build/app/outputs/bundle/release/app-release.aab"
  )
end

lane :increase_rollout do |options|
  percentage = options[:percentage] || "0.5"

  supply(
    track: "production",
    rollout: percentage,
    skip_upload_aab: true,
    skip_upload_metadata: true,
    skip_upload_images: true,
    skip_upload_screenshots: true
  )
end

lane :complete_rollout do
  supply(
    track: "production",
    rollout: "1.0",
    skip_upload_aab: true,
    skip_upload_metadata: true,
    skip_upload_images: true,
    skip_upload_screenshots: true
  )
end

Ein typischer Rollout-Zeitplan:

  1. Tag 1: 10% — Absturzraten in Firebase Crashlytics beobachten.
  2. Tag 2-3: 25% — Nutzerfeedback und Support-Tickets prüfen.
  3. Tag 4-5: 50% — Verifizieren, dass keine Performance-Regressionen im Betrieb auftreten.
  4. Tag 6-7: 100% — Vollständige Veröffentlichung.

Wenn in irgendeiner Phase etwas schiefgeht, können Sie den Rollout stoppen und einen Fix pushen, ohne alle Nutzer zu betreffen.

GitHub Actions Workflow

Hier ist ein kompletter CI/CD-Workflow für Android:

name: Deploy to Play Store

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      track:
        description: 'Play Store track'
        required: true
        default: 'internal'
        type: choice
        options:
          - internal
          - alpha
          - beta
          - production

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'
          cache: 'gradle'

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.27.x'
          channel: stable
          cache: true

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
          working-directory: android

      - name: Flutter dependencies
        run: flutter pub get

      - name: Decode keystore
        run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks

      - name: Create service account file
        run: echo '${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}' > android/play-store-key.json

      - name: Build and deploy
        working-directory: android
        env:
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
          KEYSTORE_PATH: app/keystore.jks
          PLAY_STORE_JSON_KEY: play-store-key.json
        run: bundle exec fastlane ${{ github.event.inputs.track || 'internal' }}

      - name: Cleanup secrets
        if: always()
        run: |
          rm -f android/app/keystore.jks
          rm -f android/play-store-key.json

Einige Anmerkungen zu diesem Workflow:

Ubuntu-Runner funktionieren für Android einwandfrei. Anders als iOS, das macOS erfordert, laufen Android-Builds auf Linux. Ubuntu-Runner sind günstiger und starten schneller auf GitHub Actions.

Java 17 ist erforderlich für aktuelle Versionen des Android Gradle Plugins. Wenn Ihr Projekt eine ältere AGP-Version verwendet, benötigen Sie möglicherweise Java 11, aber das wird zunehmend selten.

Der Cleanup-Schritt entfernt decodierte Secrets, auch wenn der Build fehlschlägt. Dies ist eine Defense-in-Depth-Maßnahme — GitHub Actions Runner sind kurzlebig, aber es ist eine gute Praxis.

Versionsverwaltung

Android verwendet zwei Versionsbezeichner: versionCode (eine Ganzzahl, die mit jedem Upload steigen muss) und versionName (die menschenlesbare Versionszeichenkette).

Für Flutter-Projekte kommen beide aus pubspec.yaml:

version: 1.2.3+45
# 1.2.3 = versionName
# 45 = versionCode

Ich automatisiere das Inkrementieren des Version Codes mit der CI-Laufnummer:

lane :set_version_code do
  build_number = ENV["GITHUB_RUN_NUMBER"] || "1"

  Dir.chdir("..") do
    current_version = sh("grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//'").strip
    sh("sed -i 's/^version: .*/version: #{current_version}+#{build_number}/' pubspec.yaml")
  end
end

Das stellt sicher, dass der Version Code immer steigt, auch über Branches hinweg. Wenn Sie mehr Kontrolle benötigen, können Sie den neuesten Version Code vom Play Store abrufen:

lane :smart_version_code do
  current = google_play_track_version_codes(track: "internal").max || 0
  new_code = current + 1

  Dir.chdir("..") do
    current_version = sh("grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//'").strip
    sh("sed -i 's/^version: .*/version: #{current_version}+#{new_code}/' pubspec.yaml")
  end
end

Play-Store-Einträge verwalten

Supply kann Ihren gesamten Play-Store-Eintrag aus versionskontrollierten Dateien verwalten:

android/fastlane/metadata/android/
├── en-US/
│   ├── title.txt
│   ├── short_description.txt
│   ├── full_description.txt
│   ├── changelogs/
│   │   ├── default.txt
│   │   └── 45.txt          # Changelog for versionCode 45
│   └── images/
│       ├── phoneScreenshots/
│       │   ├── 1_home.png
│       │   └── 2_detail.png
│       ├── featureGraphic.png
│       └── icon.png
└── ru-RU/
    ├── title.txt
    ├── short_description.txt
    └── full_description.txt

Um Ihren aktuellen Eintrag als Ausgangspunkt herunterzuladen:

fastlane supply init

Dann Dateien aktualisieren und Änderungen pushen:

lane :update_listing do
  supply(
    skip_upload_aab: true,
    skip_upload_apk: true
  )
end

ProGuard- und R8-Konfiguration

Wenn minifyEnabled true ist, schrumpft, verschleiert und optimiert R8 (der Nachfolger von ProGuard) Ihren Code. Das ist essentiell für Produktions-Builds — es reduziert die APK-Größe und erschwert Reverse Engineering — aber es kann auch Dinge kaputt machen, wenn es nicht richtig konfiguriert ist.

Häufige Probleme und ihre ProGuard-Regeln:

# Keep Flutter's classes
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }

# Keep Firebase classes
-keep class com.google.firebase.** { *; }

# Keep models used with JSON serialization
-keep class com.yourcompany.yourapp.models.** { *; }

# Keep classes referenced via reflection
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes InnerClasses

# Common crash fix: OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**

Testen Sie Ihren Release-Build gründlich — R8-Probleme manifestieren sich oft als Laufzeit-Abstürze, die in Debug-Builds nicht auftreten. Die --obfuscate- und --split-debug-info-Flags in Flutter ermöglichen weitere Optimierungen:

flutter build appbundle --release --obfuscate --split-debug-info=build/symbols

Bewahren Sie das build/symbols-Verzeichnis auf — Sie benötigen es, um Stack Traces aus Crash-Reports zu symbolisieren.

Das vollständige Fastfile

default_platform(:android)

platform :android do
  desc "Deploy to internal testing"
  lane :internal do
    set_version_code
    build_release
    supply(
      track: "internal",
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end

  desc "Deploy to closed beta"
  lane :beta do
    set_version_code
    build_release
    supply(
      track: "beta",
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end

  desc "Deploy to production with staged rollout"
  lane :production do
    set_version_code
    build_release
    supply(
      track: "production",
      rollout: "0.1",
      aab: "../build/app/outputs/bundle/release/app-release.aab"
    )
  end

  desc "Promote internal to beta"
  lane :promote_to_beta do
    supply(
      track: "internal",
      track_promote_to: "beta"
    )
  end

  desc "Increase production rollout"
  lane :increase_rollout do |options|
    supply(
      track: "production",
      rollout: options[:percentage] || "0.5",
      skip_upload_aab: true
    )
  end

  private_lane :build_release do
    Dir.chdir("..") do
      sh("flutter build appbundle --release --obfuscate --split-debug-info=build/symbols")
    end
  end

  private_lane :set_version_code do
    build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
    Dir.chdir("..") do
      current_version = sh("grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//'").strip
      sh("sed -i '' 's/^version: .*/version: #{current_version}+#{build_number}/' pubspec.yaml")
    end
  end
end

Unterschiede zur iOS-Automatisierung

Nachdem ich beide Plattformen automatisiert habe, gibt es einige wichtige Unterschiede, die erwähnenswert sind.

Signierung ist auf Android einfacher. Eine einzelne Keystore-Datei im Vergleich zum Zertifikats-und-Provisioning-Profil-Tanz auf iOS. Kein Bedarf an Match oder einem Zertifikatsverwaltungstool.

Keine zwingende macOS-Anforderung. Android-Builds laufen auf Linux, was günstigere und schnellere CI-Runner bedeutet.

Schnellere Review-Zeiten. Googles Review-Prozess ist typischerweise schneller als Apples, und Uploads in den internen Track erfordern gar kein Review.

Stufenweise Rollouts sind nativ. Auf iOS können Sie gestaffelte Veröffentlichungen machen, aber Sie können den Prozentsatz nicht kontrollieren oder den Rollout mitten im Prozess mit derselben Granularität stoppen.

Die Play Store API ist permissiver. Sie können fast alles programmatisch verwalten, einschließlich Store-Listing-Experimente und Preisgestaltung. Apples App Store Connect API hat mehr Einschränkungen.

Der Hauptnachteil sind Gradle-Build-Zeiten. Ein sauberer Android-Build mit R8-Optimierung kann deutlich länger dauern als ein vergleichbarer iOS-Build. Caching hilft — sowohl Gradles eingebauter Cache als auch CI-Level-Caching des .gradle-Verzeichnisses.

Wie eine ausgereifte Pipeline aussieht

Nach dem gesamten Setup sollte die tägliche Erfahrung unsichtbar sein. Push auf Main, ein Build läuft, und ein paar Minuten später haben Tester eine neue Version im internen Track. Ein Release taggen, und ein Produktions-Build läuft mit stufenweisem Rollout.

Die Pipeline eliminiert eine ganze Kategorie von "funktioniert auf meinem Rechner"-Problemen, stellt sicher, dass jeder Build reproduzierbar ist, und gibt dem Team die Zuversicht, dass das Ausliefern ein Nicht-Ereignis ist. Dieser letzte Punkt ist das eigentliche Ziel — das Deployment so routinemäßig zu machen, dass niemand mehr darüber nachdenkt.

DU

Danil Ulmashev

Full Stack Developer

Interesse an einer Zusammenarbeit?