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.
  • Key membantu Flutter merecycle widget dengan benar di list — gunakan ValueKey berdasarkan ID yang stabil, bukan indeks.
  • setState() di widget besar memicu rebuild seluruh tree — ekstrak bagian yang berubah ke StatefulWidget kecil atau gunakan select() Riverpod.
  • Hindari membuat objek yang sama berulang kali di build() — definisikan TextStyle, BoxDecoration, dan sejenisnya sebagai konstanta statik.
  • Gunakan AutomaticKeepAliveClientMixin untuk tab yang berisi konten heavy agar state tidak hilang saat berpindah tab.
  • Prioritaskan optimasi: memory leak (risiko crash) → jank di alur utamaukuran appcold start → fine-tuning lanjutan.

← Sebelumnya: Memory & App Size   Berikutnya: Platform Channels →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact