Scrivere l'app è solo metà del lavoro: il momento della pubblicazione è quello in cui emergono keystore dimenticati, profili di provisioning scaduti, requisiti di target SDK non rispettati e form sulla privacy da compilare. In questa guida vediamo l'intero percorso che porta da flutter run a un'app disponibile su Google Play e App Store, con i requisiti aggiornati al 2025.

1. Versionamento: una sola fonte di verità

Flutter centralizza la versione nel pubspec.yaml:

name: my_app
description: La mia app Flutter
version: 1.4.0+42

Il formato è versionName+versionCode:

  • 1.4.0 è la versione visibile all'utente (CFBundleShortVersionString su iOS, versionName su Android).
  • 42 è il numero di build (CFBundleVersion, versionCode): deve essere strettamente crescente su entrambi gli store, altrimenti l'upload viene rifiutato.

In CI conviene non toccare il pubspec e passare i valori da riga di comando:

flutter build appbundle --release \
  --build-name=1.4.0 \
  --build-number=$GITHUB_RUN_NUMBER

Per mostrare la versione dentro l'app (utilissimo nelle schermate di supporto) si usa package_info_plus:

import 'package:package_info_plus/package_info_plus.dart';

class AppVersion {
  static late final String label;

  static Future<void> init() async {
    final info = await PackageInfo.fromPlatform();
    label = 'v${info.version} (${info.buildNumber})';
  }
}

// In un widget
Text(AppVersion.label, style: Theme.of(context).textTheme.bodySmall);

2. Android: creare e proteggere il keystore

La firma di upload è la chiave con cui Google Play riconosce che l'artefatto arriva davvero da te. Si genera una volta sola:

keytool -genkey -v -keystore ~/upload-keystore.jks \
  -keyalg RSA -keysize 2048 -validity 10000 -alias upload

Il file non va committato. Si referenzia tramite android/key.properties (da aggiungere al .gitignore):

storePassword=********
keyPassword=********
keyAlias=upload
storeFile=/Users/mario/upload-keystore.jks

Nei progetti Flutter recenti la configurazione Gradle usa il Kotlin DSL (android/app/build.gradle.kts):

import java.util.Properties
import java.io.FileInputStream

val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}

android {
    namespace = "com.example.myapp"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.example.myapp"
        minSdk = flutter.minSdkVersion
        targetSdk = 35
        versionCode = flutter.versionCode
        versionName = flutter.versionName
    }

    signingConfigs {
        create("release") {
            keyAlias = keystoreProperties["keyAlias"] as String?
            keyPassword = keystoreProperties["keyPassword"] as String?
            storeFile = (keystoreProperties["storeFile"] as String?)?.let { file(it) }
            storePassword = keystoreProperties["storePassword"] as String?
        }
    }

    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
            isMinifyEnabled = true
            isShrinkResources = true
        }
    }
}

Attenzione al target SDK: dal 31 agosto 2025 Google Play richiede targetSdk = 35 (Android 15) per le nuove app e per gli aggiornamenti. Verifica anche la compatibilità edge-to-edge, obbligatoria su Android 15.

Play App Signing

Alla prima pubblicazione Google genera (o importa) la app signing key definitiva e conserva solo quella: la tua upload-keystore.jks può essere resettata in caso di smarrimento aprendo un ticket. Resta comunque buona prassi archiviarla in un password manager aziendale, insieme alle password.

3. Generare l'artefatto Android

Google Play accetta solo Android App Bundle per le nuove app:

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

Il file finisce in build/app/outputs/bundle/release/app-release.aab. Gli APK restano utili solo per distribuzioni dirette (sideload, store alternativi, test manuali):

flutter build apk --release --split-per-abi

Se usi --obfuscate, conserva la cartella dei simboli per ogni build: senza di essa gli stack trace di produzione sono illeggibili. Per Crashlytics o Sentry i simboli vanno caricati nello stesso step della build; per il de-obfuscation manuale si usa:

flutter symbolize -i stacktrace.txt -d build/symbols/app.android-arm64.symbols

Ricorda anche di caricare su Play Console il file di mapping R8 (build/app/outputs/mapping/release/mapping.txt): l'AAB generato da Flutter lo include automaticamente se la minificazione è attiva.

4. iOS: identificatori, certificati e capability

Sul fronte Apple servono tre pezzi che devono combaciare:

  1. App ID (bundle identifier) registrato sul portale Apple Developer, con le capability abilitate (push, Sign in with Apple, associated domains...).
  2. Certificato di distribuzione (Apple Distribution).
  3. Provisioning profile App Store collegato ai due precedenti.

In Xcode (ios/Runner.xcworkspace) imposta il Team sotto Signing & Capabilities e lascia attivo Automatically manage signing se lavori in locale; in CI è preferibile la firma manuale con certificati importati in un keychain temporaneo (fastlane match risolve bene il problema).

Nel Info.plist non dimenticare:

<key>ITSAppUsesNonExemptEncryption</key>
<false/>

Evita la domanda sull'export compliance a ogni build (impostalo a false solo se usi esclusivamente la crittografia standard di sistema, es. HTTPS).

Requisito 2025: le app inviate all'App Store devono essere compilate con Xcode 16 e SDK iOS 18. Verifica inoltre che tutti i plugin con codice nativo includano il privacy manifest (PrivacyInfo.xcprivacy): se un SDK della lista Apple ne è sprovvisto, l'upload viene rifiutato con un warning bloccante via email.

5. Build e upload dell'IPA

Dalla riga di comando:

flutter build ipa --release \
  --export-method app-store-connect \
  --obfuscate --split-debug-info=build/symbols

Se hai esigenze particolari (bitcode, simboli, distribuzione ad-hoc) puoi passare un plist dedicato:

flutter build ipa --export-options-plist=ios/ExportOptions.plist
<!-- ios/ExportOptions.plist -->
<plist version="1.0">
<dict>
  <key>method</key>
  <string>app-store-connect</string>
  <key>teamID</key>
  <string>ABCDE12345</string>
  <key>uploadSymbols</key>
  <true/>
</dict>
</plist>

L'IPA risultante (build/ios/ipa/*.ipa) si carica con l'app Transporter, con Xcode Organizer oppure, in automazione, con fastlane pilot upload.

6. Canali di test e rollout

Google Play

I track disponibili sono quattro: internal testing (fino a 100 tester, disponibile in pochi minuti), closed, open e production. Il flusso consigliato è internal → closed → produzione con rollout graduale: parti dal 5-10% degli utenti e monitora crash rate e ANR in Android Vitals prima di salire. Se qualcosa va storto puoi fermare il rilascio (ma non "tornare indietro": serve una nuova build con versionCode più alto).

Attenzione al requisito introdotto per i nuovi account sviluppatore personali: 12 tester attivi per 14 giorni consecutivi in closed testing prima di poter pubblicare in produzione.

App Store Connect

Dopo l'upload la build appare in TestFlight entro qualche minuto (dopo l'elaborazione). I tester interni (fino a 100 membri del team) ricevono la build subito; i gruppi esterni (fino a 10.000 utenti) richiedono una breve revisione di Apple. Per la produzione puoi usare il rilascio graduale su 7 giorni e la pubblicazione manuale dopo l'approvazione.

Prepara in anticipo i materiali che bloccano più spesso l'invio: screenshot nelle dimensioni richieste, note sulla privacy (App Privacy), account demo funzionante per il reviewer e una descrizione chiara dei permessi usati (le stringhe NS...UsageDescription devono spiegare il motivo reale, non essere generiche).

7. Privacy e conformità

  • Google Play Data Safety: va dichiarato quali dati raccogli, se vengono condivisi e se sono cifrati in transito. Attenzione: anche SDK di terze parti (analytics, ads, crash reporting) contano.
  • Apple App Privacy + PrivacyInfo.xcprivacy con le required reason API usate.
  • Account deletion: entrambi gli store richiedono un percorso in-app o un link web per eliminare l'account se l'app ne consente la creazione.
  • Consenso pubblicitario: se usi ads nello Spazio Economico Europeo serve una CMP certificata (es. Google UMP).

8. Automatizzare con fastlane

Un Fastfile minimale per Android evita l'upload manuale:

# android/fastlane/Fastfile
default_platform(:android)

platform :android do
  desc "Deploy su internal testing"
  lane :internal do
    sh("flutter build appbundle --release")
    upload_to_play_store(
      track: "internal",
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      skip_upload_metadata: true,
      skip_upload_images: true
    )
  end
end

Per iOS, match gestisce certificati condivisi in un repo cifrato e pilot carica su TestFlight. Collegando il tutto a GitHub Actions (con i segreti in secrets: keystore in base64, API key di Play, App Store Connect API key) ottieni un rilascio riproducibile con un tag git.

9. Checklist pre-release

  • flutter analyze e test verdi, dipendenze aggiornate (flutter pub outdated).
  • Build in modalità release provata su dispositivo fisico, non solo su emulatore.
  • Icone e splash screen generati per tutte le densità; nome app corretto per ogni flavor.
  • Endpoint di produzione configurati (niente URL di staging nei flavor sbagliati).
  • Crash reporting attivo e simboli caricati.
  • versionCode/buildNumber incrementati.
  • Offuscamento abilitato e cartella symbols archiviata insieme all'artefatto.
  • Permessi inutili rimossi dal AndroidManifest.xml e dall'Info.plist (i plugin ne aggiungono spesso più del necessario).
  • Policy sulla privacy raggiungibile da URL pubblico.

Conclusione

La pubblicazione è un processo ripetibile: una volta create le chiavi, configurati i flavor e automatizzati gli upload, rilasciare una nuova versione diventa questione di minuti. L'investimento iniziale più importante è la gestione dei segreti (keystore, certificati, API key) e la conservazione dei simboli di debug: sono le due cose che, se perse, costano davvero care mesi dopo il lancio.