## Ana çıkarımlar

  • - Her case için bloc açılmaz
  • - Her case için event - state mantığı kullanılmaz
  • - Senaryo bazlı durum yönetimi
  • - Cubit kullanımı çoğu zaman yeterlidir
  • - Testlerin kolaylığı ve tutarlılık

Flutter’da BLoC Kullanmak: State Yönetimini Günlük Hayattan Örneklerle Anlamak

Flutter öğrenirken bir noktaya kadar her şey oldukça rahat ilerler.

Bir buton koyarsın, setState() çağırırsın ve ekrandaki sayı değişir. Bir form hazırlarsın, birkaç değişken tanımlarsın ve işini çözersin.

Sonra proje büyümeye başlar.

API’den veri çekilir, kullanıcı giriş yapar, ürünler listelenir, favoriler değişir, internet bağlantısı kesilir, hata mesajları gösterilir ve ekranlar birbirleriyle veri paylaşmaya başlar.

İşte tam bu noktada şu soru ortaya çıkar:

“Bu uygulamanın durumunu nasıl düzenli şekilde yöneteceğim?”

Flutter tarafında bu problemin en yaygın çözümlerinden biri BLoC yaklaşımıdır.

Bu yazıda BLoC’u karmaşık akademik tanımlarla değil, günlük konuşma diliyle ve gerçek bir ürün listeleme örneği üzerinden ele alacağız.


Önce state nedir?#

State kelimesini Türkçeye “durum” olarak çevirebiliriz.

Bir ekranın o anda nasıl görünmesi gerektiğini belirleyen bütün bilgiler state olarak düşünülebilir.

Örneğin bir ürün listeleme ekranında şu durumlar bulunabilir:

  • Ürünler henüz yüklenmedi.
  • Ürünler yükleniyor.
  • Ürünler başarıyla geldi.
  • Ürün listesi boş geldi.
  • Bir hata oluştu.
  • Kullanıcı bir ürünü favoriye ekledi.

Flutter’ın resmi dokümantasyonunda state, kullanıcı arayüzünü herhangi bir anda yeniden oluşturmak için ihtiyaç duyduğumuz veriler şeklinde ele alınıyor.

Bunu bir restoran örneğiyle düşünelim.

Müşteri sipariş verdiğinde siparişin farklı durumları olabilir:

Sipariş alınmadı
Sipariş hazırlanıyor
Sipariş hazır
Sipariş teslim edildi
Sipariş iptal edildi

Mobil uygulamadaki state yönetimi de temelde bundan çok farklı değildir.

Uygulama, mevcut duruma bakarak kullanıcıya ne göstermesi gerektiğine karar verir.


Her şeyi setState() ile çözemez miyiz?#

Teknik olarak birçok şeyi çözebilirsin.

Küçük bir sayaç uygulamasında şu kullanım gayet mantıklıdır:

int counter = 0;

void increment() {
  setState(() {
    counter++;
  });
}

Burada ekstra bir mimariye gerek yoktur.

Fakat gerçek projelerde ekranın içinde zamanla şunlar birikmeye başlayabilir:

bool isLoading = false;
bool hasError = false;
String? errorMessage;
List<Product> products = [];
Product? selectedProduct;
bool isFavorite = false;

Bunlara bir de API çağrıları, hata yönetimi, filtreleme ve sayfalama işlemleri eklenince widget dosyası büyür.

Örneğin şöyle bir yapı ortaya çıkabilir:

Future<void> getProducts() async {
  setState(() {
    isLoading = true;
    hasError = false;
  });

  try {
    final result = await productRepository.getProducts();

    setState(() {
      products = result;
      isLoading = false;
    });
  } catch (error) {
    setState(() {
      isLoading = false;
      hasError = true;
      errorMessage = error.toString();
    });
  }
}

Bu kod ilk bakışta yanlış değildir.

Sorun, uygulamadaki her özellik için benzer kodlar yazmaya başladığımızda ortaya çıkar.

Bir süre sonra:

  • Arayüz kodu ile iş mantığı birbirine karışır.
  • Aynı işlemler farklı ekranlarda tekrar edilir.
  • Hangi değişkenin ne zaman değiştiğini takip etmek zorlaşır.
  • Test yazmak zorlaşır.
  • Bir hata düzeltildiğinde başka ekranlar bozulabilir.

BLoC’un asıl amacı setState() kullanımını tamamen yasaklamak değildir.

Amaç, büyüyen uygulamalarda ekranın işiyle uygulama mantığını birbirinden ayırmaktır.


BLoC nedir?#

BLoC, Business Logic Component ifadesinin kısaltmasıdır.

Türkçeye kabaca “iş mantığı bileşeni” şeklinde çevrilebilir.

En basit anlatımla BLoC, kullanıcıdan gelen olayları alır ve uygulamanın yeni durumunu üretir.

Event gelir
BLoC işlemi yapar
Yeni State oluşur
Ekran State’e göre güncellenir

BLoC akış şeması

Örneğin kullanıcı ürünler ekranını açtı.

Bu durumda şu event gönderilebilir:

ProductRequested()

BLoC bu olayı alır ve sırasıyla şu state’leri oluşturabilir:

ProductLoading
ProductSuccess

Bir hata oluşursa:

ProductLoading
ProductFailure

Burada ekran API çağrısının nasıl yapıldığıyla ilgilenmez.

Ekran sadece mevcut state’e bakar.

  • State ProductLoading ise yüklenme göstergesi çıkar.
  • State ProductSuccess ise ürünler gösterilir.
  • State ProductFailure ise hata mesajı gösterilir.

BLoC paketinin resmi tanımı da benzer şekilde, event akışını alıp state akışına dönüştüren öngörülebilir bir yapı sunması üzerine kuruludur.


BLoC ile Cubit arasındaki fark nedir?#

Flutter dünyasında “BLoC kullanıyorum” diyen bir geliştirici aslında bazen Cubit, bazen doğrudan Bloc sınıfını kullanıyor olabilir.

İkisi aynı paketin parçasıdır ancak çalışma biçimleri biraz farklıdır.

Cubit yaklaşımı#

Cubit’te doğrudan bir metot çağırırsın.

counterCubit.increment();

Metot yeni state’i yayınlar:

void increment() {
  emit(state + 1);
}

Akış şöyledir:

Metot çağrısı → Yeni state

Bloc yaklaşımı#

Bloc’ta önce bir event gönderirsin:

productBloc.add(ProductRequested());

BLoC bu event’i yakalar ve uygun state’i üretir:

Event → İşlem → Yeni state

Hangisini kullanmalıyız?#

Basit özelliklerde Cubit genellikle daha pratiktir.

Örneğin:

  • Tema değiştirme
  • Dil seçimi
  • Sayaç
  • Basit filtre seçimi
  • Şifreyi göster veya gizle
  • Alt menüde seçili sayfa yönetimi

Daha karmaşık ve farklı kullanıcı olaylarının bulunduğu özelliklerde Bloc daha anlamlı olabilir.

Örneğin:

  • Kullanıcı girişi
  • Mesajlaşma
  • Sipariş yönetimi
  • Ürün filtreleme
  • Ödeme işlemleri
  • WebSocket bağlantısı
  • Dosya yükleme
  • Sayfalama

Bu kesin bir kural değildir.

Basit bir özellik için de Bloc kullanılabilir. Karmaşık bir özellik Cubit ile de geliştirilebilir.

Buradaki temel soru şudur:

“Bu özelliğin davranışlarını açık event isimleriyle takip etmek bana fayda sağlar mı?”

Cevap evetse Bloc kullanmak mantıklıdır.


İlk örneğimiz: Sayaç uygulaması#

BLoC mantığını anlamak için önce basit bir Cubit örneği hazırlayalım.

Paketi eklemek#

Yazının hazırlandığı tarihte flutter_bloc paketinin güncel kararlı sürümü 9.1.1 olarak yayınlanmış durumda. Projeye eklerken her zaman pub.dev üzerindeki güncel sürümü kontrol etmek gerekir.

dependencies:
  flutter:
    sdk: flutter

  flutter_bloc: ^9.1.1

Ardından:

flutter pub get

komutunu çalıştırabiliriz.


CounterCubit oluşturmak#

import 'package:flutter_bloc/flutter_bloc.dart';

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() {
    emit(state + 1);
  }

  void decrement() {
    emit(state - 1);
  }
}

Buradaki önemli bölümleri inceleyelim.

class CounterCubit extends Cubit<int>

Cubit’in state tipi int olarak belirlenmiştir.

Yani bu Cubit yalnızca tam sayı yayınlar.

CounterCubit() : super(0);

Başlangıç değeri sıfırdır.

emit(state + 1);

Mevcut state’in bir fazlasını yeni state olarak yayınlar.

Buradaki state, Cubit’in o anki değeridir.


Cubit’i ekrana vermek#

Cubit’i widget ağacına eklemek için BlocProvider kullanabiliriz.

void main() {
  runApp(
    BlocProvider(
      create: (_) => CounterCubit(),
      child: const MyApp(),
    ),
  );
}

BlocProvider, Cubit veya Bloc örneğini altındaki widget’ların erişebileceği şekilde sağlar.

Resmî flutter_bloc dokümantasyonunda da BlocProvider, bir Bloc ya da Cubit örneğini widget ağacındaki alt bileşenlere ulaştırmak için kullanılan bağımlılık sağlama bileşeni olarak gösterilir.

Artık alt widget’larda CounterCubit’e ulaşabiliriz.


State’i ekranda dinlemek#

Ekranın state değiştiğinde yeniden oluşturulması için BlocBuilder kullanılır.

BlocBuilder<CounterCubit, int>(
  builder: (context, state) {
    return Text(
      '$state',
      style: const TextStyle(fontSize: 40),
    );
  },
)

Kullanıcı artırma butonuna bastığında:

context.read<CounterCubit>().increment();

Azaltma butonuna bastığında:

context.read<CounterCubit>().decrement();

Tam ekran şu şekilde olabilir:

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('BLoC Sayaç Örneği'),
      ),
      body: Center(
        child: BlocBuilder<CounterCubit, int>(
          builder: (context, state) {
            return Text(
              '$state',
              style: const TextStyle(
                fontSize: 48,
                fontWeight: FontWeight.bold,
              ),
            );
          },
        ),
      ),
      floatingActionButton: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          FloatingActionButton(
            heroTag: 'increment',
            onPressed: () {
              context.read<CounterCubit>().increment();
            },
            child: const Icon(Icons.add),
          ),
          const SizedBox(height: 12),
          FloatingActionButton(
            heroTag: 'decrement',
            onPressed: () {
              context.read<CounterCubit>().decrement();
            },
            child: const Icon(Icons.remove),
          ),
        ],
      ),
    );
  }
}

Bu örnekte iş mantığı widget’ın içinde değildir.

Widget sadece şunları yapar:

  1. State’i gösterir.
  2. Kullanıcı işlem yaptığında Cubit metodunu çağırır.

Sayının nasıl artırılacağına veya azaltılacağına Cubit karar verir.


Gerçek hayata daha yakın örnek: Ürün listeleme#

Sayaç örneği mantığı anlamak için faydalıdır fakat gerçek projelerde genellikle API işlemleriyle uğraşırız.

Şimdi bir takas veya ikinci el ilan uygulamasında ürünlerin listelendiğini düşünelim.

Ekranın muhtemel durumları şöyle olabilir:

Ürün ekranının farklı state durumları

Burada üç temel state görüyoruz:

ProductLoading
ProductSuccess
ProductFailure

Bir de işlem başlamadan önce kullanılabilecek başlangıç state’i ekleyebiliriz:

ProductInitial

Product modelini hazırlamak#

class Product {
  const Product({
    required this.id,
    required this.title,
    required this.price,
  });

  final String id;
  final String title;
  final double price;
}

Basit olması için ürünün yalnızca kimlik, başlık ve fiyat bilgilerini tuttuk.


Repository katmanını hazırlamak#

API işlemlerini doğrudan Bloc içine yazmak yerine repository kullanmak daha sağlıklı bir yapı oluşturur.

abstract interface class ProductRepository {
  Future<List<Product>> getProducts();
}

Örnek repository:

class FakeProductRepository implements ProductRepository {
  @override
  Future<List<Product>> getProducts() async {
    await Future<void>.delayed(
      const Duration(seconds: 2),
    );

    return const [
      Product(
        id: '1',
        title: 'MacBook Air',
        price: 42000,
      ),
      Product(
        id: '2',
        title: 'iPhone 15',
        price: 35000,
      ),
      Product(
        id: '3',
        title: 'AirPods Pro',
        price: 9000,
      ),
    ];
  }
}

Buradaki iki saniyelik gecikme gerçek bir API isteğini taklit eder.

Gerçek projede bu sınıfın içinde Dio veya başka bir HTTP istemcisi kullanılabilir.

class ApiProductRepository implements ProductRepository {
  ApiProductRepository(this.dio);

  final Dio dio;

  @override
  Future<List<Product>> getProducts() async {
    final response = await dio.get<List<dynamic>>(
      '/products',
    );

    final data = response.data ?? [];

    return data
        .map(
          (item) => Product.fromJson(
            item as Map<String, dynamic>,
          ),
        )
        .toList();
  }
}

Repository kullanmanın önemli avantajı şudur:

Bloc, verinin nereden geldiğini bilmez.

Veri:

  • REST API’den gelebilir.
  • Firebase’den gelebilir.
  • Yerel veritabanından gelebilir.
  • Test sırasında sahte bir listeden gelebilir.

Bloc için önemli olan repository’nin kendisine ürün listesini vermesidir.

Flutter’ın mimari önerilerinde de veri kaynaklarıyla uygulama mantığı arasına repository benzeri bir soyutlama katmanı koymak önerilen yaklaşımlardan biridir.


Event sınıflarını oluşturmak#

Event, kullanıcı veya sistem tarafından gerçekleştirilen bir olayı temsil eder.

sealed class ProductEvent {
  const ProductEvent();
}

Ürünlerin yüklenmesini isteyen event:

final class ProductRequested extends ProductEvent {
  const ProductRequested();
}

Sayfanın yenilenmesi için ayrı bir event de eklenebilir:

final class ProductRefreshed extends ProductEvent {
  const ProductRefreshed();
}

İsimleri geçmiş zaman veya istek anlamı taşıyacak şekilde vermek okunabilirliği artırır.

Örneğin:

ProductRequested
LoginSubmitted
MessageSent
FavoriteToggled
OrderCancelled

Şu tarz isimler ise fazla genel kalabilir:

Load
Click
Action
DoRequest
HandleData

Kodun başka biri tarafından okunduğunu düşünelim.

bloc.add(const ProductRequested());

satırı oldukça açıktır.

“Ürünler istendi.”

Ancak şu satır aynı açıklığı sağlamaz:

bloc.add(const Load());

“Neyi yükle?”

Event isimleri, uygulamada gerçekleşen olayları anlatmalıdır.


State sınıflarını oluşturmak#

sealed class ProductState {
  const ProductState();
}

Başlangıç state’i:

final class ProductInitial extends ProductState {
  const ProductInitial();
}

Yüklenme state’i:

final class ProductLoading extends ProductState {
  const ProductLoading();
}

Başarılı state:

final class ProductSuccess extends ProductState {
  const ProductSuccess(this.products);

  final List<Product> products;
}

Hata state’i:

final class ProductFailure extends ProductState {
  const ProductFailure(this.message);

  final String message;
}

State’leri birer ekran fotoğrafı gibi düşünebiliriz.

ProductLoading, ekranın yüklenme anındaki fotoğrafıdır.

ProductSuccess, ürünlerin geldiği andaki fotoğrafıdır.

ProductFailure, hata oluştuğu andaki fotoğrafıdır.


ProductBloc oluşturmak#

import 'package:flutter_bloc/flutter_bloc.dart';

class ProductBloc extends Bloc<ProductEvent, ProductState> {
  ProductBloc({
    required ProductRepository productRepository,
  })  : _productRepository = productRepository,
        super(const ProductInitial()) {
    on<ProductRequested>(_onProductRequested);
    on<ProductRefreshed>(_onProductRefreshed);
  }

  final ProductRepository _productRepository;

  Future<void> _onProductRequested(
    ProductRequested event,
    Emitter<ProductState> emit,
  ) async {
    emit(const ProductLoading());

    try {
      final products =
          await _productRepository.getProducts();

      emit(ProductSuccess(products));
    } catch (error) {
      emit(
        ProductFailure(
          'Ürünler yüklenirken bir hata oluştu.',
        ),
      );
    }
  }

  Future<void> _onProductRefreshed(
    ProductRefreshed event,
    Emitter<ProductState> emit,
  ) async {
    try {
      final products =
          await _productRepository.getProducts();

      emit(ProductSuccess(products));
    } catch (error) {
      emit(
        ProductFailure(
          'Ürünler yenilenemedi.',
        ),
      );
    }
  }
}

Akışı parça parça inceleyelim.

Başlangıç state’i#

super(const ProductInitial())

Bloc ilk oluşturulduğunda state ProductInitial olur.

Event bağlantısı#

on<ProductRequested>(_onProductRequested);

Bu satırın anlamı şudur:

ProductRequested event’i gelirse _onProductRequested metodunu çalıştır.

Yüklenme durumunu yayınlamak#

emit(const ProductLoading());

Ekran bunu gördüğünde yüklenme göstergesini açabilir.

Repository’den veri almak#

final products =
    await _productRepository.getProducts();

Başarılı sonucu yayınlamak#

emit(ProductSuccess(products));

Hata durumunu yayınlamak#

emit(
  const ProductFailure(
    'Ürünler yüklenirken bir hata oluştu.',
  ),
);

Böylece API işlemi ve hata yönetimi widget dosyasının dışına taşınmış olur.


ProductBloc’u ekrana sağlamak#

BlocProvider(
  create: (context) {
    return ProductBloc(
      productRepository: context.read<ProductRepository>(),
    )..add(const ProductRequested());
  },
  child: const ProductPage(),
)

Buradaki şu kullanım önemlidir:

..add(const ProductRequested())

ProductBloc oluşturulur oluşturulmaz ürün yükleme event’i gönderilir.

Bunun daha açık yazımı şöyledir:

final bloc = ProductBloc(
  productRepository: repository,
);

bloc.add(const ProductRequested());

return bloc;

İki kullanım da aynı işi yapar.


State’e göre ekranı oluşturmak#

BlocBuilder<ProductBloc, ProductState>(
  builder: (context, state) {
    if (state is ProductInitial) {
      return const SizedBox.shrink();
    }

    if (state is ProductLoading) {
      return const Center(
        child: CircularProgressIndicator(),
      );
    }

    if (state is ProductSuccess) {
      return ProductList(
        products: state.products,
      );
    }

    if (state is ProductFailure) {
      return ErrorView(
        message: state.message,
      );
    }

    return const SizedBox.shrink();
  },
)

Dart’ın pattern matching ve switch özellikleriyle daha okunabilir bir yapı da kurulabilir:

BlocBuilder kod örneği

BlocBuilder<ProductBloc, ProductState>(
  builder: (context, state) {
    return switch (state) {
      ProductInitial() =>
        const Text('Henüz işlem başlamadı'),

      ProductLoading() =>
        const CircularProgressIndicator(),

      ProductSuccess(:final products) =>
        ProductList(products: products),

      ProductFailure(:final message) =>
        ErrorView(message: message),
    };
  },
)

BlocBuilder, yeni state’ler oluştuğunda arayüzü yeniden oluşturmak için kullanılır. API dokümantasyonunda da StreamBuilder benzeri çalıştığı ancak BLoC kullanımına özel daha sade bir API ve bazı optimizasyonlar sunduğu belirtilir.


BlocBuilder her iş için kullanılmalı mı?#

Hayır.

BlocBuilder temel olarak ekranda bir şey değişecekse kullanılmalıdır.

Örneğin:

  • Yüklenme göstermek
  • Liste göstermek
  • Hata bileşeni göstermek
  • Butonun aktifliğini değiştirmek
  • Profil bilgisini yenilemek
  • Sepet sayısını güncellemek

Ancak bazı işlemler ekranda widget çizmekten farklıdır.

Örneğin:

  • Başka sayfaya gitmek
  • Snackbar göstermek
  • Dialog açmak
  • Bildirim göstermek
  • TextField temizlemek

Bu işlemler için BlocListener daha uygundur.


BlocListener ne işe yarar?#

Bir giriş ekranı düşünelim.

Kullanıcı giriş butonuna bastı.

Giriş başarılı olduğunda ana sayfaya yönlendirme yapmak istiyoruz.

BlocListener<LoginBloc, LoginState>(
  listener: (context, state) {
    if (state is LoginSuccess) {
      Navigator.of(context).pushReplacement(
        MaterialPageRoute<void>(
          builder: (_) => const HomePage(),
        ),
      );
    }

    if (state is LoginFailure) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(state.message),
        ),
      );
    }
  },
  child: const LoginForm(),
)

Burada BlocListener widget oluşturmaz.

State değiştiğinde bir işlem yapar.

Resmî BLoC dokümantasyonunda da BlocListener; yönlendirme, dialog veya Snackbar gösterme gibi state başına bir kez gerçekleşmesi gereken yan etkiler için önerilir.

Basit ayrım şöyledir:

Ekranda bir şey çizilecekse → BlocBuilder

Bir işlem yapılacaksa → BlocListener

BlocConsumer ne işe yarar?#

Bazı ekranlarda hem UI güncellemek hem de yan etki çalıştırmak gerekir.

Örneğin giriş ekranında:

  • Giriş sürerken butonda loading göstermek
  • Hata olursa Snackbar göstermek
  • Başarılı olursa ana sayfaya yönlendirmek

Bu durumda BlocBuilder ve BlocListener ayrı ayrı kullanılabilir.

Alternatif olarak BlocConsumer tercih edilebilir.

BlocConsumer<LoginBloc, LoginState>(
  listener: (context, state) {
    if (state is LoginSuccess) {
      Navigator.of(context).pushReplacementNamed(
        '/home',
      );
    }

    if (state is LoginFailure) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(state.message),
        ),
      );
    }
  },
  builder: (context, state) {
    final isLoading = state is LoginLoading;

    return ElevatedButton(
      onPressed: isLoading
          ? null
          : () {
              context.read<LoginBloc>().add(
                    LoginSubmitted(
                      email: emailController.text,
                      password: passwordController.text,
                    ),
                  );
            },
      child: isLoading
          ? const CircularProgressIndicator()
          : const Text('Giriş Yap'),
    );
  },
)

context.read, context.watch ve context.select farkı#

Bu üç kullanım başlangıçta sık karıştırılır.

context.read#

Bloc veya Cubit’e bir kere erişmek için kullanılır.

context.read<ProductBloc>().add(
  const ProductRequested(),
);

Genellikle:

  • Buton tıklamalarında
  • Event göndermede
  • Cubit metodu çağırmada

kullanılır.

read state değiştiğinde widget’ı yeniden oluşturmaz.


context.watch#

Bloc’un state değişikliklerini takip eder.

final state = context.watch<ProductBloc>().state;

State değiştiğinde ilgili widget yeniden oluşturulur.

Ancak geniş bir widget içinde dikkatsiz kullanılırsa gereksiz rebuild oluşturabilir.


context.select#

State’in yalnızca belirli bir bölümünü takip eder.

Örneğin sepet state’inin tamamını değil, yalnızca ürün sayısını dinlemek istiyoruz:

final itemCount = context.select(
  (CartBloc bloc) => bloc.state.items.length,
);

Sepette başka bir bilgi değişse bile ürün sayısı aynı kaldıysa widget tekrar oluşturulmayabilir.

Bu nedenle performans açısından faydalı olabilir.


MultiBlocProvider kullanımı#

Gerçek uygulamalarda aynı ekran ağacında birden fazla Bloc bulunabilir.

MultiBlocProvider(
  providers: [
    BlocProvider(
      create: (_) => AuthBloc(),
    ),
    BlocProvider(
      create: (_) => ProductBloc(
        productRepository: productRepository,
      ),
    ),
    BlocProvider(
      create: (_) => CartCubit(),
    ),
  ],
  child: const App(),
)

Bu kullanım, iç içe çok sayıda BlocProvider yazmaktan daha okunaklıdır.

İç içe kullanım şu şekilde görünürdü:

BlocProvider(
  create: (_) => AuthBloc(),
  child: BlocProvider(
    create: (_) => ProductBloc(
      productRepository: productRepository,
    ),
    child: BlocProvider(
      create: (_) => CartCubit(),
      child: const App(),
    ),
  ),
)

İki yapı da çalışır.

Ancak MultiBlocProvider kodun okunmasını kolaylaştırır.


RepositoryProvider kullanımı#

Repository sınıflarını widget ağacına sağlamak için RepositoryProvider kullanılabilir.

RepositoryProvider<ProductRepository>(
  create: (_) => ApiProductRepository(
    Dio(
      BaseOptions(
        baseUrl: 'https://api.example.com',
      ),
    ),
  ),
  child: BlocProvider(
    create: (context) {
      return ProductBloc(
        productRepository:
            context.read<ProductRepository>(),
      );
    },
    child: const ProductPage(),
  ),
)

Birden fazla repository varsa:

MultiRepositoryProvider(
  providers: [
    RepositoryProvider<AuthRepository>(
      create: (_) => AuthRepositoryImpl(),
    ),
    RepositoryProvider<ProductRepository>(
      create: (_) => ProductRepositoryImpl(),
    ),
    RepositoryProvider<MessageRepository>(
      create: (_) => MessageRepositoryImpl(),
    ),
  ],
  child: const App(),
)

Bu sayede Bloc sınıfları ihtiyaç duydukları bağımlılıkları dışarıdan alır.

Bu yaklaşıma dependency injection, yani bağımlılıkların dışarıdan verilmesi denir.


State sınıfında tek tek sınıflar mı, tek sınıf mı kullanılmalı?#

BLoC kullanırken state tasarımında iki yaygın yaklaşım vardır.

Ayrı state sınıfları#

sealed class ProductState {}

class ProductInitial extends ProductState {}

class ProductLoading extends ProductState {}

class ProductSuccess extends ProductState {
  ProductSuccess(this.products);

  final List<Product> products;
}

class ProductFailure extends ProductState {
  ProductFailure(this.message);

  final String message;
}

Bu yapı okunaklıdır ve state’ler arasındaki fark nettir.


Tek state sınıfı#

enum ProductStatus {
  initial,
  loading,
  success,
  failure,
}

class ProductState {
  const ProductState({
    this.status = ProductStatus.initial,
    this.products = const [],
    this.errorMessage,
  });

  final ProductStatus status;
  final List<Product> products;
  final String? errorMessage;
}

Yeni state oluştururken copyWith kullanılabilir:

emit(
  state.copyWith(
    status: ProductStatus.loading,
  ),
);

Bu yaklaşım özellikle state içinde birden fazla bilginin korunması gerektiğinde kullanışlıdır.

Örneğin:

  • Mevcut ürün listesi
  • Filtreler
  • Sayfalama bilgisi
  • Seçili kategori
  • Arama metni
  • Hata mesajı
  • Yüklenme durumu

Tek state sınıfı kullanıldığında mevcut bilgileri koruyup sadece gereken alanı değiştirmek daha kolay olabilir.


Hatalı bir state tasarımı örneği#

Şöyle bir state düşünelim:

class ProductState {
  bool isLoading;
  bool hasError;
  List<Product>? products;
  String? errorMessage;
}

Burada şu anlamsız kombinasyonlar oluşabilir:

isLoading = true
hasError = true
products = dolu
errorMessage = null

Ekran bu durumda ne göstermelidir?

Hem yüklenme hem hata aynı anda mı gösterilecek?

Ürün listesi varken hata neden var?

İyi bir state tasarımı, mümkün olduğunca geçersiz durumların oluşmasını engellemelidir.

Ayrı state sınıfları kullanıldığında:

ProductLoading()

yalnızca yüklenmeyi temsil eder.

ProductSuccess(products)

yalnızca başarılı sonucu temsil eder.

Bu nedenle state modelini tasarlarken sadece “hangi alanlara ihtiyacım var?” diye düşünmemek gerekir.

Şu soru daha değerlidir:

“Uygulamam hangi anlamlı durumlarda bulunabilir?”


BLoC klasör yapısı nasıl olmalı?#

Tek bir doğru klasör yapısı yoktur.

Ancak özellik bazlı bir yapı, büyüyen projelerde genellikle daha düzenli olur.

lib/
├── core/
│   ├── network/
│   ├── error/
│   └── utils/
│
├── features/
│   └── products/
│       ├── data/
│       │   ├── models/
│       │   ├── repositories/
│       │   └── services/
│       │
│       ├── logic/
│       │   └── product_bloc/
│       │       ├── product_bloc.dart
│       │       ├── product_event.dart
│       │       └── product_state.dart
│       │
│       └── presentation/
│           ├── pages/
│           └── widgets/
│
└── main.dart

Daha küçük bir proje için daha sade bir yapı da yeterlidir:

lib/
├── blocs/
│   └── product/
│       ├── product_bloc.dart
│       ├── product_event.dart
│       └── product_state.dart
│
├── models/
├── repositories/
├── pages/
└── widgets/

Klasör yapısının amacı havalı görünmek değildir.

Kodun nerede olduğunu kolayca bulabilmektir.

Bir ürün özelliğinde değişiklik yaparken bütün ilgili dosyalar aynı klasör altında bulunuyorsa geliştirme daha rahat ilerler.


BLoC kullanırken sık yapılan hatalar#

1. Bloc içine BuildContext göndermek#

Şu kullanım tavsiye edilmez:

LoginSubmitted(
  context: context,
  email: email,
  password: password,
)

Bloc’un kullanıcı arayüzüne bağımlı hale gelmesine neden olur.

Yönlendirme veya Snackbar gibi işlemler BlocListener içinde yapılmalıdır.


2. Bloc içinde Navigator kullanmak#

Navigator.of(context).push(...);

Bu kod Bloc’un içinde olmamalıdır.

Bloc, girişin başarılı olduğunu belirten state’i yayınlamalıdır:

emit(const LoginSuccess());

Yönlendirmeyi ekran yapmalıdır:

if (state is LoginSuccess) {
  Navigator.of(context).pushReplacementNamed('/home');
}

3. Bloc içine gereğinden fazla iş yüklemek#

Tek bir AppBloc oluşturup bütün uygulamayı yönetmek genellikle kötü sonuç verir.

AppBloc
├── giriş işlemleri
├── ürünler
├── mesajlar
├── bildirimler
├── sepet
├── profil
└── ayarlar

Bunun yerine özelliklere göre ayırmak daha sağlıklıdır:

AuthBloc
ProductBloc
ChatBloc
NotificationBloc
CartBloc
ProfileBloc
SettingsCubit

4. UI state’i ile uygulama state’ini karıştırmak#

Her küçük değişken için Bloc oluşturmak gerekmez.

Örneğin yalnızca bir widget’ın içindeki animasyonun açık olup olmadığı bilgisi için setState() yeterli olabilir.

bool isExpanded = false;

Bu bilgi başka ekranlarla paylaşılmıyorsa ve karmaşık bir iş mantığı oluşturmuyorsa Bloc kullanmak gereksiz olabilir.

Flutter dokümantasyonu da kısa ömürlü, tek bir widget’a ait durumlarla uygulama genelini ilgilendiren state’leri birbirinden ayırır.


5. State’i sonradan değiştirmek#

State mümkün olduğunca immutable, yani oluşturulduktan sonra değiştirilemez olmalıdır.

Kötü örnek:

state.products.add(newProduct);
emit(state);

Daha doğru yaklaşım:

final updatedProducts = [
  ...state.products,
  newProduct,
];

emit(
  state.copyWith(
    products: updatedProducts,
  ),
);

Yeni bir liste ve yeni bir state üretilir.

Bu, değişikliklerin daha öngörülebilir olmasını sağlar.


6. API hata mesajını doğrudan kullanıcıya göstermek#

catch (error) {
  emit(ProductFailure(error.toString()));
}

Bu kullanım kullanıcıya şöyle bir mesaj gösterebilir:

DioException [connection timeout]:
The request connection took longer than 0:00:30

Bu mesaj geliştirici için anlamlı olabilir ancak kullanıcı için değildir.

Daha anlaşılır hata mesajları üretmek gerekir:

catch (error) {
  emit(
    const ProductFailure(
      'Ürünler yüklenemedi. İnternet bağlantınızı kontrol ederek tekrar deneyin.',
    ),
  );
}

Teknik hata ayrıca log sistemine gönderilebilir.


Bloc test etmek#

BLoC yaklaşımının en güçlü yönlerinden biri test edilebilirliğidir.

Bir Bloc testi temelde şunu kontrol eder:

Bu event gönderildiğinde hangi state’ler oluşuyor?

Örneğin ürünler başarıyla geliyorsa beklenen akış:

ProductLoading
ProductSuccess

Test için bloc_test paketi kullanılabilir.

dev_dependencies:
  flutter_test:
    sdk: flutter

  bloc_test: ^10.0.0
  mocktail: ^1.0.4

Örnek test:

blocTest<ProductBloc, ProductState>(
  'ürünler başarıyla geldiğinde loading ve success yayınlar',
  build: () {
    when(
      () => productRepository.getProducts(),
    ).thenAnswer(
      (_) async => products,
    );

    return ProductBloc(
      productRepository: productRepository,
    );
  },
  act: (bloc) {
    bloc.add(const ProductRequested());
  },
  expect: () => [
    const ProductLoading(),
    ProductSuccess(products),
  ],
);

Hata senaryosu:

blocTest<ProductBloc, ProductState>(
  'repository hata verdiğinde loading ve failure yayınlar',
  build: () {
    when(
      () => productRepository.getProducts(),
    ).thenThrow(
      Exception('Sunucu hatası'),
    );

    return ProductBloc(
      productRepository: productRepository,
    );
  },
  act: (bloc) {
    bloc.add(const ProductRequested());
  },
  expect: () => [
    const ProductLoading(),
    const ProductFailure(
      'Ürünler yüklenirken bir hata oluştu.',
    ),
  ],
);

Widget açmaya gerek kalmadan iş mantığını test edebiliriz.

Bu, özellikle büyük ekiplerde önemli bir avantajdır.


HydratedBloc nedir?#

Normal bir Bloc veya Cubit uygulama kapandığında state bilgisini kaybeder.

Örneğin kullanıcı karanlık temayı seçti.

Uygulama yeniden açıldığında tekrar açık temaya dönmesini istemeyebiliriz.

hydrated_bloc paketi state’i yerel depolamaya kaydedip uygulama yeniden açıldığında geri yüklemek için kullanılabilir.

Örnek:

class ThemeCubit extends HydratedCubit<ThemeMode> {
  ThemeCubit() : super(ThemeMode.light);

  void toggleTheme() {
    emit(
      state == ThemeMode.light
          ? ThemeMode.dark
          : ThemeMode.light,
    );
  }

  @override
  ThemeMode? fromJson(Map<String, dynamic> json) {
    final value = json['theme'] as String?;

    return value == 'dark'
        ? ThemeMode.dark
        : ThemeMode.light;
  }

  @override
  Map<String, dynamic>? toJson(ThemeMode state) {
    return {
      'theme': state == ThemeMode.dark
          ? 'dark'
          : 'light',
    };
  }
}

Bu yapı şu özelliklerde faydalı olabilir:

  • Tema tercihi
  • Dil tercihi
  • Kullanıcının seçtiği filtreler
  • Tamamlanmamış form
  • Sepet bilgileri
  • Onboarding tamamlandı bilgisi

Ancak token, parola veya çok hassas kullanıcı bilgileri şifrelenmeden doğrudan saklanmamalıdır.


BLoC performansı kötü etkiler mi?#

BLoC tek başına uygulamayı yavaşlatan bir yapı değildir.

Performans sorunları genellikle yanlış rebuild yönetiminden kaynaklanır.

Örneğin ekranın tamamını tek bir BlocBuilder içine almak:

BlocBuilder<ProductBloc, ProductState>(
  builder: (context, state) {
    return Scaffold(
      appBar: ...,
      drawer: ...,
      body: ...,
      bottomNavigationBar: ...,
    );
  },
)

Her state değişiminde gereğinden fazla widget yeniden oluşturulabilir.

Bunun yerine yalnızca değişmesi gereken alan BlocBuilder içinde tutulabilir:

Scaffold(
  appBar: AppBar(
    title: const Text('Ürünler'),
  ),
  body: BlocBuilder<ProductBloc, ProductState>(
    builder: (context, state) {
      return ProductBody(state: state);
    },
  ),
)

Gerekirse buildWhen kullanılabilir:

BlocBuilder<ProductBloc, ProductState>(
  buildWhen: (previous, current) {
    return previous.runtimeType != current.runtimeType;
  },
  builder: (context, state) {
    return ProductBody(state: state);
  },
)

Ancak buildWhen her yere eklenmesi gereken sihirli bir performans çözümü değildir.

Önce gerçekten gereksiz rebuild olup olmadığı ölçülmelidir.


BLoC ne zaman tercih edilmeli?#

BLoC şu durumlarda güçlü bir seçenek olabilir:

  • Uygulama orta veya büyük ölçekteyse
  • Aynı özellik birden fazla ekranda kullanılıyorsa
  • API ve asenkron işlem sayısı fazlaysa
  • Loading, success ve error akışları bulunuyorsa
  • İş mantığının test edilmesi gerekiyorsa
  • Bir ekip halinde geliştiriliyorsa
  • Event akışlarının açıkça takip edilmesi isteniyorsa
  • WebSocket veya gerçek zamanlı veri akışı bulunuyorsa
  • Karmaşık form ve doğrulama süreçleri varsa

BLoC ne zaman gereksiz olabilir?#

Şu durumlarda daha basit bir çözüm yeterli olabilir:

  • Tek ekranlı küçük bir uygulama
  • Basit bir sayaç
  • Yalnızca bir widget’ı ilgilendiren aç-kapa durumu
  • Geçici animasyon state’i
  • Çok küçük bir prototip
  • Birkaç değişkenden oluşan basit form

BLoC kullanmak uygulamayı otomatik olarak profesyonel yapmaz.

Yanlış tasarlanmış bir BLoC yapısı, düzgün yazılmış basit bir setState() çözümünden daha kötü olabilir.

Önemli olan kullandığımız aracın problemin büyüklüğüne uygun olmasıdır.


Günlük hayattan son bir benzetme#

Bir restoranda garson, aşçı ve müşteri olduğunu düşünelim.

Müşteri#

Sipariş verir.

Bu, event’tir:

Hamburger siparişi verildi

Mutfak#

Siparişi alır ve hazırlar.

Bu, Bloc’tur.

Sipariş durumu#

Sipariş alındı
Hazırlanıyor
Hazır
Teslim edildi

Bunlar state’tir.

Garsonun ekranı#

Sipariş durumuna göre kullanıcıya bilgi verir.

Bu da Flutter arayüzüdür.

Müşteri mutfağa girip hamburgeri kendisi pişirmez.

Garson da etin hangi sıcaklıkta pişirildiğiyle ilgilenmez.

Herkes kendi sorumluluğunu yerine getirir.

BLoC mimarisinin sağlamaya çalıştığı şey tam olarak budur:

Her katmanın kendi işini yapması.


Sonuç#

BLoC ilk öğrenildiğinde gereğinden karmaşık görünebilir.

Event, state, provider, builder, listener ve repository gibi birçok yeni kavram aynı anda karşımıza çıkar.

Ancak temel akış aslında oldukça basittir:

Kullanıcı bir işlem yapar.
Bir event gönderilir.
Bloc iş mantığını çalıştırır.
Yeni state yayınlanır.
Ekran state’e göre güncellenir.

BLoC kullanırken en önemli amaç daha fazla dosya oluşturmak değildir.

Amaç:

  • İş mantığını arayüzden ayırmak
  • Uygulama akışını öngörülebilir hale getirmek
  • Kodun test edilmesini kolaylaştırmak
  • Büyük projelerde karmaşayı azaltmak
  • Ekip içindeki geliştirme standardını güçlendirmek

Başlangıç aşamasında her şeyi Bloc’a dönüştürmeye çalışmak yerine küçük bir özellik seçmek daha doğru olur.

Örneğin:

  1. Önce bir sayaç için Cubit yaz.
  2. Ardından API’den ürün çeken bir Bloc oluştur.
  3. Loading, success ve error state’lerini yönet.
  4. BlocListener ile Snackbar göster.
  5. Repository katmanı ekle.
  6. Son olarak Bloc testi yaz.

Bu sıralamayla ilerlediğinde BLoC, ezberlenen bir paket olmaktan çıkar ve neden kullanıldığını gerçekten anladığın bir araca dönüşür.