Best Practice #
Optimasi performa yang baik dimulai dari data, bukan asumsi. Kode yang terlihat “lambat” seringkali bukan bottleneck sesungguhnya — sementara bottleneck nyata tersembunyi di tempat yang tidak terduga. Artikel ini merangkum prinsip-prinsip yang membantu kamu membuat keputusan optimasi yang tepat sasaran.
1. Profile Dulu, Optimasi Kemudian #
Aturan pertama optimasi: jangan optimasi tanpa data profiling.
// Cara mudah mengukur waktu eksekusi kode
final stopwatch = Stopwatch()..start();
await operasiYangDicurigai();
stopwatch.stop();
debugPrint('Waktu: ${stopwatch.elapsedMilliseconds}ms');
// Untuk fungsi yang dipanggil berkali-kali -- rata-rata
Future<void> benchmark(String label, Future<void> Function() fn, {int runs = 10}) async {
final times = <int>[];
for (var i = 0; i < runs; i++) {
final sw = Stopwatch()..start();
await fn();
times.add(sw.elapsedMilliseconds);
}
final avg = times.reduce((a, b) => a + b) / times.length;
debugPrint('$label: avg ${avg.toStringAsFixed(1)}ms (${times.join(', ')}ms)');
}
// Penggunaan
await benchmark('parseProduk', () async {
await parseProdukBesar(jsonString);
});
2. Hindari Operasi Mahal di build() #
build() dipanggil setiap kali widget perlu di-render ulang. Operasi mahal di sini berarti frame yang lambat.
// ANTI-PATTERN: operasi mahal di build()
class ProdukScreen extends StatelessWidget {
final List<Map<String, dynamic>> rawData;
@override
Widget build(BuildContext context) {
// Ini dipanggil setiap rebuild!
final produk = rawData
.map((e) => Produk.fromJson(e)) // parsing berat
.where((p) => p.tersedia) // filter
.sorted((a, b) => a.nama.compareTo(b.nama)) // sort
.toList();
return ProdukList(produk: produk);
}
}
// BENAR: hitung di luar build -- di notifier atau di memoize
class ProdukNotifier extends AsyncNotifier<List<Produk>> {
@override
Future<List<Produk>> build() async {
final rawData = await ref.watch(rawDataProvider.future);
// Kalkulasi di notifier, bukan di widget build
return rawData
.map((e) => Produk.fromJson(e))
.where((p) => p.tersedia)
.sorted((a, b) => a.nama.compareTo(b.nama))
.toList();
}
}
3. Gunakan Key dengan Benar #
Key membantu Flutter mengidentifikasi dan merecycle widget dengan benar, terutama dalam list yang berubah urutan atau isinya.
// ANTI-PATTERN: tidak ada key di list yang bisa berubah
ListView.builder(
itemCount: items.length,
itemBuilder: (ctx, i) => ItemWidget(item: items[i]),
// Tanpa key, Flutter mungkin salah mengasosiasikan state dengan item
)
// BENAR: gunakan ValueKey berdasarkan ID yang stabil
ListView.builder(
itemCount: items.length,
itemBuilder: (ctx, i) => ItemWidget(
key: ValueKey(items[i].id), // ID yang unik dan stabil
item: items[i],
),
)
// Untuk ReorderableListView
ReorderableListView.builder(
itemCount: items.length,
itemBuilder: (ctx, i) => ListTile(
key: ValueKey(items[i].id), // Key wajib untuk reorderable!
title: Text(items[i].nama),
),
onReorder: (oldIndex, newIndex) { ... },
)
4. Batasi setState ke Scope Terkecil #
Semakin besar widget yang memanggil setState(), semakin banyak yang di-rebuild.
// ANTI-PATTERN: setState di widget root
class HomeScreen extends StatefulWidget { ... }
class _HomeScreenState extends State<HomeScreen> {
int _cartCount = 0;
@override
Widget build(BuildContext context) {
// Saat _cartCount berubah, SELURUH HomeScreen rebuild!
return Scaffold(
appBar: AppBar(
actions: [Badge(count: _cartCount)],
),
body: const ExpensiveBody(), // ini juga rebuild!
);
}
}
// BENAR: ekstrak ke StatefulWidget kecil yang terpisah
class CartBadge extends StatefulWidget { ... }
class _CartBadgeState extends State<CartBadge> {
int _count = 0;
@override
Widget build(BuildContext context) {
// Hanya CartBadge yang rebuild -- bukan seluruh HomeScreen
return Badge(count: _count);
}
}
// Atau dengan Riverpod -- select untuk granular rebuild
class CartBadge extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(cartProvider.select((c) => c.items.length));
return Badge(count: count);
}
}
5. Hindari Membuat Objek Berlebihan #
Setiap objek yang dibuat harus di-GC (Garbage Collected). Alokasi berlebihan menyebabkan GC sering berjalan, yang dapat menyebabkan frame drop.
// ANTI-PATTERN: membuat TextStyle baru setiap build
class ProdukCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text(
produk.nama,
style: TextStyle( // objek baru setiap rebuild!
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
);
}
}
// BENAR: definisikan sebagai konstanta
class ProdukCard extends StatelessWidget {
static const _namaStyle = TextStyle( // dibuat sekali, dipakai berulang
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
);
@override
Widget build(BuildContext context) {
return Text(produk.nama, style: _namaStyle);
}
}
// Atau gunakan Theme untuk konsistensi
Text(
produk.nama,
style: Theme.of(context).textTheme.titleMedium,
)
6. Gunakan AutomaticKeepAlive untuk Tab #
Saat pengguna berpindah tab, Flutter biasanya membuang widget tab yang tidak aktif dan membangunnya ulang saat kembali. Gunakan AutomaticKeepAlive untuk mempertahankan state:
// Tanpa keep alive: state hilang setiap ganti tab
class ProdukTab extends StatefulWidget { ... }
class _ProdukTabState extends State<ProdukTab> {
ScrollPosition? _scrollPos; // akan hilang saat tab tidak aktif
}
// DENGAN keep alive: state dipertahankan
class _ProdukTabState extends State<ProdukTab>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true; // pertahankan state saat tab tidak aktif
@override
Widget build(BuildContext context) {
super.build(context); // WAJIB dipanggil dengan mixin ini
return ProdukList();
}
}
Anti-Pattern Performa yang Harus Dihindari #
// ✗ MediaQuery.of(context) di dalam loop atau build yang sering dipanggil
// Setiap panggilan perlu traverse widget tree
// ✓ Simpan hasilnya sekali: final size = MediaQuery.of(context).size;
// ✗ String interpolasi untuk URL yang dibuild berulang
final url = 'https://api.example.com/produk/${produk.id}/gambar/${ukuran}';
// Setiap rebuild = string baru di heap
// ✓ Cache URL yang tidak berubah
// ✗ addPostFrameCallback berulang kali dalam loop
for (final item in items) {
WidgetsBinding.instance.addPostFrameCallback((_) { ... });
// Satu callback untuk setiap item -- bisa ribuan!
}
// ✓ Satu callback yang memproses semua items
// ✗ SizedBox.shrink() sebagai placeholder -- tidak perlu, gunakan Container()
// Keduanya sama-sama ringan, tapi SizedBox lebih semantik untuk spacing
// ✗ Nested SingleChildScrollView -- menyebabkan layout conflict
SingleChildScrollView(
child: SingleChildScrollView( // ERROR: scroll axis conflict
child: content,
),
)
// ✓ Gunakan satu ScrollView yang tepat
// ✗ Image.network tanpa ukuran -- Flutter tidak bisa reserve space
Image.network(url)
// ✓ Selalu tentukan width/height atau AspectRatio
Image.network(url, width: 100, height: 100, fit: BoxFit.cover)
Kapan Mulai Optimasi? #
JANGAN optimasi dulu jika:
✗ App belum memiliki pengguna nyata
✗ Belum ada laporan masalah performa
✗ Belum ada profiling data
✗ Fitur utama belum selesai
MULAI optimasi jika:
✓ Ada laporan konkret: "scroll terasa berat di halaman X"
✓ Frame chart menunjukkan banyak frame merah
✓ Memory terus naik selama penggunaan normal
✓ App crash karena OOM (Out of Memory) di device target
✓ Build time > 10 detik menghambat development
Urutan prioritas optimasi:
1. Fix memory leak (risiko crash)
2. Fix jank di alur utama (login, scroll, navigasi)
3. Kurangi ukuran app jika > 50MB
4. Optimasi cold start time
5. Fine-tuning performa lanjutan
Checklist Performa Sebelum Release #
RENDERING:
□ Tidak ada setState di widget besar yang bisa dipecah
□ Semua list panjang menggunakan ListView.builder
□ Widget konstan menggunakan keyword const
□ Provider menggunakan select() untuk granular rebuild
□ Tidak ada widget Opacity statis (gunakan Visibility)
MEMORY:
□ Semua AnimationController di-dispose di dispose()
□ Semua StreamSubscription dibatalkan di dispose()
□ Semua Timer dibatalkan di dispose()
□ Gambar memiliki cacheWidth/cacheHeight yang tepat
□ Tidak ada memory leak yang terdeteksi di DevTools
UKURAN APP:
□ Menggunakan App Bundle (aab) untuk Play Store
□ Asset gambar sudah dioptimasi (WebP, atau PNG yang dikompres)
□ Tidak ada font weight yang tidak dipakai
□ flutter build --analyze-size sudah dijalankan
PROFILING:
□ Performance view tidak menunjukkan frame merah konsisten
□ Tidak ada operasi berat di main isolate (gunakan compute)
□ Cold start time < 2 detik di device target terendah
Ringkasan #
- Profile dulu dengan DevTools sebelum menulis satu baris optimasi — asumsi tentang bottleneck sering salah.
- Jangan taruh operasi mahal (parsing, sorting, filter) di
build()— pindah ke notifier atau gunakan memoization.Keymembantu Flutter merecycle widget dengan benar di list — gunakanValueKeyberdasarkan ID yang stabil, bukan indeks.setState()di widget besar memicu rebuild seluruh tree — ekstrak bagian yang berubah ke StatefulWidget kecil atau gunakanselect()Riverpod.- Hindari membuat objek yang sama berulang kali di
build()— definisikanTextStyle,BoxDecoration, dan sejenisnya sebagai konstanta statik.- Gunakan
AutomaticKeepAliveClientMixinuntuk tab yang berisi konten heavy agar state tidak hilang saat berpindah tab.- Prioritaskan optimasi: memory leak (risiko crash) → jank di alur utama → ukuran app → cold start → fine-tuning lanjutan.
← Sebelumnya: Memory & App Size Berikutnya: Platform Channels →