Best Practice #
Memilih pustaka manajemen state yang tepat untuk proyek kita hanyalah separuh dari perjalanan. Separuh perjalanan lainnya yang tidak kalah krusial adalah bagaimana kita menerapkannya di dalam arsitektur kode sehari-hari. Banyak pengembang membangun aplikasi menggunakan pustaka yang sangat bagus seperti Riverpod atau Bloc, namun dengan cara yang keliru — misalnya, logika bisnis yang rumit masih ditulis di dalam widget UI, mutasi state dilakukan secara langsung dari sembarang tempat, atau kode ditulis sedemikian rupa sehingga tidak bisa diuji menggunakan unit test.
Penerapan manajemen state yang buruk akan melahirkan tumpukan hutang teknis (technical debt) yang lambat laun membuat aplikasi sulit dikembangkan, tidak stabil, dan rentan terhadap bug. Dokumen ini merangkum kumpulan praktik terbaik (best practices) arsitektur manajemen state yang berlaku lintas pustaka di Flutter. Dengan menerapkan prinsip-prinsip ini, kita dapat membangun kode aplikasi yang bersih, modular, mudah diuji, dan ramah untuk dikerjakan bersama tim.
Arsitektur Aplikasi Clean & Layered #
Sebelum kita membahas aturan penulisan kode secara spesifik, kita perlu menyepakati fondasi arsitektur aplikasi yang sehat. Desain arsitektur modern selalu membagi aplikasi ke dalam lapisan-lapisan (layers) yang terisolasi dengan tanggung jawab yang jelas (separation of concerns).
Berikut adalah diagram arsitektur berlapis (layered architecture) yang menggambarkan bagaimana UI, pengendali state, dan layer data saling berkomunikasi secara teratur:
graph TD
classDef default stroke:#333,stroke-width:2px;
subgraph UI_Layer["Layer Antarmuka (UI Layer)"]
A["Widget / Halaman Screen"]
end
subgraph Logic_Layer["Layer Logika Bisnis (State Management Layer)"]
B["Notifier / Bloc / Store (ViewModel)"]
C["State (Immutable Data Class)"]
end
subgraph Domain_Layer["Layer Domain & Data (Repository Layer)"]
D["Repository Interface / Implementation"]
E["Network API Client (Dio / Http)"]
F["Local Database (Hive / Drift / SharePref)"]
end
A -->|1. Mengirim Aksi / Interaksi| B
B -->|2. Meminta Data| D
D -->|3. Fetch Jaringan| E
D -->|4. Query Lokal| F
E -. "5. Kembalikan Data Mentah" .-> D
F -. "6. Kembalikan Data Lokal" .-> D
D -->|7. Kembalikan Model / Entitas| B
B -->|8. Memancarkan State Baru| C
C -. "9. Memicu Rebuild Granular" .-> ADi dalam arsitektur ini, aliran data dan kendali bergerak secara searah. Widget UI mengirimkan event atau memicu metode pada pengendali state (Notifier/Bloc/Store). Pengendali state kemudian berinteraksi dengan layer repository untuk mengambil atau mengubah data. Hasilnya kemudian dipancarkan kembali dalam bentuk state baru yang memicu antarmuka untuk menggambar ulang (rebuild) dirinya secara presisi.
1. Memisahkan UI dari Logika Bisnis (Separation of Concerns) #
Prinsip paling mendasar yang wajib kita tegakkan adalah: widget UI hanya bertanggung jawab untuk urusan rendering visual. Widget seharusnya tidak tahu bagaimana cara memvalidasi alamat email, menghitung harga setelah diskon, atau memformat parameter untuk dikirim ke server. Logika-logika tersebut sepenuhnya milik layer pengendali state.
Mari kita bandingkan perbedaan kualitas kode pada skenario proses pembayaran (checkout) berikut:
Anti-Pattern: Menulis Logika Bisnis di Widget UI #
Menulis perhitungan, validasi, dan alur I/O langsung di dalam fungsi tombol atau kelas StatefulWidget membuat kode sulit dibaca dan mustahil diuji secara terpisah.
// ANTI-PATTERN: Logika bisnis dan data dicampur di dalam tombol widget
class HalamanCheckout extends StatefulWidget {
const HalamanCheckout({super.key});
@override
State<HalamanCheckout> createState() => _HalamanCheckoutState();
}
class _HalamanCheckoutState extends State<HalamanCheckout> {
bool _isLoading = false;
Future<void> _bayarSekarang() async {
// 1. Validasi keranjang belanja lokal
if (keranjangLokal.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Keranjang belanja kosong!')),
);
return;
}
// 2. Kalkulasi harga dan pajak di UI
final subtotal = keranjangLokal.fold(0.0, (sum, item) => sum + item.harga);
final pajak = subtotal * 0.11;
final total = subtotal + pajak;
if (total > saldoPengguna) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Saldo Anda tidak mencukupi!')),
);
return;
}
setState(() => _isLoading = true);
try {
// 3. Panggilan API langsung di widget
final responsePesanan = await http.post(
Uri.parse('https://api.toko.com/pesanan'),
body: {'total': total.toString(), 'items': keranjangLokal.map((e) => e.id).toList()},
);
if (responsePesanan.statusCode == 200) {
// Navigasi UI langsung
Navigator.push(context, MaterialPageRoute(builder: (_) => const SuksesScreen()));
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: _isLoading ? null : _bayarSekarang,
child: _isLoading ? const CircularProgressIndicator() : const Text('Bayar'),
);
}
}
Pola Bersih: Widget Hanya Mengorkestrasi visual #
Widget hanya mengamati state dan memicu action. Logika perhitungan dan I/O dipindahkan ke Notifier.
// BENAR: Widget hanya berurusan dengan rendering UI
class TombolBayarBersih extends ConsumerWidget {
const TombolBayarBersih({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final stateCheckout = ref.watch(checkoutNotifierProvider);
return ElevatedButton(
onPressed: stateCheckout.isLoading
? null
: () => ref.read(checkoutNotifierProvider.notifier).lakukanPembayaran(),
child: stateCheckout.isLoading
? const CircularProgressIndicator()
: const Text('Bayar Sekarang'),
);
}
}
// Logika bisnis berada sepenuhnya di Notifier (State Controller)
class CheckoutNotifier extends AutoDisposeAsyncNotifier<CheckoutState> {
@override
Future<CheckoutState> build() async => const CheckoutState.initial();
Future<void> lakukanPembayaran() async {
final keranjang = ref.read(keranjangNotifierProvider);
state = const AsyncLoading();
try {
// 1. Validasi di layer bisnis
if (keranjang.isEmpty) throw Exception('Keranjang belanja kosong!');
// 2. Kalkulasi terisolasi
final total = _hitungTotal(keranjang);
// 3. Delegasikan pemanggilan data ke Repository
final pesanan = await ref.read(orderRepositoryProvider).buatPesanan(keranjang, total);
await ref.read(paymentRepositoryProvider).prosesBayar(pesanan.id);
state = AsyncData(CheckoutState.sukses(pesanan));
} catch (e, stack) {
state = AsyncError(e, stack);
}
}
double _hitungTotal(List<Item> items) {
final subtotal = items.fold(0.0, (sum, item) => sum + item.harga);
return subtotal + (subtotal * 0.11);
}
}
2. Single Source of Truth (Satu Sumber Kebenaran Data) #
Di dalam aplikasi yang kompleks, data yang sama sering kali dibutuhkan oleh beberapa layar yang berbeda. Prinsip Single Source of Truth menegaskan bahwa setiap data aplikasi hanya boleh disimpan dan dikelola di satu tempat. Duplikasi penyimpanan data di beberapa controller atau widget lokal adalah penyebab utama munculnya bug “data tidak sinkron” yang sangat sulit di-debug.
Kasus Kesalahan Sinkronisasi: #
Misalkan kita menyalin data nama pengguna dari state global ke dalam variabel lokal sebuah layar edit profil:
// ANTI-PATTERN: Menyalin data global ke state lokal widget
class _HalamanEditProfilState extends State<HalamanEditProfil> {
late String _namaLokal;
@override
void initState() {
super.initState();
// Menyalin data. Sekarang ada dua tempat penyimpanan untuk data yang sama!
_namaLokal = context.read<AuthNotifier>().user.nama;
}
// Jika nama diubah di server oleh bagian aplikasi lain (misal update background),
// widget lokal ini tidak akan pernah tahu karena ia memelihara salinannya sendiri.
}
Pola Penyelesaian yang Tepat: #
Selalu arahkan widget untuk membaca langsung dari satu sumber kebenaran global menggunakan mekanisme seleksi (selector atau select) agar widget hanya rebuild ketika properti spesifik yang ia baca berubah.
// BENAR: Membaca secara real-time dari sumber tunggal
class TampilanNamaProfil extends ConsumerWidget {
const TampilanNamaProfil({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Membaca langsung dan spesifik menggunakan 'select'
final namaUser = ref.watch(authNotifierProvider.select((s) => s.user.nama));
return Text(namaUser);
}
}
3. State Bersifat Imutabel (Immutable State) #
State imutabel artinya kelas penampung state tidak boleh diubah propertinya secara langsung setelah diinisialisasi. Modifikasi state hanya boleh dilakukan dengan cara membuat instansi (instance) baru dari kelas tersebut menggunakan salinan dari data lama (menggunakan metode copyWith).
Mengapa Imutabilitas Sangat Penting? #
- Keamanan dari Mutasi Tidak Sengaja: Menghindari perubahan data secara diam-diam oleh widget di luar layer logika bisnis.
- Perbandingan State yang Efisien: Flutter dapat membandingkan perubahan state dengan sangat cepat hanya dengan mengecek referensi memori (
oldState != newState). Jika referensi memori berbeda, Flutter tahu bahwa state telah berubah dan UI harus di-rebuild. - Kemudahan Debugging: Memungkinkan pencatatan sejarah perubahan state (time-travel debugging) karena setiap perubahan menghasilkan objek snapshot state yang unik di memori.
// BENAR: Mendefinisikan state kelas secara imutabel
@immutable
class StateDaftarProduk {
final List<Produk> produk;
final bool isLoading;
final String? pesanError;
const StateDaftarProduk({
this.produk = const [],
this.isLoading = false,
this.pesanError,
});
// Metode wajib untuk memodifikasi state secara aman
StateDaftarProduk copyWith({
List<Produk>? produk,
bool? isLoading,
String? pesanError,
}) {
return StateDaftarProduk(
// Jika parameter bernilai null, gunakan nilai lama
produk: produk ?? this.produk,
isLoading: isLoading ?? this.isLoading,
pesanError: pesanError ?? this.pesanError,
);
}
}
Saat memperbarui state di dalam Notifier, kita selalu memancarkan instansi baru:
// Memperbarui loading
state = state.copyWith(isLoading: true);
// Memperbarui data setelah sukses fetch API
state = state.copyWith(
isLoading: false,
produk: listHasilApi,
);
4. Merancang State yang Granular #
Hindari membuat satu kelas state raksasa yang menampung seluruh informasi aplikasi kita (sering disebut God State). Jika kita menyimpan konfigurasi tema, data keranjang belanja, status autentikasi, dan daftar produk di dalam satu kelas state yang sama, maka setiap kali ada item baru masuk ke keranjang belanja, seluruh widget aplikasi yang menampilkan nama profil atau menu settings akan ikut mengalami rebuild secara tidak perlu.
Solusi: Pecah State Berdasarkan Domain #
Bagi state kita menjadi modul-modul kecil yang mandiri (granular) berdasarkan fungsionalitasnya:
AuthState: Khusus menangani data login pengguna, token, dan hak akses.CartState: Khusus mengelola daftar belanjaan di keranjang.ThemeState: Khusus menyimpan preferensi mode gelap/terang aplikasi.ProductListState: Khusus mengelola daftar produk dan filter pencarian.
Setiap widget hanya boleh berlangganan ke domain state yang ia perlukan untuk menggambar dirinya sendiri:
// Widget ikon keranjang belanja hanya subscribe ke CartState
final jumlahItem = ref.watch(cartNotifierProvider.select((s) => s.items.length));
5. Menangani Seluruh State Asinkron Secara Eksplisit #
Data yang datang dari internet selalu memiliki sifat asinkron dan tidak pasti. Ada tiga kemungkinan kondisi yang harus selalu kita tangani secara visual di UI:
- Loading: Menampilkan indikator pemuatan data agar pengguna tahu sistem sedang memproses permintaan.
- Error: Menampilkan pesan kesalahan yang jelas beserta tombol untuk mencoba kembali (retry) jika koneksi gagal.
- Data: Menampilkan hasil data yang sebenarnya, termasuk menangani kasus khusus jika data tersebut kosong (empty state).
Kegagalan menangani salah satu dari ketiga kondisi di atas dapat membuat aplikasi kita terlihat hang, memunculkan layar putih kosong, atau bahkan mengalami crash di perangkat pengguna.
Jika kita menggunakan Riverpod, manfaatkanlah kelas AsyncValue yang memaksa kita menangani ketiga kondisi ini di level kompilasi:
class DaftarProdukWidget extends ConsumerWidget {
const DaftarProdukWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncProduk = ref.watch(produkProvider);
return asyncProduk.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Gagal memuat data: $error'),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => ref.invalidate(produkProvider),
child: const Text('Coba Lagi'),
),
],
),
),
data: (listProduk) {
if (listProduk.isEmpty) {
return const Center(child: Text('Tidak ada produk yang tersedia.'));
}
return ListView.builder(
itemCount: listProduk.length,
itemBuilder: (context, index) => ListTile(title: Text(listProduk[index].nama)),
);
},
);
}
}
6. Validasi Data di Layer Logika Bisnis #
Banyak pengembang menulis logika validasi formulir (misalnya verifikasi format email, minimal panjang password, atau kecocokan konfirmasi sandi) langsung di dalam event callback tombol UI. Ini adalah kebiasaan buruk karena validasi tersebut tidak bisa diuji melalui unit test otomatis secara mandiri.
UI seharusnya hanya bertugas mengumpulkan string mentah dari input pengguna, lalu mengirimkannya ke controller untuk divalidasi di sana:
// UI Widget mengirim data mentah
ElevatedButton(
onPressed: () {
ref.read(authNotifierProvider.notifier).login(
emailController.text,
passwordController.text,
);
},
child: const Text('Login'),
)
// Layer State Controller (Notifier) memproses validasi
class AuthNotifier extends AutoDisposeAsyncNotifier<AuthState> {
@override
Future<AuthState> build() async => const AuthState.unauthenticated();
Future<void> login(String email, String password) async {
// Jalankan validasi di sini sebelum hit ke server API
if (email.trim().isEmpty) {
throw ValidationException('Alamat email tidak boleh kosong!');
}
if (!email.contains('@')) {
throw ValidationException('Format alamat email tidak valid!');
}
if (password.length < 8) {
throw ValidationException('Password minimal harus 8 karakter!');
}
state = const AsyncLoading();
try {
final user = await ref.read(authRepositoryProvider).masuk(email, password);
state = AsyncData(AuthState.authenticated(user));
} catch (e, stack) {
state = AsyncError(e, stack);
}
}
}
Menulis validasi di layer bisnis memungkinkan kita membuat unit test untuk memverifikasi apakah validasi email kosong atau password pendek sudah bekerja dengan benar tanpa harus membuat simulator menjalankan UI aplikasi.
7. Menulis Unit Test Tanpa Flutter Widget Tree #
Salah satu keunggulan terbesar memisahkan logika dari UI adalah kemudahan dalam penulisan tes otomatis. Unit test yang baik harus berjalan sangat cepat (dalam hitungan milidetik) dan tidak boleh bergantung pada komponen visual Flutter.
Berikut adalah contoh bagaimana kita menguji logika AuthNotifier kita secara murni menggunakan Dart unit test:
// test/auth_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
// ... import model dan notifier
void main() {
group('Pengujian Autentikasi Notifier', () {
late ProviderContainer container;
late MockAuthRepository mockRepo;
setUp(() {
mockRepo = MockAuthRepository();
container = ProviderContainer(
overrides: [
// Mengganti repository asli dengan mock untuk isolasi testing
authRepositoryProvider.overrideWith((ref) => mockRepo),
],
);
});
tearDown(() {
container.dispose();
});
test('Harus melempar ValidationException jika email tidak memiliki karakter @', () async {
final notifier = container.read(authNotifierProvider.notifier);
expect(
() => notifier.login('emailsalah.com', 'password123'),
throwsA(isA<ValidationException>()),
);
});
test('Harus mengubah state menjadi authenticated setelah login berhasil', () async {
final userMock = User(id: '100', nama: 'Budi');
// Mengatur mock repository agar mengembalikan user saat dipanggil
when(mockRepo.masuk('[email protected]', 'password123'))
.thenAnswer((_) => Future.value(userMock));
final notifier = container.read(authNotifierProvider.notifier);
await notifier.login('[email protected]', 'password123');
final stateAkhir = container.read(authNotifierProvider);
expect(stateAkhir.value?.isAuthenticated, isTrue);
expect(stateAkhir.value?.currentUser?.nama, equals('Budi'));
});
});
}
8. Menghindari Anti-Pattern God Notifier #
Sama halnya dengan God State, kita harus menghindari pembuatan kelas Notifier atau Bloc raksasa yang mengurus terlalu banyak logika bisnis yang tidak saling berhubungan. Pengebalan kelas pengendali state yang terlalu gemuk akan menyulitkan pembacaan kode, mempersulit proses testing, dan menurunkan performa aplikasi kita.
Pecah logika bisnis tersebut menjadi kelas-kelas controller yang fokus pada satu domain tanggung jawab saja:
┌────────────────────────┐
│ Aplikasi Toko Kita │
└───────────┬────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ AuthNotifier │ │ CartNotifier │ │ProductNotifier │
├────────────────┤ ├────────────────┤ ├────────────────┤
│ Logika Login │ │ Tambah Item │ │ Ambil Produk │
│ Logika Logout │ │ Hapus Item │ │ Filter Cari │
│ Kelola Token │ │ Hitung Diskon │ │ Urutkan Harga │
└────────────────┘ └────────────────┘ └────────────────┘
Jika Notifier A membutuhkan data dari Notifier B (misalnya CartNotifier membutuhkan data ID pengguna dari AuthNotifier untuk melakukan checkout), gunakan mekanisme dependency injection atau pembacaan provider yang disediakan oleh pustaka manajemen state pilihan kita:
// Membaca Notifier lain di dalam Riverpod
class CartNotifier extends AutoDisposeAsyncNotifier<CartState> {
@override
Future<CartState> build() async => const CartState();
Future<void> checkout() async {
// Mengakses state auth secara aman
final authState = ref.read(authNotifierProvider);
final userId = authState.user.id;
await ref.read(orderRepositoryProvider).kirimKeServer(userId, state.items);
}
}
9. Pengelolaan Memori: Mematikan Resource (Dispose) #
Banyak aplikasi Flutter mengalami masalah kebocoran memori (memory leak) setelah digunakan beberapa menit karena pengembang lupa menghentikan proses pengamatan data atau menutup koneksi stream yang berjalan di latar belakang.
Setiap kali kita membuka koneksi stream, membuat animasi controller, menggunakan timer, atau mendaftarkan listener MobX reaction, kita wajib menutupnya ketika widget atau store tersebut tidak lagi digunakan di layar.
// MENUTUP STREAM DI BLOC:
class SensorBloc extends Bloc<SensorEvent, SensorState> {
late final StreamSubscription _sensorSubscription;
SensorBloc(SensorService service) : super(SensorInitial()) {
// Memulai mendengarkan stream sensor
_sensorSubscription = service.dataStream.listen((data) {
add(SensorDataUpdated(data));
});
}
@override
Future<void> close() {
// WAJIB: Batalkan subscription stream saat Bloc dihancurkan
_sensorSubscription.cancel();
return super.close();
}
}
Jika kita menggunakan Riverpod, manfaatkanlah modifier .autoDispose. Ini akan secara otomatis mematikan Notifier dan menghapus datanya dari memori perangkat ketika layar yang menggunakannya ditutup oleh pengguna:
// State otomatis dibersihkan saat widget tidak lagi memantau provider ini
final detailProdukProvider = FutureProvider.autoDispose.family<Produk, String>((ref, id) {
return ref.read(produkRepositoryProvider).ambilDetail(id);
});
10. Lembar Periksa (Checklist) Review State Management #
Untuk memastikan kualitas arsitektur manajemen state aplikasi kita tetap terjaga sebelum digabungkan ke cabang utama kode (main branch), gunakan lembar periksa berikut sebagai acuan selama proses code review:
Desain & Struktur: #
- Apakah UI widget sudah bersih dari perhitungan logika bisnis, validasi input, dan panggilan API?
- Apakah setiap data hanya dikelola oleh satu sumber kebenaran (Single Source of Truth)?
- Apakah kelas state didefinisikan secara imutabel menggunakan kata kunci
finaldan dilengkapi dengan metodecopyWith? - Apakah ukuran state sudah dirancang secara granular untuk menghindari rebuild makro yang sia-sia?
Asinkron & Kesalahan (Async/Error Handling): #
- Apakah semua layar yang memuat data dari internet sudah menampilkan animasi pemuatan (loading state)?
- Apakah jika terjadi error, UI menampilkan pesan kesalahan yang dapat dimengerti dan menyediakan tombol coba lagi (retry)?
- Apakah terdapat pengecekan status keaktifan widget (
mounted) jika kita menggunakansetStatesetelah proses asinkronawaitselesai? - Apakah semua respons error dari API ditangani secara terstruktur di layer repository sebelum dikirim ke UI?
Performa & Efisiensi: #
- Apakah widget UI sudah memanfaatkan pemfilteran reaktivitas (seperti
selectdi Riverpod/Provider, atau widgetObservergranular di MobX)? - Apakah kita membandingkan perubahan objek menggunakan perbandingan nilai (seperti menggunakan paket
equatableatau overrides operator==) untuk menghindari rebuild yang tidak perlu? - Apakah data turunan sudah di-cache menggunakan computed properties atau mekanisme memoization?
Pengujian & Pemeliharaan (Testing & Maintenance): #
- Apakah logika bisnis utama sudah memiliki cakupan unit test (unit test coverage) yang memadai?
- Apakah unit test dapat berjalan secara terisolasi tanpa memerlukan inisialisasi simulator/widget tree?
- Apakah semua dependensi eksternal (seperti modul jaringan API atau basis data lokal) sudah di-mock menggunakan library mocking?
- Apakah kita sudah mematikan semua subscription, timer, reaction, dan controller saat widget dihancurkan (dispose)?
Ringkasan #
- Pemisahan UI dan Logika: UI widget hanya boleh fokus menampilkan visual. Segala kalkulasi, validasi formulir, dan orkestrasi data harus dipindahkan ke layer pengendali state (Notifier/Bloc/Store).
- Satu Sumber Kebenaran: Hindari menyalin data global ke state lokal. Gunakan mekanisme seleksi real-time agar data selalu konsisten di seluruh bagian aplikasi.
- State Imutabel (Immutable): Selalu rancang kelas state menggunakan properti
finaldan perbarui nilainya dengan memancarkan objek baru hasil salinancopyWith.- Modul Granular: Jangan membuat satu pengendali state raksasa untuk mengurus seluruh aplikasi. Bagi logika bisnis kita menjadi modul-modul kecil berdasarkan fitur/domain data.
- Kondisi Asinkron Lengkap: Jangan pernah melewatkan penanganan kondisi loading dan error saat menampilkan data yang diambil dari server eksternal.
- Unit Testing Independen: Pemisahan logika bisnis yang bersih memungkinkan penulisan unit test otomatis yang sangat cepat tanpa ketergantungan pada UI Framework Flutter.
- Disposal yang Disiplin: Batalkan semua subscription stream, matikan reaction, dan kosongkan controller saat store atau widget ditutup guna menghindari kebocoran memori.
← Sebelumnya: Perbandingan & Kapan Memilih Berikutnya: Networking Overview →