Best Practice #
Membangun sistem jaringan (networking) yang baik di Flutter bukan sekadar tentang memastikan bahwa request asinkron yang dikirimkan berhasil mengembalikan status 200 OK. Jaringan internet sangat fluktuatif dan tidak dapat diprediksi. Tantangan sesungguhnya adalah bagaimana aplikasi kita tetap berperilaku anggun saat koneksi internet lambat, saat sinyal terputus di tengah jalan, saat server API backend sedang kelebihan beban kerja, atau saat pengguna berpindah halaman dengan cepat di tengah proses request yang masih menggantung.
Mengabaikan detail perilaku jaringan dapat merusak pengalaman pengguna secara keseluruhan, melahirkan persepsi bahwa aplikasi kita lambat, tidak responsif, atau tidak stabil. Artikel ini merangkum pola-pola perancangan (design patterns) dan praktik terbaik (best practices) yang telah teruji untuk membuat layer networking aplikasi Flutter kita terasa cepat, andal, hemat memori, dan profesional.
Dalam panduan penutup bagian jaringan ini, kita akan membahas strategi caching modern, arsitektur offline-first, pembaruan UI secara optimistik (optimistic updates), konfigurasi multi-lingkungan yang aman, logging terenkripsi, penanganan anti-pattern, hingga daftar periksa review jaringan.
1. Strategi Caching: Stale-While-Revalidate #
Salah satu cara paling efektif untuk membuat aplikasi kita terasa instan dan responsif adalah dengan mengimplementasikan strategi caching yang cerdas. Mengharuskan pengguna melihat animasi loading (shimmer/spinner) setiap kali mereka membuka halaman yang sama adalah pemborosan waktu dan bandwidth.
Strategi caching modern yang sangat populer di industri adalah Stale-While-Revalidate (SWR). Konsep dasarnya adalah:
- Stale: Klien langsung menyajikan data lama yang tersimpan di cache lokal kepada pengguna sesaat setelah layar dibuka (sehingga UI ter-render secara instan tanpa loading).
- Revalidate: Di latar belakang, klien secara diam-diam mengirimkan request jaringan internet ke server untuk mengambil data terbaru.
- Update: Begitu data segar dari server tiba, klien akan memperbarui database cache lokal dan memperbarui tampilan UI secara halus.
Berikut adalah diagram alir bagaimana data mengalir di dalam strategi Stale-While-Revalidate untuk menjamin rendering antarmuka yang instan:
graph TD
classDef default stroke:#333,stroke-width:2px;
A["Klien Meminta Data"] --> B{"Apakah ada Cache?"}
B -->|Ya| C["1. Emit Data Cache Segera ke UI (UI Cepat Ter-render)"]
B -->|Tidak| D["2. Tampilkan Loading Indicator"]
C --> E["3. Hit Server di Background"]
D --> E
E --> F{"Hit Server Sukses?"}
F -->|Ya| G["4. Update DB Cache Lokal & Emit Data Baru ke UI"]
F -->|Tidak| H{"Apakah tadi pakai Data Cache?"}
H -->|Ya| I["Tetap tampilkan Cache & Beri tahu Offline Banner"]
H -->|Tidak| J["Tampilkan Layar Error (ErrorView)"]Implementasi Pola SWR dengan Stream Dart #
Kita dapat menerapkan pola SWR dengan memanfaatkan fitur Stream di Dart yang dikombinasikan dengan StreamProvider di Riverpod:
class ProdukRepository {
final ProdukRemoteDataSource _remote;
final ProdukLocalDataSource _local;
ProdukRepository(this._remote, this._local);
// Mengembalikan Stream agar dapat memancarkan data cache lalu data fresh berurutan
Stream<List<Produk>> pantauDaftarProduk() async* {
// Langkah 1: Pancarkan data cache lokal segera (jika ada)
final cacheLokal = await _local.ambilCacheProduk();
if (cacheLokal != null) {
yield cacheLokal.map((dto) => dto.toDomain()).toList();
}
// Langkah 2: Lakukan revalidasi data ke server backend di latar belakang
try {
final freshDtos = await _remote.dapatkanProduk();
await _local.simpanCacheProduk(freshDtos);
// Pancarkan data terbaru dari server
yield freshDtos.map((dto) => dto.toDomain()).toList();
} catch (e) {
// Jika fetch gagal dan tidak ada cache sama sekali, lemparkan error ke UI
if (cacheLokal == null) rethrow;
// Jika data cache lama sudah tampil, biarkan saja (pengguna tetap bisa melihat data lama)
}
}
}
2. Arsitektur Offline-First #
Aplikasi seluler yang hebat harus tetap fungsional meskipun perangkat pengguna sama sekali tidak terhubung ke koneksi internet. Untuk membangun aplikasi offline-first yang sesungguhnya, kita harus mendeteksi status koneksi perangkat secara dinamis dan menyediakan alur fallback data ke database lokal.
Kita menggunakan pustaka connectivity_plus untuk memantau status jaringan perangkat:
import 'package:connectivity_plus/connectivity_plus.dart';
class LayananKoneksi {
final Connectivity _connectivity = Connectivity();
// Memantau perubahan status koneksi secara real-time
Stream<bool> get statusKoneksiStream => _connectivity.onConnectivityChanged.map(
(hasil) => hasil != ConnectivityResult.none,
);
// Memeriksa status koneksi instan saat ini
Future<bool> get apakahOnline async {
final hasil = await _connectivity.checkConnectivity();
return hasil != ConnectivityResult.none;
}
}
Fallback Data Transparan di Repository #
class BeritaRepository {
final BeritaRemoteDataSource _remote;
final BeritaLocalDataSource _local;
final LayananKoneksi _koneksi;
BeritaRepository(this._remote, this._local, this._koneksi);
Future<List<Berita>> ambilBeritaUtama() async {
final statusOnline = await _koneksi.apakahOnline;
if (!statusOnline) {
// Skenario Offline: Muat data lama dari database cache lokal
final cacheLokal = await _local.ambilCacheBerita();
if (cacheLokal != null) return cacheLokal;
throw const NetworkException('Tidak ada koneksi internet dan cache lokal kosong.');
}
try {
final freshData = await _remote.ambilBerita();
await _local.simpanCacheBerita(freshData);
return freshData;
} catch (_) {
// Skenario Jaringan Down: Jika API server gagal, fallback ke cache lokal
final cacheLokal = await _local.ambilCacheBerita();
if (cacheLokal != null) return cacheLokal;
rethrow;
}
}
}
3. Optimistic Updates untuk Antarmuka Responsif #
Ketika pengguna menekan tombol “Tambah ke Favorit” atau “Like” pada suatu postingan, aplikasi standar biasanya memunculkan loading indicator kecil, menunggu request API selesai (1-2 detik), lalu mengubah warna ikon tombol. Jeda waktu tunggu ini membuat aplikasi terasa berat dan lamban.
Aplikasi profesional menerapkan teknik Optimistic Update. Pola pikirnya adalah: asumsikan request asinkron ke server pasti akan sukses. Kita langsung memperbarui tampilan antarmuka (UI) dan database cache lokal sesaat setelah tombol diklik. Jika di tengah jalan ternyata request ke server gagal (misal koneksi mati), kita melakukan proses pembatalan (roll-back) untuk mengembalikan status UI ke keadaan sebelumnya dan memunculkan notifikasi error ke pengguna.
Berikut adalah implementasi Optimistic Update pada fitur keranjang belanja belanjaan:
class NotifierKeranjangOptimistik extends AsyncNotifier<List<CartItem>> {
@override
Future<List<CartItem>> build() async {
return ref.watch(keranjangRepositoryProvider).ambilKeranjang();
}
Future<void> tambahItemBelanja(Produk produk) async {
// 1. Simpan salinan state lama sebelum dimutasi untuk cadangan roll-back
final stateLama = state;
final itemBaru = CartItem(produk: produk, kuantitas: 1);
// 2. Modifikasi state UI secara optimistik (langsung update UI tanpa nunggu API)
state = AsyncValue.data([
...?state.valueOrNull,
itemBaru,
]);
try {
// 3. Eksekusi panggilan API sebenarnya ke server
final listTerbaru = await ref.read(keranjangRepositoryProvider).tambahItemApi(produk.id);
// 4. Perbarui state dengan data resmi dari server
state = AsyncValue.data(listTerbaru);
} catch (e) {
// 5. ROLLBACK: Jika server gagal, kembalikan UI ke kondisi semula
state = stateLama;
// Terapkan penanganan error (seperti menampilkan Toast ke pengguna)
rethrow;
}
}
}
4. Environment Configuration Tanpa Hardcode #
Melakukan hardcoding alamat URL API langsung di dalam berkas klien jaringan (baseUrl: 'https://api.tokokita.com') adalah kebiasaan buruk yang sangat berbahaya. URL server pengembangan (Development), pengujian (Staging), dan produksi (Production) wajib dipisahkan untuk menghindari salah menghapus atau salah mengisi database produksi saat masa pengembangan.
Kita dapat mengelola konfigurasi lingkungan ini secara aman menggunakan enumerasi terstruktur di Flutter:
// core/config/app_config.dart
enum TipeEnvironment { development, staging, production }
class AppConfig {
static late TipeEnvironment _env;
static late String _baseUrl;
static late bool _showLogConsole;
static void inisialisasi(TipeEnvironment env) {
_env = env;
switch (env) {
case TipeEnvironment.development:
_baseUrl = 'https://dev-api.tokokita.com/v1';
_showLogConsole = true;
case TipeEnvironment.staging:
_baseUrl = 'https://staging-api.tokokita.com/v1';
_showLogConsole = true;
case TipeEnvironment.production:
_baseUrl = 'https://api.tokokita.com/v1';
_showLogConsole = false; // Matikan pencatatan log di production demi performa
}
}
static String get baseUrl => _baseUrl;
static bool get showLog => _showLogConsole;
static bool get apakahProduksi => _env == TipeEnvironment.production;
}
Menggunakan Entry Point Berbeda (Multi-Main File) #
Kita membuat beberapa berkas entry point utama yang berbeda untuk memicu kompilasi lingkungan yang spesifik:
// main_development.dart
void main() {
AppConfig.inisialisasi(TipeEnvironment.development);
runApp(const AplikasiKita());
}
// main_production.dart
void main() {
AppConfig.inisialisasi(TipeEnvironment.production);
runApp(const AplikasiKita());
}
Saat menjalankan aplikasi lewat terminal, kita cukup memilih berkas masuk yang kita inginkan:
rtk flutter run -t lib/main_development.dart
5. Logging Jaringan yang Aman & Informatif #
Mencetak detail request dan response HTTP ke dalam log konsol (debugging logging) sangat membantu kita mempercepat pelacakan bug selama masa pengembangan. Namun, mencetak seluruh informasi mentah secara polos sangat berbahaya bagi keamanan aplikasi. Informasi kredensial sensitif pengguna seperti password login, token JWT, atau nomor kartu kredit dapat bocor ke sistem log perangkat.
Kita wajib merancang Sanitized Logging Interceptor kustom yang secara otomatis menyensor data-data sensitif tersebut sebelum dicetak ke log.
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
class SanitizedLoggingInterceptor extends Interceptor {
final bool aktif;
SanitizedLoggingInterceptor({required this.aktif});
// Kumpulan nama kunci (keys) JSON yang datanya wajib kita sensor
final List<String> _daftarKeySensitif = [
'password',
'token',
'access_token',
'refresh_token',
'credit_card_number',
'cvv'
];
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (!aktif) return handler.next(options);
debugPrint('┌── [HTTP REQUEST] ────────────────────────────────');
debugPrint('│ METODE : ${options.method}');
debugPrint('│ URL : ${options.uri}');
if (options.data != null) {
final dataSensored = _sensorDataSensitif(options.data);
debugPrint('│ PAYLOAD: $dataSensored');
}
debugPrint('└──────────────────────────────────────────────────');
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
if (!aktif) return handler.next(response);
debugPrint('┌── [HTTP RESPONSE] ───────────────────────────────');
debugPrint('│ STATUS : ${response.statusCode}');
debugPrint('│ URL : ${response.requestOptions.uri}');
debugPrint('└──────────────────────────────────────────────────');
handler.next(response);
}
// Fungsi rekursif untuk menyembunyikan data sensitif di dalam Map JSON
dynamic _sensorDataSensitif(dynamic data) {
if (data is Map) {
return Map.from(data).map((key, value) {
final keyString = key.toString().toLowerCase();
if (_daftarKeySensitif.contains(keyString)) {
return MapEntry(key, '*** [DATA DISENSOR] ***');
}
// Jika value berupa nested map, panggil fungsi ini kembali secara rekursif
return MapEntry(key, _sensorDataSensitif(value));
});
}
return data;
}
}
6. Strategi Pagination yang Konsisten #
Menampilkan daftar data yang sangat panjang (seperti riwayat transaksi atau katalog produk) mengharuskan kita menerapkan teknik pemuatan data bertahap (Pagination).
Mengapa Memilih Cursor-Based Pagination? #
Terdapat dua strategi utama pagination:
- Offset-Based Pagination: Klien mengirimkan parameter
pagedanlimit(misal: ambil halaman 2 dengan limit 10). Strategi ini sangat mudah diimplementasikan, namun rentan melahirkan masalah data duplikat atau data terlewat jika ada data baru masuk di server saat pengguna sedang men-scroll layar. - Cursor-Based Pagination (Direkomendasikan): Klien tidak meminta halaman numerik, melainkan mengirimkan penunjuk ID unik terakhir dari data yang didapatkan sebelumnya (cursor). Strategi ini sangat stabil dan konsisten karena server mengambil data baru secara presisi berdasarkan posisi ID terakhir, tidak peduli seberapa banyak data baru yang ditambahkan di atasnya.
Berikut adalah kerangka controller pagination berbasis cursor:
class NotifierPaginationProduk extends AsyncNotifier<List<Produk>> {
String? _cursorBerikutnya;
bool _memilikiDataLanjut = true;
@override
Future<List<Produk>> build() async {
return _muatHalaman(null);
}
Future<List<Produk>> _muatHalaman(String? cursor) async {
final repository = ref.read(produkRepositoryProvider);
final hasilResponse = await repository.ambilProdukPaginated(cursor: cursor);
_cursorBerikutnya = hasilResponse.cursorSelanjutnya;
_memilikiDataLanjut = hasilResponse.adaDataLanjut;
return hasilResponse.listProduk;
}
Future<void> muatHalamanBerikutnya() async {
// Cegah pemanggilan ganda jika proses loading sedang berjalan atau data sudah habis
if (!_memilikiDataLanjut || state.isLoading) return;
final listLama = state.valueOrNull ?? [];
// Set status menjadi loading tanpa menghapus data lama di UI
state = const AsyncLoading();
try {
final listBaru = await _muatHalaman(_cursorBerikutnya);
// Gabungkan data lama dengan data baru
state = AsyncValue.data([...listLama, ...listBaru]);
} catch (e, stack) {
state = AsyncError(e, stack);
}
}
}
7. Anti-Pattern Jaringan yang Wajib Dihindari #
Selama merancang arsitektur jaringan aplikasi Flutter kita, pastikan untuk menghindari kebiasaan buruk (anti-patterns) berikut:
- Instansiasi Dio Berulang Kali: Membuat objek
final dio = Dio()baru di setiap kelas API akan memboroskan pemakaian RAM dan memicu kebocoran file descriptor koneksi TCP. Selalu gunakan pola Singleton untuk instansi DioClient. - Hardcoding Alamat Base URL: Menuliskan string URL fisik secara mentah di banyak tempat menyulitkan proses migrasi server dan rentan memicu salah tembak database produksi. Gunakan kelas
AppConfigterpadu. - Mengabaikan Batas Waktu Toleransi (Timeout): Membiarkan properti timeout kosong membuat aplikasi kita menggantung selamanya (infinite loading) saat pengguna berada di jaringan internet yang mati total atau berada di belakang portal captive portal Wi-Fi bandara. Selalu atur batas timeout di
BaseOptions. - Menyembunyikan Kesalahan (Silent Catch): Menuliskan blok
catch (e) {}yang kosong tanpa mengalirkan error ke UI membuat aplikasi tampak tidak merespons perintah pengguna saat terjadi kegagalan jaringan. - Lupa Menyertakan CancelToken: Membiarkan request pencarian tetap berjalan saat pengguna mengetik huruf baru dengan cepat memboroskan daya baterai perangkat dan bandwidth server backend kita.
- Melakukan Parsing JSON di Build Method: Menguraikan data DTO Map ke objek kelas di dalam method
build()widget akan menurunkan performa rendering layar secara signifikan karena parsing terjadi berulang kali di setiap frame UI digambar. Lakukan parsing di layer Data Source atau Repository. - Menyimpan Kredensial Sensitif di SharedPreferences Biasa: Menuliskan JWT Token atau kata sandi pengguna ke Shared Preferences biasa memudahkan peretas mencuri data tersebut di perangkat yang telah di-root. Selalu gunakan
flutter_secure_storage.
8. Lembar Periksa (Checklist) Review Layer Jaringan #
Gunakan lembar periksa terstruktur berikut selama proses pengulasan kode (code review) untuk memastikan layer networking aplikasi Flutter kita siap dirilis ke pasar:
Konstruksi & Konfigurasi: #
- Apakah instansi DioClient telah diinisialisasi menggunakan pola Singleton?
- Apakah batas waktu
connectTimeout,sendTimeout, danreceiveTimeoutsudah dikonfigurasi dengan aman? - Apakah base URL diambil secara dinamis dari kelas konfigurasi environment?
- Apakah pustaka HTTP Client menggunakan protokol HTTPS yang aman untuk semua endpoint?
Penanganan Kesalahan & Keamanan: #
- Apakah semua error jaringan (Offline, Timeout, Bad Response) telah dipetakan secara terpusat?
- Apakah penanganan JWT Token expired (401) sudah terintegrasi untuk melakukan logout paksa dan pengarahan otomatis ke layar login?
- Apakah Access Token dan Refresh Token disimpan secara aman di dalam
flutter_secure_storage? - Apakah logging console telah disaring agar data kredensial sensitif pengguna tidak bocor di terminal?
Performa & Pengalaman Pengguna (UX): #
- Apakah data yang jarang berubah (seperti daftar negara) telah disimpan di cache lokal menggunakan strategi caching?
- Apakah search-as-you-type dan request asinkron yang ditinggalkan telah diamankan dengan
CancelToken? - Apakah jika terjadi kegagalan koneksi sementara, sistem melakukan percobaan ulang otomatis (auto retry) sebelum melempar error?
- Apakah widget
ErrorViewtelah menyediakan opsi coba lagi (retry button) yang bersahabat bagi pengguna?
Ringkasan #
- Stale-While-Revalidate (SWR) memanjakan pengguna dengan menyajikan data cache lokal secara instan sesaat setelah halaman dibuka, lalu melakukan pembaruan data segar secara halus di latar belakang.
- Offline-First Support menjamin aplikasi tetap fungsional tanpa jaringan internet dengan menyajikan data lokal terenkripsi lengkap dengan indikator banner yang informatif.
- Optimistic Updates meningkatkan kesan responsif aplikasi dengan langsung memperbarui visual UI sebelum respons server selesai, dan melakukan roll-back jika terjadi kegagalan.
- AppConfig Terpusat memisahkan rute API Development, Staging, dan Production secara aman untuk menghindari risiko kesalahan penulisan data uji coba ke server produksi.
- Sanitized Logging mengamankan data sensitif pengguna dengan menyaring kunci kredensial dari log terminal selama masa debugging.
- Cursor-Based Pagination menghadirkan pemuatan data bertahap yang konsisten tanpa risiko duplikasi atau kelewatan data saat baris database mengalami pembaruan aktif.
- Pembersihan Anti-Pattern: Selalu atur timeout koneksi, terapkan singleton client, gunakan secure storage, batasi request dengan CancelToken, dan hindari menelan error asinkron secara diam-diam.