Mobile App

Tutorial Manajemen State Flutter dengan BLoC dan Clean Architecture untuk Aplikasi Skala Besar

R

Raka Haikal

Tim Teknis Arkatek

·
7 min baca
·
17x dibaca
Ilustrasi Tutorial Manajemen State Flutter dengan BLoC dan Clean Architecture untuk Aplikasi Skala Besar

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.

Poin Penting / Key Takeaways
  • 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:

Terminal / CLI
BASH
flutter pub add flutter_bloc equatable fpdart get_it
flutter pub add --dev bloc_test mocktail
Dependensi runtime dan dependensi pengujian.
  • flutter_bloc: BLoC, Cubit, dan widget integrasi (BlocProvider, BlocBuilder, BlocListener).

  • equatable: kesetaraan nilai untuk Event, State, dan entity.

  • fpdart: tipe Either untuk 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, atau AuthFailure).

BLOC PATTERN

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.

Event → State

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:

  1. Presentation Layer: Berisi Widget UI, Halaman (Pages), dan BLoC/Cubit. Lapisan ini hanya bertugas menampilkan data State dan menangkap interaksi pengguna.

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

  3. 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
Jangan mengimpor flutter/material.dart di direktori Domain, dan jangan membawa detail HTTP atau database ke sana. Paket Dart murni seperti equatable dan fpdart boleh dipakai. Dengan begitu aturan bisnis tetap utuh walau UI atau sumber data berganti.

Struktur folder untuk fitur autentikasi dalam tutorial ini:

  • lib/core/error/failure.dart

  • lib/injection.dart

  • lib/features/auth/domain/: entities/user.dart, repositories/auth_repository.dart, usecases/login_user.dart

  • lib/features/auth/data/: datasources/auth_remote_data_source.dart, repositories/auth_repository_impl.dart

  • lib/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:

lib/core/error/failure.dart
DART
1import "package:equatable/equatable.dart";
2
3class Failure extends Equatable {
4 const Failure(this.message);
5
6 final String message;
7
8 @override
9 List<Object?> get props => [message];
10}
Failure: kegagalan yang dipahami oleh lapisan domain.
lib/features/auth/domain/entities/user.dart
DART
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 @override
10 List<Object?> get props => [id, email];
11}
Entity User: model bisnis murni tanpa dependensi Flutter.

Kontrak repositori dan use case ditulis di domain. Hasilnya dibungkus Either: sisi kiri berisi Failure, sisi kanan berisi data sukses.

lib/features/auth/domain/repositories/auth_repository.dart
DART
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}
Kontrak repositori: domain hanya tahu apa yang dibutuhkan, bukan bagaimana caranya.
lib/features/auth/domain/usecases/login_user.dart
DART
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}
Use case: satu aturan alur kerja, satu kelas.

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:

lib/features/auth/data/datasources/auth_remote_data_source.dart
DART
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}
Kontrak data source. Implementasi HTTP-nya ditulis terpisah di lapisan data.
lib/features/auth/data/repositories/auth_repository_impl.dart
DART
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 @override
14 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}
Repositori memetakan data mentah dan exception menjadi Entity dan Failure.

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.

lib/features/auth/presentation/bloc/auth_bloc.dart
DART
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 @override
11 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 @override
21 List<Object?> get props => [email, password];
22}
23
24// --- STATES ---
25sealed class AuthState extends Equatable {
26 const AuthState();
27
28 @override
29 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 @override
46 List<Object?> get props => [userId];
47}
48
49final class AuthFailure extends AuthState {
50 const AuthFailure(this.message);
51
52 final String message;
53
54 @override
55 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}
AuthBloc modern: sealed Event dan State, penanganan hasil lewat Either.
Cegah Event Ganda
Secara default Bloc memproses event secara konkuren. Untuk event yang tidak boleh berjalan dobel, misalnya tombol login yang ditekan dua kali, tambahkan transformer droppable() dari paket bloc_concurrency pada on LoginSubmitted, atau nonaktifkan tombol saat state AuthLoading.

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.

lib/features/auth/presentation/pages/login_page.dart
DART
1class LoginPage extends StatelessWidget {
2 const LoginPage({super.key});
3
4 @override
5 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 @override
17 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}
BlocProvider menyediakan Bloc, BlocConsumer memisahkan efek samping dari penggambaran UI.
lib/injection.dart
DART
1import "package:get_it/get_it.dart";
2
3final getIt = GetIt.instance;
4
5void setupDependencies() {
6 getIt
7 ..registerLazySingleton<AuthRemoteDataSource>(
8 () => AuthRemoteDataSourceImpl(), // implementasi berbasis Dio milik Anda
9 )
10 ..registerLazySingleton<AuthRepository>(
11 () => AuthRepositoryImpl(getIt()),
12 )
13 ..registerLazySingleton(() => LoginUser(getIt()))
14 ..registerFactory(() => AuthBloc(loginUser: getIt()));
15}
Dependency injection dengan get_it. Panggil setupDependencies() sebelum runApp.

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:

test/features/auth/presentation/bloc/auth_bloc_test.dart
DART
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}
Dua tes: jalur sukses dan jalur gagal, dijalankan dengan flutter test.

5. Praktik Terbaik BLoC di Proyek Nyata

  • Sealed class untuk Event dan State: memungkinkan switch exhaustive sehingga compiler memperingatkan bila ada State yang belum ditangani di UI.

  • Efek samping lewat BlocListener: navigasi, snackbar, dan dialog ditangani di listener, bukan di dalam builder.

  • 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 buildWhen atau BlocSelector agar 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.

AspekProvider (ChangeNotifier)RiverpodCubitBloc
KonsepChangeNotifier dan notifyListenersProvider reaktif dengan refMethod yang memancarkan StateEvent masuk, State keluar
BoilerplatePaling sedikitSedangRendah sampai sedangPaling banyak
Jejak perubahanTerbatasLewat observer dan tooling RiverpodJejak transisi StateEvent dan transisi lengkap
Cocok untukAplikasi kecil dan sederhanaProyek baru yang mengutamakan ergonomiState sederhana dengan struktur BLoCAlur kompleks yang butuh keterlacakan dan event transformer

Evaluasi: BLoC dan Clean Architecture

Keunggulan
  • 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
Pertimbangan
  • 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.

SOLUSI TEKNOLOGI ARKATEK

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.

R

// DITULIS OLEH

Raka Haikal

Pengembang perangkat lunak & spesialis arsitektur sistem digital di Arkatek. Berfokus pada pembangunan solusi software scalable, otomatisasi sistem bisnis, dan optimasi performa.

// EKSPLORASI TOPIK

Artikel Terkait Lainnya

Lihat Semua Artikel →