Flutter Uygulama Mimarisi: BLoC

Mirkan
Flutter İzmir
Published in
7 min readJun 16, 2019

--

Uygulamanızı baştan sağlam bir mimariye oturtmak, uygulamanız büyüdüğü zaman işleri kolaylaştıracaktır. Yeni özellikler eklemek işkence olmaktan çıkacaktır ve uygulamadaki hatalar daha kolay yakalanacaktır. Zaten bu yazıyı okuyorsanız, bu konunun gerekliliğinin az çok farkındasınızdır.

Flutter oyun motoru mantığıyla çalışıyor, kullanıcı arayüzü(UI) değiştiğinde tekrar yaratılıp ekrana Skia grafik motoru kullanılarak tekrar çiziliyor. Yani ekranda birşeyleri modifiye etmek yerine, Widget’ları tekrar en baştan yaratıyorsunuz. İşin zor kısmını Flutter hallediyor ve gerekirse bunu her karede yapabilecek kadar hızlı. Flutter UI’ı 60 FPS’de güncelliyor ve çoğunlukla bunun için GPU’yu kullanıyor. Bu da doğal olarak kullanıcılar için yağ gibi akan bir arayüz demek. Fakat bu tabii ki oyun motorlarından daha etkili çalışan bir sistem, aksi durumda mobil cihazlarımızın pili oyun oynuyormuşcasına emilirdi.

State Kavramı

Widget’ların tekrar yaratılma sebebi kullanıcı etkileşimleri ve çeşitli diğer etmenler. Tekrar baştan yaratılması için ise StatefulWidget’tan türetilmiş widget’ınızın state’ini değiştirmeniz gerekiyor. Bunu da setState metodu ile yapıyoruz ki framework bir sonraki build için kendini ayarlasın.

setState(() => _counter++);

Varsayılan Flutter uygulamasını hatırlayın. Sağ altta bir buton vardı ve _counter değişkenini setState metodu içinde arttırıyordu. Böylece build metodu tekrar çağrılıyor ve Flutter ekrandaki Text widget’ını _counter değişkeninin yeni değeriyle çiziyor.

E peki sıkıntı nedir? Bunu kullansak yetmez mi?

Büyük bir widget hiyerarşiniz olduğunda yetmez. Bunun sebebini alttaki görselde açıklamaya çalışacağım.

Widget tree

Solda görüldüğü gibi üstteki widget’ın state’ini aşağıdaki widget’a iletmekte sıkıntı yok. Hatta Inherited Widget kullanarak direkt olarak da ulaştırabiliriz. Fakat state’i 2 adım yukarı sonra biraz aşağı itelemek istediğinizde işler ilginçleşiyor çünkü veri yukarıdan aşağı gidiyor. React dokümanındaki Lifting State Up yazısında anlatıldığı gibi, eğer iki child aynı state’i paylaşacaksa, bu state’i parent widget’a taşıyabilirsiniz. Fakat bu da widget’ınızın aslında umrunda olmaması gereken şeyleri tutması demek.

İhtiyacımız olan şey state yönetimi. React evreninde kendini kanıtlamış Redux, Flutter ekibinin geçen seneki konferansta önerdiği BLoC, belki biraz daha basit çözüm arayanlar için Provider ve Scoped Model mevcut.
*Provider son
konferansta önerildi.

Konumuz BLoC pattern fakat önce biraz Future, Stream ve Streambuilder kavramlarına bakalım.

Future ve Stream

Future’ları tamamlanmamış bir hesaplama olarak düşünebiliriz. Haliyle elinizde bir sonuç yerine tamamlanması eninde sonunda söz verilmiş bir sonuç oluyor. Normal bir fonksiyon size String döndürüyorsa, asenkron bir fonksiyon size Future<String> döndürür. Bu sonuç hazır olduğunda da Future bunu size söyler. .then(() {}) callback fonksiyonu veya await rezerve kelimesi ile bu sonuç geldiğinde bununla ne yapacağınızı belirleyebilirsiniz.

Stream’leri ise asenkron olaylar gibi düşünebiliriz, daha çok asenkron bir Iterable, ya da kelime anlamıyla akış. Bir event hazır olduğunda size söyler.

Peki bu sonuçlar, bu event’ler neden hazır değil ki? Flutter falan mı yavaş çalışıyor?

Hayır, artık mobil uygulamalarımız veri tabanlarından, API’lardan istekler alıyor, sensörlerden veriler alıyor fakat bu veriler çat diye gelmiyor. Bunlar hazır olduğunda bunları çözümlememiz kullanmamız lazım.

StreamBuilder

StreamBuilder bir widget, adı üstünde de bir builder. Yani stream’den gelen veriler hazır olduğunda ve her yeni veri geldiğinde, builder fonksiyonunu tekrar çağırıyor ve tekrar widget ağacını yaratıyor. Bu sayede yeni verileri gösterebiliyorsunuz. Tekrar yaratmayı setState için de konuştuk, ufak ufak esas konuya geliyoruz. Fakat sadece bu kadarla sınırlı değil, StreamBuilder size tam bir kontrol veriyor.

Çeşitli bağlantı durumlarında(snapshot.ConnectionState) ne olacağından, veri olmadığında(!snapshot.hasData) veya hata ile karşılaşıldığında(snapshot.hasError) ne olacağına kadar geniş yelpazede bir destek sunuyor. initialData verip ilk başta ne göstereceğinizi de belirtebiliyorsunuz.

Bağlantılar, istekler, veri tabanı, API vs. diyorsunuz, mimari üzerine bir yazı bekliyorduk?

Evet doğru yerdesiniz, küçük bir uygulama üzerinden esas konumuzu anlatmaya başlıyorum. Todo uygulaması yapacağız.

Neden TODO uygulaması?

Sizin için küçük bir TODO uygulaması yazdım ve bunun üzerinden şimdi anlatacağım. TODO uygulaması olma sebebi hem anlamak için basit hem de bu BLoC pattern’ı anlatabilmem için yeterince karışık olması. TODO demek yapılacaklar demek bu arada, yani bir yapılacaklar listesi uygulaması yapıyoruz.

Kod anlatımı ve BLoC

BLoC’u böyle yazma sebebim, Business Logic Component’ın kısaltılmışı olması. Burda amaç uygulamanın mantığıyla, arayüzünü ayırmak. Her bir UI elementi uygulamanın mantığından parçalar içeriyorsa bunların bakımını sağlamak, test etmek ve değişiklikler yapmak zorlaşır. Üstelik AngularDart tarafında da bir uygulama yazıyorsanız, Flutter’a has UI kodlarının ve mantık içeren Dart kodlarının olabildiğince ayrılması, kodunuzun tekrar kullanılabilirliğini arttırır.

Yani bir buton sizin buluta veriyi nasıl ilettiğinizi en ince ayrıntısına kadar bilmemeli. Bunun için BLoC’lara ihtiyacımız var. Peki bu component’lar nasıl çalışacak?

BLoC

Component’a gelen her event için component dışarı state çıkaracak ve nasıl ki setState build metodunu tekrar tetikliyorsa, Stream olarak gelen state de StreamBuilder’ımızın builder metodunu tetikleyecek.

Event ve State’lerden önce TODO’ların neye benzeyeceğini belirleyelim ve TODO class’ı yaratalım.

todo.dart dosyamız aşağıda gördüğünüz gibi. lib/ içine koyabilirsiniz, ben lib/src/model’in içine koydum.

import 'package:meta/meta.dart';

class Todo {
Todo({@required this.title, this.isCompleted = false});
String title;
bool isCompleted;
}

TODO’nun ne olduğunu içeren bir title(mesela alışverişe çık)ve tamamlanma ve tamamlanmama durumu(isCompleted) var. Şimdi bu uygulamada hangi event’lerin olabileceğini belirleyelim.

TODO ekleyebiliriz, silebiliriz ve tamamlanma/tamamlanmama durumunu değiştirebiliriz. 3 eventimiz var.

lib/src/bloc/ içinde todo_event.dart dosyası

import 'package:vanilla_bloc_todos/src/model/todo.dart';

abstract class TodoEvent {}

class AddTodoEvent extends TodoEvent {
Todo todo;
AddTodoEvent({this.todo});
}

class DeleteTodoEvent extends TodoEvent {
int index;
DeleteTodoEvent({this.index});
}

class ToggleTodoEvent extends TodoEvent {
int index;
ToggleTodoEvent({this.index});
}

TODO ekleyeceğimiz zaman, bu event ile eklenecek TODO objesini de taşıyoruz. Silmek ve tamamlanma durumu için ise index yeterli, todolist’ten bunları bulabiliriz index ile.

Şimdi esas bu TODO event’lerinden sorumlu component’a geldik. Todo’ları tutmak için _todos isimli TODO listesi var.

class TodoBloc {
List<Todo> _todos = [];
final _todoEventController = StreamController<TodoEvent>();
Sink<TodoEvent> get todoEventSink => _todoEventController.sink
//...}

Sonrasında ise yazdığımız TODO event’leri buraya göndermemiz gerekiyor. Hatırlarsanız Stream bu veri akışı için gerekliydi, fakat bir de bu event’leri buraya eklememiz gerek. Bunun için Sink’e ihtiyacımız var.
Stream ve Sink’i bir boru gibi düşünebilirsiniz, Sink borunun bir ucu, Stream diğer ucu.

Bu uçlar için bize StreamController lazım. TODO event’leri için bir StreamController yarattıktan sonra, bunun sink property’sine ulaşmak için bir getter yazıyoruz. Yani artık todoEventSink ile bu sink’e ulaşabiliriz ve event’ler ekleyebiliriz. TodoBloc class’ının içine bunları ekliyoruz.

final _todoStateController = StreamController<List<Todo>>();StreamSink<List<Todo>> get _inTodoSink => _todoStateController.sink;
Stream<List<Todo>> get todos => _todoStateController.stream;

TODO State’leri için de bir StreamController açıyorum, Sink ve Stream için de getter yazıyorum. StreamController’ın tipi List<Todo>, yani buna TODO listesi ekleyebilirim ve burdan TODO listesi alabilirim. Zaten uygulamamızın durumu da TODO listemizden ibaret.

TodoBloc() {
_todoEventController.stream.listen(_mapEventToState);
}

TodoBloc’un constructor’ında event stream’ini dinliyorum ve gelen her yeni bir değer için _mapEventToState metodunu çağırıyorum. Böylece gelen her bir event için yeni bir state yaratacak bu fonksiyon.

void _mapEventToState(TodoEvent event) {
if (event is AddTodoEvent) {
_todos.add(event.todo);
} else if (event is DeleteTodoEvent) {
_todos.removeAt(event.index);
} else if (event is ToggleTodoEvent) {
_todos.asMap().forEach((index, todo) {
if (index == event.index) {
todo.isCompleted = !todo.isCompleted;
}
});
}

_inTodoSink.add(_todos);
}

Bu metod event’lerin tipine göre, eğer AddTodoEvent ise içerdeki TODO listesine(_todos) bu event’ten gelen TODO’yu ekliyor, DeleteTodoEvent ise bu event’teki index’i alıp removeAt ile _todos’tan ilgili TODO’yu siliyor. Tamamlanma/tamamlanmama için ise bu index’e gelen TODO’nun isCompleted değerini değiştiriyor.

asMap() kullanma sebebim bir liste ile forEach() kullandığınızda index parametresi olmuyor oluşu.

todo_bloc.dart dosyamızın tam hali aşağıda, dispose() ve dispatch()’e döneceğiz.

lib/src/bloc içindeki todo_bloc.dart

import 'dart:async';
import 'package:vanilla_bloc_todos/src/bloc/todo_event.dart';
import 'package:vanilla_bloc_todos/src/model/todo.dart';

class TodoBloc {
List<Todo> _todos = [];

final _todoStateController = StreamController<List<Todo>>();
StreamSink<List<Todo>> get _inTodoSink => _todoStateController.sink;

Stream<List<Todo>> get todos => _todoStateController.stream;

final _todoEventController = StreamController<TodoEvent>();
Sink<TodoEvent> get todoEventSink => _todoEventController.sink;

TodoBloc() {
_todoEventController.stream.listen(_mapEventToState);
}

void _mapEventToState(TodoEvent event) {
if (event is AddTodoEvent) {
_todos.add(event.todo);
} else if (event is DeleteTodoEvent) {
_todos.removeAt(event.index);
} else if (event is ToggleTodoEvent) {
_todos.asMap().forEach((index, todo) {
if (index == event.index) {
todo.isCompleted = !todo.isCompleted;
}
});
}

_inTodoSink.add(_todos);
}

void dispatch(TodoEvent event) {
todoEventSink.add(event);
}

void dispose() {
_todoEventController.close();
_todoStateController.close();
}
}

Anasayfa ve Kullanıcı Arayüzü

Şimdi artık anasayfamıza bakabiliriz. Alttaki kodu ekranın bir diğer yarısında açmakta fayda var. _bloc adında bir TodoBloc yarattık. Sağ altta bir FloatingActionButton’umuz var ve basınca bir dialog gösteriyor. _showDialog’a döneceğiz.

StreamBuilder’a stream olarak _bloc.todos’u verdik, yani TodoBloc’taki TODO state stream’i. StreamBuilder’a gelen her yeni veriye, snapshot.data ile ulaşabiliriz. Snapshot’ı stream’den gelen son verinin o andaki fotoğrafı gibi düşünebilirsiniz. Bu yüzden snapshot.data, aynı _bloc.todos stream’i gibi List<Todo> tipinde.

Yani StreamBuilder’a sürekli TODO listesi geliyor. snapshot.hasData ile veri gelip gelmediğini kontrol ediyoruz ve yoksa ekranda “Yeni todo ekle” yazısı gösteriyoruz.

Eğer snapshot.hasData koşulu sağlıyorsa, snapshot.data’da gelen TODO listesindeki her bir TODO için, ListTile gösteriyoruz.

ListTile’da sadece TODO’nun title’ını gösteriyoruz, yani Alışverişe çık gibi.

ListTile widget’larını InkWell widget’ına sarıp dokunmatik kabiliyetler ekliyoruz. Çift dokunulduğunda tamamlanıp tamamlanmama event’ini, basılı tutulduğunda silme event’ini TodoBloc’taki event sink’ine ekliyor.

TodoBloc’taki dispatch fonksiyonu aslında sadece event sink’ine parametre olarak aldığı event’i ekleme işini yapıyor. Bu sayede detaylarla ilgilenmeden sadece bu event’i BLoC’a ileterek bir abstraction sağlanmış oluyor.

Son olarak da ListTile’ların içindeki Text widget’ının stili, TODO’nun tamamlanıp tamamlanmamasına göre değişiyor(Üstü çiziliyor).

import 'package:flutter/material.dart';
import 'package:vanilla_bloc_todos/src/bloc/todo_bloc.dart';
import 'package:vanilla_bloc_todos/src/bloc/todo_event.dart';
import 'package:vanilla_bloc_todos/src/model/todo.dart';

class Homepage extends StatefulWidget {
@override
_HomepageState createState() => _HomepageState();
}

class _HomepageState extends State<Homepage> {
final _bloc = TodoBloc();

@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
_showDialog(context, _bloc);
}),
body: StreamBuilder(
stream: _bloc.todos,
builder: (BuildContext context, AsyncSnapshot<List<Todo>> snapshot) {
if (!snapshot.hasData)
return Center(
child: Text('Add new todo'),
);
return ListView.builder(
itemCount: snapshot.data.length,
itemBuilder: (BuildContext context, int index) {
return InkWell(
onDoubleTap: () =>
_bloc.dispatch(ToggleTodoEvent(index: index)),
onLongPress: () =>
_bloc.dispatch(DeleteTodoEvent(index: index)),
child: ListTile(
title: Text(
snapshot.data[index].title,
style: TextStyle(
decoration: snapshot.data[index].isCompleted
? TextDecoration.lineThrough
: TextDecoration.none),
)),
);
},
);
},
),
);
}
}

_showDialog ise dialog gösteriyor, TextField’a girdiğimiz Çöpü at vs. gibi görevler ile TODO yaratıyor. dispatch ile AddTodoEvent’i gönderiyor.

_showDialog(BuildContext context, TodoBloc bloc) async {
TextEditingController _controller = TextEditingController();
await showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
contentPadding: const EdgeInsets.all(16.0),
content: Row(
children: <Widget>[
Expanded(
child: TextField(
controller: _controller,
autofocus: true,
decoration: InputDecoration(
labelText: 'What to do?', hintText: 'eg. Go to mall'),
),
)
],
),
actions: <Widget>[
FlatButton(
child: const Text('CANCEL'),
onPressed: () {
Navigator.pop(context);
}),
FlatButton(
child: const Text('SAVE'),
onPressed: () {
if (_controller.text != '') {
bloc.dispatch(
AddTodoEvent(
todo: Todo(title: '${_controller.text.toString()}'),
),
);
}
Navigator.pop(context);
})
],
);
});
}

Dispose

Bu küçük uygulamada bile 2 tane StreamController var. Daha büyük uygulamalarda bunların sayısı çok daha fazla olabilir. İşi biteni kapatmamıza yarıyor dispose metodu. Yoksa bellek sızıntıları(memory leaks) oluşabilir ve uygulamamız yavaşlayabilir.

TodoBloc classındaki dispose metodu:

void dispose() {
_todoEventController.close();
_todoStateController.close();
}

Homepage widgetımızın State classındaki dispose metodu ise kendi yarattığı TodoBloc objesindeki dispose metodunu çağırıyor.

@override
void dispose() {
_bloc.dispose();
super.dispose();
}

Uygulamanın kodlarına burdan ulaşabilirsiniz: https://github.com/mirkancal/medium_article_bloc

Geri bildirimleri için

’a teşekkür ederim. Sorularınızı Twitter @mirkancal ve LinkedIn üzerinden yazabilirsiniz, sağlıcakla kalın.

--

--