Cos'è la server-driven UI

Nella maggior parte delle app Flutter il layout è compilato dentro il binario: per cambiare l'ordine di due sezioni della home o aggiungere un banner promozionale serve una nuova release e l'attesa della review degli store.

La server-driven UI (SDUI) ribalta questo modello: il backend non restituisce solo i dati, ma anche una descrizione dichiarativa dell'interfaccia (tipicamente JSON). L'app contiene un renderer che traduce quella descrizione in widget Flutter reali. Il risultato è nativo al 100%, ma il contenuto e la struttura sono aggiornabili da remoto in tempo reale.

È un approccio usato da tempo da Airbnb, Spotify e Lyft, e in Flutter è particolarmente naturale perché il framework è già dichiarativo: un albero di widget è concettualmente identico a un albero JSON.

Quando conviene (e quando no)

La SDUI ha senso quando:

  • hai schermate a contenuto variabile: home, feed, landing page promozionali, cataloghi;
  • fai A/B testing frequenti su layout e copy;
  • devi supportare campagne marketing con tempi di pubblicazione inferiori al ciclo di release;
  • hai più client (Flutter, web, TV) che devono mostrare le stesse sezioni.

È invece controproducente quando:

  • la schermata è altamente interattiva (editor, mappe, giochi, form complessi con validazione custom);
  • hai bisogno di animazioni fini o di stato locale articolato;
  • il team backend non è pronto a mantenere un contratto UI stabile nel tempo.

Regola pratica: rendi server-driven il 20% di schermate che cambia l'80% delle volte, e lascia il resto in codice nativo. La SDUI "totale" è quasi sempre un errore di over-engineering.

Progettare il contratto JSON

Il punto più delicato non è il codice Flutter, ma lo schema. Un errore comune è mappare 1:1 i widget Flutter (Column, Padding, Expanded): così si accoppia il backend ai dettagli del framework e ogni cambio di design richiede modifiche al server.

Meglio usare un vocabolario semantico e di dominio, con un numero chiuso di componenti:

{
  "schemaVersion": 2,
  "screenId": "home",
  "root": {
    "type": "vstack",
    "props": { "spacing": 16, "padding": 16 },
    "children": [
      {
        "type": "hero_banner",
        "props": {
          "title": "Saldi di primavera",
          "imageUrl": "https://cdn.example.com/spring.jpg"
        },
        "action": { "type": "navigate", "route": "/promo/spring" }
      },
      {
        "type": "product_carousel",
        "props": { "title": "Novità", "endpoint": "/api/products?tag=new" }
      },
      {
        "type": "primary_button",
        "props": { "label": "Scopri di più" },
        "action": { "type": "open_url", "url": "https://example.com/news" }
      }
    ]
  }
}

Tre regole d'oro per lo schema:

  1. Nodi con type, props, children e action: struttura uniforme, facile da validare e da estendere.
  2. Nessuna logica nel JSON: niente espressioni da interpretare, niente codice. Solo dati.
  3. Componenti chiusi e versionati: ogni tipo è documentato lato client e lato server, con un contratto esplicito sulle props obbligatorie.

Il modello dei nodi

Iniziamo con una rappresentazione minimale e immutabile dell'albero:

class SduNode {
  const SduNode({
    required this.type,
    this.props = const {},
    this.children = const [],
    this.action,
  });

  final String type;
  final Map<String, dynamic> props;
  final List<SduNode> children;
  final Map<String, dynamic>? action;

  factory SduNode.fromJson(Map<String, dynamic> json) {
    return SduNode(
      type: json['type'] as String? ?? 'unknown',
      props: (json['props'] as Map<String, dynamic>?) ?? const {},
      children: ((json['children'] as List<dynamic>?) ?? const [])
          .whereType<Map<String, dynamic>>()
          .map(SduNode.fromJson)
          .toList(growable: false),
      action: json['action'] as Map<String, dynamic>?,
    );
  }

  T? prop<T>(String key) {
    final value = props[key];
    return value is T ? value : null;
  }
}

Il metodo prop<T> evita cast sparsi ovunque e restituisce null quando il backend manda un tipo inatteso: fondamentale, perché un payload remoto non è mai affidabile.

Il registry dei componenti

Il cuore del renderer è una mappa type -> builder. Ogni builder riceve il nodo e un contesto applicativo (dispatcher delle azioni, tema, funzione ricorsiva per i figli).

typedef SduBuilder = Widget Function(BuildContext context, SduNode node, SduRenderer renderer);

class SduRenderer {
  SduRenderer({
    required Map<String, SduBuilder> builders,
    required this.onAction,
    this.fallback,
  }) : _builders = Map.unmodifiable(builders);

  final Map<String, SduBuilder> _builders;
  final void Function(BuildContext context, Map<String, dynamic> action) onAction;
  final SduBuilder? fallback;

  Widget build(BuildContext context, SduNode node) {
    final builder = _builders[node.type] ?? fallback;
    if (builder == null) {
      // Nodo sconosciuto: non crashare mai, degrada in silenzio.
      assert(false, 'Componente SDUI non registrato: ${node.type}');
      return const SizedBox.shrink();
    }
    return builder(context, node, this);
  }

  List<Widget> buildChildren(BuildContext context, SduNode node) =>
      node.children.map((child) => build(context, child)).toList(growable: false);
}

E i builder concreti:

final Map<String, SduBuilder> defaultBuilders = {
  'vstack': (context, node, r) {
    final spacing = (node.prop<num>('spacing') ?? 0).toDouble();
    final padding = (node.prop<num>('padding') ?? 0).toDouble();
    return Padding(
      padding: EdgeInsets.all(padding),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        spacing: spacing, // Flutter 3.27+
        children: r.buildChildren(context, node),
      ),
    );
  },
  'text': (context, node, r) {
    final style = switch (node.prop<String>('variant')) {
      'title' => Theme.of(context).textTheme.titleLarge,
      'caption' => Theme.of(context).textTheme.bodySmall,
      _ => Theme.of(context).textTheme.bodyMedium,
    };
    return Text(node.prop<String>('value') ?? '', style: style);
  },
  'primary_button': (context, node, r) => FilledButton(
        onPressed: node.action == null
            ? null
            : () => r.onAction(context, node.action!),
        child: Text(node.prop<String>('label') ?? ''),
      ),
  'hero_banner': (context, node, r) => HeroBannerCard(
        title: node.prop<String>('title') ?? '',
        imageUrl: node.prop<String>('imageUrl'),
        onTap: node.action == null ? null : () => r.onAction(context, node.action!),
      ),
};

Nota un dettaglio importante: hero_banner non compone il layout nel renderer, ma delega a un widget Flutter reale già esistente (HeroBannerCard). Il JSON decide cosa mostrare, il codice decide come. Questo mantiene il design system sotto controllo del team mobile e limita drasticamente la superficie d'errore.

Gestire le azioni

Le azioni vanno trattate come comandi tipizzati, con whitelist esplicita:

void handleAction(BuildContext context, Map<String, dynamic> action) {
  switch (action['type']) {
    case 'navigate':
      final route = action['route'];
      if (route is String && route.startsWith('/')) {
        context.push(route); // go_router
      }
    case 'open_url':
      final url = Uri.tryParse(action['url'] as String? ?? '');
      if (url != null && url.scheme == 'https') {
        launchUrl(url, mode: LaunchMode.externalApplication);
      }
    case 'track_event':
      analytics.logEvent(name: action['name'] as String? ?? 'sdui_action');
    default:
      debugPrint('Azione SDUI ignorata: ${action['type']}');
  }
}

Non accettare mai deep link arbitrari o schemi non https: un payload compromesso diventerebbe un vettore di phishing dentro la tua app.

Versionamento e retrocompatibilità

L'app in circolazione non si aggiorna tutta insieme. Servono quindi due meccanismi:

  • il client invia la versione dello schema supportata (header o query param): GET /api/screens/home?schemaVersion=2;
  • il renderer degrada elegantemente sui tipi sconosciuti, con un fallback che può essere SizedBox.shrink() in produzione e un placeholder rosso in debug.
final renderer = SduRenderer(
  builders: defaultBuilders,
  onAction: handleAction,
  fallback: kDebugMode
      ? (context, node, r) => ColoredBox(
            color: Colors.red.withValues(alpha: 0.2),
            child: Text('Tipo sconosciuto: ${node.type}'),
          )
      : (context, node, r) => const SizedBox.shrink(),
);

Regole di evoluzione del contratto: si possono aggiungere props opzionali e nuovi tipi, non si rinominano né si rimuovono campi esistenti finché ci sono versioni attive in produzione. Un semplice endpoint di telemetria che segnala i tipi non renderizzati ti dice quando puoi dismettere una versione.

Caching e comportamento offline

Una schermata server-driven senza rete è una schermata vuota: inaccettabile. La strategia più efficace è stale-while-revalidate, con un payload di default incluso negli asset:

Future<SduNode> loadScreen(String id) async {
  final cached = await _cache.read(id); // file o Isar/Drift
  if (cached != null) {
    unawaited(_refreshInBackground(id));
    return SduNode.fromJson(jsonDecode(cached));
  }
  try {
    final remote = await _api.fetchScreen(id).timeout(const Duration(seconds: 4));
    await _cache.write(id, jsonEncode(remote));
    return SduNode.fromJson(remote);
  } catch (_) {
    final bundled = await rootBundle.loadString('assets/sdui/$id.json');
    return SduNode.fromJson(jsonDecode(bundled));
  }
}

Così il primo frame è sempre immediato (cache o asset), e l'aggiornamento arriva silenziosamente al giro successivo. Aggiungi un ETag lato server per evitare download inutili.

Sicurezza e policy degli store

Un punto che genera spesso dubbi: la SDUI non viola le linee guida di Apple e Google, perché non stai scaricando ed eseguendo codice, ma dati. Il confine da non superare è l'interpretazione di script o bytecode arbitrario che modifichi il comportamento dell'app oltre a quanto già approvato.

Checklist minima:

  • valida il payload lato client (schema JSON, tipi, campi obbligatori) prima di renderizzare;
  • whitelist di tipi, azioni, rotte e domini per le immagini;
  • servi i payload solo su HTTPS, con autenticazione se contengono dati personali;
  • non mettere segreti nel JSON: passa dalla sessione già autenticata.

Testare il renderer

La SDUI si presta benissimo ai test automatici: i payload sono file, quindi diventano fixture.

testWidgets('renderizza un hero banner con azione', (tester) async {
  final json = jsonDecode(await File('test/fixtures/home_v2.json').readAsString());
  final node = SduNode.fromJson(json['root'] as Map<String, dynamic>);

  await tester.pumpWidget(MaterialApp(
    home: Scaffold(body: Builder(builder: (c) => renderer.build(c, node))),
  ));

  expect(find.text('Saldi di primavera'), findsOneWidget);
});

Aggiungi un golden test per ogni componente e un test che verifica che un tipo sconosciuto non faccia crashare l'albero: sono le due regressioni più frequenti.

Soluzioni pronte all'uso

Se non vuoi scrivere il renderer da zero:

  • rfw (Remote Flutter Widgets), mantenuto dal team Flutter: usa un DSL testuale compilabile in formato binario, supporta un modello di dati e logica dichiarativa limitata. È la scelta più solida per progetti seri, a costo di una curva di apprendimento maggiore.
  • stac (evoluzione di Mirai): mappa direttamente widget Flutter da JSON, ottimo per prototipare rapidamente.
  • json_dynamic_widget: molto ampio, con registry estendibile e supporto a espressioni.

Anche adottando un pacchetto, i principi restano gli stessi: vocabolario chiuso, fallback sicuri, versionamento del contratto e caching.

Conclusioni

La server-driven UI in Flutter non è una scorciatoia per evitare di scrivere UI, ma uno strumento per disaccoppiare il ritmo dei contenuti dal ritmo delle release. Il valore sta tutto nella disciplina del contratto: componenti semantici, props opzionali, degradazione elegante e cache locale.

Parti da una singola schermata ad alto tasso di cambiamento, misura quante release hai evitato e solo allora estendi il vocabolario. È l'unico modo per ottenere i benefici della SDUI senza costruire, senza accorgertene, un secondo framework dentro la tua app.