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/catchche ignora l'errore è intenzionale. - Fare il check a ogni rebuild. Esegui il controllo in
initStateo all'AppLifecycleState.resumedcon 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_updatesia 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
versionma 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.