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.

← Sebelumnya: GraphQL   Berikutnya: Local Storage Overview →

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