Ketika pertama kali belajar Flutter, menggunakan setState untuk memperbarui tampilan aplikasi terasa sangat cepat dan mudah. Namun, begitu aplikasi bertumbuh menjadi produk enterprise dengan puluhan layar, transaksi keranjang belanja, autentikasi multi-peran, dan sinkronisasi data offline, meletakkan logika bisnis di dalam berkas UI (Widget) akan menciptakan mimpi buruk pemeliharaan perangkat lunak.
Untuk membangun aplikasi mobile yang skalabel, mudah diuji (testable), dan dapat dikembangkan secara simultan oleh banyak engineer, salah satu kombinasi yang paling banyak dipakai di ekosistem Flutter adalah pola BLoC (Business Logic Component) dengan prinsip Clean Architecture. Ini bukan satu-satunya jalur: panduan arsitektur resmi Flutter menyarankan pemisahan UI layer dan data layer, dan pustaka lain seperti Riverpod juga pilihan yang sah. Tutorial ini membedah arsitekturnya lewat satu irisan fitur login yang lengkap: domain, data, presentation, injeksi dependensi, sampai pengujian.
- Pola BLoC memisahkan antarmuka (UI) dari logika bisnis dengan aliran data satu arah: UI mengirim Event, dan BLoC memancarkan State baru.
- Clean Architecture membagi kode ke tiga lapisan: Presentation, Domain (aturan bisnis murni), dan Data. Arah dependensi selalu menuju Domain.
- Lapisan Domain harus bebas dari dependensi Flutter dan detail infrastruktur seperti HTTP atau database. Paket Dart murni seperti equatable dan fpdart boleh dipakai.
- Gunakan sealed class untuk Event dan State agar UI dapat menangani semua kemungkinan secara exhaustive dengan switch Dart 3.
- Logika bisnis di BLoC dapat diuji cepat dan terisolasi dengan bloc_test dan mocktail, tanpa emulator.
- BLoC dan Clean Architecture adalah pilihan yang sah, bukan satu-satunya standar. Untuk aplikasi kecil, struktur yang lebih ringan sering sudah cukup.
Prasyarat & Dependensi
Tutorial ini memakai Dart 3 dan Flutter versi stabil terbaru. Tambahkan dependensi lewat perintah berikut, sehingga versi terbaru otomatis dipilih dari pub.dev:
flutter pub add flutter_bloc equatable fpdart get_itflutter pub add --dev bloc_test mocktail
flutter_bloc: BLoC, Cubit, dan widget integrasi (
BlocProvider,BlocBuilder,BlocListener).equatable: kesetaraan nilai untuk Event, State, dan entity.
fpdart: tipe
Eitheruntuk hasil sukses atau gagal tanpa melempar exception.get_it: dependency injection.
bloc_test dan mocktail: pengujian BLoC dan mocking dependensi.
1. Konsep Aliran BLoC: Event In, State Out
Kunci kekuatan BLoC terletak pada kesederhanaan kontrak komunikasinya yang deterministik:
Events: Masukan yang dipicu oleh interaksi pengguna atau notifikasi eksternal (contoh: tombol login ditekan, halaman di-refresh, atau token kedaluwarsa).
BLoC: Komponen pemroses yang menerima Event, memanggil lapisan Use Case yang relevan, menangani hasil atau error, dan memancarkan status baru.
States: Kondisi aplikasi saat ini yang dikonsumsi UI untuk digambar ulang (contoh:
AuthInitial,AuthLoading,AuthSuccess, atauAuthFailure).
Aliran Data Satu Arah
UI mengirim Event, BLoC memprosesnya lewat use case dan memancarkan State baru. Aliran satu arah membuat setiap perubahan mudah dilacak dan diuji.
Cubit atau Bloc?
Pustaka flutter_bloc menyediakan dua varian. Cubit lebih ringkas: Anda memanggil method yang langsung memancarkan State, tanpa kelas Event. Bloc menerima Event lewat on<Event>, sehingga setiap perubahan punya jejak event dan transisi yang lebih lengkap untuk dilacak, serta mendukung event transformer seperti debounce atau droppable. Gunakan Cubit untuk state sederhana dan Bloc bila alurnya kompleks atau butuh keterlacakan. Tutorial ini memakai Bloc.
2. Tiga Lapisan Clean Architecture pada Flutter
Membagi proyek Flutter ke dalam tiga lapisan modular menjaga kode tetap rapi saat aplikasi memiliki ratusan fitur:
Presentation Layer: Berisi Widget UI, Halaman (Pages), dan BLoC/Cubit. Lapisan ini hanya bertugas menampilkan data State dan menangkap interaksi pengguna.
Domain Layer (Jantung Sistem): Berisi Entities (model bisnis murni), Use Cases (aturan alur kerja spesifik, misalnya
LoginUser), dan kontrak/interface repositori. Lapisan ini tidak boleh mengimpor package Flutter.Data Layer: Berisi Data Sources (pemanggilan REST API dan database lokal) serta implementasi repositori yang mengubah data mentah menjadi Entities domain dan memetakan error menjadi
Failure.
Aturan utamanya adalah arah dependensi: Presentation dan Data bergantung pada Domain, tidak sebaliknya. Domain tidak tahu apa pun tentang UI maupun sumber data, sehingga keduanya dapat diganti tanpa menyentuh aturan bisnis.
Aturan Emas: Domain Bebas dari Flutter
Struktur folder untuk fitur autentikasi dalam tutorial ini:
lib/core/error/failure.dartlib/injection.dartlib/features/auth/domain/:entities/user.dart,repositories/auth_repository.dart,usecases/login_user.dartlib/features/auth/data/:datasources/auth_remote_data_source.dart,repositories/auth_repository_impl.dartlib/features/auth/presentation/:bloc/auth_bloc.dart,pages/login_page.dart
3. Implementasi Kode: Fitur Login dari Domain sampai UI
Domain Layer
Mulai dari lapisan paling dalam. Failure merepresentasikan kegagalan yang dipahami domain, dan User adalah entity murni:
1import "package:equatable/equatable.dart";2 3class Failure extends Equatable {4 const Failure(this.message);5 6 final String message;7 8 @override9 List<Object?> get props => [message];10}
1import "package:equatable/equatable.dart";2 3class User extends Equatable {4 const User({required this.id, required this.email});5 6 final String id;7 final String email;8 9 @override10 List<Object?> get props => [id, email];11}
Kontrak repositori dan use case ditulis di domain. Hasilnya dibungkus Either: sisi kiri berisi Failure, sisi kanan berisi data sukses.
1import "package:fpdart/fpdart.dart";2 3import "../../../../core/error/failure.dart";4import "../entities/user.dart";5 6abstract interface class AuthRepository {7 Future<Either<Failure, User>> login(String email, String password);8}
1import "package:fpdart/fpdart.dart";2 3import "../../../../core/error/failure.dart";4import "../entities/user.dart";5import "../repositories/auth_repository.dart";6 7class LoginUser {8 const LoginUser(this._repository);9 10 final AuthRepository _repository;11 12 Future<Either<Failure, User>> call(String email, String password) {13 return _repository.login(email, password);14 }15}
Data Layer
Data source mengambil data mentah, misalnya dari API REST yang dibangun mengikuti Tutorial RESTful API Skalabel Laravel & PostgreSQL. Implementasi konkretnya (misalnya memakai Dio) berada di lapisan ini, sedangkan repositori memetakan hasil dan error ke tipe domain:
1class ServerException implements Exception {2 const ServerException(this.message);3 4 final String message;5}6 7abstract interface class AuthRemoteDataSource {8 /// Mengembalikan JSON pengguna, atau melempar ServerException.9 Future<Map<String, dynamic>> login(String email, String password);10}
1import "package:fpdart/fpdart.dart";2 3import "../../../../core/error/failure.dart";4import "../../domain/entities/user.dart";5import "../../domain/repositories/auth_repository.dart";6import "../datasources/auth_remote_data_source.dart";7 8class AuthRepositoryImpl implements AuthRepository {9 const AuthRepositoryImpl(this._remote);10 11 final AuthRemoteDataSource _remote;12 13 @override14 Future<Either<Failure, User>> login(String email, String password) async {15 try {16 final json = await _remote.login(email, password);17 return Right(18 User(id: json["id"] as String, email: json["email"] as String),19 );20 } on ServerException catch (e) {21 return Left(Failure(e.message));22 } catch (_) {23 return const Left(Failure("Terjadi kesalahan tak terduga."));24 }25 }26}
Presentation Layer: AuthBloc dengan Sealed Class
Event dan State dibuat sebagai sealed class dan memakai Equatable agar perubahan state dibandingkan berdasarkan nilai. Karena sealed, UI dapat menangani semua kemungkinan State secara exhaustive.
1import "package:equatable/equatable.dart";2import "package:flutter_bloc/flutter_bloc.dart";3 4import "../../domain/usecases/login_user.dart";5 6// --- EVENTS ---7sealed class AuthEvent extends Equatable {8 const AuthEvent();9 10 @override11 List<Object?> get props => [];12}13 14final class LoginSubmitted extends AuthEvent {15 const LoginSubmitted({required this.email, required this.password});16 17 final String email;18 final String password;19 20 @override21 List<Object?> get props => [email, password];22}23 24// --- STATES ---25sealed class AuthState extends Equatable {26 const AuthState();27 28 @override29 List<Object?> get props => [];30}31 32final class AuthInitial extends AuthState {33 const AuthInitial();34}35 36final class AuthLoading extends AuthState {37 const AuthLoading();38}39 40final class AuthSuccess extends AuthState {41 const AuthSuccess(this.userId);42 43 final String userId;44 45 @override46 List<Object?> get props => [userId];47}48 49final class AuthFailure extends AuthState {50 const AuthFailure(this.message);51 52 final String message;53 54 @override55 List<Object?> get props => [message];56}57 58// --- BLOC ---59class AuthBloc extends Bloc<AuthEvent, AuthState> {60 AuthBloc({required LoginUser loginUser})61 : _loginUser = loginUser,62 super(const AuthInitial()) {63 on<LoginSubmitted>(_onLoginSubmitted);64 }65 66 final LoginUser _loginUser;67 68 Future<void> _onLoginSubmitted(69 LoginSubmitted event,70 Emitter<AuthState> emit,71 ) async {72 emit(const AuthLoading());73 74 final result = await _loginUser(event.email, event.password);75 76 result.fold(77 (failure) => emit(AuthFailure(failure.message)),78 (user) => emit(AuthSuccess(user.id)),79 );80 }81}
Cegah Event Ganda
Menghubungkan UI dan Dependency Injection
Halaman menyediakan Bloc lewat BlocProvider, sedangkan view mengonsumsinya lewat BlocConsumer. listener dipakai untuk efek samping (snackbar, navigasi), dan builder untuk menggambar UI. LoginForm pada contoh adalah widget form email dan password biasa yang memanggil onSubmit.
1class LoginPage extends StatelessWidget {2 const LoginPage({super.key});3 4 @override5 Widget build(BuildContext context) {6 return BlocProvider(7 create: (_) => getIt<AuthBloc>(),8 child: const LoginView(),9 );10 }11}12 13class LoginView extends StatelessWidget {14 const LoginView({super.key});15 16 @override17 Widget build(BuildContext context) {18 return BlocConsumer<AuthBloc, AuthState>(19 listener: (context, state) {20 if (state is AuthFailure) {21 ScaffoldMessenger.of(context).showSnackBar(22 SnackBar(content: Text(state.message)),23 );24 } else if (state is AuthSuccess) {25 // Navigasi ke beranda, misalnya dengan go_router.26 }27 },28 builder: (context, state) {29 return switch (state) {30 AuthLoading() => const Center(child: CircularProgressIndicator()),31 _ => LoginForm(32 onSubmit: (email, password) => context.read<AuthBloc>().add(33 LoginSubmitted(email: email, password: password),34 ),35 ),36 };37 },38 );39 }40}
1import "package:get_it/get_it.dart";2 3final getIt = GetIt.instance;4 5void setupDependencies() {6 getIt7 ..registerLazySingleton<AuthRemoteDataSource>(8 () => AuthRemoteDataSourceImpl(), // implementasi berbasis Dio milik Anda9 )10 ..registerLazySingleton<AuthRepository>(11 () => AuthRepositoryImpl(getIt()),12 )13 ..registerLazySingleton(() => LoginUser(getIt()))14 ..registerFactory(() => AuthBloc(loginUser: getIt()));15}
4. Menguji BLoC dengan bloc_test
Karena logika bisnis terpisah dari widget, pengujiannya cepat dan tidak membutuhkan emulator. blocTest menjalankan Bloc, mengirim Event, lalu membandingkan urutan State yang dipancarkan. Use case di-mock dengan mocktail:
1import "package:bloc_test/bloc_test.dart";2import "package:flutter_test/flutter_test.dart";3import "package:fpdart/fpdart.dart";4import "package:mocktail/mocktail.dart";5 6// Impor AuthBloc, LoginUser, User, dan Failure sesuai struktur proyek Anda.7 8class MockLoginUser extends Mock implements LoginUser {}9 10void main() {11 late MockLoginUser loginUser;12 13 setUp(() {14 loginUser = MockLoginUser();15 });16 17 blocTest<AuthBloc, AuthState>(18 "emit AuthLoading lalu AuthSuccess saat login berhasil",19 build: () {20 when(() => loginUser("a@b.com", "rahasia")).thenAnswer(21 (_) async => const Right(User(id: "1", email: "a@b.com")),22 );23 return AuthBloc(loginUser: loginUser);24 },25 act: (bloc) => bloc.add(26 const LoginSubmitted(email: "a@b.com", password: "rahasia"),27 ),28 expect: () => const [AuthLoading(), AuthSuccess("1")],29 );30 31 blocTest<AuthBloc, AuthState>(32 "emit AuthLoading lalu AuthFailure saat login gagal",33 build: () {34 when(() => loginUser("a@b.com", "salah")).thenAnswer(35 (_) async => const Left(Failure("Kredensial tidak valid")),36 );37 return AuthBloc(loginUser: loginUser);38 },39 act: (bloc) => bloc.add(40 const LoginSubmitted(email: "a@b.com", password: "salah"),41 ),42 expect: () => const [AuthLoading(), AuthFailure("Kredensial tidak valid")],43 );44}
5. Praktik Terbaik BLoC di Proyek Nyata
Sealed class untuk Event dan State: memungkinkan
switchexhaustive sehingga compiler memperingatkan bila ada State yang belum ditangani di UI.Efek samping lewat BlocListener: navigasi, snackbar, dan dialog ditangani di
listener, bukan di dalambuilder.Hindari ketergantungan langsung antar BLoC: biarkan BLoC berkomunikasi lewat UI atau repositori bersama agar alurnya tetap mudah dilacak.
Pasang BlocObserver di main.dart: untuk mencatat event, transisi, dan error di satu tempat saat debugging.
Batasi rebuild: gunakan
buildWhenatauBlocSelectoragar widget hanya digambar ulang saat bagian State yang relevan berubah. Teknik profiling dan optimasi lainnya dibahas di Cara Optimasi Ukuran dan Performa Aplikasi Flutter.
Perbandingan Pendekatan State Management
Tabel berikut merangkum perbedaan secara kualitatif. Tidak ada pilihan yang benar untuk semua tim.
| Aspek | Provider (ChangeNotifier) | Riverpod | Cubit | Bloc |
|---|---|---|---|---|
| Konsep | ChangeNotifier dan notifyListeners | Provider reaktif dengan ref | Method yang memancarkan State | Event masuk, State keluar |
| Boilerplate | Paling sedikit | Sedang | Rendah sampai sedang | Paling banyak |
| Jejak perubahan | Terbatas | Lewat observer dan tooling Riverpod | Jejak transisi State | Event dan transisi lengkap |
| Cocok untuk | Aplikasi kecil dan sederhana | Proyek baru yang mengutamakan ergonomi | State sederhana dengan struktur BLoC | Alur kompleks yang butuh keterlacakan dan event transformer |
Evaluasi: BLoC dan Clean Architecture
- Aliran data terprediksi dan mudah dilacak lewat event dan transisi saat debugging
- UI terpisah jelas dari logika bisnis, sehingga logika dapat diuji tanpa widget
- Perangkat pendukung matang: BlocObserver, bloc_test, dan event transformer
- Struktur seragam memudahkan banyak engineer bekerja secara paralel
- Boilerplate lebih banyak: kelas Event, State, dan beberapa lapisan untuk satu fitur
- Ada kurva belajar bagi tim yang terbiasa dengan gaya imperatif
- Clean Architecture lengkap bisa berlebihan untuk aplikasi kecil atau prototipe
Integrasi dengan Lapisan Backend API
Aplikasi mobile yang tangguh memerlukan backend yang mampu menyajikan data secara cepat dan aman. Hubungkan lapisan data Anda dengan API berkinerja tinggi sesuai panduan Tutorial RESTful API Skalabel Laravel & PostgreSQL serta lindungi sesi token pengguna menggunakan standar Autentikasi Modern OAuth2 & JWT kami. Jika Anda masih menimbang teknologi mobile yang tepat, baca Flutter vs Native vs React Native sebelum menentukan arsitektur.
Ingin Membangun Aplikasi Flutter Berstandar Enterprise?
Kembangkan aplikasi mobile berskala besar yang rapi, cepat, dan mudah dipelihara bersama tim mobile engineer Arkatek.
Pertanyaan Umum Seputar Flutter BLoC dan Clean Architecture
Apakah BLoC wajib dipakai bersama Clean Architecture?
Tidak. Keduanya bisa dipakai terpisah: BLoC dapat dipasangkan dengan struktur yang lebih sederhana, dan Clean Architecture dapat memakai state management lain. Panduan arsitektur resmi Flutter sendiri menekankan pemisahan UI layer dan data layer sebagai prinsip utama.
Cubit atau Bloc, mana yang sebaiknya dipilih?
Pakai Cubit untuk state sederhana yang cukup diubah lewat method. Pilih Bloc bila Anda butuh jejak event yang lengkap atau event transformer seperti debounce dan droppable. Memulai dari Cubit lalu naik ke Bloc saat kompleksitas bertambah adalah jalur yang wajar.
BLoC atau Riverpod?
Keduanya matang dan sah. Riverpod umumnya lebih ringkas, sementara BLoC lebih eksplisit lewat event dan keterlacakannya. Pilih berdasarkan pengalaman tim dan kebutuhan proyek, karena konsistensi dalam satu codebase lebih penting daripada pilihan itu sendiri.
Apakah semua aplikasi perlu lapisan Domain?
Tidak. Untuk aplikasi sederhana, UI layer dan data layer sudah cukup. Lapisan Domain berguna bila aturan bisnis kompleks, dipakai di banyak fitur, atau perlu diuji secara terpisah.
Bagaimana mencegah event ganda, misalnya tombol login ditekan dua kali?
Gunakan transformer droppable() dari paket bloc_concurrency pada handler event tersebut, dan nonaktifkan tombol selama state AuthLoading sebagai lapisan pengaman tambahan di UI.
Kesimpulan
BLoC dengan Clean Architecture adalah investasi yang masuk akal untuk aplikasi Flutter yang akan tumbuh besar dan dikerjakan banyak engineer: aliran data satu arah, logika bisnis yang terisolasi, dan pengujian yang cepat. Harganya adalah boilerplate dan struktur yang lebih banyak, sehingga untuk aplikasi kecil pendekatan yang lebih ringan sering sudah memadai. Mulailah dari irisan fitur seperti contoh login di atas, ukur manfaatnya di tim Anda, lalu perluas secara bertahap.
Referensi: Guide to app architecture (Flutter), bloclibrary.dev, dan flutter_bloc di pub.dev.