✦ Selamat Idul Fitri 1447 H πŸŒ™ Taqabbalallahu minna wa minkum. Mohon maaf lahir dan batin. ✦
ALFAkwt

Menangani Koneksi Tidak Stabil di Aplikasi Flutter: Strategi Offline-First yang Saya Gunakan

Β· 0 komentar Β· Β± 6 menit baca Β· πŸ‘ 629 dilihat
Diagram arsitektur offline-first di aplikasi Flutter

Di Jakarta atau Surabaya, koneksi internet yang cepat dan stabil mungkin sudah terasa seperti hak dasar. Tapi kalau Anda membangun aplikasi untuk pengguna Indonesia secara luas β€” termasuk yang tinggal di daerah dengan sinyal 2G atau 3G yang tidak konsisten β€” pola pikir itu harus berubah.

Saya belajar ini saat aplikasi pertama saya menerima keluhan dari pengguna di luar Jawa: "aplikasinya loading terus", "data hilang kalau sinyal jelek", "harus buka ulang terus". Semua masalah yang tidak pernah muncul saat testing di kantor dengan WiFi stabil.

Sejak saat itu, setiap aplikasi yang saya bangun mengikuti prinsip offline-first: fungsionalitas dasar harus bekerja tanpa koneksi, dan sinkronisasi terjadi secara cerdas ketika koneksi tersedia.


Memahami State Koneksi dengan connectivity_plus

Langkah pertama adalah mengetahui status koneksi secara real-time. Package connectivity_plus menyediakan stream yang mengemisikan perubahan status koneksi:

import 'package:connectivity_plus/connectivity_plus.dart';

class KoneksiService {
final _connectivity = Connectivity();
Stream<bool> get statusKoneksi {
return _connectivity.onConnectivityChanged.map((result) {
return result != ConnectivityResult.none;
});
}
Future<bool> get sedangTerhubung async {
final result = await _connectivity.checkConnectivity();
return result != ConnectivityResult.none;
}
}

Penting: connectivity_plus mendeteksi apakah perangkat terhubung ke jaringan (WiFi atau data), tapi tidak menjamin internet benar-benar bisa diakses. Koneksi WiFi tanpa internet (seperti WiFi hotel yang memerlukan login) akan terdeteksi sebagai "terhubung". Untuk verifikasi yang lebih akurat, lakukan ping ke endpoint terpercaya.

Future<bool> get internetBisaDiakses async {
try {
final result = await InternetAddress.lookup('google.com')
.timeout(const Duration(seconds: 3));
return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
} catch (_) {
return false;
}
}

Arsitektur Repository: Cache-First dengan Network Fallback

Pola yang paling sering saya gunakan adalah cache-first: selalu tampilkan data dari cache lokal terlebih dahulu, lalu update dari network jika koneksi tersedia.

class ArtikelRepository {
final ArtikelApiService _api;
final ArtikelLocalSource _lokal;
final KoneksiService _koneksi;
ArtikelRepository({
required ArtikelApiService api,
required ArtikelLocalSource lokal,
required KoneksiService koneksi,
}) : _api = api,
_lokal = lokal,
_koneksi = koneksi;
Stream<List<Artikel>> getDaftarArtikel() async* {
// 1. Langsung emit data lokal (instan, tanpa loading)
final dataLokal = await _lokal.getSemuaArtikel();
if (dataLokal.isNotEmpty) yield dataLokal;
// 2. Coba update dari network jika terhubung
final terhubung = await _koneksi.sedangTerhubung;
if (!terhubung) return;
try {
final dataRemote = await _api.getDaftarArtikel();
// 3. Simpan ke cache lokal
await _lokal.simpanArtikel(dataRemote);
// 4. Emit data terbaru
yield dataRemote;
} on SocketException {
// Koneksi terputus saat request β€” data lokal sudah diemit, aman
} on TimeoutException {
// Request timeout β€” data lokal sudah diemit, aman
}
}
}

Pola ini memberikan pengalaman yang jauh lebih baik: pengguna langsung melihat konten (dari cache), kemudian konten diperbarui secara diam-diam jika ada yang baru. Tidak ada loading spinner yang membekukan layar.


Antrian Aksi Offline: Sinkronisasi Ketika Koneksi Kembali

Pola cache-first bekerja baik untuk membaca data. Tapi bagaimana dengan menulis β€” submit formulir, menyimpan komentar, atau melakukan transaksi ketika tidak ada koneksi?

Solusinya adalah antrian aksi offline: simpan aksi yang gagal karena tidak ada koneksi, dan eksekusi ulang secara otomatis ketika koneksi kembali.

@HiveType(typeId: 10)
class AksiOffline extends HiveObject {
@HiveField(0)
late String tipe; // 'buat_komentar', 'update_profil', dll
@HiveField(1)
late String payload; // JSON string dari data aksi
@HiveField(2)
late DateTime waktuDibuat;
@HiveField(3)
late int jumlahPercobaan;
}
class AntrianOfflineService {
final Box<AksiOffline> _antrian;
final KoneksiService _koneksi;
AntrianOfflineService(this._antrian, this._koneksi) {
// Mulai mendengarkan perubahan koneksi
_koneksi.statusKoneksi.listen((terhubung) {
if (terhubung) _prosesAntrian();
});
}
Future<void> tambahAksi(String tipe, Map<String, dynamic> data) async {
final aksi = AksiOffline()
..tipe = tipe
..payload = jsonEncode(data)
..waktuDibuat = DateTime.now()
..jumlahPercobaan = 0;
await _antrian.add(aksi);
// Coba langsung kalau ada koneksi
final terhubung = await _koneksi.sedangTerhubung;
if (terhubung) _prosesAntrian();
}
Future<void> _prosesAntrian() async {
final aksiPending = _antrian.values.toList();
for (final aksi in aksiPending) {
try {
await _eksekusiAksi(aksi);
await aksi.delete(); // hapus dari antrian jika berhasil
} catch (e) {
aksi.jumlahPercobaan++;
if (aksi.jumlahPercobaan >= 5) {
// Setelah 5 kali gagal, tandai untuk review manual
// atau hapus dengan notifikasi ke pengguna
await _tanganiAksiBatal(aksi);
} else {
await aksi.save();
}
}
}
}
Future<void> _eksekusiAksi(AksiOffline aksi) async {
final data = jsonDecode(aksi.payload) as Map<String, dynamic>;
switch (aksi.tipe) {
case 'buat_komentar':
await ApiService.buatKomentar(data);
break;
case 'update_profil':
await ApiService.updateProfil(data);
break;
// tambah tipe aksi lain...
}
}
}

Menampilkan Status Koneksi kepada Pengguna

Pengguna harus tahu kapan mereka sedang dalam mode offline. Bukan dengan dialog yang mengganggu, tapi dengan indikator halus yang informatif.

class BannerOffline extends ConsumerWidget {
const BannerOffline({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final terhubung = ref.watch(statusKoneksiProvider);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: terhubung
? const SizedBox.shrink()
: Container(
key: const ValueKey('offline_banner'),
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 6),
color: Colors.orange.shade700,
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.cloud_off, color: Colors.white, size: 14),
SizedBox(width: 6),
Text(
'Mode offline β€” data mungkin tidak terbaru',
style: TextStyle(color: Colors.white, fontSize: 12),
),
],
),
),
);
}
}

Banner ini muncul dengan animasi halus ketika koneksi terputus, dan menghilang ketika koneksi kembali. Tidak ada dialog yang perlu di-dismiss, tidak ada interupsi ke alur kerja pengguna.


Retry dengan Exponential Backoff

Untuk request yang gagal karena masalah sementara (bukan karena tidak ada koneksi), retry langsung sering kali memperburuk keadaan β€” server yang sudah kelebihan beban akan semakin terbebani jika ribuan client langsung retry bersamaan.

Exponential backoff adalah solusinya: setiap retry menunggu lebih lama dari sebelumnya:

Future<T> retryWithBackoff<T>(
Future<T> Function() operasi, {
int maxPercobaan = 4,
Duration jedaAwal = const Duration(seconds: 1),
}) async {
int percobaan = 0;
Duration jeda = jedaAwal;
while (true) {
try {
return await operasi();
} catch (e) {
percobaan++;
if (percobaan >= maxPercobaan) rethrow;
// Tunggu, lalu coba lagi
await Future.delayed(jeda);
// Gandakan waktu tunggu, tambah sedikit jitter
jeda = jeda * 2 + Duration(
milliseconds: Random().nextInt(500),
);
}
}
}
// Penggunaan:
final data = await retryWithBackoff(
() => ApiService.getDaftarArtikel(),
maxPercobaan: 4,
);

Jitter (nilai acak yang ditambahkan ke jeda) penting untuk menghindari "thundering herd" β€” kondisi di mana semua client retry pada waktu yang sama persis.


Testing Skenario Offline

Ini yang sering dilewatkan: testing tanpa koneksi jarang dilakukan karena tidak nyaman. Beberapa cara untuk membuatnya lebih mudah:

Di emulator Android: Emulator β†’ Three-dot menu β†’ Cellular β†’ Network type: None

Di device fisik: Mode pesawat adalah cara paling mudah untuk mensimulasikan tidak ada koneksi.

Di unit test: Mock KoneksiService untuk mengembalikan false:

class MockKoneksiService implements KoneksiService {
@override
Future<bool> get sedangTerhubung async => false;
@override
Stream<bool> get statusKoneksi => Stream.value(false);
}

Kesimpulan

Offline-first bukan fitur tambahan β€” ini komitmen arsitektur yang harus dibuat sejak awal. Menambahkan dukungan offline ke aplikasi yang sudah jadi jauh lebih mahal dari merancangnya dengan asumsi offline sejak awal.

Pengguna di Indonesia β€” khususnya di luar kota besar β€” akan merasakan perbedaannya secara langsung. Dan aplikasi yang "tetap bekerja" bahkan dengan sinyal lemah adalah aplikasi yang mendapatkan ulasan bintang lima, bukan ulasan keluhan.

Itulah standar yang perlu kita kejar.


Komentar

Belum ada komentar. Jadilah yang pertama menulis.

Tulis Komentar

↑