İçeriğe geç / Skip to content / Zum Inhalt

Flutter: StreamBuilder ile Gerçek Zamanlı Veri Akışı

Ahmet Balaman

Son güncelleme:

7 dk okuma

FlutterStreamBuilderStreamStreamControllerAsyncRealtime
Flutter: StreamBuilder ile Gerçek Zamanlı Veri Akışı

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ı

Zaman çizgisinde Future tek bir değer üretip biter, Stream ise arka arkaya birçok değer üretir ve her değerde build çağrılır

Ö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'daki StreamProvider (ya da provider paketindeki StreamProvider) 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) Stream ağır kalır; ValueNotifier ile ValueListenableBuilder ya da düz setState yeter.
  • Gelen değere tepki olarak sayfa değiştirecek ya da bildirim gösterecek isen StreamBuilder değil listen() 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 value

Bir 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.

Yorumlar