Rendering Optimization #
Flutter merender UI dengan cara yang sangat efisien — ia hanya merender ulang bagian yang berubah. Tapi efisiensi ini bisa rusak dengan mudah jika kita tidak hati-hati: satu setState di widget root bisa memicu rebuild seluruh tree. Memahami kapan dan mengapa Flutter merender ulang widget adalah kunci untuk menjaga aplikasi tetap smooth di 60fps atau lebih.
Memahami Rebuild #
Flutter memanggil build() pada widget setiap kali state berubah. Rebuild itu sendiri tidak selalu mahal — Flutter cukup pintar untuk hanya merender ulang bagian yang benar-benar berbeda. Masalah terjadi ketika rebuild dipanggil terlalu sering atau pada widget yang berat.
// Cara melihat jumlah rebuild:
// 1. Buka DevTools → Performance → centang "Track Widget Builds"
// 2. Gunakan debugPrintRebuildDirtyWidgets = true di main()
void main() {
debugPrintRebuildDirtyWidgets = true; // print widget yang rebuild ke console
runApp(const MyApp());
}
const — Pencegah Rebuild Paling Murah #
Widget const hanya dibuat sekali dan tidak pernah di-rebuild, meskipun parent-nya rebuild. Ini adalah optimasi paling mudah dan paling sering diabaikan.
// ANTI-PATTERN: tidak menggunakan const di mana seharusnya
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Beranda')), // bukan const
body: Column(
children: [
Icon(Icons.home, size: 48), // bukan const
Text('Selamat Datang'), // bukan const
ProdukList(),
],
),
);
}
}
// BENAR: gunakan const di mana memungkinkan
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key}); // constructor const
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Beranda')),
body: const Column(
children: [
Icon(Icons.home, size: 48),
Text('Selamat Datang'),
// ProdukList() tidak bisa const jika punya state
],
),
);
}
}
// Aktifkan lint untuk otomatis mengingatkan
// di analysis_options.yaml:
// linter:
// rules:
// - prefer_const_constructors
// - prefer_const_literals_to_create_immutables
select() — Rebuild Selektif dengan Riverpod #
Jangan watch seluruh state jika widget hanya butuh sebagian kecil darinya:
// Model state yang besar
@freezed
class KeranjangState with _$KeranjangState {
const factory KeranjangState({
required List<Item> items,
required double total,
required bool isLoading,
required String? error,
}) = _KeranjangState;
}
// ANTI-PATTERN: watch seluruh state -- rebuild setiap ada perubahan apapun
class BadgeWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(keranjangProvider); // rebuild jika isLoading berubah!
return Badge(count: state.items.length);
}
}
// BENAR: select hanya nilai yang dibutuhkan
class BadgeWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Hanya rebuild jika jumlah item berubah, bukan saat isLoading berubah
final count = ref.watch(keranjangProvider.select((s) => s.items.length));
return Badge(count: count);
}
}
// Widget lain yang butuh total -- hanya rebuild saat total berubah
class TotalWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final total = ref.watch(keranjangProvider.select((s) => s.total));
return Text('Total: ${formatRupiah(total)}');
}
}
ListView.builder — Lazy Rendering untuk List #
ListView biasa merender semua item sekaligus, bahkan yang di luar layar. ListView.builder hanya merender item yang sedang terlihat.
// ANTI-PATTERN: ListView biasa untuk list panjang
ListView(
children: produkList.map((p) => ProdukCard(produk: p)).toList(),
// Semua 1000 item dibuat sekaligus -- berat!
)
// BENAR: ListView.builder -- hanya render item yang terlihat
ListView.builder(
itemCount: produkList.length,
// itemExtent: 120, // opsional, tapi sangat membantu performa jika tinggi item seragam
itemBuilder: (context, index) {
return ProdukCard(produk: produkList[index]);
// Hanya dibuat saat akan muncul di layar
},
)
// Untuk grid
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
),
itemCount: produkList.length,
itemBuilder: (context, index) => ProdukCard(produk: produkList[index]),
)
// Untuk performa maksimal: tambahkan key dan itemExtent
ListView.builder(
itemCount: produkList.length,
itemExtent: 120.0, // tinggi tetap -- Flutter tidak perlu hitung layout tiap item
itemBuilder: (context, index) {
return ProdukCard(
key: ValueKey(produkList[index].id), // membantu Flutter track perubahan
produk: produkList[index],
);
},
)
RepaintBoundary — Isolasi Area yang Sering Berubah #
RepaintBoundary membuat layer baru di raster — widget di dalamnya di-repaint secara independen tanpa mempengaruhi widget di sekitarnya.
// Cocok untuk:
// - Widget animasi yang berubah terus (progress bar, clock, counter)
// - Widget yang sering di-repaint tapi ukurannya tidak berubah
// TANPA RepaintBoundary:
// Saat jam berdetik, seluruh halaman di-repaint -- mahal!
Column(
children: [
HeavyStaticContent(),
LiveClock(), // berubah setiap detik
AnotherHeavyWidget(),
],
)
// DENGAN RepaintBoundary:
// LiveClock punya layer sendiri -- hanya ia yang di-repaint setiap detik
Column(
children: [
HeavyStaticContent(),
RepaintBoundary(
child: LiveClock(), // di-repaint sendiri, tidak ganggu tetangganya
),
AnotherHeavyWidget(),
],
)
// HATI-HATI: RepaintBoundary tidak selalu lebih baik
// Setiap RepaintBoundary membuat texture baru di GPU -- memori bertambah
// Gunakan hanya jika widget SERING berubah dan BESAR
// Jangan bungkus setiap widget dengan RepaintBoundary!
Widget Mahal yang Harus Digunakan Hati-Hati #
Beberapa widget Flutter memiliki biaya rendering yang signifikan:
// OPACITY -- sangat mahal karena offscreen rendering
// ANTI-PATTERN: Opacity untuk hide/show
Opacity(
opacity: isVisible ? 1.0 : 0.0,
child: ExpensiveWidget(),
)
// BENAR: gunakan Visibility atau kondisional
Visibility(
visible: isVisible,
child: ExpensiveWidget(),
)
// atau
if (isVisible) ExpensiveWidget(),
// ANIMASI opacity: gunakan AnimatedOpacity atau FadeTransition
// (keduanya menggunakan layer tersendiri, lebih efisien)
AnimatedOpacity(
opacity: isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: ExpensiveWidget(),
)
// CLIPRECT / CLIPPING -- membutuhkan offscreen buffer
// Batasi penggunaannya; hindari nested clipping
// Gunakan borderRadius di Container alih-alih ClipRRect jika memungkinkan
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), // lebih efisien dari ClipRRect
color: Colors.white,
),
child: content,
)
// BACKDROPFILTER (blur) -- sangat mahal
// Hanya gunakan jika benar-benar perlu, dan batasi areanya
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(color: Colors.white.withOpacity(0.3)),
// Ini membuat Flutter merender semua yang ada di belakang widget ini
// lalu menerapkan blur -- sangat berat di device low-end
)
// SHADER -- jika menggunakan custom shader, warmup di awal
// (lihat bagian Shader Warmup di bawah)
Impeller — Rendering Engine Baru #
Impeller adalah rendering engine baru Flutter yang menggantikan Skia. Ia mengeliminasi shader compilation jank dengan mengompilasi shader di awal, bukan saat pertama digunakan.
// Impeller status (per awal 2025):
// Android: diaktifkan by default sejak Flutter 3.27
// iOS: diaktifkan by default sejak Flutter 3.10
// Web: belum tersedia
// Cek apakah Impeller aktif:
flutter run --profile --enable-impeller // paksa aktif
flutter run --profile --no-enable-impeller // paksa nonaktif (Skia)
// Di AndroidManifest.xml untuk kontrol per-app:
// <meta-data android:name="io.flutter.embedding.android.EnableImpeller"
// android:value="true" />
// Jika menemukan rendering glitch dengan Impeller:
// 1. Laporkan ke Flutter repo dengan repro case
// 2. Sementara nonaktifkan: flutter run --no-enable-impeller
Shader Warmup (Skia Fallback) #
Jika masih menggunakan Skia atau Impeller belum menyelesaikan semua kasus:
// Warmup shader saat startup untuk menghindari jank saat pertama render
import 'package:flutter/painting.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Warmup shader -- jalankan di background sebelum UI tampil
await ShaderWarmUp().execute(); // atau custom ShaderWarmUp
runApp(const MyApp());
}
// Custom shader warmup untuk animasi spesifik
class MyShaderWarmUp extends ShaderWarmUp {
@override
Future<void> warmUpOnCanvas(Canvas canvas) async {
// Gambar ulang animasi yang akan digunakan di awal app
final paint = Paint()
..shader = LinearGradient(
colors: [Colors.blue, Colors.purple],
).createShader(const Rect.fromLTWH(0, 0, 100, 100));
canvas.drawRect(const Rect.fromLTWH(0, 0, 100, 100), paint);
}
}
Ringkasan #
constwidget tidak pernah di-rebuild — gunakanprefer_const_constructorslint untuk mengingatkan secara otomatis. Ini adalah optimasi termudah dan paling signifikan.select()di Riverpod membatasi rebuild hanya saat nilai yang diwatch berubah — gunakan untuk widget kecil yang bergantung pada sebagian kecil state yang besar.ListView.builderhanya merender item yang terlihat — wajib digunakan untuk list yang berpotensi panjang. TambahkanitemExtentjika tinggi item seragam untuk performa maksimal.RepaintBoundarymengisolasi area yang sering berubah ke layer terpisah — cocok untuk widget animasi, tapi jangan berlebihan karena setiap boundary mengkonsumsi memori GPU.- Widget
Opacity(statis),ClipRect, danBackdropFiltermembutuhkan offscreen rendering — mahal. Gunakan alternatif:Visibility,ContainerdenganborderRadius, atauAnimatedOpacity.- Impeller mengeliminasi shader compilation jank dan sudah aktif by default di Android dan iOS pada Flutter versi terbaru. Tidak perlu konfigurasi tambahan.
- Gunakan DevTools → “Track Widget Builds” untuk melihat widget mana yang rebuild terlalu sering sebelum memutuskan apa yang perlu dioptimasi.