Tampilan
Bab 12 — Di Balik Layar: Tiga Pohon & Keys
Bab ini berbeda dari yang lain. Tidak ada fitur baru yang bisa langsung kamu pakai, tidak ada widget yang perlu dihafal. Yang ada adalah penjelasan tentang bagaimana Flutter bekerja sebenarnya — dan pemahaman itu akan menjelaskan sejumlah perilaku aneh yang mungkin sudah kamu temui.
Kalau kamu pernah bertanya-tanya kenapa menghapus satu item dari daftar membuat item lain ikut berubah, atau kenapa const disebut mempercepat aplikasi, atau apa gunanya parameter key yang selalu muncul di setiap konstruktor widget — jawabannya ada di sini.
Ada tiga pohon, bukan satu
Selama ini kita bicara tentang "widget tree". Kenyataannya, Flutter mengelola tiga pohon yang berjalan berdampingan.
WIDGET TREE ELEMENT TREE RENDER TREE
(cetak biru) (penghubung) (piksel)
─────────── ──────────── ───────────
Ringan Sedang Berat
Sering dibuang Bertahan lama Bertahan lama
Immutable Mutable Mutable
MyApp ──────► Element ──────► (tidak merender)
│ │
Scaffold ──────► Element ──────► RenderBox
│ │
Column ──────► Element ──────► RenderFlex
│ │
├─ Text ──────► Element ──────► RenderParagraph
└─ Button ──────► Element ──────► RenderBoxWidget tree adalah yang kamu tulis. Objek konfigurasi yang ringan dan tidak bisa diubah. Flutter membuangnya dan membuat ulang berkali-kali per detik.
Element tree adalah lapisan penghubung. Setiap element menunjuk ke satu widget dan satu render object. Element inilah yang bertahan ketika widget dibuang — dan inilah yang menyimpan State milik StatefulWidget.
Render tree adalah yang mengerjakan pekerjaan berat: menghitung ukuran, menentukan posisi, dan menggambar piksel.
Kenapa ini penting untukmu? Karena element tree adalah alasan setState bisa efisien. Ketika build() dipanggil ulang, Flutter tidak membuang seluruh antarmuka. Ia membandingkan widget baru dengan widget lama pada setiap element, dan hanya memperbarui yang benar-benar berbeda.
Bagaimana pembaruan bekerja
Ketika setState dipanggil, inilah yang terjadi:
1. setState() dipanggil
│
▼
2. Element ditandai "kotor" (dirty)
│
▼
3. Pada frame berikutnya, build() dipanggil
│
▼
4. Widget BARU dihasilkan
│
▼
5. Flutter membandingkan widget baru vs lama
pada setiap posisi di pohon:
│
├─ Tipe runtime SAMA? ──► perbarui widget di element yang ada
│ (element & state DIPERTAHANKAN)
│
└─ Tipe runtime BEDA? ──► buang element lama, buat yang baru
(state HILANG)
│
▼
6. Render object diperbarui, hanya bagian yang berubah digambar ulangKunci dari seluruh mekanisme ini ada di langkah 5. Flutter memutuskan apakah akan menggunakan ulang sebuah element berdasarkan dua hal:
- Tipe runtime widget — apakah keduanya sama-sama
Text? Sama-samaKartuPengeluaran? - Key — kalau ada.
Kalau keduanya cocok, element dan State-nya dipertahankan. Kalau tidak, semuanya dibuang dan dibuat ulang dari nol.
Kenapa const mempercepat aplikasi
Sekarang penjelasan const menjadi masuk akal.
dart
// Setiap build() menghasilkan objek Text BARU
Text('Judul')
// Objek Text yang SAMA persis, dibuat sekali seumur hidup aplikasi
const Text('Judul')Ketika Flutter membandingkan widget lama dan baru, ada pemeriksaan pintasan: kalau kedua objeknya adalah objek yang sama secara identitas (identical(a, b)), Flutter langsung tahu tidak ada yang berubah dan melewati seluruh proses pembandingan untuk cabang itu.
Widget const selalu memenuhi syarat itu, karena Dart menyimpan satu instance saja untuk setiap nilai const yang identik — ini disebut canonicalization.
dart
class _HalamanState extends State<Halaman> {
int _hitungan = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Seluruh cabang ini dilewati saat rebuild
const HeaderBerat(),
// Hanya ini yang benar-benar dibandingkan dan diperbarui
Text('$_hitungan'),
ElevatedButton(
onPressed: () => setState(() => _hitungan++),
child: const Text('Tambah'),
),
],
);
}
}Inilah kenapa linter Flutter terus-menerus menyarankanmu menambahkan const. Itu bukan sekadar kerapian gaya.
Memecah widget membatasi rebuild
Prinsip kedua yang mengikuti dari model tiga pohon: setState hanya memengaruhi element tempat ia dipanggil dan keturunannya.
dart
// ❌ Satu setState membangun ulang SEMUANYA
class _BerandaState extends State<Beranda> {
int _hitungan = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
PetaBerat(), // dibangun ulang percuma
GrafikBerat(), // dibangun ulang percuma
Text('$_hitungan'),
ElevatedButton(
onPressed: () => setState(() => _hitungan++),
child: const Text('Tambah'),
),
],
);
}
}dart
// ✅ setState hanya menyentuh cabang yang relevan
class Beranda extends StatelessWidget {
const Beranda({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: [
PetaBerat(), // tidak pernah tersentuh
GrafikBerat(), // tidak pernah tersentuh
BagianPenghitung(), // punya state sendiri
],
);
}
}
class BagianPenghitung extends StatefulWidget {
const BagianPenghitung({super.key});
@override
State<BagianPenghitung> createState() => _BagianPenghitungState();
}
class _BagianPenghitungState extends State<BagianPenghitung> {
int _hitungan = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('$_hitungan'),
ElevatedButton(
onPressed: () => setState(() => _hitungan++),
child: const Text('Tambah'),
),
],
);
}
}Aturannya: turunkan state serendah mungkin di pohon. Semakin dekat state dengan widget yang benar-benar memakainya, semakin kecil cakupan pembangunan ulangnya.
Mengekstrak widget vs mengekstrak method
Ada godaan untuk memecah build() menjadi method-method kecil:
dart
Widget build(BuildContext context) {
return Column(
children: [
_bangunHeader(), // ❌ tidak membantu performa
_bangunKonten(),
],
);
}Ini memang meningkatkan keterbacaan, tetapi tidak membantu performa — method dipanggil dari build() yang sama, jadi tetap dieksekusi ulang setiap kali. Hanya widget terpisah yang membuat element tree bercabang, dan hanya itu yang membatasi rebuild.
Masalah yang dipecahkan oleh Keys
Sekarang bagian yang paling praktis. Mari lihat bug nyata.
Bayangkan daftar kotak berwarna, masing-masing sebuah StatefulWidget yang menyimpan berapa kali ia diketuk:
dart
class KotakHitung extends StatefulWidget {
const KotakHitung({super.key, required this.warna});
final Color warna;
@override
State<KotakHitung> createState() => _KotakHitungState();
}
class _KotakHitungState extends State<KotakHitung> {
int _ketukan = 0; // ← state yang tersimpan di element
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _ketukan++),
child: Container(
width: 100,
height: 100,
color: widget.warna,
alignment: Alignment.center,
child: Text(
'$_ketukan',
style: const TextStyle(fontSize: 32, color: Colors.white),
),
),
);
}
}Dan daftarnya, tanpa key:
dart
class _DaftarKotakState extends State<DaftarKotak> {
List<Color> _warna = [Colors.red, Colors.green, Colors.blue];
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
for (final warna in _warna)
KotakHitung(warna: warna), // ← tanpa key
],
),
ElevatedButton(
onPressed: () => setState(() => _warna.removeAt(0)),
child: const Text('Hapus yang pertama'),
),
],
);
}
}Sekarang lakukan ini: ketuk kotak merah tiga kali, ketuk hijau lima kali, lalu tekan "Hapus yang pertama".
Yang kamu harapkan: merah hilang, hijau tetap menunjukkan 5, biru tetap 0.
Yang sebenarnya terjadi: hijau menunjukkan 3.
Kenapa bisa begitu
Karena Flutter membandingkan berdasarkan posisi dan tipe:
SEBELUM penghapusan SESUDAH penghapusan
───────────────────── ─────────────────────
Posisi 0: KotakHitung(merah) Posisi 0: KotakHitung(hijau)
State: ketukan = 3 ← element LAMA dipakai ulang!
State ketukan = 3 tertinggal
Posisi 1: KotakHitung(hijau) Posisi 1: KotakHitung(biru)
State: ketukan = 5 ← element lama dipakai ulang
State ketukan = 5 tertinggal
Posisi 2: KotakHitung(biru) (posisi 2 dibuang)
State: ketukan = 0Flutter melihat posisi 0 dan berpikir: "widget baru di sini bertipe KotakHitung, widget lama juga KotakHitung — tipenya sama, jadi aku pakai ulang element-nya, cukup perbarui propertinya."
Ia tidak tahu bahwa merah dan hijau adalah entitas yang berbeda. Dari sudut pandangnya, keduanya hanyalah "KotakHitung di posisi 0".
State-nya menempel pada posisi, bukan pada data.
Solusinya: berikan identitas
Key adalah cara memberi tahu Flutter identitas sebuah widget, terlepas dari posisinya.
dart
for (final warna in _warna)
KotakHitung(
key: ValueKey(warna), // ← identitas berbasis data
warna: warna,
),Sekarang perbandingannya menjadi:
SEBELUM SESUDAH
───────────────────── ─────────────────────
Posisi 0: key = merah Posisi 0: key = hijau
ketukan = 3 ← key tidak cocok dengan
element lama di posisi 0
Posisi 1: key = hijau ─────────────────► Flutter mencari element
ketukan = 5 dengan key "hijau",
menemukannya di posisi 1,
lalu MEMINDAHKANNYA ke posisi 0
beserta State ketukan = 5 ✅Flutter kini bisa mencocokkan element berdasarkan key, bukan posisi. Ia memindahkan element yang benar dan membuang yang benar.
Jenis-jenis Key
ValueKey
Yang paling sering dipakai. Identitas berdasarkan sebuah nilai:
dart
ValueKey(pengeluaran.id)
ValueKey('item-$indeks') // ⚠️ hati-hati, lihat catatan di bawah
ValueKey(warna)ObjectKey
Identitas berdasarkan identitas objek itu sendiri. Berguna kalau objekmu tidak punya ID unik tetapi instansnya memang berbeda:
dart
ObjectKey(pengeluaran)UniqueKey
Membuat identitas yang selalu berbeda setiap kali dibuat:
dart
UniqueKey()UniqueKey hampir selalu salah
Karena dibuat baru pada setiap build(), key ini tidak akan pernah cocok dengan element sebelumnya. Akibatnya seluruh element dan state-nya dibuang serta dibuat ulang setiap frame — kebalikan dari yang kamu inginkan.
Gunakan hanya kalau kamu memang sengaja ingin memaksa Flutter membuang state, misalnya untuk memicu ulang sebuah animasi.
GlobalKey
Berbeda dari yang lain: GlobalKey memberi akses ke state widget dari mana pun di aplikasi.
dart
final _kunciForm = GlobalKey<FormState>();
// Di tempat lain, akses state-nya
_kunciForm.currentState!.validate();
_kunciForm.currentState!.save();Ini yang kamu pakai di Bab 6 untuk Form.
GlobalKey mahal dan harus dipakai hemat. Ia juga harus dibuat sekali dan disimpan sebagai properti final, tidak pernah di dalam build():
dart
// ✅ Benar
class _FormSayaState extends State<FormSaya> {
final _kunciForm = GlobalKey<FormState>();
// ❌ Salah — dibuat ulang setiap build, state formulir hilang terus
Widget build(BuildContext context) {
final kunciForm = GlobalKey<FormState>();Kapan kamu butuh Key
Kamu butuh key ketika ketiga syarat ini terpenuhi bersamaan:
- Kamu punya daftar widget sejenis.
- Widget-widget itu stateful — punya
StatefulWidget, animasi, posisi gulir, atau input teks di dalamnya. - Daftarnya bisa berubah urutan — item ditambah, dihapus, diurutkan, atau disaring.
Kalau salah satu tidak terpenuhi, key biasanya tidak diperlukan.
dart
// ✅ Tidak butuh key — widget stateless
Column(
children: [
for (final teks in daftarTeks) Text(teks),
],
)
// ✅ Tidak butuh key — daftar tidak pernah berubah urutan
ListView(
children: const [
PengaturanNotifikasi(),
PengaturanPrivasi(),
PengaturanAkun(),
],
)
// ⚠️ BUTUH key — stateful + bisa berubah urutan
ListView.builder(
itemCount: _tugas.length,
itemBuilder: (ctx, i) => KartuTugas(
key: ValueKey(_tugas[i].id),
tugas: _tugas[i],
),
)
// ⚠️ WAJIB key — Dismissible menuntut key
Dismissible(
key: ValueKey(item.id),
onDismissed: (_) => _hapus(item),
child: /* ... */,
)Pakai ID, bukan indeks
Ini kesalahan paling umum ketika orang baru mulai memakai key.
dart
// ❌ Indeks berubah setelah penghapusan — sama saja dengan tanpa key
ValueKey(indeks)
// ✅ ID menempel pada data, tidak berubah
ValueKey(item.id)Kalau kamu memakai indeks, kamu kembali ke masalah semula: setelah item pertama dihapus, item kedua berpindah ke indeks 0 dan mewarisi state yang salah.
Kalau modelmu belum punya ID, tambahkan. Paket uuid membuatnya sepele:
dart
import 'package:uuid/uuid.dart';
const uuid = Uuid();
class Pengeluaran {
Pengeluaran({
required this.judul,
required this.jumlah,
required this.tanggal,
}) : id = uuid.v4(); // ID unik dibuat otomatis
final String id;
final String judul;
final double jumlah;
final DateTime tanggal;
}var, final, dan const dilihat dari sisi memori
Sekarang kita bisa melihat kembali tiga kata kunci dari Bab 2 dengan pemahaman yang lebih dalam.
Ketika kamu menulis:
dart
final daftar = [1, 2, 3];Variabel daftar tidak menyimpan list itu sendiri. Ia menyimpan alamat tempat list itu berada di memori.
Variabel Memori
──────── ──────
daftar ──────────► [1, 2, 3]
(alamat) (objek sesungguhnya)final mengunci alamatnya, bukan isi di alamat itu:
dart
final daftar = [1, 2, 3];
daftar.add(4); // ✅ isi di alamat itu berubah
daftar = [5, 6]; // ❌ alamatnya yang dikunciconst mengunci keduanya — objeknya dibekukan seluruhnya:
dart
const daftar = [1, 2, 3];
daftar.add(4); // ❌ error saat runtime: Unsupported operationPemahaman ini penting ketika bekerja dengan state, karena inilah sumber bug klasik berikut:
dart
// ❌ Tampilan tidak berubah meskipun setState dipanggil
void tambahItem(Item baru) {
setState(() {
_daftar.add(baru); // objek yang sama, hanya isinya berubah
});
}Ini sebenarnya berfungsi untuk setState biasa, karena setState menandai element sebagai kotor tanpa peduli objeknya berubah alamat atau tidak.
Tetapi ia gagal pada solusi state management yang membandingkan objek — seperti Riverpod dan BLoC. Keduanya memeriksa apakah state baru berbeda dari yang lama, dan kalau alamatnya sama, mereka menyimpulkan tidak ada perubahan.
Karena itu di Bagian III kamu akan selalu melihat pola ini:
dart
// ✅ Buat list BARU, bukan mengubah yang lama
state = [...state, itemBaru];
// ✅ Buat objek BARU dengan copyWith
state = state.copyWith(hitungan: state.hitungan + 1);Masalah ini dibahas tuntas di Bab 23 dan bab tentang Equatable.
Memakai Flutter DevTools untuk melihat pohonnya
Semua ini bukan teori abstrak — kamu bisa melihatnya langsung.
Jalankan aplikasi dalam mode debug, lalu buka DevTools:
bash
flutter run
# Tekan 'v' di terminal, atau buka URL yang ditampilkanDi tab Flutter Inspector, kamu bisa:
- Melihat widget tree yang sesungguhnya, lengkap dengan properti tiap widget.
- Mengaktifkan Highlight Repaints — setiap widget yang digambar ulang akan dikelilingi garis berwarna. Kalau seluruh layar berkedip setiap kali kamu menekan tombol, itu pertanda rebuild yang terlalu luas.
- Mengaktifkan Show Guidelines untuk melihat batas-batas layout.
- Memilih widget di layar dan langsung melompat ke barisnya di kode.
Tab Performance menunjukkan berapa lama setiap frame dibangun. Frame yang melebihi 16 milidetik akan terasa tersendat pada layar 60 Hz.
DevTools dibahas lebih lengkap di Bab 13.
Ikhtisar
- Flutter mengelola tiga pohon: widget (cetak biru, sering dibuang), element (penghubung, bertahan, menyimpan
State), dan render (menggambar piksel). - Saat rebuild, Flutter memakai ulang element kalau tipe runtime widget lama dan baru cocok — dan kalau ada
key, key-nya juga harus cocok. constmempercepat karena Dart hanya membuat satu instance untuk nilaiconstyang identik, sehingga Flutter bisa melewati pembandingan seluruh cabang itu.- Mengekstrak widget membatasi cakupan rebuild; mengekstrak method tidak — method tetap dieksekusi dalam
build()yang sama. - Turunkan state serendah mungkin di pohon.
- Tanpa key, state menempel pada posisi, bukan pada data. Inilah sebabnya menghapus item dari daftar bisa membuat state item lain tertukar.
- Butuh key ketika daftar + stateful + urutan bisa berubah terjadi bersamaan.
- Pakai
ValueKey(item.id), janganValueKey(indeks)— indeks berubah setelah penghapusan. GlobalKeyuntuk akses state dari luar (sepertiForm); mahal, harus dibuat sekali sebagai propertifinal.UniqueKeymembuang state setiap frame — hampir selalu bukan yang kamu mau.finalmengunci alamat,constmengunci alamat dan isinya. Perbedaan ini menjadi krusial di state management, tempat objek baru harus dibuat agar perubahan terdeteksi.- Flutter Inspector di DevTools memungkinkanmu melihat semua ini secara langsung, termasuk widget mana yang digambar ulang.
Berikutnya: Bab 13 — Debugging & DevTools, tempat kita belajar membaca pesan error alih-alih menakutinya.
Transkrip asli
Disintesis dari 1_flutter_core/6_todo_internals.md (9 video, termasuk "Three Trees", "Understanding Keys", dan "Mutating Values in Memory"). Lihat PDF Flutter Core.