Flutter: StreamBuilder ile Gerçek Zamanlı Veri Akışı
Son güncelleme:
7 dk okuma

StreamBuilder, zaman içinde birden fazla değer üreten bir kaynağı (sohbet mesajları, sipariş durumu, sensör verisi, Firestore sorgusu) widget ağacına bağlar. Verdiğin Stream'e abone olur, her yeni değerde builder'ı yeniden çağırır ve State ile setState yükünü senden alır. Tek seferlik bir sonuç için FutureBuilder yeterliyken, "veri geldikçe ekran güncellensin" dediğin anda aracın budur. Yazıda kullanımın yanında en çok takılınan üç konuya ağırlık vereceğim: stream'i build içinde oluşturmak, aynı stream'i iki kez dinlemeye çalışmak ve StreamController'ı kapatmayı unutmak.
Stream ve Future Farkı
| Özellik | Future | Stream |
|---|---|---|
| Değer sayısı | Tek değer | Sıfır, bir ya da çok değer |
| Ne zaman biter | Değer gelince | Kaynak kapanınca (done) |
| Örnek | HTTP isteği, dosya okuma | WebSocket, Firestore snapshots(), zamanlayıcı |
| Widget | FutureBuilder |
StreamBuilder |
Bir Future "sonuç gelecek" sözüdür; bir Stream ise açık bir kanaldır: değerler gelir, araya hata girebilir, en sonunda kanal kapanır ya da kapanmaz. async/await ve Future tarafı henüz oturmadıysa önce Dart'ta asenkron işlemler ve hata yönetimi yazısına bak; stream'ler o temelin üstüne kuruluyor.
Canlı Demo
Aşağıdaki interaktif örnekte bu widget'ı deneyebilirsin:
💡 Eğer yukarıdaki örnek açılmazsa, DartPad linkine tıklayarak yeni sekmede çalıştırabilirsin.
Temel Kullanım
Her saniye bir sayı üreten bir stream'i ekrana bağlayalım. Stream State içinde bir kez oluşturuluyor; build kaç kez çalışırsa çalışsın aynı nesne kullanılıyor:
class CounterStreamPage extends StatefulWidget {
const CounterStreamPage({super.key});
@override
State<CounterStreamPage> createState() => _CounterStreamPageState();
}
class _CounterStreamPageState extends State<CounterStreamPage> {
// Stream bir kez oluşturulur; build her çağrıldığında aynı nesne verilir
late final Stream<int> _counter = _createCounter();
Stream<int> _createCounter() async* {
for (var i = 1; i <= 10; i++) {
await Future.delayed(const Duration(seconds: 1));
yield i; // stream'e değer gönder
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sayaç Stream')),
body: Center(
child: StreamBuilder<int>(
stream: _counter,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('Hata: ${snapshot.error}');
}
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return const Text('Başlıyor...');
case ConnectionState.active:
return Text(
'${snapshot.data}',
style: const TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
);
case ConnectionState.done:
return Text('Tamamlandı, son değer: ${snapshot.data}');
}
},
),
),
);
}
}async* ile yazılan fonksiyon çağrıldığında bir Stream döndürür; gövde ancak biri dinlemeye başlayınca çalışır ve her yield bir değer gönderir. Döngü bitince stream kendiliğinden kapanır ve snapshot done durumuna geçer.
Önemli Özellikler
| Özellik | Açıklama |
|---|---|
stream |
Dinlenecek Stream; null verilirse durum none olur |
builder |
Her yeni değer, hata ve durum değişiminde çağrılır |
initialData |
İlk değer gelene kadar snapshot.data olarak gösterilecek değer |
connectionState'i Stream İçin Okumak
FutureBuilder'da waiting ve done yeterdi; stream'de dört durumun dördü de anlamlı:
| Durum | Anlamı |
|---|---|
none |
stream alanı null, abonelik yok |
waiting |
Abone olundu ama henüz hiç değer gelmedi |
active |
En az bir değer (ya da hata) geldi, kanal hâlâ açık |
done |
Kaynak kapandı; son data ya da error snapshot'ta durur |
İki ayrıntı önemli. Birincisi, done olduğunda son değer silinmez; "yayın bitti ama son durum ekranda kalsın" istiyorsan snapshot.data'yı okumaya devam edebilirsin. İkincisi, stream alanına farklı bir nesne verirsen StreamBuilder eski aboneliği iptal eder, yenisine abone olur ve bunu yaparken önceki data'yı korur; kaynak değişirken ekran boşalmaz.
initialData: İlk Kareyi Doldurmak
Stream'ler çoğu zaman ilk değeri bir süre sonra üretir. Bu boşlukta spinner göstermek istemiyorsan initialData ver:
StreamBuilder<int>(
stream: _counter,
initialData: 0,
builder: (context, snapshot) {
// snapshot.data ilk kareden itibaren 0, sonra gelen değerler
return Text('${snapshot.data}');
},
)Dikkat: initialData verildiğinde hasData daha ilk kareden true olur. "Yükleniyor" durumunu !snapshot.hasData ile değil connectionState == ConnectionState.waiting ile anlaman gerekir.
StreamController ile Kendi Stream'ini Üretmek
Veriyi kendin üretiyorsan (buton tıklaması, socket mesajı, zamanlayıcı) StreamController kullanırsın. Aşağıdaki örnek bir mesaj listesi tutuyor; her eklemede listenin güncel kopyasını stream'e veriyor, StreamBuilder da listeyi çiziyor:
import 'dart:async';
import 'package:flutter/material.dart';
class MessageStreamPage extends StatefulWidget {
const MessageStreamPage({super.key});
@override
State<MessageStreamPage> createState() => _MessageStreamPageState();
}
class _MessageStreamPageState extends State<MessageStreamPage> {
final _controller = StreamController<List<String>>();
final List<String> _messages = [];
void _addMessage(String text) {
_messages.add(text);
_controller.add(List.unmodifiable(_messages)); // yeni değer yayınla
}
@override
void dispose() {
_controller.close(); // abonelik ve bellek sızıntısını önle
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Mesaj Stream')),
body: Column(
children: [
Wrap(
spacing: 8,
children: [
FilledButton(onPressed: () => _addMessage('Merhaba!'), child: const Text('Merhaba')),
FilledButton(onPressed: () => _addMessage('Nasılsın?'), child: const Text('Nasılsın?')),
],
),
Expanded(
child: StreamBuilder<List<String>>(
stream: _controller.stream,
initialData: const [],
builder: (context, snapshot) {
final messages = snapshot.data ?? const [];
if (messages.isEmpty) {
return const Center(child: Text('Henüz mesaj yok'));
}
return ListView.builder(
itemCount: messages.length,
itemBuilder: (context, index) => ListTile(
leading: const Icon(Icons.message),
title: Text(messages[index]),
),
);
},
),
),
],
),
);
}
}builder yalnızca gelen veriyi çizer, listeye kendisi eklemez. Bu ayrım önemli: builder Flutter'ın istediği kadar sık çağrılabilir; içinde liste değiştirirsen aynı mesaj birkaç kez eklenir.
Broadcast ve Tek Abonelikli Stream
StreamController() ile üretilen stream tek abonelikli (single-subscription) bir stream'dir: yalnızca bir dinleyici kabul eder, ikincisi hata alır. async* fonksiyonların döndürdüğü stream'ler de böyledir. Aynı kaynağı birden fazla widget dinleyecekse broadcast kullan:
// Tek dinleyici: ikinci listen() çağrısı hata fırlatır
final single = StreamController<int>();
// Çoklu dinleyici: istediğin kadar StreamBuilder ve listen()
final shared = StreamController<int>.broadcast();Broadcast stream'in bedeli şudur: dinleyici yokken gönderilen değerler kaybolur, geç gelen abone eski değerleri görmez. Bir "son değeri hatırlayan" kaynak gerekiyorsa değeri ayrıca bir alanda tutup initialData olarak ver; aşağıdaki senaryoda bunu yapıyoruz. Elinde tek abonelikli bir stream varsa asBroadcastStream() ile paylaşılabilir hale getirebilirsin, ama daha temiz çözüm genellikle tek bir StreamBuilder'ı ağaçta yukarı taşımaktır.
Stream Dönüşümleri
Bir stream'i dinlemeden önce map, where, take gibi metodlarla şekillendirebilirsin; her biri yeni bir stream döndürür:
final ticks = Stream.periodic(const Duration(seconds: 1), (count) => count).take(10);
final evenOnly = ticks.where((n) => n.isEven);
final labels = ticks.map((n) => 'Saniye: $n');Dönüşümü de build dışında yap; stream: ticks.map(...) yazmak her build'de yeni bir stream üretmek demektir ve aşağıdaki ilk hataya girer.
StreamBuilder mı, initState İçinde listen() mi?
İkisi de aynı stream'i dinler, farkları amaçtır. StreamBuilder gelen değeri ekrana çevirir; abonelik yönetimini (abone ol, stream değişince yenile, widget ölünce iptal et) kendisi yapar. listen() ise değer geldiğinde bir yan etki tetiklemek içindir: SnackBar göstermek, sayfa değiştirmek, yerel veritabanına yazmak. Yan etki builder içine ait değildir çünkü builder çizim sırasında çalışır ve Navigator.push ya da showSnackBar çağırmak orada hataya yol açar.
late final StreamSubscription<OrderStatus> _sub;
@override
void initState() {
super.initState();
_sub = tracker.status.listen((status) {
if (!mounted) return;
if (status == OrderStatus.delivered) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Siparişin teslim edildi')),
);
}
});
}
@override
void dispose() {
_sub.cancel(); // aboneliği bırak
super.dispose();
}listen() ile veriyi ekrana da yansıtmak istersen setState çağırırsın; o zaman mounted kontrolü ve cancel senin sorumluluğundadır. dispose sırasının neden önemli olduğunu StatefulWidget yaşam döngüsü yazısında ayrıntılı anlattım.
Ne Zaman Kullanılır, Ne Zaman Kullanılmaz?
StreamBuilder'ı, tek bir ekranın canlı bir kaynağı doğrudan yansıttığı yerlerde seç: sohbet listesi, sipariş durumu, sayaç, Firestore snapshots() ya da WebSocket mesajları. Kaynak kapandığında ekran da kapanıyorsa ve veriyi başka kimse kullanmıyorsa fazlası gerekmez.
Şu durumlarda başka bir araç daha doğru:
- Kaynak tek bir sonuç üretiyorsa (API isteği, dosya okuma)
Stream'e sarmak gereksiz; FutureBuilder kullan. - Aynı stream'i birden fazla ekran dinleyecek ya da son değer ekranlar arasında korunacaksa aboneliği bir widget'ın
State'ine bağlamak kırılgan olur. Riverpod'dakiStreamProvider(ya daproviderpaketindekiStreamProvider) aboneliği ekrandan bağımsız tutar ve sana aynı "yükleniyor, hata, veri" üçlüsünü verir. - Değer yalnızca aynı widget içinde değişiyorsa (sayaç, seçili sekme)
Streamağır kalır;ValueNotifierileValueListenableBuilderya da düzsetStateyeter. - Gelen değere tepki olarak sayfa değiştirecek ya da bildirim gösterecek isen
StreamBuilderdeğillisten()gerekir; yukarıdaki bölüme bak.
Sık Yapılan Hatalar
1. Stream'i build içinde oluşturmak
Bu yazının eski sürümünde de bulunan klasik tuzak:
// YANLIŞ: getter her okunduğunda yeni bir stream üretir
Stream<int> get counterStream async* { /* ... */ }
// build içinde
StreamBuilder<int>(stream: counterStream, ...)Belirti: sayaç her setState'te, klavye açılınca ya da ekran döndürülünce başa döner; loglarda aynı işlem tekrar tekrar başlar. build her çalıştığında counterStream yeni bir Stream nesnesi döndürür, StreamBuilder "stream değişti" deyip eskisini bırakır, yenisine abone olur ve üretim baştan başlar. Çözüm, stream'i late final bir alanda ya da initState içinde bir kez oluşturmak; temel kullanım bölümündeki kod bunu yapıyor. Aynı kural stream: controller.stream.map(...) gibi dönüşümler için de geçerli.
2. Aynı stream'i iki yerde dinlemek
Belirti: ikinci StreamBuilder ya da listen() çağrısı şu hatayla düşer:
Bad state: Stream has already been listened to.Sebep: stream tek abonelikli. Çözüm ya StreamController<T>.broadcast() kullanmak ya da daha iyisi, stream'i tek bir StreamBuilder ile üstte dinleyip veriyi alt widget'lara parametre olarak geçmek. Hot reload sırasında da aynı hatayı görebilirsin; stream'in build'de değil State'te oluşturulduğundan emin ol.
3. StreamController'ı kapatmamak ya da kapandıktan sonra yazmak
İki belirti var. Kapatmazsan sayfa kapandıktan sonra da dinleyiciler çalışmaya devam eder; listen() içindeki setState "setState() called after dispose()" uyarısı üretir ve bellek sızar. Kapattıktan sonra add çağırırsan şu hata gelir:
Bad state: Cannot add event after closingÇözüm: dispose içinde _controller.close() ve her StreamSubscription için cancel(). Kaynağı kapatan kod birden fazla yerden çalışabiliyorsa isClosed kontrolü ekle: if (!_controller.isClosed) _controller.add(value).
4. hasError kontrolü olmadan data! kullanmak
Belirti: kaynak bir hata gönderdiğinde ekran şu mesajla çöker:
Null check operator used on a null valueBir stream'de hata, akışı bitirmek zorunda değildir: StreamController.addError kanalı açık bırakır, sonraki değer hatayı siler. Hata geldiğinde snapshot.data null olur; hasError'a bakmadan data! yazan kod düşer. Önce hasError, sonra veri; kullanıcıya bir "tekrar dene" yolu ver. async* fonksiyonlarda throw ise stream'i hatayla kapatır, sonrasında done gelir.
Mini Senaryo: Canlı Sipariş Takibi
Bir yemek siparişi ekranı düşün: durum "alındı, hazırlanıyor, yolda, teslim edildi" adımlarında ilerliyor. Ekran adımları StreamBuilder ile çiziyor, teslimat anında listen() ile SnackBar gösteriyor. Kaynak broadcast bir StreamController; son durum ayrıca bir alanda tutulup initialData olarak veriliyor, böylece geç abone olan widget boş kare görmüyor.
import 'dart:async';
import 'package:flutter/material.dart';
enum OrderStatus {
received('Sipariş alındı'),
preparing('Hazırlanıyor'),
onTheWay('Yolda'),
delivered('Teslim edildi');
const OrderStatus(this.label);
final String label;
}
/// Gerçek uygulamada burası bir WebSocket ya da Firestore dinleyicisi olurdu.
class OrderTracker {
final _controller = StreamController<OrderStatus>.broadcast();
Timer? _timer;
OrderStatus _current = OrderStatus.received;
Stream<OrderStatus> get status => _controller.stream;
OrderStatus get current => _current;
void start() {
_timer = Timer.periodic(const Duration(seconds: 3), (timer) {
_current = OrderStatus.values[_current.index + 1];
_controller.add(_current);
if (_current == OrderStatus.delivered) {
timer.cancel();
_controller.close(); // akış bitti: StreamBuilder done'a geçer
}
});
}
void dispose() {
_timer?.cancel();
if (!_controller.isClosed) _controller.close();
}
}
class OrderPage extends StatefulWidget {
const OrderPage({super.key});
@override
State<OrderPage> createState() => _OrderPageState();
}
class _OrderPageState extends State<OrderPage> {
final _tracker = OrderTracker();
late final StreamSubscription<OrderStatus> _sub;
@override
void initState() {
super.initState();
_sub = _tracker.status.listen((status) {
if (!mounted || status != OrderStatus.delivered) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Afiyet olsun, siparişin kapıda!')),
);
});
_tracker.start();
}
@override
void dispose() {
_sub.cancel();
_tracker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Sipariş Takibi')),
body: StreamBuilder<OrderStatus>(
stream: _tracker.status,
initialData: _tracker.current,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('Bağlantı hatası: ${snapshot.error}'));
}
final status = snapshot.data ?? OrderStatus.received;
final finished = snapshot.connectionState == ConnectionState.done;
return ListView(
children: [
for (final step in OrderStatus.values)
ListTile(
leading: Icon(
step.index <= status.index
? Icons.check_circle
: Icons.radio_button_unchecked,
color: step.index <= status.index ? scheme.primary : null,
),
title: Text(step.label),
),
if (finished)
const Padding(
padding: EdgeInsets.all(16),
child: Text('Takip tamamlandı.'),
),
],
);
},
),
);
}
}Burada dört karar var. Kaynak broadcast, çünkü aynı stream'i hem StreamBuilder hem listen() dinliyor; tek abonelikli olsaydı ikincisi hata alırdı. initialData: _tracker.current sayesinde ilk kare boş değil, mevcut adım hemen çiziliyor. Teslimatta close() çağrılıyor; StreamBuilder done'a geçiyor ama son değer (delivered) snapshot'ta kaldığı için adımların hepsi işaretli görünmeye devam ediyor. dispose sırası da bilinçli: önce abonelik iptal ediliyor, sonra kaynak kapatılıyor, en son super.dispose().
Sık Sorulan Sorular
StreamBuilder neden sürekli baştan başlıyor?
stream alanına build içinde oluşturulan bir nesne (getter, async* çağrısı ya da .map(...) dönüşümü) veriyorsun. Her build yeni bir stream üretir ve StreamBuilder ona yeniden abone olur. Stream'i initState içinde ya da late final bir alanda bir kez oluştur.
"Stream has already been listened to" hatası ne demek?
Tek abonelikli bir stream'i ikinci kez dinlemeye çalışıyorsun. Birden fazla dinleyici gerekiyorsa StreamController<T>.broadcast() kullan ya da stream'i tek bir StreamBuilder ile üstte dinleyip veriyi alt widget'lara geçir.
StreamBuilder ile FutureBuilder arasındaki fark nedir?
FutureBuilder tek bir sonuç bekler ve done olduktan sonra değişmez; StreamBuilder bir kanalı dinler, her yeni değerde yeniden çizer ve kanal kapanınca done olur. Tek seferlik istek için ilkini, canlı veri için ikincisini kullan.
StreamController'ı ne zaman kapatmalıyım?
Onu oluşturan nesne ölürken: bir State içindeyse dispose metodunda, bir servis sınıfındaysa o servisin kendi dispose metodunda. Kapatmazsan dinleyiciler çalışmaya devam eder ve bellek sızar.
İlgili Yazılar
Flutter: SharedPreferences ile Yerel Veri Saklama
Flutter'da shared_preferences ile tema ve ayar saklama: SharedPreferencesAsync, WithCache ve eski API farkı, geçiş aracı ve orada saklanmaması gerekenler.
Flutter: HTTP İstekleri ve REST API Kullanımı
Flutter'da http paketiyle GET, POST, PUT ve DELETE, JSON'dan model sınıfı, durum kodları, zaman aşımı ve FutureBuilder ile yükleniyor ve hata ekranı.
Flutter: FutureBuilder ile Asenkron Listeleme
Flutter FutureBuilder rehberi: snapshot durumlarını doğru okuma, Future'ı build içinde oluşturma hatası, initState çözümü ve setState ile yenileme.