Perché serve una strategia di aggiornamento

Quando un'app Flutter è in produzione, non tutti gli utenti aggiornano. Le statistiche degli store mostrano che una quota non trascurabile di installazioni resta ferma a versioni vecchie anche per mesi: significa bug già risolti che continuano a generare crash, API di backend che devi mantenere retrocompatibili all'infinito e feature nuove che nessuno vede.

La soluzione è costruire un piccolo update gate: all'avvio l'app chiede al backend qual è la versione minima supportata e quella consigliata, confronta con la versione installata e decide se mostrare un blocco (force update), un suggerimento (soft update) o nulla.

Nello stesso flusso conviene ragionare anche sul prompt di recensione: chiederla nel momento sbagliato abbassa il rating, chiederla dopo un'azione di successo lo alza. Vediamo entrambi gli aspetti con codice pronto all'uso.

I pacchetti necessari

dependencies:
  package_info_plus: ^8.0.0     # versione e build number installati
  in_app_update: ^4.2.3         # solo Android: Play In-App Updates
  in_app_review: ^2.0.9         # prompt recensione nativo iOS/Android
  url_launcher: ^6.3.0          # fallback verso lo store
  shared_preferences: ^2.3.0    # throttling del prompt

Su iOS non esiste un'API equivalente a Play In-App Updates: l'unica strada supportata è mostrare una schermata nostra e aprire l'App Store.

Confrontare le versioni nel modo giusto

Il primo errore classico è confrontare le versioni come stringhe: "1.10.0" risulterebbe minore di "1.9.0". Serve un confronto per componenti numeriche.

/// Ritorna < 0 se a è precedente a b, 0 se uguali, > 0 se successiva.
int compareVersions(String a, String b) {
  List<int> parse(String v) => v
      .split('+')
      .first // ignora il build number "1.2.3+45"
      .split('.')
      .map((p) => int.tryParse(p.replaceAll(RegExp(r'[^0-9]'), '')) ?? 0)
      .toList();

  final pa = parse(a);
  final pb = parse(b);
  final len = pa.length > pb.length ? pa.length : pb.length;

  for (var i = 0; i < len; i++) {
    final x = i < pa.length ? pa[i] : 0;
    final y = i < pb.length ? pb[i] : 0;
    if (x != y) return x.compareTo(y);
  }
  return 0;
}

Recuperare la versione installata è immediato con package_info_plus:

import 'package:package_info_plus/package_info_plus.dart';

final info = await PackageInfo.fromPlatform();
final currentVersion = info.version;     // es. "1.4.2"
final buildNumber = info.buildNumber;    // es. "142"

Il modello di policy lato server

L'endpoint (o un parametro di Firebase Remote Config, o un JSON su CDN) può restituire qualcosa del genere:

{
  "minSupportedVersion": "1.3.0",
  "latestVersion": "1.6.0",
  "storeUrlIos": "https://apps.apple.com/app/id1234567890",
  "storeUrlAndroid": "https://play.google.com/store/apps/details?id=com.example.app",
  "message": "Abbiamo risolto un problema nella sincronizzazione offline."
}

Modelliamo il risultato con un enum e una piccola classe:

enum UpdateStatus { none, optional, required }

class UpdatePolicy {
  const UpdatePolicy({
    required this.minSupportedVersion,
    required this.latestVersion,
    required this.storeUrl,
    this.message,
  });

  final String minSupportedVersion;
  final String latestVersion;
  final String storeUrl;
  final String? message;

  UpdateStatus evaluate(String installedVersion) {
    if (compareVersions(installedVersion, minSupportedVersion) < 0) {
      return UpdateStatus.required;
    }
    if (compareVersions(installedVersion, latestVersion) < 0) {
      return UpdateStatus.optional;
    }
    return UpdateStatus.none;
  }
}

Separare minSupportedVersion da latestVersion è la chiave: la prima è una regola di compatibilità (tipicamente legata al contratto delle API), la seconda è solo un invito.

Android: Play In-App Updates

Su Android il Play Store offre due modalità native, entrambe esposte dal pacchetto in_app_update:

  • Flexible: l'aggiornamento viene scaricato in background, l'utente continua a usare l'app e poi conferma il riavvio.
  • Immediate: schermata a tutto schermo bloccante gestita da Google Play, ideale per i force update.
import 'dart:io';
import 'package:in_app_update/in_app_update.dart';

Future<bool> tryPlayUpdate({required bool immediate}) async {
  if (!Platform.isAndroid) return false;
  try {
    final info = await InAppUpdate.checkForUpdate();
    if (info.updateAvailability != UpdateAvailability.updateAvailable) {
      return false;
    }

    if (immediate && info.immediateUpdateAllowed) {
      await InAppUpdate.performImmediateUpdate();
      return true;
    }

    if (!immediate && info.flexibleUpdateAllowed) {
      await InAppUpdate.startFlexibleUpdate();
      await InAppUpdate.completeFlexibleUpdate();
      return true;
    }
  } catch (_) {
    // Nessun Play Store, build di debug, sideload: si passa al fallback.
  }
  return false;
}

Attenzione: checkForUpdate() funziona solo su build installate dal Play Store (anche tramite canale di test interno). In debug lancia sempre un'eccezione, quindi il fallback verso url_launcher è obbligatorio.

iOS e fallback: aprire lo store

import 'package:url_launcher/url_launcher.dart';

Future<void> openStore(String url) async {
  final uri = Uri.parse(url);
  if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
    throw Exception('Impossibile aprire lo store');
  }
}

Se non vuoi mantenere latestVersion a mano, su iOS puoi interrogare l'endpoint pubblico di lookup di iTunes (https://itunes.apple.com/lookup?bundleId=...) e leggere il campo version. È comodo, ma ha una propagazione della cache di qualche ora: per i force update critici resta più affidabile una configurazione lato server.

L'UpdateGate: bloccare l'app quando serve

Un widget che avvolge l'app e mostra una schermata non dismissibile in caso di versione non supportata:

class UpdateGate extends StatefulWidget {
  const UpdateGate({super.key, required this.child, required this.loadPolicy});

  final Widget child;
  final Future<UpdatePolicy> Function() loadPolicy;

  @override
  State<UpdateGate> createState() => _UpdateGateState();
}

class _UpdateGateState extends State<UpdateGate> {
  UpdateStatus _status = UpdateStatus.none;
  UpdatePolicy? _policy;

  @override
  void initState() {
    super.initState();
    _check();
  }

  Future<void> _check() async {
    try {
      final policy = await widget.loadPolicy();
      final info = await PackageInfo.fromPlatform();
      final status = policy.evaluate(info.version);

      if (status == UpdateStatus.required) {
        final handled = await tryPlayUpdate(immediate: true);
        if (handled) return; // Play gestisce tutto
      }
      if (!mounted) return;
      setState(() {
        _policy = policy;
        _status = status;
      });
    } catch (_) {
      // In caso di errore di rete NON bloccare mai l'utente.
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_status == UpdateStatus.required && _policy != null) {
      return _ForceUpdateScreen(policy: _policy!);
    }
    return widget.child;
  }
}

La schermata bloccante usa PopScope per impedire il back su Android:

class _ForceUpdateScreen extends StatelessWidget {
  const _ForceUpdateScreen({required this.policy});
  final UpdatePolicy policy;

  @override
  Widget build(BuildContext context) {
    return PopScope(
      canPop: false,
      child: Scaffold(
        body: Center(
          child: Padding(
            padding: const EdgeInsets.all(24),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Icon(Icons.system_update, size: 64),
                const SizedBox(height: 16),
                Text('Aggiornamento necessario',
                    style: Theme.of(context).textTheme.headlineSmall),
                const SizedBox(height: 8),
                Text(
                  policy.message ??
                      'Questa versione non è più supportata. Aggiorna per continuare.',
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 24),
                FilledButton(
                  onPressed: () => openStore(policy.storeUrl),
                  child: const Text('Aggiorna ora'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Per l'aggiornamento opzionale evita la schermata a tutto schermo: un MaterialBanner o una SnackBar con azione "Aggiorna" è molto meno invasiva e non fa crollare le sessioni.

Chiedere la recensione nel momento giusto

Sia iOS (SKStoreReviewController) sia Android (Play In-App Review) espongono un prompt nativo che non lascia l'app. Il pacchetto in_app_review li unifica:

import 'package:in_app_review/in_app_review.dart';

final inAppReview = InAppReview.instance;

if (await inAppReview.isAvailable()) {
  await inAppReview.requestReview();
}

Due punti fondamentali, spesso ignorati:

  • Il sistema può decidere di non mostrare nulla. iOS limita il prompt a 3 volte per anno per utente, Play applica quote analoghe. Non esiste alcun callback che ti dica se l'utente ha davvero recensito.
  • Per questo motivo non va mai collegato a un pulsante "Lascia una recensione": per quello esiste openStoreListing(), che porta esplicitamente alla scheda dello store.

Un ReviewService con throttling

Dato che le quote di sistema sono silenziose, conviene sprecarle il meno possibile: chiedi la recensione solo a utenti "felici", cioè dopo N azioni di successo, mai al primo avvio e non più di una volta ogni X mesi.

import 'package:shared_preferences/shared_preferences.dart';

class ReviewService {
  static const _kEvents = 'review_success_events';
  static const _kLastAsk = 'review_last_ask_ms';
  static const _minEvents = 5;
  static const _cooldown = Duration(days: 120);

  final InAppReview _review = InAppReview.instance;

  /// Da chiamare dopo un'azione positiva (ordine completato,
  /// allenamento salvato, export riuscito...).
  Future<void> registerSuccess() async {
    final prefs = await SharedPreferences.getInstance();
    final events = (prefs.getInt(_kEvents) ?? 0) + 1;
    await prefs.setInt(_kEvents, events);

    if (events < _minEvents) return;

    final lastAsk = prefs.getInt(_kLastAsk);
    if (lastAsk != null) {
      final elapsed = DateTime.now().millisecondsSinceEpoch - lastAsk;
      if (elapsed < _cooldown.inMilliseconds) return;
    }

    if (!await _review.isAvailable()) return;

    await _review.requestReview();
    await prefs.setInt(_kLastAsk, DateTime.now().millisecondsSinceEpoch);
    await prefs.setInt(_kEvents, 0);
  }

  Future<void> openStorePage() =>
      _review.openStoreListing(appStoreId: '1234567890');
}

Un ulteriore livello, usato dalle app con rating alti, è il pre-prompt: prima chiedi "Ti stai trovando bene?" con una UI tua; se la risposta è positiva mostri requestReview(), se è negativa apri il form di feedback interno. Così le quote di sistema vengono spese solo sugli utenti soddisfatti.

Errori comuni da evitare

  • Bloccare l'app se la chiamata alla policy fallisce. Se il backend è giù, l'app deve partire comunque: il try/catch che ignora l'errore è intenzionale.
  • Fare il check a ogni rebuild. Esegui il controllo in initState o all'AppLifecycleState.resumed con un debounce di qualche ora, non a ogni navigazione.
  • Mostrare il prompt di recensione all'avvio o dopo un errore. È il modo più veloce per raccogliere una stella.
  • Non testare il flusso su build reali. Sia in_app_update sia il prompt di recensione richiedono un'installazione proveniente dallo store (track interno o TestFlight). In debug il codice deve degradare senza crash.
  • Dimenticare il build number. Se pubblichi hotfix con la stessa version ma build diverse, includi il build number nella policy di confronto.

Testare la logica

La parte più preziosa da testare è il confronto di versioni, che è codice Dart puro:

test('valuta correttamente lo stato di aggiornamento', () {
  const policy = UpdatePolicy(
    minSupportedVersion: '1.3.0',
    latestVersion: '1.6.0',
    storeUrl: 'https://example.com',
  );

  expect(policy.evaluate('1.2.9'), UpdateStatus.required);
  expect(policy.evaluate('1.3.0'), UpdateStatus.optional);
  expect(policy.evaluate('1.10.0'), UpdateStatus.none);
});

Conclusioni

Aggiornamenti e recensioni sono due funzionalità "di contorno" che incidono direttamente su crash rate, costi di manutenzione del backend e visibilità negli store. Con poche decine di righe di Dart puoi avere un update gate robusto — force update lato server, Play In-App Updates su Android, fallback verso lo store su iOS — e un prompt di recensione che rispetta l'utente e le quote di sistema. La regola d'oro resta una: blocca solo quando è davvero necessario e chiedi la recensione solo quando l'utente ha appena vissuto un successo.