Chat in tempo reale in Flutter con WebSocket e web_socket_channel

Foto di Hikarinoshita Hikari su Unsplash

GuideIntermedio45 min Flutter 3.x

Chat in tempo reale in Flutter con WebSocket e web_socket_channel

Le API REST vanno benissimo per leggere e scrivere dati su richiesta, ma quando serve il tempo reale (chat, notifiche live, quotazioni, posizioni GPS condivise) il polling continuo diventa costoso e lento. La soluzione è il protocollo WebSocket: un canale bidirezionale persistente tra app e server.

In questo tutorial costruiamo un client WebSocket production-ready in Flutter usando il pacchetto ufficiale web_socket_channel. Non ci limiteremo ad aprire una connessione: affronteremo i problemi che si incontrano davvero in produzione, cioè la caduta della rete, i server che chiudono le connessioni inattive e la gestione del ciclo di vita.

Al termine avrai:

  • un servizio WebSocketService riutilizzabile con stream broadcast di messaggi e di stato;
  • heartbeat (ping applicativo) per tenere viva la connessione;
  • riconnessione automatica con backoff esponenziale;
  • messaggi JSON serializzati in modelli Dart;
  • una UI di chat reattiva basata su StreamBuilder.

Per provare subito useremo l'echo server pubblico wss://echo.websocket.events, che rimanda indietro tutto ciò che gli invii: perfetto per testare senza backend.

  1. 1

    Installare le dipendenze e configurare i permessi

    Aggiungi web_socket_channel al progetto. Il pacchetto è mantenuto dal team Dart e funziona su Android, iOS, web, desktop (su web usa internamente le WebSocket del browser).

    Da terminale:

    flutter pub add web_socket_channel
    

    Poi verifica i permessi di rete:

    • Android: apri android/app/src/main/AndroidManifest.xml e assicurati che sia presente <uses-permission android:name="android.permission.INTERNET"/> fuori dal tag <application>.
    • macOS: in macos/Runner/DebugProfile.entitlements e Release.entitlements serve com.apple.security.network.client a true.
    • iOS: nessuna configurazione necessaria per wss://. Se devi usare ws:// in chiaro (solo in sviluppo) dovrai configurare NSAppTransportSecurity.

    Usa sempre wss:// (TLS) in produzione: ws:// trasmette i dati in chiaro e viene bloccato dalle policy di sicurezza delle piattaforme.

    # pubspec.yaml (estratto)
    dependencies:
      flutter:
        sdk: flutter
      web_socket_channel: ^3.0.1

    Risultato atteso

    Il comando `flutter pub get` termina senza errori e `web_socket_channel` compare tra le dipendenze del progetto.

  2. 2

    Creare il servizio WebSocket con stream broadcast

    Non colleghiamo il canale direttamente ai widget: incapsuliamo tutto in un servizio. Questo ci permette di esporre stream broadcast (ascoltabili da più widget), di centralizzare la logica di riconnessione e di testare il codice senza UI.

    Punti chiave:

    • WebSocketChannel.connect() apre il canale; channel.ready è un Future che completa quando l'handshake è andato a buon fine (ed è il modo corretto per intercettare gli errori di connessione iniziale).
    • channel.stream è un single-subscription stream: possiamo ascoltarlo una sola volta. Per questo lo ri-pubblichiamo su uno StreamController.broadcast().
    • Esponiamo anche uno stream di SocketStatus, utile per mostrare in UI un badge "Connesso / Riconnessione in corso".
    • onDone viene invocato quando il server chiude la connessione; onError in caso di errori di rete. In entrambi i casi pianificheremo una riconnessione (prossimo passo).
    import 'dart:async';
    
    import 'package:web_socket_channel/status.dart' as ws_status;
    import 'package:web_socket_channel/web_socket_channel.dart';
    
    enum SocketStatus { disconnected, connecting, connected }
    
    class WebSocketService {
      WebSocketService(this.uri);
    
      final Uri uri;
    
      WebSocketChannel? _channel;
      StreamSubscription<dynamic>? _subscription;
    
      final _messages = StreamController<String>.broadcast();
      final _status = StreamController<SocketStatus>.broadcast();
    
      /// Messaggi grezzi in arrivo dal server.
      Stream<String> get messages => _messages.stream;
    
      /// Stato della connessione, utile per la UI.
      Stream<SocketStatus> get status => _status.stream;
    
      SocketStatus _current = SocketStatus.disconnected;
      SocketStatus get currentStatus => _current;
    
      bool _manuallyClosed = false;
    
      void _setStatus(SocketStatus value) {
        _current = value;
        if (!_status.isClosed) _status.add(value);
      }
    
      Future<void> connect() async {
        if (_current == SocketStatus.connecting ||
            _current == SocketStatus.connected) {
          return;
        }
    
        _manuallyClosed = false;
        _setStatus(SocketStatus.connecting);
    
        try {
          final channel = WebSocketChannel.connect(uri);
          await channel.ready; // lancia se l'handshake fallisce
          _channel = channel;
          _setStatus(SocketStatus.connected);
    
          _subscription = channel.stream.listen(
            (data) => _messages.add(data.toString()),
            onDone: _handleDisconnection,
            onError: (Object error) => _handleDisconnection(),
            cancelOnError: true,
          );
        } catch (_) {
          _handleDisconnection();
        }
      }
    
      void send(String message) {
        if (_current != SocketStatus.connected) return;
        _channel?.sink.add(message);
      }
    
      void _handleDisconnection() {
        _setStatus(SocketStatus.disconnected);
        // La logica di retry arriva nel passo successivo.
      }
    
      Future<void> dispose() async {
        _manuallyClosed = true;
        await _subscription?.cancel();
        await _channel?.sink.close(ws_status.normalClosure);
        await _messages.close();
        await _status.close();
      }
    }

    Risultato atteso

    Il servizio compila e, chiamando `connect()` verso `wss://echo.websocket.events`, lo stato passa da `connecting` a `connected`.

  3. 3

    Aggiungere heartbeat e riconnessione con backoff esponenziale

    Due problemi tipici del mondo reale:

    1. Connessioni "zombie": proxy e load balancer chiudono le connessioni inattive dopo 30-60 secondi. La soluzione è un heartbeat: inviare periodicamente un messaggio di ping applicativo.
    2. Cadute di rete: passaggio Wi-Fi → 4G, tunnel, app in background. Serve riconnettersi, ma senza martellare il server: usiamo un backoff esponenziale con tetto massimo.

    Aggiorniamo il servizio aggiungendo due Timer e un contatore di tentativi. Il contatore va azzerato a ogni connessione riuscita, altrimenti dopo la prima caduta resteresti sempre sul ritardo massimo.

    import 'dart:async';
    import 'dart:math';
    
    // ... dentro la classe WebSocketService
    
      Timer? _heartbeatTimer;
      Timer? _reconnectTimer;
      int _attempt = 0;
    
      static const _heartbeatInterval = Duration(seconds: 20);
      static const _maxBackoff = Duration(seconds: 30);
    
      void _startHeartbeat() {
        _heartbeatTimer?.cancel();
        _heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
          if (_current == SocketStatus.connected) {
            _channel?.sink.add('{"type":"ping"}');
          }
        });
      }
    
      void _stopTimers() {
        _heartbeatTimer?.cancel();
        _heartbeatTimer = null;
        _reconnectTimer?.cancel();
        _reconnectTimer = null;
      }
    
      void _scheduleReconnect() {
        if (_manuallyClosed || _reconnectTimer != null) return;
    
        // 1s, 2s, 4s, 8s, 16s, 30s, 30s... con jitter per evitare picchi
        final seconds = min(pow(2, _attempt).toInt(), _maxBackoff.inSeconds);
        final jitterMs = Random().nextInt(500);
        final delay = Duration(seconds: seconds, milliseconds: jitterMs);
        _attempt++;
    
        _reconnectTimer = Timer(delay, () {
          _reconnectTimer = null;
          connect();
        });
      }
    
      void _handleDisconnection() {
        _subscription?.cancel();
        _subscription = null;
        _channel = null;
        _heartbeatTimer?.cancel();
        _setStatus(SocketStatus.disconnected);
        _scheduleReconnect();
      }
    
    // Nel metodo connect(), dopo `_setStatus(SocketStatus.connected);` aggiungi:
    //   _attempt = 0;
    //   _startHeartbeat();
    //
    // In dispose() ricordati di chiamare _stopTimers();

    Risultato atteso

    Spegnendo il Wi-Fi lo stato diventa `disconnected` e nei log vedi i tentativi di riconnessione distanziati progressivamente (1s, 2s, 4s...). Riattivando la rete la connessione torna `connected`.

  4. 4

    Definire il protocollo: messaggi JSON tipizzati

    Scambiare stringhe grezze funziona solo nelle demo. In un'app reale si definisce un piccolo protocollo applicativo: ogni messaggio è un oggetto JSON con un campo type che ne determina il significato.

    Creiamo un modello ChatMessage con fromJson/toJson scritti a mano (per progetti più grandi puoi generare il codice con json_serializable) e due metodi di comodo nel servizio per inviare e ricevere oggetti invece di stringhe.

    Nota la gestione difensiva: un messaggio malformato o di tipo sconosciuto non deve far crashare l'app, quindi lo filtriamo restituendo null e usando whereType<ChatMessage>().

    import 'dart:convert';
    
    class ChatMessage {
      ChatMessage({
        required this.id,
        required this.author,
        required this.text,
        required this.sentAt,
        this.mine = false,
      });
    
      final String id;
      final String author;
      final String text;
      final DateTime sentAt;
      final bool mine;
    
      Map<String, dynamic> toJson() => {
            'type': 'message',
            'id': id,
            'author': author,
            'text': text,
            'sentAt': sentAt.toIso8601String(),
          };
    
      static ChatMessage? tryParse(String raw) {
        try {
          final json = jsonDecode(raw);
          if (json is! Map<String, dynamic>) return null;
          if (json['type'] != 'message') return null; // ignora ping/pong e altro
          return ChatMessage(
            id: json['id'] as String,
            author: json['author'] as String? ?? 'anonimo',
            text: json['text'] as String? ?? '',
            sentAt: DateTime.tryParse(json['sentAt'] as String? ?? '') ??
                DateTime.now(),
          );
        } catch (_) {
          return null; // payload non valido: lo scartiamo
        }
      }
    }
    
    // Estensione del servizio: API tipizzata sopra lo stream di stringhe.
    extension ChatSocket on WebSocketService {
      Stream<ChatMessage> get chatMessages =>
          messages.map(ChatMessage.tryParse).whereType<ChatMessage>();
    
      void sendChat(ChatMessage message) => send(jsonEncode(message.toJson()));
    }

    Risultato atteso

    Inviando un `ChatMessage` all'echo server ricevi indietro lo stesso oggetto già deserializzato in un'istanza di `ChatMessage`.

  5. 5

    Costruire la UI della chat con StreamBuilder

    Ora colleghiamo tutto a un widget. Alcune accortezze importanti:

    • Il servizio va creato in initState e chiuso in dispose, altrimenti i timer di riconnessione continuano a girare anche dopo che la schermata è stata smontata (classico memory leak).
    • Accumuliamo i messaggi in una List locale sottoscrivendo lo stream: StreamBuilder da solo mostrerebbe solo l'ultimo evento.
    • Mostriamo un indicatore di stato nell'AppBar usando il secondo stream.
    • La ListView è reverse: true così i nuovi messaggi compaiono in basso senza dover gestire manualmente lo scroll.
    class ChatPage extends StatefulWidget {
      const ChatPage({super.key});
    
      @override
      State<ChatPage> createState() => _ChatPageState();
    }
    
    class _ChatPageState extends State<ChatPage> {
      late final WebSocketService _service;
      final _controller = TextEditingController();
      final _messages = <ChatMessage>[];
      StreamSubscription<ChatMessage>? _sub;
    
      @override
      void initState() {
        super.initState();
        _service = WebSocketService(Uri.parse('wss://echo.websocket.events'));
        _sub = _service.chatMessages.listen((msg) {
          if (!mounted) return;
          setState(() => _messages.insert(0, msg));
        });
        _service.connect();
      }
    
      @override
      void dispose() {
        _sub?.cancel();
        _service.dispose();
        _controller.dispose();
        super.dispose();
      }
    
      void _send() {
        final text = _controller.text.trim();
        if (text.isEmpty) return;
        _service.sendChat(ChatMessage(
          id: DateTime.now().microsecondsSinceEpoch.toString(),
          author: 'io',
          text: text,
          sentAt: DateTime.now(),
        ));
        _controller.clear();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Chat realtime'),
            bottom: PreferredSize(
              preferredSize: const Size.fromHeight(24),
              child: StreamBuilder<SocketStatus>(
                stream: _service.status,
                initialData: _service.currentStatus,
                builder: (context, snapshot) {
                  final status = snapshot.data ?? SocketStatus.disconnected;
                  final (label, color) = switch (status) {
                    SocketStatus.connected => ('Connesso', Colors.green),
                    SocketStatus.connecting => ('Connessione...', Colors.orange),
                    SocketStatus.disconnected => ('Offline', Colors.red),
                  };
                  return Container(
                    width: double.infinity,
                    color: color.withValues(alpha: 0.15),
                    padding: const EdgeInsets.symmetric(vertical: 4),
                    child: Text(label, textAlign: TextAlign.center),
                  );
                },
              ),
            ),
          ),
          body: Column(
            children: [
              Expanded(
                child: ListView.builder(
                  reverse: true,
                  padding: const EdgeInsets.all(12),
                  itemCount: _messages.length,
                  itemBuilder: (context, index) {
                    final msg = _messages[index];
                    return Align(
                      alignment: Alignment.centerRight,
                      child: Card(
                        child: Padding(
                          padding: const EdgeInsets.all(10),
                          child: Text('${msg.author}: ${msg.text}'),
                        ),
                      ),
                    );
                  },
                ),
              ),
              SafeArea(
                child: Padding(
                  padding: const EdgeInsets.all(8),
                  child: Row(
                    children: [
                      Expanded(
                        child: TextField(
                          controller: _controller,
                          onSubmitted: (_) => _send(),
                          decoration: const InputDecoration(
                            hintText: 'Scrivi un messaggio...',
                            border: OutlineInputBorder(),
                          ),
                        ),
                      ),
                      const SizedBox(width: 8),
                      IconButton.filled(
                        onPressed: _send,
                        icon: const Icon(Icons.send),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        );
      }
    }

    Risultato atteso

    L'app mostra la chat: scrivendo un messaggio lo vedi comparire nella lista (rimandato indietro dall'echo server) e l'indicatore nell'AppBar segnala lo stato della connessione in tempo reale.

  6. 6

    Gestire il ciclo di vita dell'app e la coda di invio offline

    Quando l'app va in background, il sistema operativo può sospendere la connessione: iOS la chiude quasi sempre dopo pochi secondi. Conviene quindi disconnettere esplicitamente in paused e riconnettere in resumed, così eviti socket in stato incerto e consumi di batteria.

    Inoltre, se l'utente invia un messaggio mentre la connessione è caduta, oggi il messaggio viene perso silenziosamente. Aggiungiamo una piccola coda in memoria che viene svuotata al ritorno della connessione.

    Per osservare il ciclo di vita usiamo AppLifecycleListener (disponibile da Flutter 3.13) oppure il classico WidgetsBindingObserver.

    // 1) Coda di invio nel servizio
      final List<String> _outbox = [];
      static const _maxOutbox = 50;
    
      void send(String message) {
        if (_current == SocketStatus.connected) {
          _channel?.sink.add(message);
        } else {
          if (_outbox.length >= _maxOutbox) _outbox.removeAt(0);
          _outbox.add(message);
        }
      }
    
      void _flushOutbox() {
        if (_current != SocketStatus.connected) return;
        final pending = List<String>.from(_outbox);
        _outbox.clear();
        for (final message in pending) {
          _channel?.sink.add(message);
        }
      }
      // chiama _flushOutbox() subito dopo _startHeartbeat() in connect()
    
      /// Chiusura volontaria: niente retry automatico.
      Future<void> disconnect() async {
        _manuallyClosed = true;
        _stopTimers();
        await _subscription?.cancel();
        _subscription = null;
        await _channel?.sink.close(ws_status.goingAway);
        _channel = null;
        _setStatus(SocketStatus.disconnected);
      }
    
    // 2) Ciclo di vita nella pagina
    late final AppLifecycleListener _lifecycle;
    
    @override
    void initState() {
      super.initState();
      // ... creazione del servizio
      _lifecycle = AppLifecycleListener(
        onPause: () => _service.disconnect(),
        onResume: () => _service.connect(),
      );
    }
    
    @override
    void dispose() {
      _lifecycle.dispose();
      // ... resto del dispose
      super.dispose();
    }

    Risultato atteso

    Mandando l'app in background la connessione si chiude in modo pulito; al ritorno in foreground si riapre e i messaggi scritti offline vengono inviati automaticamente.

  7. 7

    Testare il servizio e portarlo in produzione

    Un servizio WebSocket va testato senza rete reale. Il pacchetto web_socket_channel include StreamChannelController (da stream_channel) per simulare un canale in memoria; in alternativa puoi iniettare una factory di connessione sostituibile nei test.

    Refactor minimo per la testabilità: trasforma la creazione del canale in una dipendenza iniettabile.

    typedef ChannelFactory = WebSocketChannel Function(Uri uri);
    
    class WebSocketService {
      WebSocketService(this.uri, {ChannelFactory? factory})
          : _factory = factory ?? WebSocketChannel.connect;
      final ChannelFactory _factory;
      // ... usa _factory(uri) al posto di WebSocketChannel.connect(uri)
    }
    

    Checklist prima del rilascio:

    • Usa solo wss:// e, se l'endpoint è critico, valuta il certificate pinning.
    • Autentica la connessione: passa il token in un header (WebSocketChannel.connect supporta gli header su mobile/desktop) oppure invia un messaggio di auth come primo frame, dato che su Web gli header personalizzati non sono disponibili.
    • Limita la dimensione della coda offline e della lista messaggi in memoria.
    • Logga le transizioni di stato: ti salveranno ore di debug sui dispositivi degli utenti.
    • Se la logica di chat è condivisa tra più schermate, esponi il servizio con un provider (Riverpod, Provider o get_it) invece di crearlo nello State.
    import 'dart:convert';
    
    import 'package:flutter_test/flutter_test.dart';
    import 'package:stream_channel/stream_channel.dart';
    import 'package:web_socket_channel/web_socket_channel.dart';
    
    void main() {
      test('deserializza i messaggi di tipo message e ignora i ping', () async {
        final controller = StreamChannelController<dynamic>(sync: true);
        final service = WebSocketService(
          Uri.parse('wss://test.local'),
          factory: (_) => WebSocketChannel(controller.foreign),
        );
    
        await service.connect();
    
        final received = <ChatMessage>[];
        final sub = service.chatMessages.listen(received.add);
    
        controller.local.sink.add(jsonEncode({'type': 'pong'}));
        controller.local.sink.add(jsonEncode({
          'type': 'message',
          'id': '1',
          'author': 'bot',
          'text': 'ciao',
          'sentAt': DateTime.now().toIso8601String(),
        }));
    
        await Future<void>.delayed(Duration.zero);
    
        expect(received, hasLength(1));
        expect(received.first.text, 'ciao');
    
        await sub.cancel();
        await service.dispose();
      });
    }

    Risultato atteso

    Il test passa con `flutter test`: il messaggio di tipo `pong` viene ignorato e solo il `ChatMessage` valido raggiunge lo stream tipizzato.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!