Best Practice #
Membangun networking yang benar bukan hanya tentang membuat request berhasil — melainkan tentang bagaimana aplikasi berperilaku saat koneksi lambat, saat offline, saat server bermasalah, dan saat pengguna beralih halaman di tengah request. Artikel ini merangkum pola-pola yang membuat aplikasi Flutter terasa cepat, andal, dan profesional.
1. Caching Strategy #
Cache yang baik membuat aplikasi terasa cepat bahkan di koneksi lambat dan memungkinkan penggunaan offline terbatas.
Stale-While-Revalidate #
Tampilkan data cache segera, refresh di background tanpa membuat pengguna menunggu:
class ProdukRepository {
final ProdukRemoteDataSource _remote;
final ProdukLocalDataSource _local;
// Stale-while-revalidate: tampilkan cache, refresh di background
Stream<List<Produk>> watchProduk() async* {
// 1. Emit data dari cache segera (jika ada)
final cached = await _local.getProduk();
if (cached != null) yield cached;
// 2. Fetch dari server di background
try {
final fresh = await _remote.getProduk();
await _local.saveProduk(fresh);
yield fresh; // 3. Emit data terbaru
} catch (e) {
// Jika fetch gagal dan tidak ada cache, baru throw error
if (cached == null) rethrow;
// Jika ada cache, biarkan -- pengguna sudah lihat data lama
}
}
}
// Di Riverpod -- StreamProvider sangat cocok untuk pola ini
final produkStreamProvider = StreamProvider<List<Produk>>((ref) {
return ref.watch(produkRepositoryProvider).watchProduk();
});
Cache Invalidation #
// Strategi invalidasi yang umum:
// 1. TTL (Time To Live) -- cache expired setelah durasi tertentu
class CacheEntry<T> {
final T data;
final DateTime cachedAt;
final Duration ttl;
bool get isExpired => DateTime.now().isAfter(cachedAt.add(ttl));
}
// 2. Tag-based invalidation -- invalidate semua cache yang berkaitan
class CacheManager {
final Map<String, CacheEntry> _cache = {};
final Map<String, Set<String>> _tags = {}; // tag → set of keys
void set(String key, dynamic data, {List<String> tags = const [], Duration? ttl}) {
_cache[key] = CacheEntry(data: data, cachedAt: DateTime.now(), ttl: ttl ?? const Duration(minutes: 30));
for (final tag in tags) {
_tags.putIfAbsent(tag, () => {}).add(key);
}
}
void invalidateTag(String tag) {
final keys = _tags[tag] ?? {};
for (final key in keys) {
_cache.remove(key);
}
_tags.remove(tag);
}
}
// Contoh: setelah tambah produk, invalidate semua cache produk
await _remote.tambahProduk(produk);
cacheManager.invalidateTag('produk'); // cache daftar, detail, search -- semua terhapus
2. Offline-First Architecture #
Desain aplikasi agar fungsional bahkan tanpa koneksi:
// Deteksi koneksi
import 'package:connectivity_plus/connectivity_plus.dart';
class ConnectivityService {
final _connectivity = Connectivity();
Stream<bool> get isOnline => _connectivity.onConnectivityChanged.map(
(result) => result != ConnectivityResult.none,
);
Future<bool> get currentlyOnline async {
final result = await _connectivity.checkConnectivity();
return result != ConnectivityResult.none;
}
}
// Repository dengan offline support
class ProdukRepository {
Future<List<Produk>> getProduk() async {
final isOnline = await connectivityService.currentlyOnline;
if (!isOnline) {
// Offline: gunakan cache, tampilkan banner offline
final cached = await _local.getProduk();
if (cached != null) return cached;
throw const NetworkException('Tidak ada koneksi dan data cache tidak tersedia.');
}
try {
final fresh = await _remote.getProduk();
await _local.saveProduk(fresh); // simpan untuk offline
return fresh;
} on NetworkException {
// Koneksi ada tapi request gagal -- fallback ke cache
final cached = await _local.getProduk();
if (cached != null) return cached;
rethrow;
}
}
}
// Offline indicator di UI
class OfflineBanner extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOnline = ref.watch(isOnlineProvider);
return isOnline.when(
data: (online) => online
? const SizedBox.shrink()
: Container(
color: Colors.orange,
padding: const EdgeInsets.symmetric(vertical: 4),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.wifi_off, size: 16, color: Colors.white),
SizedBox(width: 8),
Text('Offline — menampilkan data tersimpan',
style: TextStyle(color: Colors.white, fontSize: 12)),
],
),
),
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
}
3. Optimistic Update #
Update UI segera seolah operasi berhasil, rollback jika gagal:
class KeranjangNotifier extends AsyncNotifier<KeranjangState> {
@override
Future<KeranjangState> build() async {
return ref.watch(keranjangRepositoryProvider).getKeranjang();
}
Future<void> tambahItem(Produk produk) async {
final previousState = state;
// Optimistic update: update UI segera
state = AsyncData(
state.valueOrNull!.copyWith(
items: [...state.valueOrNull!.items, CartItem(produk: produk, jumlah: 1)],
),
);
try {
final updated = await ref.read(keranjangRepositoryProvider).tambahItem(produk.id);
state = AsyncData(updated);
} catch (e) {
// Rollback ke state sebelumnya jika gagal
state = previousState;
// Tampilkan error ke pengguna
rethrow;
}
}
Future<void> hapusItem(String itemId) async {
final previousState = state;
// Optimistic: hapus dari UI segera
state = AsyncData(
state.valueOrNull!.copyWith(
items: state.valueOrNull!.items.where((i) => i.id != itemId).toList(),
),
);
try {
await ref.read(keranjangRepositoryProvider).hapusItem(itemId);
} catch (e) {
state = previousState; // rollback
rethrow;
}
}
}
4. Environment Configuration #
Jangan hardcode URL API — gunakan environment config yang bisa berbeda per build:
// core/config/app_config.dart
enum Environment { development, staging, production }
class AppConfig {
static late Environment _environment;
static late String _baseUrl;
static late bool _enableLogging;
static void init(Environment env) {
_environment = env;
switch (env) {
case Environment.development:
_baseUrl = 'https://dev-api.contoh.com';
_enableLogging = true;
case Environment.staging:
_baseUrl = 'https://staging-api.contoh.com';
_enableLogging = true;
case Environment.production:
_baseUrl = 'https://api.contoh.com';
_enableLogging = false;
}
}
static String get baseUrl => _baseUrl;
static bool get enableLogging => _enableLogging;
static bool get isDevelopment => _environment == Environment.development;
}
// main_development.dart
void main() {
AppConfig.init(Environment.development);
runApp(const MyApp());
}
// main_production.dart
void main() {
AppConfig.init(Environment.production);
runApp(const MyApp());
}
Menggunakan –dart-define #
# Build dengan environment variable
flutter run --dart-define=BASE_URL=https://api.contoh.com \
--dart-define=ENV=production
// Akses di kode
const baseUrl = String.fromEnvironment('BASE_URL', defaultValue: 'https://dev-api.contoh.com');
const env = String.fromEnvironment('ENV', defaultValue: 'development');
5. Logging yang Informatif #
Log yang baik sangat membantu debugging tanpa mengekspos data sensitif di production:
class LoggingInterceptor extends Interceptor {
final bool enabled;
LoggingInterceptor({required this.enabled});
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (!enabled) return handler.next(options);
debugPrint('┌── REQUEST ─────────────────────────────');
debugPrint('│ ${options.method} ${options.uri}');
if (options.queryParameters.isNotEmpty) {
debugPrint('│ Params: ${options.queryParameters}');
}
if (options.data != null) {
final body = _sanitize(options.data);
debugPrint('│ Body: $body');
}
debugPrint('└────────────────────────────────────────');
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
if (!enabled) return handler.next(response);
debugPrint('┌── RESPONSE ────────────────────────────');
debugPrint('│ ${response.statusCode} ${response.requestOptions.uri}');
debugPrint('│ Duration: ${response.extra['duration']} ms');
debugPrint('└────────────────────────────────────────');
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (!enabled) return handler.next(err);
debugPrint('┌── ERROR ───────────────────────────────');
debugPrint('│ ✗ ${err.response?.statusCode} ${err.requestOptions.uri}');
debugPrint('│ ${err.message}');
debugPrint('└────────────────────────────────────────');
handler.next(err);
}
// Sembunyikan data sensitif dari log
dynamic _sanitize(dynamic data) {
if (data is Map) {
return Map.from(data).map((k, v) {
if (['password', 'token', 'credit_card'].contains(k.toString().toLowerCase())) {
return MapEntry(k, '***');
}
return MapEntry(k, v);
});
}
return data;
}
}
6. Pagination yang Efisien #
// Cursor-based pagination (lebih konsisten dari offset)
class ProdukNotifier extends AsyncNotifier<PaginatedResult<Produk>> {
String? _nextCursor;
bool _hasMore = true;
@override
Future<PaginatedResult<Produk>> build() => _fetchPage(null);
Future<PaginatedResult<Produk>> _fetchPage(String? cursor) async {
final result = await ref.read(produkRepositoryProvider).getProduk(cursor: cursor);
_nextCursor = result.nextCursor;
_hasMore = result.hasMore;
return result;
}
Future<void> loadMore() async {
if (!_hasMore || state.isLoading) return;
final previous = state.valueOrNull?.items ?? [];
final more = await _fetchPage(_nextCursor);
state = AsyncData(more.copyWith(
items: [...previous, ...more.items],
));
}
}
Anti-Pattern yang Harus Dihindari #
// ✗ ANTI-PATTERN 1: Membuat Dio baru di setiap request
Future<void> fetchData() async {
final dio = Dio(); // objek baru setiap call -- boros!
await dio.get('/data');
}
// ✓ Gunakan singleton Dio
// ✗ ANTI-PATTERN 2: Hardcode URL
final response = await dio.get('https://api.contoh.com/produk');
// ✓ Gunakan AppConfig.baseUrl atau BaseOptions di Dio
// ✗ ANTI-PATTERN 3: Tidak handle timeout
final response = await dio.get('/data'); // bisa menggantung selamanya!
// ✓ Selalu set connectTimeout, sendTimeout, receiveTimeout di BaseOptions
// ✗ ANTI-PATTERN 4: Catch semua exception diam-diam
try {
await _remote.getProduk();
} catch (e) {
print(e); // error tersembunyi, pengguna tidak tahu!
}
// ✓ Handle secara spesifik, tampilkan ke pengguna
// ✗ ANTI-PATTERN 5: Request tanpa cancel token di screen yang bisa di-navigate
Future<void> search(String query) async {
await dio.get('/search?q=$query'); // tidak bisa dibatalkan!
}
// ✓ Gunakan CancelToken, batalkan saat widget dispose
// ✗ ANTI-PATTERN 6: Parsing JSON di widget
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: dio.get('/produk'),
builder: (_, snapshot) {
if (!snapshot.hasData) return const SizedBox();
final produk = Produk.fromJson(snapshot.data!.data); // parsing di build!
return Text(produk.nama);
},
);
}
// ✓ Parsing di repository/data source, widget hanya render
// ✗ ANTI-PATTERN 7: Token disimpan di SharedPreferences biasa
await SharedPreferences.getInstance().then((prefs) {
prefs.setString('token', accessToken); // tidak terenkripsi!
});
// ✓ Gunakan flutter_secure_storage
Checklist Review Networking #
SETUP:
□ Satu instance Dio (singleton) dengan BaseOptions yang benar
□ connectTimeout, sendTimeout, receiveTimeout terdefinisi
□ BaseUrl dari environment config, bukan hardcode
□ Interceptors: auth, logging, error, retry terpasang
KEAMANAN:
□ Token disimpan di flutter_secure_storage
□ Log tidak mengekspos data sensitif (password, token)
□ HTTPS selalu digunakan (bukan HTTP)
□ Certificate pinning untuk aplikasi high-security
ERROR HANDLING:
□ Semua tipe error ditangani (network, timeout, 4xx, 5xx)
□ Pesan error user-friendly, bukan stack trace teknis
□ Tombol retry tersedia untuk error sementara
□ 401 memicu logout global
PERFORMA:
□ Cache diimplementasikan untuk data yang jarang berubah
□ CancelToken digunakan untuk search dan request yang bisa dibatalkan
□ Pagination untuk daftar yang panjang
□ Optimistic update untuk aksi yang sering dilakukan
OFFLINE:
□ Deteksi koneksi dan tampilkan banner offline
□ Data tersimpan di cache lokal untuk akses offline
□ Fallback ke cache saat request gagal
TESTABILITY:
□ Repository menggunakan interface — bisa di-mock
□ Unit test untuk repository tanpa jaringan nyata
□ Integration test untuk alur happy path dan error
Ringkasan #
- Stale-while-revalidate: tampilkan cache segera, refresh di background — pengguna tidak pernah menunggu loading yang tidak perlu.
- Offline-first: simpan semua response ke cache lokal, tampilkan data lama saat offline, beri tahu pengguna dengan banner yang jelas.
- Optimistic update: update UI segera, rollback jika server mengembalikan error — membuat aplikasi terasa responsif.
- Environment config yang terpisah untuk dev, staging, dan production — jangan hardcode URL API.
- Logging interceptor yang menyembunyikan data sensitif (password, token) — berguna saat development, nonaktifkan di production.
- Pagination dengan cursor-based lebih konsisten dari offset-based — tidak ada duplikasi atau data yang terlewat saat item baru ditambahkan.
- Hindari: membuat Dio baru per request, URL hardcoded, tidak set timeout, catch exception diam-diam, parsing JSON di widget, dan token di SharedPreferences biasa.
- Networking yang baik tidak hanya membuat request berhasil — melainkan menentukan bagaimana aplikasi berperilaku saat koneksi lambat, offline, dan saat terjadi error.