Perché gRPC in un'app Flutter
Quando il backend espone API REST, in Flutter si finisce quasi sempre con Dio (o Retrofit) più la serializzazione JSON. Funziona bene, ma porta con sé alcuni costi ricorrenti: il contratto tra client e server vive nella documentazione (o nella testa di qualcuno), i modelli vanno riscritti a mano o generati da JSON, e lo streaming bidirezionale richiede WebSocket gestiti a parte.
gRPC risolve questi problemi alla radice: il contratto è un file .proto, condiviso tra backend e client, da cui si genera codice tipizzato per entrambi. I messaggi viaggiano in formato binario (Protocol Buffers), più compatto del JSON, su HTTP/2 con multiplexing delle richieste sulla stessa connessione. Lo streaming è parte del protocollo, non un'aggiunta.
gRPC ha senso soprattutto quando:
- il backend è già in gRPC (molto comune in architetture a microservizi Go, Kotlin, Rust, .NET);
- servono flussi realtime (feed, posizioni, notifiche in-app, chat) senza inventare un protocollo sopra i WebSocket;
- la banda conta: payload binari e compressione riducono traffico e latenza su reti mobili.
Non ha senso se il backend è una API pubblica REST di terze parti, o se l'app gira solo sul web senza possibilità di mettere un proxy davanti al server (ci torniamo).
Setup del progetto
Aggiungi le dipendenze runtime e il generatore:
dependencies:
grpc: ^4.0.1
protobuf: ^4.0.0
dev_dependencies:
protoc_plugin: ^22.0.1
Serve anche il compilatore protoc installato localmente (su macOS brew install protobuf, su Linux dal package manager, su Windows dai release di GitHub) e il plugin Dart nel PATH:
dart pub global activate protoc_plugin
export PATH="$PATH:$HOME/.pub-cache/bin"
Il contratto: il file .proto
Il file .proto è la single source of truth. Conviene tenerlo in una cartella protos/ versionata (o in un repository Git condiviso, incluso come submodule) in modo che backend e app partano sempre dalla stessa definizione.
syntax = "proto3";
package chat.v1;
service ChatService {
// Chiamata unary: una richiesta, una risposta
rpc GetRoom(GetRoomRequest) returns (Room);
// Server streaming: una richiesta, N risposte
rpc WatchMessages(WatchMessagesRequest) returns (stream Message);
// Bidirezionale: N richieste, N risposte
rpc Chat(stream Message) returns (stream Message);
}
message GetRoomRequest {
string room_id = 1;
}
message Room {
string id = 1;
string name = 2;
int32 members_count = 3;
}
message WatchMessagesRequest {
string room_id = 1;
}
message Message {
string id = 1;
string room_id = 2;
string author = 3;
string text = 4;
int64 sent_at_unix_ms = 5;
}
Due regole d'oro del versionamento Protobuf: i numeri di campo non si riusano mai e i campi obsoleti si marcano reserved. Così un client vecchio continua a funzionare contro un server nuovo.
Generare il codice Dart
mkdir -p lib/src/generated
protoc \
--dart_out=grpc:lib/src/generated \
-Iprotos \
protos/chat/v1/chat.proto
Vengono prodotti quattro file per ogni .proto: i messaggi (chat.pb.dart), il client/servizio gRPC (chat.pbgrpc.dart), gli enum (chat.pbenum.dart) e i descrittori JSON (chat.pbjson.dart).
Conviene incapsulare il comando in uno script (tool/protogen.sh) ed escludere la cartella generata dall'analisi statica:
# analysis_options.yaml
analyzer:
exclude:
- lib/src/generated/**
Creare il canale e il client
Il ClientChannel rappresenta la connessione HTTP/2: va creato una volta sola e riusato, non per ogni chiamata.
import 'package:grpc/grpc.dart';
import 'generated/chat/v1/chat.pbgrpc.dart';
ClientChannel createChannel() {
return ClientChannel(
'api.example.com',
port: 443,
options: const ChannelOptions(
credentials: ChannelCredentials.secure(),
connectionTimeout: Duration(seconds: 10),
idleTimeout: Duration(minutes: 5),
userAgent: 'my-flutter-app/1.0.0',
codecRegistry: CodecRegistry(codecs: [GzipCodec(), IdentityCodec()]),
),
);
}
final channel = createChannel();
final chatClient = ChatServiceClient(channel);
In sviluppo, contro un server locale senza TLS, si usa ChannelCredentials.insecure(). Attenzione all'host su emulatore Android: localhost punta all'emulatore stesso, serve 10.0.2.2.
Alla chiusura dell'app (o quando l'utente fa logout) ricordati di liberare le risorse:
await channel.shutdown();
Chiamate unary e streaming
Una chiamata unary è un semplice Future:
Future<Room> loadRoom(String roomId) async {
final response = await chatClient.getRoom(
GetRoomRequest(roomId: roomId),
options: CallOptions(timeout: const Duration(seconds: 8)),
);
return response;
}
Una chiamata server-streaming restituisce una ResponseStream, che è uno Stream a tutti gli effetti e si integra naturalmente con StreamBuilder, BLoC o Riverpod:
Stream<Message> watchMessages(String roomId) {
final call = chatClient.watchMessages(
WatchMessagesRequest(roomId: roomId),
);
return call; // cancellabile annullando la subscription
}
Per lo streaming bidirezionale si passa uno Stream di richieste, tipicamente alimentato da uno StreamController:
class ChatSession {
ChatSession(this._client);
final ChatServiceClient _client;
final _outgoing = StreamController<Message>();
ResponseStream<Message>? _incoming;
Stream<Message> connect() {
_incoming = _client.chat(_outgoing.stream);
return _incoming!;
}
void send(Message message) => _outgoing.add(message);
Future<void> dispose() async {
await _outgoing.close();
await _incoming?.cancel();
}
}
Regola pratica: ogni stream aperto va chiuso. In un widget significa cancellare la subscription in dispose(); in un repository, esporre un metodo di chiusura e chiamarlo dal container di dependency injection.
Autenticazione e interceptor
Il modo più diretto per inviare un token è il CallOptions con un metadata provider, che viene eseguito a ogni chiamata (quindi può rinfrescare il token scaduto):
CallOptions authOptions(TokenStore store) {
return CallOptions(
providers: [
(Map<String, String> metadata, String uri) async {
final token = await store.validAccessToken();
metadata['authorization'] = 'Bearer $token';
},
],
timeout: const Duration(seconds: 15),
);
}
Per logica trasversale (logging, metriche, retry, gestione del 401) si usa invece un ClientInterceptor applicato all'intero client:
class LoggingInterceptor extends ClientInterceptor {
@override
ResponseFuture<R> interceptUnary<Q, R>(
ClientMethod<Q, R> method,
Q request,
CallOptions options,
ClientUnaryInvoker<Q, R> invoker,
) {
final stopwatch = Stopwatch()..start();
final response = invoker(method, request, options);
response.then(
(_) => debugPrint('${method.path} ok in ${stopwatch.elapsedMilliseconds}ms'),
onError: (Object e) => debugPrint('${method.path} failed: $e'),
);
return response;
}
@override
ResponseStream<R> interceptStreaming<Q, R>(
ClientMethod<Q, R> method,
Stream<Q> requests,
CallOptions options,
ClientStreamingInvoker<Q, R> invoker,
) {
debugPrint('stream aperto: ${method.path}');
return invoker(method, requests, options);
}
}
final chatClient = ChatServiceClient(
channel,
interceptors: [LoggingInterceptor()],
);
Gestire gli errori: GrpcError e status code
gRPC non usa i codici HTTP ma un set di status code propri. Mapparli su errori di dominio evita di spargere try/catch generici nella UI:
sealed class AppFailure {}
class NetworkFailure extends AppFailure {}
class UnauthorizedFailure extends AppFailure {}
class NotFoundFailure extends AppFailure {}
class ServerFailure extends AppFailure {
ServerFailure(this.message);
final String message;
}
AppFailure mapGrpcError(Object error) {
if (error is GrpcError) {
return switch (error.code) {
StatusCode.unauthenticated || StatusCode.permissionDenied =>
UnauthorizedFailure(),
StatusCode.notFound => NotFoundFailure(),
StatusCode.unavailable ||
StatusCode.deadlineExceeded => NetworkFailure(),
_ => ServerFailure(error.message ?? 'Errore sconosciuto'),
};
}
return ServerFailure(error.toString());
}
I codici che vedrai più spesso in mobilità sono unavailable (server irraggiungibile o connessione caduta) e deadlineExceeded (timeout). Entrambi sono tipicamente ritentabili; invalidArgument e notFound no.
Un retry minimale con backoff esponenziale, limitato alle sole chiamate idempotenti:
Future<T> withRetry<T>(
Future<T> Function() action, {
int maxAttempts = 3,
}) async {
var attempt = 0;
while (true) {
try {
return await action();
} on GrpcError catch (e) {
final retryable = e.code == StatusCode.unavailable ||
e.code == StatusCode.deadlineExceeded;
attempt++;
if (!retryable || attempt >= maxAttempts) rethrow;
await Future<void>.delayed(Duration(milliseconds: 300 * (1 << attempt)));
}
}
}
Per gli stream, invece, il retry va gestito riaprendo la chiamata: una strategia efficace è incapsulare lo stream in un controller che, su errore unavailable, attende e riesegue la sottoscrizione passando l'ultimo cursore ricevuto (es. l'id dell'ultimo messaggio), così il server può riprendere da dove si era interrotto.
Flutter Web: serve gRPC-Web
I browser non permettono il controllo diretto dei frame HTTP/2 richiesto da gRPC, quindi su Flutter Web si usa gRPC-Web, che richiede un proxy davanti al server (Envoy, il filtro gRPC-Web di NGINX, oppure un server che lo supporti nativamente come ConnectRPC).
Il codice generato è lo stesso: cambia solo il canale.
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:grpc/grpc.dart';
import 'package:grpc/grpc_connection_interface.dart';
import 'package:grpc/grpc_web.dart';
ClientChannelBase buildChannel() {
if (kIsWeb) {
return GrpcWebClientChannel.xhr(Uri.parse('https://grpc-web.example.com'));
}
return ClientChannel(
'api.example.com',
port: 443,
options: const ChannelOptions(credentials: ChannelCredentials.secure()),
);
}
Due limiti da conoscere: con gRPC-Web lo streaming client e bidirezionale non è supportato (solo unary e server streaming), e il proxy deve esporre gli header CORS corretti, incluso grpc-status e grpc-message tra gli Access-Control-Expose-Headers.
Testare senza backend
Per i test, il package grpc consente di avviare un server in-process: definisci un'implementazione fittizia del servizio generato e fai puntare il client a localhost.
class FakeChatService extends ChatServiceBase {
@override
Future<Room> getRoom(ServiceCall call, GetRoomRequest request) async {
return Room(id: request.roomId, name: 'Stanza di test', membersCount: 3);
}
@override
Stream<Message> watchMessages(
ServiceCall call,
WatchMessagesRequest request,
) async* {
yield Message(id: '1', roomId: request.roomId, text: 'ciao');
}
@override
Stream<Message> chat(ServiceCall call, Stream<Message> request) =>
request; // echo
}
// nel test
final server = Server.create(services: [FakeChatService()]);
await server.serve(port: 0);
final channel = ClientChannel(
'localhost',
port: server.port!,
options: const ChannelOptions(credentials: ChannelCredentials.insecure()),
);
È il modo più realistico di testare il layer di rete: verifichi serializzazione, interceptor e gestione degli errori senza mock fragili.
Best practice operative
- Isola il codice generato dietro un repository: la UI non dovrebbe mai vedere i tipi
.pb.dart. Converti i messaggi Protobuf in modelli di dominio (magari con Freezed) nel layer dati. - Un canale per app, gestito dal container di DI, con
shutdown()sul teardown. - Sempre un timeout nelle
CallOptions: senza deadline, una chiamata può restare appesa finché la connessione non cade. - Attiva il keepalive se l'app mantiene stream aperti a lungo: le reti mobili chiudono le connessioni inattive e senza ping l'app se ne accorge solo al primo messaggio perso.
- Riconnetti al ritorno in foreground: combina
AppLifecycleListenere lo stato di connettività per riaprire gli stream quando l'app torna attiva. - Versiona il package nel
.proto(chat.v1) fin dall'inizio: cambiare contratto in modo incompatibile su un'app già pubblicata negli store è molto più costoso che su un backend.
Conclusione
gRPC porta in Flutter un contratto condiviso e verificabile a compile time, payload compatti e streaming di prima classe. Il costo d'ingresso è la toolchain di generazione e, sul web, la necessità di un proxy gRPC-Web. In cambio si eliminano intere classi di bug — campi JSON rinominati, tipi sbagliati, documentazione disallineata — che in un'app distribuita sugli store si pagano a caro prezzo.