Perché WebRTC in Flutter
Quando un'app deve offrire videochiamate, audio call o condivisione dello schermo in tempo reale, il protocollo di riferimento è WebRTC: uno standard supportato da tutti i browser e disponibile nativamente su Android e iOS. In Flutter il binding ufficiale è il pacchetto flutter_webrtc, mantenuto dalla community di CloudWebRTC, che espone un'API molto vicina a quella JavaScript (getUserMedia, RTCPeerConnection, RTCDataChannel).
A differenza di soluzioni "chiavi in mano" come Agora, Twilio Video o LiveKit, con flutter_webrtc si lavora direttamente sul livello più basso: questo significa controllo totale, zero vendor lock-in e costi legati solo alla propria infrastruttura, ma anche la responsabilità di implementare il signaling e di fornire server STUN/TURN.
In questa guida costruiamo una videochiamata 1-a-1 (peer-to-peer) completa, con signaling via WebSocket.
I tre pezzi del puzzle
Prima di scrivere codice è fondamentale avere chiaro il modello mentale:
- Media: i flussi audio/video locali, ottenuti dalla fotocamera e dal microfono con
getUserMedia. - Signaling: il canale fuori banda (non definito da WebRTC) con cui i due peer si scambiano le descrizioni di sessione SDP (offer/answer) e i candidati ICE. Può essere un WebSocket, Firestore, MQTT o qualsiasi altra cosa.
- Connettività (ICE): STUN per scoprire l'indirizzo pubblico dietro NAT, TURN per fare da relay quando la connessione diretta è impossibile.
Regola pratica: senza un server TURN, dal 10% al 20% delle chiamate reali fallisce (reti aziendali, NAT simmetrici, alcune reti mobili). Lo STUN pubblico di Google va bene per i test, non per la produzione.
Installazione e configurazione nativa
dependencies:
flutter_webrtc: ^0.12.5
web_socket_channel: ^3.0.1
permission_handler: ^11.3.1
Android
In android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-feature android:name="android.hardware.camera" />
Il minSdkVersion deve essere almeno 23. Se usi R8/ProGuard, il pacchetto include già le regole necessarie.
iOS
In ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>L'app usa la fotocamera per le videochiamate</string>
<key>NSMicrophoneUsageDescription</key>
<string>L'app usa il microfono per le chiamate</string>
E nel Podfile imposta platform :ios, '13.0' o superiore. Per ricevere chiamate ad app chiusa servirà poi l'integrazione con CallKit + PushKit (vedi ultima sezione).
Inizializzare i renderer e il media locale
I flussi video si mostrano con RTCVideoView, che richiede un RTCVideoRenderer inizializzato (e, importantissimo, smaltito nel dispose).
import 'package:flutter_webrtc/flutter_webrtc.dart';
class CallController {
final localRenderer = RTCVideoRenderer();
final remoteRenderer = RTCVideoRenderer();
MediaStream? _localStream;
RTCPeerConnection? _pc;
Future<void> initRenderers() async {
await localRenderer.initialize();
await remoteRenderer.initialize();
}
Future<void> openUserMedia() async {
final constraints = <String, dynamic>{
'audio': true,
'video': {
'facingMode': 'user',
'width': {'ideal': 1280},
'height': {'ideal': 720},
'frameRate': {'ideal': 30},
},
};
_localStream = await navigator.mediaDevices.getUserMedia(constraints);
localRenderer.srcObject = _localStream;
}
Future<void> dispose() async {
await _localStream?.dispose();
await _pc?.close();
await localRenderer.dispose();
await remoteRenderer.dispose();
}
}
Nota: le constraints sono suggerimenti. WebRTC adatta dinamicamente risoluzione e bitrate alle condizioni di rete, quindi non serve (e non conviene) forzare valori rigidi.
Creare la RTCPeerConnection
Future<void> createPeerConnection_() async {
final config = <String, dynamic>{
'iceServers': [
{'urls': 'stun:stun.l.google.com:19302'},
{
'urls': 'turn:turn.miodominio.it:3478',
'username': 'utente',
'credential': 'segreto-temporaneo',
},
],
// 'unified-plan' è lo standard moderno (default nelle versioni recenti)
'sdpSemantics': 'unified-plan',
};
_pc = await createPeerConnection(config);
// Pubblica le tracce locali verso il peer remoto
for (final track in _localStream!.getTracks()) {
await _pc!.addTrack(track, _localStream!);
}
// Riceve le tracce remote
_pc!.onTrack = (RTCTrackEvent event) {
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
remoteRenderer.srcObject = event.streams.first;
}
};
// Candidati ICE da inviare all'altro peer
_pc!.onIceCandidate = (RTCIceCandidate candidate) {
_signaling.send({
'type': 'candidate',
'candidate': candidate.toMap(),
});
};
_pc!.onConnectionState = (RTCPeerConnectionState state) {
debugPrint('Connection state: $state');
if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed) {
_pc!.restartIce(); // tentativo di recupero
}
};
}
Credenziali TURN: mai hardcoded
Le credenziali TURN vanno generate dal backend con il meccanismo delle ephemeral credentials (REST API di coturn): username = timestamp:userId, password = HMAC-SHA1 del username con un segreto condiviso. Validità tipica: 1-2 ore. Inserire user e password statici nel binario dell'app equivale a regalare banda a chiunque decompili l'APK.
Il signaling: chi chiama e chi risponde
Il flusso canonico di una chiamata 1-a-1 è questo:
- Caller:
createOffer()→setLocalDescription()→ invia l'SDP. - Callee: riceve l'offer →
setRemoteDescription()→createAnswer()→setLocalDescription()→ rinvia l'SDP. - Entrambi si scambiano i candidati ICE man mano che vengono generati (trickle ICE).
Un client di signaling minimale su WebSocket:
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
class SignalingClient {
SignalingClient(this._uri);
final Uri _uri;
WebSocketChannel? _channel;
final _controller = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messages => _controller.stream;
Future<void> connect(String roomId, String token) async {
_channel = WebSocketChannel.connect(
_uri.replace(queryParameters: {'room': roomId, 'token': token}),
);
_channel!.stream.listen(
(raw) => _controller.add(jsonDecode(raw as String) as Map<String, dynamic>),
onDone: _scheduleReconnect,
onError: (_) => _scheduleReconnect(),
);
}
void send(Map<String, dynamic> message) =>
_channel?.sink.add(jsonEncode(message));
void _scheduleReconnect() {
// backoff esponenziale con jitter: il signaling deve sopravvivere
// a cambi di rete Wi-Fi/4G senza far cadere la chiamata già stabilita
}
}
Punto spesso sottovalutato: se il signaling cade dopo che la connessione è stabilita, la chiamata continua a funzionare. WebRTC scambia media direttamente tra i peer. Il signaling serve di nuovo solo in caso di rinegoziazione o ICE restart.
Lato caller
Future<void> makeCall() async {
await createPeerConnection_();
final offer = await _pc!.createOffer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': true,
});
await _pc!.setLocalDescription(offer);
_signaling.send({
'type': 'offer',
'sdp': offer.sdp,
'sdpType': offer.type,
});
}
Lato callee
Future<void> handleOffer(Map<String, dynamic> msg) async {
await createPeerConnection_();
await _pc!.setRemoteDescription(
RTCSessionDescription(msg['sdp'] as String, msg['sdpType'] as String),
);
final answer = await _pc!.createAnswer();
await _pc!.setLocalDescription(answer);
_signaling.send({
'type': 'answer',
'sdp': answer.sdp,
'sdpType': answer.type,
});
await _drainPendingCandidates();
}
Candidati ICE e il problema dell'ordine
I candidati remoti possono arrivare prima che setRemoteDescription sia stato chiamato: in quel caso addCandidate fallisce. La soluzione è bufferizzarli:
final List<RTCIceCandidate> _pendingCandidates = [];
bool _remoteDescriptionSet = false;
Future<void> handleCandidate(Map<String, dynamic> msg) async {
final map = msg['candidate'] as Map<String, dynamic>;
final candidate = RTCIceCandidate(
map['candidate'] as String?,
map['sdpMid'] as String?,
map['sdpMLineIndex'] as int?,
);
if (_remoteDescriptionSet) {
await _pc!.addCandidate(candidate);
} else {
_pendingCandidates.add(candidate);
}
}
Future<void> _drainPendingCandidates() async {
_remoteDescriptionSet = true;
for (final c in _pendingCandidates) {
await _pc!.addCandidate(c);
}
_pendingCandidates.clear();
}
La UI della chiamata
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// Video remoto a tutto schermo
Positioned.fill(
child: RTCVideoView(
controller.remoteRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
// Anteprima locale in alto a destra
Positioned(
top: MediaQuery.of(context).padding.top + 16,
right: 16,
width: 110,
height: 160,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: RTCVideoView(
controller.localRenderer,
mirror: true, // specchia la fotocamera frontale
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
),
const Align(
alignment: Alignment.bottomCenter,
child: CallControlsBar(),
),
],
),
);
}
Il flag mirror: true va usato solo sull'anteprima locale con fotocamera frontale: specchiare il video remoto è un errore classico che confonde chi legge testo o mostra oggetti.
Controlli della chiamata
// Mute / unmute del microfono
void toggleMic() {
final track = _localStream!.getAudioTracks().first;
track.enabled = !track.enabled;
}
// Spegnere la fotocamera (la traccia resta negoziata, non serve rinegoziare)
void toggleCamera() {
final track = _localStream!.getVideoTracks().first;
track.enabled = !track.enabled;
}
// Passare da frontale a posteriore
Future<void> switchCamera() async {
final track = _localStream!.getVideoTracks().first;
await Helper.switchCamera(track);
}
// Instradare l'audio sull'altoparlante
Future<void> enableSpeaker(bool on) async {
await Helper.setSpeakerphoneOn(on);
}
Attenzione: track.enabled = false interrompe l'invio dei dati ma non spegne il LED della fotocamera né rilascia il device. Se vuoi davvero liberare l'hardware devi fermare la traccia e rinegoziare con replaceTrack.
Chiudere correttamente la chiamata
Una gestione sciatta della chiusura è la prima causa di fotocamere che restano accese e di memory leak:
Future<void> hangUp() async {
_signaling.send({'type': 'bye'});
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
await track.stop();
}
await _localStream?.dispose();
_localStream = null;
await _pc?.close();
await _pc?.dispose();
_pc = null;
localRenderer.srcObject = null;
remoteRenderer.srcObject = null;
}
Monitorare la qualità con getStats
WebRTC espone metriche dettagliate che permettono di mostrare all'utente un indicatore di qualità della rete:
Future<void> logQuality() async {
final reports = await _pc!.getStats();
for (final report in reports) {
if (report.type == 'inbound-rtp' && report.values['kind'] == 'video') {
final lost = report.values['packetsLost'];
final jitter = report.values['jitter'];
final fps = report.values['framesPerSecond'];
debugPrint('Video in: lost=$lost jitter=$jitter fps=$fps');
}
if (report.type == 'candidate-pair' && report.values['state'] == 'succeeded') {
debugPrint('RTT: ${report.values['currentRoundTripTime']}');
}
}
}
Una buona euristica: RTT sopra i 300 ms o packet loss sopra il 5% → mostra l'avviso "connessione instabile" e valuta di disattivare il video mantenendo l'audio.
Oltre il peer-to-peer: quando serve un SFU
Il modello mesh peer-to-peer funziona bene solo fino a 3-4 partecipanti: ogni peer deve inviare il proprio video a tutti gli altri, quindi l'upload cresce linearmente e la batteria si esaurisce rapidamente. Per riunioni con più persone serve un SFU (Selective Forwarding Unit): ogni client invia un solo flusso al server, che lo smista agli altri.
Opzioni open source compatibili con flutter_webrtc: LiveKit (che offre anche un SDK Flutter già pronto), mediasoup, Janus, ion-sfu. Se il progetto prevede stanze multi-utente, partire direttamente da LiveKit fa risparmiare settimane di lavoro.
Chiamate in arrivo ad app chiusa
Per un'esperienza da "vera app di telefonia" servono due pezzi aggiuntivi:
- Android: una notifica high-priority tramite FCM con un
ForegroundServicedi tipomicrophone|camera. - iOS: PushKit (VoIP push) + CallKit, obbligatori dal momento che iOS non permette di risvegliare l'app con una notifica normale per mostrare una schermata di chiamata. Importante: iOS richiede che ogni push VoIP ricevuto riporti immediatamente una chiamata a CallKit, altrimenti il sistema blocca l'app.
Il pacchetto flutter_callkit_incoming astrae entrambe le piattaforme ed è il compagno naturale di flutter_webrtc.
Checklist finale
- Chiedi i permessi di camera e microfono prima di
getUserMedia, con una schermata esplicativa. - Configura sempre un server TURN con credenziali effimere.
- Bufferizza i candidati ICE ricevuti prima della remote description.
- Gestisci
RTCPeerConnectionStateFailedconrestartIce(). - Ferma le tracce e chiudi la peer connection alla fine della chiamata.
- Testa su rete mobile reale e con passaggio Wi-Fi → 4G: è lì che emergono i bug veri.
- Non testare su emulatore: la fotocamera virtuale e lo stack di rete non sono rappresentativi.
WebRTC ha una curva di apprendimento ripida, ma flutter_webrtc offre un'API sufficientemente fedele allo standard da permettere di riutilizzare tutta la documentazione e i pattern del mondo web. Una volta compresi offer/answer e ICE, il resto è ingegneria di prodotto.