[{"data":1,"prerenderedAt":27},["ShallowReactive",2],{"articolo-server-driven-ui-in-flutter-costruire-interfacce-pilotate-dal-backend":3,"comments-article-server-driven-ui-in-flutter-costruire-interfacce-pilotate-dal-backend":26},{"id":4,"title":5,"slug":6,"excerpt":7,"body":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"status":16,"published_at":17,"meta_title":18,"meta_description":19,"category":20,"author":24},110,"Server-driven UI in Flutter: costruire interfacce pilotate dal backend","server-driven-ui-in-flutter-costruire-interfacce-pilotate-dal-backend","Come progettare un'architettura server-driven UI in Flutter: schema JSON, registry di widget, gestione delle azioni, versionamento, caching offline e sicurezza. Con esempi di codice e una panoramica su rfw e stac.","## Cos'è la server-driven UI\n\nNella 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.\n\nLa **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.\n\nÈ 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.\n\n## Quando conviene (e quando no)\n\nLa SDUI ha senso quando:\n\n- hai **schermate a contenuto variabile**: home, feed, landing page promozionali, cataloghi;\n- fai **A\u002FB testing** frequenti su layout e copy;\n- devi supportare **campagne marketing** con tempi di pubblicazione inferiori al ciclo di release;\n- hai più client (Flutter, web, TV) che devono mostrare le stesse sezioni.\n\nÈ invece controproducente quando:\n\n- la schermata è **altamente interattiva** (editor, mappe, giochi, form complessi con validazione custom);\n- hai bisogno di animazioni fini o di stato locale articolato;\n- il team backend non è pronto a mantenere un contratto UI stabile nel tempo.\n\n> 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.\n\n## Progettare il contratto JSON\n\nIl 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.\n\nMeglio usare un vocabolario **semantico e di dominio**, con un numero chiuso di componenti:\n\n```json\n{\n  \"schemaVersion\": 2,\n  \"screenId\": \"home\",\n  \"root\": {\n    \"type\": \"vstack\",\n    \"props\": { \"spacing\": 16, \"padding\": 16 },\n    \"children\": [\n      {\n        \"type\": \"hero_banner\",\n        \"props\": {\n          \"title\": \"Saldi di primavera\",\n          \"imageUrl\": \"https:\u002F\u002Fcdn.example.com\u002Fspring.jpg\"\n        },\n        \"action\": { \"type\": \"navigate\", \"route\": \"\u002Fpromo\u002Fspring\" }\n      },\n      {\n        \"type\": \"product_carousel\",\n        \"props\": { \"title\": \"Novità\", \"endpoint\": \"\u002Fapi\u002Fproducts?tag=new\" }\n      },\n      {\n        \"type\": \"primary_button\",\n        \"props\": { \"label\": \"Scopri di più\" },\n        \"action\": { \"type\": \"open_url\", \"url\": \"https:\u002F\u002Fexample.com\u002Fnews\" }\n      }\n    ]\n  }\n}\n```\n\nTre regole d'oro per lo schema:\n\n1. **Nodi con `type`, `props`, `children` e `action`**: struttura uniforme, facile da validare e da estendere.\n2. **Nessuna logica nel JSON**: niente espressioni da interpretare, niente codice. Solo dati.\n3. **Componenti chiusi e versionati**: ogni tipo è documentato lato client e lato server, con un contratto esplicito sulle props obbligatorie.\n\n## Il modello dei nodi\n\nIniziamo con una rappresentazione minimale e immutabile dell'albero:\n\n```dart\nclass SduNode {\n  const SduNode({\n    required this.type,\n    this.props = const {},\n    this.children = const [],\n    this.action,\n  });\n\n  final String type;\n  final Map\u003CString, dynamic> props;\n  final List\u003CSduNode> children;\n  final Map\u003CString, dynamic>? action;\n\n  factory SduNode.fromJson(Map\u003CString, dynamic> json) {\n    return SduNode(\n      type: json['type'] as String? ?? 'unknown',\n      props: (json['props'] as Map\u003CString, dynamic>?) ?? const {},\n      children: ((json['children'] as List\u003Cdynamic>?) ?? const [])\n          .whereType\u003CMap\u003CString, dynamic>>()\n          .map(SduNode.fromJson)\n          .toList(growable: false),\n      action: json['action'] as Map\u003CString, dynamic>?,\n    );\n  }\n\n  T? prop\u003CT>(String key) {\n    final value = props[key];\n    return value is T ? value : null;\n  }\n}\n```\n\nIl metodo `prop\u003CT>` evita cast sparsi ovunque e restituisce `null` quando il backend manda un tipo inatteso: fondamentale, perché un payload remoto **non è mai affidabile**.\n\n## Il registry dei componenti\n\nIl 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).\n\n```dart\ntypedef SduBuilder = Widget Function(BuildContext context, SduNode node, SduRenderer renderer);\n\nclass SduRenderer {\n  SduRenderer({\n    required Map\u003CString, SduBuilder> builders,\n    required this.onAction,\n    this.fallback,\n  }) : _builders = Map.unmodifiable(builders);\n\n  final Map\u003CString, SduBuilder> _builders;\n  final void Function(BuildContext context, Map\u003CString, dynamic> action) onAction;\n  final SduBuilder? fallback;\n\n  Widget build(BuildContext context, SduNode node) {\n    final builder = _builders[node.type] ?? fallback;\n    if (builder == null) {\n      \u002F\u002F Nodo sconosciuto: non crashare mai, degrada in silenzio.\n      assert(false, 'Componente SDUI non registrato: ${node.type}');\n      return const SizedBox.shrink();\n    }\n    return builder(context, node, this);\n  }\n\n  List\u003CWidget> buildChildren(BuildContext context, SduNode node) =>\n      node.children.map((child) => build(context, child)).toList(growable: false);\n}\n```\n\nE i builder concreti:\n\n```dart\nfinal Map\u003CString, SduBuilder> defaultBuilders = {\n  'vstack': (context, node, r) {\n    final spacing = (node.prop\u003Cnum>('spacing') ?? 0).toDouble();\n    final padding = (node.prop\u003Cnum>('padding') ?? 0).toDouble();\n    return Padding(\n      padding: EdgeInsets.all(padding),\n      child: Column(\n        crossAxisAlignment: CrossAxisAlignment.stretch,\n        spacing: spacing, \u002F\u002F Flutter 3.27+\n        children: r.buildChildren(context, node),\n      ),\n    );\n  },\n  'text': (context, node, r) {\n    final style = switch (node.prop\u003CString>('variant')) {\n      'title' => Theme.of(context).textTheme.titleLarge,\n      'caption' => Theme.of(context).textTheme.bodySmall,\n      _ => Theme.of(context).textTheme.bodyMedium,\n    };\n    return Text(node.prop\u003CString>('value') ?? '', style: style);\n  },\n  'primary_button': (context, node, r) => FilledButton(\n        onPressed: node.action == null\n            ? null\n            : () => r.onAction(context, node.action!),\n        child: Text(node.prop\u003CString>('label') ?? ''),\n      ),\n  'hero_banner': (context, node, r) => HeroBannerCard(\n        title: node.prop\u003CString>('title') ?? '',\n        imageUrl: node.prop\u003CString>('imageUrl'),\n        onTap: node.action == null ? null : () => r.onAction(context, node.action!),\n      ),\n};\n```\n\nNota 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.\n\n## Gestire le azioni\n\nLe azioni vanno trattate come comandi tipizzati, con whitelist esplicita:\n\n```dart\nvoid handleAction(BuildContext context, Map\u003CString, dynamic> action) {\n  switch (action['type']) {\n    case 'navigate':\n      final route = action['route'];\n      if (route is String && route.startsWith('\u002F')) {\n        context.push(route); \u002F\u002F go_router\n      }\n    case 'open_url':\n      final url = Uri.tryParse(action['url'] as String? ?? '');\n      if (url != null && url.scheme == 'https') {\n        launchUrl(url, mode: LaunchMode.externalApplication);\n      }\n    case 'track_event':\n      analytics.logEvent(name: action['name'] as String? ?? 'sdui_action');\n    default:\n      debugPrint('Azione SDUI ignorata: ${action['type']}');\n  }\n}\n```\n\nNon accettare mai deep link arbitrari o schemi non `https`: un payload compromesso diventerebbe un vettore di phishing dentro la tua app.\n\n## Versionamento e retrocompatibilità\n\nL'app in circolazione **non si aggiorna tutta insieme**. Servono quindi due meccanismi:\n\n- il client invia la versione dello schema supportata (header o query param): `GET \u002Fapi\u002Fscreens\u002Fhome?schemaVersion=2`;\n- il renderer **degrada elegantemente** sui tipi sconosciuti, con un fallback che può essere `SizedBox.shrink()` in produzione e un placeholder rosso in debug.\n\n```dart\nfinal renderer = SduRenderer(\n  builders: defaultBuilders,\n  onAction: handleAction,\n  fallback: kDebugMode\n      ? (context, node, r) => ColoredBox(\n            color: Colors.red.withValues(alpha: 0.2),\n            child: Text('Tipo sconosciuto: ${node.type}'),\n          )\n      : (context, node, r) => const SizedBox.shrink(),\n);\n```\n\nRegole 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.\n\n## Caching e comportamento offline\n\nUna schermata server-driven senza rete è una schermata vuota: inaccettabile. La strategia più efficace è **stale-while-revalidate**, con un payload di default incluso negli asset:\n\n```dart\nFuture\u003CSduNode> loadScreen(String id) async {\n  final cached = await _cache.read(id); \u002F\u002F file o Isar\u002FDrift\n  if (cached != null) {\n    unawaited(_refreshInBackground(id));\n    return SduNode.fromJson(jsonDecode(cached));\n  }\n  try {\n    final remote = await _api.fetchScreen(id).timeout(const Duration(seconds: 4));\n    await _cache.write(id, jsonEncode(remote));\n    return SduNode.fromJson(remote);\n  } catch (_) {\n    final bundled = await rootBundle.loadString('assets\u002Fsdui\u002F$id.json');\n    return SduNode.fromJson(jsonDecode(bundled));\n  }\n}\n```\n\nCosì 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.\n\n## Sicurezza e policy degli store\n\nUn 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.\n\nChecklist minima:\n\n- valida il payload lato client (schema JSON, tipi, campi obbligatori) prima di renderizzare;\n- whitelist di tipi, azioni, rotte e domini per le immagini;\n- servi i payload solo su HTTPS, con autenticazione se contengono dati personali;\n- non mettere segreti nel JSON: passa dalla sessione già autenticata.\n\n## Testare il renderer\n\nLa SDUI si presta benissimo ai test automatici: i payload sono file, quindi diventano **fixture**.\n\n```dart\ntestWidgets('renderizza un hero banner con azione', (tester) async {\n  final json = jsonDecode(await File('test\u002Ffixtures\u002Fhome_v2.json').readAsString());\n  final node = SduNode.fromJson(json['root'] as Map\u003CString, dynamic>);\n\n  await tester.pumpWidget(MaterialApp(\n    home: Scaffold(body: Builder(builder: (c) => renderer.build(c, node))),\n  ));\n\n  expect(find.text('Saldi di primavera'), findsOneWidget);\n});\n```\n\nAggiungi 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.\n\n## Soluzioni pronte all'uso\n\nSe non vuoi scrivere il renderer da zero:\n\n- **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.\n- **stac** (evoluzione di Mirai): mappa direttamente widget Flutter da JSON, ottimo per prototipare rapidamente.\n- **json_dynamic_widget**: molto ampio, con registry estendibile e supporto a espressioni.\n\nAnche adottando un pacchetto, i principi restano gli stessi: vocabolario chiuso, fallback sicuri, versionamento del contratto e caching.\n\n## Conclusioni\n\nLa 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.\n\nParti 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.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Farticles\u002F25a7c9d0-3835-4671-827d-ce8124b2cbf7.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1774901128283-64c62117216a?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3OTA0ODE2OTR8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"Bernd 📷 Dittrich","https:\u002F\u002Funsplash.com\u002F@hdbernd","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fcomputer-screen-displaying-code-and-terminal-output-fmH6yLBwEPw",null,"published","2026-09-27T04:01:34+00:00","Server-driven UI in Flutter: guida pratica","Come realizzare una server-driven UI in Flutter: schema JSON, registry di widget, azioni, versionamento, cache offline, sicurezza e pacchetti come rfw e stac.",{"id":21,"name":22,"slug":23},1,"Guide","guide",{"id":21,"name":25},"Flutter Bot",[],1792316908780]