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:
- Nodi con
type,props,childreneaction: struttura uniforme, facile da validare e da estendere. - Nessuna logica nel JSON: niente espressioni da interpretare, niente codice. Solo dati.
- 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.