Overview #
Memahami arsitektur Flutter bukan hanya tentang bagaimana framework ini bekerja di balik layar, melainkan juga tentang bagaimana kita sebagai pengembang menyusun struktur kode aplikasi agar tetap bersih, teruji (testable), dan mudah dipelihara (maintainable) seiring bertambahnya skala projek. Ketika kita membangun aplikasi tanpa fondasi arsitektur yang kokoh, kita akan dengan cepat terperangkap dalam masalah kode spaghetti, di mana logika bisnis, pemanggilan API, database, dan tampilan antarmuka tercampur aduk dalam satu file widget yang sama. Untuk menghindari hal ini, kita perlu membedah arsitektur Flutter dari dua sudut pandang yang berbeda namun saling melengkapi: bagaimana sistem internal Flutter itu sendiri dibangun, dan bagaimana kita seharusnya menstrukturkan kode aplikasi kita berdasarkan praktik terbaik industri serta panduan resmi dari tim Google.
Dua Perspektif Arsitektur Flutter #
Sebelum kita melangkah lebih jauh, sangat penting bagi kita untuk membedakan dua konsep yang sering kali bercampur aduk ketika orang berbicara tentang “arsitektur Flutter”.
Konsep pertama adalah Arsitektur Internal Flutter. Ini berkaitan dengan bagaimana tim insinyur Google membangun framework Flutter itu sendiri. Ini mencakup pembagian kerja antara lapisan atas yang ditulis dalam Dart (Framework), lapisan tengah yang ditulis dalam C++ (Engine), dan lapisan bawah native (Platform Embedder). Kita sebagai pengembang aplikasi tidak memodifikasi arsitektur internal ini; kita hanya memanfaatkannya.
Konsep kedua adalah Arsitektur Aplikasi Flutter. Ini berkaitan dengan bagaimana kita mengorganisasikan kode sumber yang kita tulis untuk memecahkan masalah bisnis. Ini mencakup pembagian kode menjadi beberapa lapisan seperti UI Layer, Domain Layer, dan Data Layer. Kita memiliki kendali penuh atas arsitektur aplikasi ini dan bertanggung jawab untuk mendesainnya secara optimal.
flowchart TD
subgraph InternalArch["Perspektif 1: Arsitektur Internal Flutter (Bagaimana Framework Dibangun)"]
direction TB
FW["Framework Layer (Dart)"]
ENG["Engine Layer (C++)"]
EMB["Platform Embedder (Native OS)"]
FW --> ENG
ENG --> EMB
end
subgraph AppArch["Perspektif 2: Arsitektur Aplikasi Flutter (Bagaimana Kode Kita Disusun)"]
direction TB
UI["UI Layer (Widgets / ViewModels)"]
DOM["Domain Layer (Use Cases - Opsional)"]
DAT["Data Layer (Repositories / Data Sources)"]
UI --> DOM
DOM --> DAT
UI -.->|"Akses Langsung (Jika Tanpa Domain)"| DAT
end
style InternalArch stroke:#0288d1,stroke-width:2px
style AppArch stroke:#388e3c,stroke-width:2pxKedua perspektif ini berjalan beriringan. Pemahaman tentang arsitektur internal membantu kita memahami keterbatasan dan potensi rendering Flutter, sedangkan pemahaman arsitektur aplikasi membantu kita menulis kode produk yang skalabel untuk jangka panjang.
Arsitektur Internal: Layered System #
Secara internal, Flutter dirancang menggunakan prinsip Layered System (sistem berlapis) yang sangat bersih. Keindahan dari desain ini adalah pemisahan kekuasaan yang tegas: setiap lapisan memiliki kontrak tanggung jawab yang spesifik dan hanya berkomunikasi dengan lapisan tepat di bawahnya. Lapisan yang lebih tinggi tidak diperbolehkan memintas lapisan di bawahnya untuk langsung mengakses perangkat keras, dan lapisan bawah tidak memiliki pengetahuan tentang detail implementasi lapisan atas.
flowchart TD
App["Aplikasi Kita (Dart)"] -->|"Menggunakan Widget & API"| FW["Flutter Framework (Dart)"]
FW -->|"Binding & Hook dart:ui"| ENG["Flutter Engine (C++)"]
ENG -->|"ABI Stabil & Surface"| EMB["Platform Embedder (Native)"]
EMB -->|"API & Driver Native"| OS["Sistem Operasi (Android / iOS / Desktop / Web)"]
style FW stroke:#0288d1,stroke-width:2px
style ENG stroke:#388e3c,stroke-width:2px
style EMB stroke:#f57c00,stroke-width:2pxKeuntungan utama dari sistem berlapis internal ini bagi kita sebagai pengembang meliputi:
- Sistem Desain yang Dapat Diganti: Karena lapisan Material dan Cupertino berada di puncak framework Dart, kedua lapisan ini sepenuhnya opsional. Jika kita ingin membangun sistem desain kustom sendiri yang sangat berbeda dari Material Design milik Google atau Cupertino milik Apple, kita dapat menulisnya langsung di atas lapisan
Widgetsbawaan. - Transparansi Sumber Kode: Seluruh Framework ditulis menggunakan bahasa Dart. Hal ini berarti ketika kita mengalami bug visual, kita dapat melakukan klik kanan pada widget bawaan (seperti
ListViewatauContainer) di IDE kita dan langsung membaca kode sumber aslinya. Hal ini membuat proses debugging menjadi sangat transparan. - Portabilitas Perangkat Keras: C++ Engine bersifat platform-agnostic (tidak peduli pada jenis sistem operasi). Ketika kita ingin menjalankan aplikasi Flutter di sistem baru (seperti Smart TV kustom), kita hanya perlu menulis Platform Embedder kecil untuk menginisialisasi permukaan rendering (rendering surface) dan meneruskan input, sementara kode aplikasi Dart kita tetap berjalan tanpa perubahan.
Arsitektur Aplikasi: Panduan Resmi Google #
Beralih ke perspektif kedua, bagaimana kita menyusun kode aplikasi kita sendiri? Sejak tahun 2023, Google secara resmi merilis panduan arsitektur aplikasi Flutter yang merekomendasikan pembagian kode ke dalam dua hingga tiga lapisan utama berdasarkan prinsip Separation of Concerns (pemisahan tanggung jawab).
Mari kita bedah setiap lapisan tersebut secara mendalam.
1. UI Layer (Lapisan Antarmuka) #
UI Layer memiliki tanggung jawab tunggal untuk menyajikan data ke layar fisik dan menangani interaksi pengguna. Menurut pola MVVM (Model-View-ViewModel), lapisan ini dibagi menjadi dua komponen:
- View (Widget): Komponen antarmuka visual yang dibangun menggunakan komposisi widget Dart. View bersifat pasif dan deklaratif; ia tidak boleh memiliki logika bisnis, tidak boleh melakukan panggilan API langsung, dan tidak boleh memanipulasi data mentah. Tugasnya hanya me-render UI berdasarkan state saat ini dan meneruskan interaksi pengguna ke ViewModel.
- ViewModel (State Holder): Komponen yang bertugas memproses interaksi pengguna (commands), berkomunikasi dengan Data Layer, serta mengekspos state yang siap pakai untuk dikonsumsi oleh View. Di Flutter, kita dapat mengimplementasikan ViewModel menggunakan berbagai state management seperti
ChangeNotifier, Bloc, Cubit, atau Riverpod.
Mari kita amati contoh kode implementasi View yang bersih di bawah ini:
// BENAR: View hanya bertanggung jawab untuk menampilkan UI berdasarkan state
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
// Membaca state dari ViewModel menggunakan provider
final viewModel = context.watch<ProfileViewModel>();
return Scaffold(
appBar: AppBar(title: const Text('Profil Pengguna')),
body: Center(
child: switch (viewModel.state) {
ProfileLoading() => const CircularProgressIndicator(),
ProfileLoaded(:final user) => ProfileContent(user: user),
ProfileError(:final message) => ErrorView(message: message),
},
),
);
}
}
Dan berikut adalah contoh ViewModel yang menyertainya:
// BENAR: ViewModel memegang state UI dan menjembatani ke Data Layer
class ProfileViewModel extends ChangeNotifier {
final UserRepository _userRepository;
ProfileState _state = const ProfileLoading();
ProfileState get state => _state;
ProfileViewModel(this._userRepository);
// Command yang dipanggil oleh View saat inisialisasi
Future<void> loadProfile(String userId) async {
_state = const ProfileLoading();
notifyListeners();
try {
final user = await _userRepository.getUser(userId);
_state = ProfileLoaded(user: user);
} catch (e) {
_state = ProfileError(message: e.toString());
}
notifyListeners();
}
}
2. Logic / Domain Layer (Lapisan Bisnis - Opsional) #
Domain Layer adalah lapisan opsional yang disisipkan di antara UI Layer dan Data Layer. Lapisan ini murni berisi logika bisnis aplikasi yang independen dari UI maupun asal data. Komponen utama di sini adalah Use Cases atau Interactors.
Kita membutuhkan Domain Layer jika:
- Aplikasi kita memiliki aturan kalkulasi bisnis yang kompleks di sisi klien (misalnya, menghitung premi asuransi berdasarkan riwayat medis).
- Suatu alur kerja melibatkan koordinasi data dari beberapa repository sekaligus.
Mari kita lihat contoh implementasi Use Case:
// BENAR: Use case mengoordinasikan logika bisnis dari beberapa repository
class GetRecommendedProductsUseCase {
final ProductRepository _productRepository;
final UserRepository _userRepository;
GetRecommendedProductsUseCase(this._productRepository, this._userRepository);
Future<List<Product>> execute(String userId) async {
// 1. Ambil data pengguna
final user = await _userRepository.getUser(userId);
// 2. Ambil semua produk yang tersedia
final allProducts = await _productRepository.getProducts();
// 3. Jalankan filter logika bisnis client-side
return allProducts
.where((product) => user.preferences.contains(product.category))
.take(10)
.toList();
}
}
Kapan Kita Harus Menghindari Domain Layer? Jika aplikasi kita sebagian besar hanya melakukan operasi CRUD sederhana (mengambil data dari API lalu menampilkannya apa adanya ke layar), menambahkan Domain Layer adalah bentuk over-engineering. Dalam kasus seperti ini, ViewModel di UI Layer diperbolehkan untuk langsung memanggil Repository di Data Layer tanpa perantara Use Case.
3. Data Layer (Lapisan Data) #
Data Layer bertanggung jawab penuh untuk mengelola operasi data aplikasi. Lapisan ini bertindak sebagai gerbang masuk dan keluar bagi seluruh informasi eksternal. Komponen utamanya terbagi menjadi:
- Repository: Kelas yang menjadi pintu gerbang tunggal (Single Source of Truth) bagi lapisan di atasnya untuk mengakses data tertentu. Repository bertugas memutuskan kapan harus mengambil data dari server internet dan kapan harus mengambilnya dari penyimpanan lokal.
- Data Sources: Kelas tingkat rendah yang melakukan operasi I/O mentah dengan satu sumber data eksternal spesifik, seperti melakukan HTTP Request ke REST API (Remote Data Source) atau membaca file database SQLite (Local Data Source).
Berikut adalah contoh koordinasi data yang rapi di dalam Repository:
// BENAR: Repository mengoordinasikan beberapa data sources dan memegang SSOT
class UserRepository {
final UserRemoteDataSource _remoteDataSource;
final UserLocalDataSource _localDataSource;
UserRepository({
required UserRemoteDataSource remoteDataSource,
required UserLocalDataSource localDataSource,
}) : _remoteDataSource = remoteDataSource,
_localDataSource = localDataSource;
Future<User> getUser(String userId) async {
// 1. Coba ambil data dari database lokal/cache terlebih dahulu
final localUser = await _localDataSource.getCachedUser(userId);
if (localUser != null) {
return localUser;
}
// 2. Jika tidak ada di lokal, ambil dari server melalui API
final remoteUser = await _remoteDataSource.fetchUser(userId);
// 3. Simpan data terbaru ke penyimpanan lokal untuk cache masa depan
await _localDataSource.cacheUser(remoteUser);
return remoteUser;
}
}
Unidirectional Data Flow (UDF) #
Untuk mencegah terjadinya inkonsistensi state di mana satu bagian UI menampilkan data yang berbeda dengan bagian UI lainnya, arsitektur Flutter menerapkan prinsip Unidirectional Data Flow (UDF) atau Aliran Data Satu Arah.
Dalam arsitektur UDF, alur data mengalir secara ketat melalui siklus satu arah:
- State mengalir ke bawah (Data -> UI): Ketika ada perubahan data di dalam Data Layer (misalnya, item baru ditambahkan ke keranjang belanja), perubahan tersebut dikirimkan ke ViewModel, yang kemudian memperbarui objek state UI-nya. State baru ini mengalir turun ke View (Widget), memicu pembangunan ulang (rebuild) visual secara deklaratif.
- Event mengalir ke atas (UI -> Data): Ketika pengguna berinteraksi dengan layar (misalnya, menekan tombol “Hapus Item”), View tidak boleh langsung mengubah data di memori. Sebagai gantinya, View mengirimkan sinyal interaksi berupa Event ke atas kepada ViewModel. ViewModel memanggil fungsi Command yang sesuai pada Repository di Data Layer. Repository mengubah data dasar, dan siklus pun kembali ke langkah pertama.
flowchart TD
subgraph DataFlow["Alur Data & Event (UDF)"]
direction TB
Repo["Data Layer (Repository)"] -->|"1. Kirim Data Baru (State)"| VM["UI Layer (ViewModel)"]
VM -->|"2. Perbarui Tampilan"| View["UI Layer (View / Widget)"]
View -->|"3. Trigger Interaksi (Event)"| VM
VM -->|"4. Panggil Perubahan Data"| Repo
end
style Repo stroke:#e91e63,stroke-width:2px
style VM stroke:#0288d1,stroke-width:2px
style View stroke:#4caf50,stroke-width:2pxDengan menerapkan UDF, kode kita menjadi sangat mudah diprediksi karena kita tahu persis di mana modifikasi data terjadi (hanya di Data Layer/Repository) dan bagaimana modifikasi tersebut didistribusikan ke seluruh komponen visual tanpa ada jalan pintas yang membingungkan.
Single Source of Truth (SSOT) #
Prinsip krusial berikutnya yang wajib kita terapkan adalah Single Source of Truth (SSOT) atau Satu Sumber Kebenaran. Konsep ini menyatakan bahwa untuk setiap jenis data tertentu dalam aplikasi, hanya boleh ada satu objek otoritatif yang menyimpan dan mengelola data tersebut.
Mari kita bandingkan kesalahan umum penduplikatan data dengan solusi SSOT yang benar:
// ANTI-PATTERN: Menyimpan salinan data keranjang belanja di widget lokal.
// Jika kita pindah ke halaman lain, data keranjang di halaman ini akan hilang atau tidak sinkron.
class CartScreen extends StatefulWidget {
const CartScreen({super.key});
@override
State<CartScreen> createState() => _CartScreenState();
}
class _CartScreenState extends State<CartScreen> {
List<CartItem> _localCartItems = []; // ✗ Duplikasi state lokal yang rawan tidak sinkron
void _addItem(CartItem item) {
setState(() {
_localCartItems.add(item);
});
}
@override
Widget build(BuildContext context) {
return Container(); // render UI...
}
}
Sebagai solusinya, kita harus memindahkan kepemilikan data tersebut ke kelas data layer tunggal (Repository) dan menyebarkannya menggunakan mekanisme aliran data reaktif:
// BENAR: Menggunakan CartRepository sebagai Single Source of Truth untuk seluruh aplikasi
class CartRepository {
final ValueNotifier<List<CartItem>> _itemsNotifier = ValueNotifier<List<CartItem>>([]);
// Mengekspos ValueListenable agar UI bisa mengobservasi perubahan secara dinamis
ValueListenable<List<CartItem>> get items => _itemsNotifier;
void addItem(CartItem item) {
// Menambahkan item baru dan memicu notifikasi perubahan
_itemsNotifier.value = [..._itemsNotifier.value, item];
}
void removeItem(String itemId) {
_itemsNotifier.value = _itemsNotifier.value.where((item) => item.id != itemId).toList();
}
}
Dengan memusatkan data keranjang belanja pada CartRepository, halaman keranjang belanja, ikon lencana jumlah barang di halaman beranda, dan halaman pembayaran akan selalu menampilkan data yang 100% konsisten karena mereka semua membaca dari satu sumber kebenaran yang sama.
Separation of Concerns dalam Praktik #
Inti dari arsitektur yang baik adalah disiplin dalam memisahkan tanggung jawab (Separation of Concerns). Setiap kelas yang kita tulis harus fokus melakukan satu hal dengan sangat baik.
Tabel di bawah ini merangkum batas tanggung jawab untuk setiap komponen utama arsitektur aplikasi kita:
| Komponen | Tanggung Jawab Utama | Hal yang Dilarang Keras |
|---|---|---|
| View (Widget) | Menyusun tata letak visual, menangani rendering dekoratif, dan meneruskan input pengguna. | Melakukan kalkulasi harga, memanggil HTTP API, menulis query database, memegang business logic. |
| ViewModel | Mengelola state UI spesifik layar, memproses masukan pengguna, menjembatani UI dengan Data. | Melakukan koneksi HTTP mentah, mengelola file database, menyimpan state yang bersifat global lintas fitur. |
| Repository | Menyediakan API akses data yang bersih, mengelola cache lokal, memediasi beberapa data sources. | Menyimpan data visual (seperti warna tombol), memicu dialog pop-up UI secara langsung. |
| Data Source | Melakukan query mentah ke database lokal, mengirimkan request HTTP mentah ke server API. | Melakukan pemformatan mata uang, memutuskan logika bisnis aplikasi. |
Mari kita amati perbandingan nyata pemisahan logika bisnis dari widget:
// ANTI-PATTERN: Melakukan kalkulasi diskon dan pemformatan mata uang di dalam Widget build
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({super.key, required this.product});
@override
Widget build(BuildContext context) {
// JANGAN lakukan logika perhitungan & formatting di sini!
final finalPrice = product.originalPrice * (1 - product.discountRate);
final formattedPrice = 'Rp ${finalPrice.toStringAsFixed(0)}';
return Card(
child: Text('Harga: $formattedPrice'),
);
}
}
Kode di atas menyulitkan kita jika di masa mendatang kita perlu menguji logika diskon secara otomatis (unit testing), karena kita harus menginisialisasi seluruh framework widget Flutter hanya untuk menguji matematika perkalian sederhana.
Berikut adalah pendekatan yang benar dengan memindahkan logika tersebut ke ViewModel atau Model representasi data:
// BENAR: Logika perhitungan dipindahkan ke ViewModel atau representasi Model data
class ProductViewModel extends ChangeNotifier {
final Product _product;
ProductViewModel(this._product);
double get finalPrice => _product.originalPrice * (1 - _product.discountRate);
String get formattedPrice {
// Logika pemformatan bersih dan dapat diuji secara terpisah tanpa UI
return 'Rp ${finalPrice.toStringAsFixed(0)}';
}
}
// Di dalam Widget View, kita cukup memanggil properti yang sudah matang
class ProductCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final viewModel = context.watch<ProductViewModel>();
return Card(
child: Text('Harga: ${viewModel.formattedPrice}'),
);
}
}
Struktur Folder yang Direkomendasikan #
Implementasi arsitektur yang bersih harus tecermin secara visual pada struktur direktori proyek kita. Ada dua pendekatan struktur folder yang umum digunakan di industri:
1. Layer-based Structure (Struktur Berbasis Lapisan) #
Pendekatan ini mengelompokkan berkas kode berdasarkan lapisan teknis arsitekturnya. Sangat cocok digunakan untuk proyek berskala kecil hingga menengah karena struktur kodenya sangat mudah dipahami oleh pengembang baru.
lib/
├── main.dart
├── app.dart
├── ui/ # Lapisan UI
│ ├── home/
│ │ ├── home_screen.dart
│ │ └── home_view_model.dart
│ └── profile/
│ ├── profile_screen.dart
│ └── profile_view_model.dart
├── domain/ # Lapisan Bisnis (Opsional)
│ ├── models/
│ │ └── user.dart
│ └── use_cases/
│ └── get_recommended_products.dart
└── data/ # Lapisan Data
├── repositories/
│ └── user_repository.dart
└── data_sources/
├── remote/
│ └── user_api_data_source.dart
└── local/
└── user_database_data_source.dart
2. Feature-based Structure (Struktur Berbasis Fitur) #
Pendekatan ini mengelompokkan berkas kode berdasarkan fitur fungsional aplikasi (misalnya fitur autentikasi, fitur keranjang belanja, fitur profil). Di dalam setiap folder fitur, barulah dibagi menjadi lapisan arsitektur. Struktur ini sangat direkomendasikan untuk proyek berskala besar yang dikerjakan oleh banyak tim paralel karena meminimalkan konflik penggabungan kode (git merge conflict).
lib/
├── main.dart
├── app.dart
└── features/
├── auth/ # Fitur Autentikasi
│ ├── presentation/ # Lapisan UI Fitur Auth
│ │ ├── login_screen.dart
│ │ └── login_view_model.dart
│ ├── domain/ # Lapisan Bisnis Fitur Auth
│ └── data/ # Lapisan Data Fitur Auth
└── cart/ # Fitur Keranjang Belanja
├── presentation/
├── domain/
└── data/
Kita bebas memilih salah satu dari kedua pendekatan di atas, asalkan kita menerapkannya secara konsisten di seluruh bagian proyek.
Ringkasan #
- Dua Perspektif — Arsitektur Flutter dapat dilihat dari sisi internal (bagaimana framework didesain dengan sistem berlapis) dan sisi aplikasi (bagaimana kita menyusun kode proyek kita).
- Tiga Lapisan Aplikasi — Google merekomendasikan pembagian kode menjadi UI Layer (View & ViewModel), Domain Layer (Use Cases untuk logika bisnis kompleks), dan Data Layer (Repository & Data Sources).
- Separation of Concerns — Setiap komponen harus memiliki satu tanggung jawab spesifik. Jangan pernah menaruh pemanggilan API, query database, atau perhitungan matematika bisnis di dalam Widget.
- Unidirectional Data Flow (UDF) — Aliran data harus berjalan satu arah: state mengalir dari bawah ke atas (Data ke UI) untuk merender visual, sedangkan event interaksi dikirim dari atas ke bawah (UI ke Data) untuk modifikasi.
- Single Source of Truth (SSOT) — Hindari duplikasi penyimpanan data di widget lokal yang rentan tidak sinkron. Simpan data otoritatif pada Repository tunggal dan sebarkan menggunakan aliran reaktif.
- Pilihan Struktur Direktori — Gunakan struktur berbasis lapisan (layer-based) untuk projek kecil-menengah demi kemudahan navigasi, atau gunakan berbasis fitur (feature-based) untuk aplikasi skala besar demi skalabilitas tim.
← Sebelumnya: Engine, Framework & Embedder Berikutnya: Framework Layer →