Il problema: un hotfix che costa giorni

Hai pubblicato la release 2.4.0 della tua app Flutter. Dopo due ore arriva la segnalazione: un null check operator used on a null value manda in crash la schermata di checkout su una casistica particolare. La correzione è una riga di codice Dart. Il rilascio, però, richiede build, upload, review dello store e — soprattutto — attesa che gli utenti aggiornino. Nel frattempo perdi conversioni.

Shorebird risolve esattamente questo scenario: è un servizio di code push (aggiornamenti over-the-air) pensato specificamente per Flutter, creato da un team che include ex ingegneri del core team Flutter. Permette di spedire modifiche al codice Dart di un'app già installata, senza passare da App Store e Play Store.

Come funziona sotto il cofano

Un'app Flutter in release è compilata AOT in codice macchina nativo: non è possibile "sostituire" il Dart a runtime come si farebbe con un bundle JavaScript in React Native. Shorebird aggira il problema con due componenti:

  1. Un fork dell'engine Flutter che integra un updater scritto in Rust. È l'engine che viene incluso nella tua app quando compili con la CLI di Shorebird.
  2. Le patch binarie: quando crei una patch, Shorebird compila di nuovo il codice Dart e calcola un diff rispetto allo snapshot AOT della release originale. La patch è tipicamente di poche decine o centinaia di KB.

All'avvio, l'app contatta il server, scarica la patch se disponibile e la applica: il codice aggiornato entra in vigore al riavvio successivo. Il flusso è quindi "scarica ora, attiva al prossimo lancio", una scelta deliberata per non alterare lo stato dell'app durante l'uso.

Nota importante sulle policy: gli store consentono aggiornamenti di codice interpretato o comunque non nativo purché non modifichino lo scopo primario dell'app (Apple, linea guida 3.3.2; Google Play analogamente). Shorebird è progettato per rientrare in questi limiti, ma la responsabilità del contenuto delle patch resta tua: non usare il code push per introdurre funzionalità che non avresti potuto sottoporre a review.

Cosa puoi (e non puoi) aggiornare

È la parte che genera più fraintendimenti. Con una patch puoi modificare:

  • codice Dart della tua app e dei package Dart puri;
  • asset dichiarati nel pubspec.yaml (immagini, JSON, font già presenti nella struttura degli asset);
  • stringhe di localizzazione e logica di business.

Non puoi modificare con una patch:

  • codice nativo Kotlin/Java/Swift/Objective-C o plugin che ne cambiano la parte nativa;
  • il AndroidManifest.xml, l'Info.plist, permessi, icone, nome dell'app;
  • la versione di Flutter usata per la release (una patch deve essere compilata con lo stesso engine della release);
  • il numero di versione dell'app.

Regola pratica: se tocchi il nativo, serve una nuova release sullo store. Per tutto il resto, la patch è sufficiente.

Setup del progetto

Installazione della CLI (macOS/Linux):

curl --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/shorebirdtech/install/main/install.sh -sSf | bash
shorebird --version
shorebird login

Su Windows è disponibile uno script PowerShell equivalente. Verificato il login, dalla root del progetto:

shorebird init

Il comando crea un file shorebird.yaml in lib/ (o nella root, a seconda della versione) e lo registra tra gli asset del pubspec.yaml:

# shorebird.yaml
app_id: 5a2f2a0f-9f3b-4b5e-9c40-1f5c8e1b2a34
auto_update: true

L'app_id identifica l'app sul backend Shorebird. Il flag auto_update: true (default) fa sì che l'updater controlli la presenza di patch a ogni avvio; impostandolo a false decidi tu quando e come farlo, via codice.

Controlla che nel pubspec.yaml sia presente:

flutter:
  assets:
    - shorebird.yaml

Creare una release

La release è il pacchetto che carichi sullo store: contiene l'engine Shorebird ed è la "base" su cui si innestano le patch.

# Android (app bundle)
shorebird release android

# iOS
shorebird release ios

Gli artefatti finiscono nelle solite cartelle (build/app/outputs/bundle/release/... per Android, l'.ipa per iOS) e vanno caricati normalmente sugli store. Sotto il cofano Shorebird invoca flutter build con l'engine patchato, quindi puoi passare gli stessi argomenti:

shorebird release android \
  --flavor production \
  --target lib/main_production.dart \
  -- --dart-define=API_URL=https://api.example.com

Con i flavor, shorebird.yaml usa una mappa di app_id invece di uno singolo:

app_ids:
  dev: 1d2f...
  staging: 7ab3...
  production: 5a2f...

Per garantire riproducibilità, blocca la versione di Flutter usata per la release:

shorebird release android --flutter-version=3.27.1

Pubblicare una patch

Dopo aver corretto il bug in Dart, senza toccare nulla di nativo:

shorebird patch android --release-version 2.4.0+42
shorebird patch ios --release-version 2.4.0+42

La CLI verifica che le modifiche siano compatibili con il code push: se rileva cambiamenti nel codice nativo o nelle dipendenze native, ti avvisa e (a seconda della configurazione) blocca l'operazione. Un output tipico ti mostra la dimensione della patch e chiede conferma prima di pubblicarla.

Track: staging, beta e stable

Non spedire mai una patch a tutti senza averla provata su un dispositivo reale. Shorebird espone dei track:

# pubblica sul track di staging
shorebird patch android --release-version 2.4.0+42 --track staging

# quando sei sicuro, promuovi su stable
shorebird patch android --release-version 2.4.0+42 --track stable

Puoi anche installare localmente una build di release già pubblicata per verificarne il comportamento:

shorebird preview --release-version 2.4.0+42

Rollback

Se una patch si rivela problematica, la strategia è pubblicarne subito una nuova che ripristina il codice precedente: la patch più recente vince sempre. Dalla console web puoi inoltre disattivare una patch, impedendo che venga distribuita a nuovi dispositivi.

Controllare gli aggiornamenti dal codice

Se vuoi mostrare all'utente un banner del tipo "Aggiornamento disponibile, riavvia l'app", usa il package ufficiale shorebird_code_push (API v2):

dependencies:
  shorebird_code_push: ^2.0.0
import 'package:flutter/material.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';

class UpdateService {
  UpdateService() : _updater = ShorebirdUpdater();

  final ShorebirdUpdater _updater;

  /// L'updater è attivo solo nelle build create con `shorebird release`.
  bool get isEnabled => _updater.isAvailable;

  Future<Patch?> currentPatch() => _updater.readCurrentPatch();

  Future<bool> checkAndDownload() async {
    if (!isEnabled) return false;

    final status = await _updater.checkForUpdate();
    if (status != UpdateStatus.outdated) return false;

    try {
      await _updater.update();
      return true; // patch scaricata: attiva al prossimo riavvio
    } on UpdateException catch (error) {
      debugPrint('Aggiornamento fallito: ${error.message}');
      return false;
    }
  }
}

Un widget che avvisa l'utente in modo non invasivo:

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

  final Widget child;

  @override
  State<UpdateBanner> createState() => _UpdateBannerState();
}

class _UpdateBannerState extends State<UpdateBanner> {
  final _service = UpdateService();
  bool _ready = false;

  @override
  void initState() {
    super.initState();
    // Non bloccare il primo frame: il download avviene in background.
    WidgetsBinding.instance.addPostFrameCallback((_) async {
      final updated = await _service.checkAndDownload();
      if (mounted && updated) setState(() => _ready = true);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        if (_ready)
          MaterialBanner(
            content: const Text(
              'Aggiornamento pronto: riavvia l\'app per applicarlo.',
            ),
            actions: [
              TextButton(
                onPressed: () => setState(() => _ready = false),
                child: const Text('OK'),
              ),
            ],
          ),
        Expanded(child: widget.child),
      ],
    );
  }
}

Se vuoi il controllo totale, imposta auto_update: false in shorebird.yaml e invoca checkForUpdate()/update() solo in condizioni specifiche (per esempio soltanto sotto Wi-Fi, sfruttando connectivity_plus).

Utile anche loggare il numero di patch attiva negli errori inviati al crash reporter, così sai esattamente quale codice stava girando:

final patch = await ShorebirdUpdater().readCurrentPatch();
Sentry.configureScope(
  (scope) => scope.setTag('shorebird_patch', '${patch?.number ?? 0}'),
);

Integrazione con CI/CD

Shorebird pubblica una GitHub Action ufficiale. Un workflow che crea una patch a ogni push su hotfix/*:

name: Shorebird Patch

on:
  push:
    branches: ['hotfix/*']

jobs:
  patch:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: 3.27.1
      - uses: shorebirdtech/setup-shorebird@v1
      - name: Crea la patch Android
        env:
          SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
        run: |
          shorebird patch android \
            --release-version ${{ vars.RELEASE_VERSION }} \
            --track staging \
            --no-confirm

Il token si genera con shorebird login:ci e va conservato come secret. Per iOS servono runner macOS e i soliti certificati di firma.

Best practice operative

  • Una release per ogni versione dello store: le patch sono legate a una specifica release-version. Se supporti più versioni in produzione, devi pubblicare la patch per ciascuna.
  • Pin della versione di Flutter: cambiare minor version di Flutter significa nuova release. Blocca la versione nel progetto (FVM o --flutter-version) e nella CI.
  • Patch piccole e mirate: una patch è un hotfix, non un canale di rilascio alternativo. Le feature grandi passano dallo store.
  • Test prima di stable: usa il track staging e un gruppo interno di dispositivi.
  • Monitora l'adozione: la console mostra quante installazioni hanno applicato ogni patch; ricorda che l'attivazione avviene al riavvio, quindi l'adozione non è istantanea.
  • Coerenza con il backend: se la patch cambia il contratto con le API, gestisci la retrocompatibilità per gli utenti che non hanno ancora riavviato.

Costi e alternative

Shorebird offre un piano gratuito con un numero limitato di installazioni patchate al mese e piani a pagamento per volumi maggiori; esiste anche la possibilità di self-hosting per casi enterprise. Le alternative reali sono poche: soluzioni come Firebase Remote Config permettono di attivare/disattivare comportamenti già presenti nel binario (i feature flag), ma non di distribuire codice nuovo. Sono strumenti complementari, non sostituti.

Conclusioni

Il code push non elimina il ciclo di rilascio tradizionale: lo affianca, riducendo drasticamente il tempo di risposta agli incidenti. Con Shorebird un hotfix Dart passa da "due giorni e una review" a "dieci minuti e un riavvio dell'app". Il prezzo da pagare è una disciplina in più: engine e versione di Flutter da tenere sotto controllo, patch limitate al mondo Dart e un processo di staging serio prima di toccare la produzione. Se gestisci un'app con utenti reali e SLA stringenti, è un investimento che si ripaga al primo incidente evitato.