Perché add-to-app

La narrazione più diffusa su Flutter è quella del progetto greenfield: flutter create, si parte, si pubblica. Nella realtà aziendale, però, la domanda più frequente è un'altra: ho già un'app Android in Kotlin e una in Swift con centinaia di migliaia di utenti, posso introdurre Flutter gradualmente?

La risposta è sì, ed è la modalità add-to-app: Flutter viene integrato come libreria all'interno del progetto nativo e utilizzato per costruire singole schermate o interi flussi, mentre il resto dell'app rimane nativo. È la strada scelta da molte migrazioni incrementali, perché permette di:

  • validare Flutter su una feature non critica prima di impegnarsi;
  • condividere una sola implementazione di flussi complessi (onboarding, checkout, wizard) tra Android e iOS;
  • evitare il big bang rewrite, che è la modalità di migrazione con il tasso di fallimento più alto.

Il prezzo da pagare esiste ed è concreto: aumento della dimensione del binario, complessità della build, e alcune limitazioni tecniche che vedremo alla fine. Vale la pena conoscerle prima di iniziare.

Creare il modulo Flutter

Il punto di partenza non è un'app Flutter ma un module, un template pensato per essere consumato da progetti nativi:

cd /percorso/del/tuo/progetto
flutter create --template module my_flutter

Il modulo ha una struttura familiare (lib/, pubspec.yaml) più due cartelle nascoste generate automaticamente: .android/ e .ios/. Contengono i progetti wrapper che permettono di eseguire il modulo in standalone (utile per lo sviluppo con hot reload) e che espongono gli artefatti alle build native. Non vanno modificate a mano e non vanno versionate come codice di progetto.

Il punto d'ingresso resta lib/main.dart, ma in add-to-app conviene ragionare per widget-schermata più che per app intera:

import 'package:flutter/material.dart';

void main() => runApp(const MyFlutterModule());

class MyFlutterModule extends StatelessWidget {
  const MyFlutterModule({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(useMaterial3: true),
      // La rotta iniziale può essere imposta dal lato nativo
      initialRoute: '/',
      routes: {
        '/': (_) => const LoyaltyScreen(),
        '/rewards': (_) => const RewardsScreen(),
      },
    );
  }
}

Integrazione su Android

Ci sono due strategie: source code integration (il progetto nativo compila il modulo Flutter) e AAR (il modulo viene pubblicato come dipendenza Maven).

Source code integration

Nel settings.gradle del progetto Android:

include ':app'

setBinding(new Binding([gradle: this]))
evaluate(new File(
  settingsDir.parentFile,
  'my_flutter/.android/include_flutter.groovy'
))

E nel build.gradle del modulo app:

dependencies {
  implementation project(':flutter')
}

android {
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_11
    targetCompatibility JavaVersion.VERSION_11
  }
}

Questo approccio richiede che tutti gli sviluppatori Android abbiano il Flutter SDK installato, ma abilita hot reload e debug integrato: è l'opzione consigliata durante lo sviluppo attivo.

Integrazione via AAR

Se il team nativo non deve installare Flutter (scenario tipico in aziende con pipeline consolidate), si pubblica il modulo:

cd my_flutter
flutter build aar

Il comando produce un repository Maven locale e stampa lo snippet da incollare nel Gradle. Da notare la mappatura dei build type: Flutter genera varianti debug, profile e release, e il progetto nativo deve dichiarare un profile build type per non rompere la risoluzione delle dipendenze.

Lanciare una schermata

Il modo più semplice è FlutterActivity:

startActivity(FlutterActivity.createDefaultIntent(this))

Per inserire Flutter dentro una UI nativa esistente (per esempio in un tab di una BottomNavigationView) si usa invece FlutterFragment:

val fragment = FlutterFragment.withCachedEngine("main_engine")
  .build<FlutterFragment>()

supportFragmentManager.beginTransaction()
  .replace(R.id.flutter_container, fragment)
  .commit()

Integrazione su iOS

Su iOS la via standard è CocoaPods. Nel Podfile:

flutter_application_path = '../my_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

target 'MyApp' do
  install_all_flutter_pods(flutter_application_path)
end

post_install do |installer|
  flutter_post_install(installer) if defined?(flutter_post_install)
end

Serve inoltre una build phase che esegua lo script xcode_backend.sh del modulo (lo podhelper.rb documenta i passaggi). L'alternativa "senza Flutter SDK" è:

cd my_flutter
flutter build ios-framework --output=../Flutter/

che produce Flutter.xcframework, App.xcframework e i framework dei plugin, da linkare manualmente o distribuire via CocoaPods privato/SPM.

Per mostrare una schermata:

import Flutter

let flutterViewController = FlutterViewController(
  engine: engine,
  nibName: nil,
  bundle: nil
)
flutterViewController.modalPresentationStyle = .fullScreen
present(flutterViewController, animated: true)

Pre-riscaldare l'engine: la differenza tra fluido e lento

Creare un FlutterEngine significa avviare la VM Dart, caricare gli asset e registrare i plugin. Farlo nel momento in cui l'utente tocca il pulsante produce un ritardo percepibile. La soluzione è istanziare l'engine in anticipo e metterlo in cache.

Su Android, nella Application:

class MyApplication : Application() {
  lateinit var flutterEngine: FlutterEngine

  override fun onCreate() {
    super.onCreate()
    flutterEngine = FlutterEngine(this)
    flutterEngine.dartExecutor.executeDartEntrypoint(
      DartExecutor.DartEntrypoint.createDefault()
    )
    FlutterEngineCache.getInstance().put("main_engine", flutterEngine)
  }
}

E poi:

startActivity(
  FlutterActivity.withCachedEngine("main_engine").build(this)
)

Su iOS, nell'AppDelegate:

import Flutter
import FlutterPluginRegistrant

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  lazy var flutterEngine = FlutterEngine(name: "main_engine")

  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    flutterEngine.run()
    GeneratedPluginRegistrant.register(with: flutterEngine)
    return true
  }
}

Attenzione: su iOS la registrazione dei plugin per un engine creato manualmente è a carico tuo, altrimenti i plugin non funzioneranno.

Un engine pre-riscaldato consuma memoria anche quando nessuna schermata Flutter è visibile: valuta se pre-caricarlo all'avvio o poco prima dell'ingresso nel flusso (per esempio quando l'utente apre la sezione che contiene il pulsante).

Più schermate Flutter con FlutterEngineGroup

Se l'app ha bisogno di più istanze Flutter contemporaneamente (due tab, una schermata più un modale), creare più FlutterEngine indipendenti è costoso: ognuno porta con sé il proprio isolate e il proprio snapshot. FlutterEngineGroup risolve il problema condividendo codice, snapshot GPU e risorse tra gli engine, riducendo drasticamente l'overhead di ogni engine aggiuntivo.

val engineGroup = FlutterEngineGroup(context)

val engineA = engineGroup.createAndRunDefaultEngine(context)
val engineB = engineGroup.createAndRunEngine(
  context,
  DartExecutor.DartEntrypoint(
    FlutterInjector.instance().flutterLoader().findAppBundlePath(),
    "ordersMain"
  )
)
let group = FlutterEngineGroup(name: "my_group", project: nil)
let engineA = group.makeEngine(withEntrypoint: nil, libraryURI: nil)
let engineB = group.makeEngine(withEntrypoint: "ordersMain", libraryURI: nil)

Gli entrypoint alternativi vanno marcati per non essere rimossi dal tree shaking:

@pragma('vm:entry-point')
void ordersMain() => runApp(const OrdersApp());

Ricorda che gli engine di un gruppo non condividono lo stato Dart: ogni engine ha il suo isolate, quindi variabili globali, singleton e provider sono separati. Lo stato condiviso va gestito lato nativo o tramite persistenza.

Routing e comunicazione con il nativo

Per dire a Flutter quale schermata mostrare hai tre opzioni.

1. Rotta iniziale con un engine nuovo:

startActivity(
  FlutterActivity
    .withNewEngine()
    .initialRoute("/rewards")
    .build(this)
)

2. setInitialRoute prima di eseguire l'entrypoint (funziona solo alla prima esecuzione di un engine):

flutterEngine.run(withEntrypoint: nil, initialRoute: "/rewards")

3. MethodChannel, l'approccio più flessibile con engine in cache, perché permette di navigare anche quando l'engine è già avviato:

final channel = MethodChannel('app/navigation');

void setupHostNavigation(GlobalKey<NavigatorState> navigatorKey) {
  channel.setMethodCallHandler((call) async {
    if (call.method == 'navigate') {
      final route = call.arguments as String;
      await navigatorKey.currentState?.pushNamed(route);
    }
    return null;
  });
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "app/navigation")
  .invokeMethod("navigate", "/rewards")

Lo stesso canale serve nella direzione opposta, ad esempio per chiudere la schermata Flutter o per ottenere il token di autenticazione già presente nell'app nativa:

final token = await MethodChannel('app/session')
    .invokeMethod<String>('getAuthToken');

È una buona pratica non duplicare l'autenticazione nel modulo Flutter: la sessione resta una sola, quella nativa, e viene esposta via channel (o via flutter_secure_storage se condividi keychain/keystore).

Sviluppo e debug

Durante lo sviluppo puoi lavorare in due modi:

  • standalone: cd my_flutter && flutter run esegue il modulo nel wrapper .android/.ios, con hot reload completo. Ideale per sviluppare la UI, a patto di mockare i MethodChannel del lato nativo;
  • attach: avvii l'app nativa da Android Studio/Xcode, poi flutter attach si collega all'engine in esecuzione abilitando hot reload, DevTools e logging.
cd my_flutter
flutter attach

Per i canali, isola l'accesso alla piattaforma dietro un'interfaccia: in standalone iniettarne una fake diventa banale.

abstract interface class SessionGateway {
  Future<String?> authToken();
}

class NativeSessionGateway implements SessionGateway {
  static const _channel = MethodChannel('app/session');

  @override
  Future<String?> authToken() => _channel.invokeMethod<String>('getAuthToken');
}

class FakeSessionGateway implements SessionGateway {
  @override
  Future<String?> authToken() async => 'dev-token';
}

Limiti e insidie da conoscere

  • Dimensione dell'app: l'engine aggiunge alcuni MB per architettura. Su Android usa App Bundle e split per ABI; su iOS considera l'impatto sul limite di download cellulare.
  • Un engine, una view: un FlutterEngine può essere collegato a una sola FlutterViewController/FlutterActivity per volta. Riutilizzare lo stesso engine per due schermate visibili contemporaneamente causa comportamenti anomali: usa FlutterEngineGroup.
  • Ciclo di vita: quando distruggi l'engine, perdi lo stato Dart. Se l'utente deve poter tornare alla schermata Flutter trovandola come l'ha lasciata, tieni l'engine in cache; altrimenti chiamalo destroy()/rilascialo per liberare memoria.
  • Plugin: alcuni plugin assumono di vivere in un'app Flutter pura e possono collidere con la controparte nativa (analytics, push, permessi). Valuta se non sia meglio esporre la funzionalità nativa esistente via MethodChannel.
  • Transizioni e back gesture: su iOS lo swipe-to-go-back e su Android il predictive back richiedono coordinamento tra il Navigator di Flutter e lo stack nativo. La regola pratica è scegliere un solo padrone della navigazione per ogni flusso: o Flutter gestisce tutto il flusso interno e il nativo solo l'ingresso/uscita, o viceversa.
  • Screenshot e test end-to-end: gli strumenti nativi (Espresso, XCUITest) non vedono l'albero dei widget Flutter. Pianifica test di widget/golden sul modulo e test nativi solo sull'integrazione.

Strategia consigliata

  1. Comincia con una sola schermata a basso rischio e misura: tempo di apertura, memoria, dimensione del binario prima e dopo.
  2. Definisci subito i contratti dei channel e versionali: sono l'API tra due mondi che evolvono con cadenze diverse.
  3. Automatizza la build del modulo in CI producendo AAR e xcframework versionati, così i team nativi restano indipendenti dal Flutter SDK.
  4. Espandi per flussi verticali completi, non per pezzetti di UI mescolati: ogni confine nativo/Flutter è un costo di integrazione.

Add-to-app non è la modalità più comoda di usare Flutter, ma è quella che rende il framework adottabile in contesti dove riscrivere da zero non è un'opzione. Con engine pre-riscaldato, FlutterEngineGroup e confini chiari, la qualità percepita è indistinguibile da quella di un'app pienamente nativa.