Asynchronous Programming #

Aplikasi Flutter yang responsif adalah aplikasi yang tidak pernah mengunci antarmuka pengguna (UI jank atau freezing) saat menjalankan tugas-tugas berat seperti mengambil data dari API, membaca basis data lokal, atau memproses file berukuran besar. Keberhasilan ini bertumpu sepenuhnya pada pemrograman asinkron (asynchronous programming). Di dalam ekosistem Dart, konsep ini diimplementasikan dengan sangat elegan melalui model konkurensi berbasis single thread yang digerakkan oleh mekanisme Event Loop. Kita akan membedah secara mendalam bagaimana Dart mengelola operasi non-blocking ini menggunakan instrumen fundamental seperti Future, async/await, Stream, serta bagaimana mendesain arsitektur asinkron yang tangguh dan bebas dari kebocoran memori.

Mengapa Pemrograman Asinkron Sangat Vital #

Aplikasi mobile dan desktop modern dituntut untuk berjalan pada kecepatan 60 bingkai per detik (frames per second atau FPS), bahkan hingga 120 FPS pada perangkat dengan layar modern. Ini berarti UI Thread hanya memiliki waktu sekitar 16,6 milidetik (untuk 60 FPS) atau 8,3 milidetik (untuk 120 FPS) untuk menyelesaikan kalkulasi tata letak, penggambaran elemen, dan pemrosesan masukan pengguna di setiap bingkainya.

Jika kita menjalankan operasi yang membutuhkan waktu lama secara sinkron di dalam thread utama, seluruh antarmuka pengguna akan berhenti merespons selama operasi tersebut berlangsung. Kejadian ini biasa disebut sebagai UI Hang atau Jank.

Di bahasa pemrograman tradisional seperti Java, C#, atau C++, masalah ini umumnya diselesaikan dengan membuat thread baru yang berjalan secara paralel. Namun, model multi-threading konvensional yang berbagi memori (shared-memory threading) membawa kompleksitas yang sangat tinggi:

  • Race Conditions: Dua thread mencoba memodifikasi data yang sama secara bersamaan, menyebabkan korupsi status aplikasi.
  • Deadlocks: Thread A menunggu kunci yang dipegang Thread B, sementara Thread B menunggu kunci yang dipegang Thread A, membuat aplikasi terkunci selamanya.
  • Resource Overhead: Setiap thread memiliki overhead alokasi memori stack yang cukup besar dan membebani sistem operasi.

Dart mengambil pendekatan yang berbeda. Secara bawaan, Dart berjalan pada model single-threaded execution di dalam sebuah wadah terisolasi yang disebut Isolate. Karena memori tidak dibagi dengan thread lain, kita terhindar dari race conditions dan deadlocks. Untuk menangani tugas berdurasi lama tanpa memblokir thread utama, Dart mengandalkan sistem asinkron non-blocking yang dipimpin oleh Event Loop.

Mari kita bandingkan perbedaan eksekusi sinkron (yang salah) dengan eksekusi asinkron (yang benar):

// ANTI-PATTERN: Pemanggilan data secara sinkron yang memblokir UI Thread
String fetchUserSummarySync() {
  // Menahan eksekusi thread selama 3 detik penuh secara sinkron
  // Selama 3 detik ini, aplikasi Flutter akan BEKU total!
  sleep(const Duration(seconds: 3));
  return 'Pengguna VIP';
}

void updateDashboardSync() {
  print('1. Memulai sinkronisasi dasbor...');
  final summary = fetchUserSummarySync(); 
  print('2. Data pengguna: $summary');
  print('3. Dasbor selesai diperbarui.');
}

// ====================================================================

// BENAR: Pemanggilan data secara asinkron (non-blocking)
Future<String> fetchUserSummaryAsync() {
  // Mengembalikan kontrol ke thread utama segera
  // Operasi tunggu dijadwalkan di latar belakang
  return Future.delayed(const Duration(seconds: 3), () => 'Pengguna VIP');
}

void updateDashboardAsync() {
  print('1. Memulai sinkronisasi dasbor...');
  fetchUserSummaryAsync().then((summary) {
    print('2. Data pengguna: $summary');
  });
  print('3. Dasbor tetap responsif saat data sedang dimuat...');
}

Event Loop — Jantung Konkurensi Dart #

Setiap Isolate di Dart memiliki satu thread eksekusi utama dan satu Event Loop yang berjalan secara terus-menerus. Event Loop bertugas mengambil tugas-tugas yang masuk ke dalam antrean eksekusi dan menjalankannya satu per satu pada thread utama tersebut.

Secara struktural, Event Loop mengelola dua antrean (queue) yang memiliki tingkat prioritas yang sangat berbeda:

  1. Microtask Queue (Prioritas Tinggi): Antrean ini digunakan untuk tugas-tugas internal Dart yang sangat pendek dan harus segera diselesaikan sebelum tugas-tugas eksternal diproses.
  2. Event Queue (Prioritas Normal): Antrean ini berisi semua kejadian eksternal seperti I/O (akses file atau jaringan), respon dari timer, masukan pengguna (sentuhan layar, ketukan keyboard), serta penggambaran UI oleh Flutter.

Mekanisme kerja Event Loop dapat divisualisasikan melalui diagram alir berikut:

flowchart TD
    Start["Mulai Aplikasi (Main)"] --> RunSync["Jalankan Kode Sinkron Hingga Selesai"]
    RunSync --> LoopStart{"Apakah Ada Microtask di Queue?"}
    LoopStart -- "Ya" --> ExecMicro["Jalankan Microtask Terdepan"]
    ExecMicro --> LoopStart
    LoopStart -- "Tidak" --> CheckEvent{"Apakah Ada Event di Queue?"}
    CheckEvent -- "Ya" --> ExecEvent["Jalankan Event Terdepan"]
    ExecEvent --> LoopStart
    CheckEvent -- "Tidak" --> WaitEvent["Tunggu Event Baru Muncul"]
    WaitEvent --> LoopStart

Aturan Eksekusi Prioritas #

Event Loop akan selalu menghabiskan seluruh tugas yang ada di dalam Microtask Queue terlebih dahulu sebelum mengambil satu tugas dari Event Queue. Setiap kali satu event selesai dieksekusi, Event Loop akan kembali memeriksa Microtask Queue secara menyeluruh sebelum memproses event berikutnya.

Mari kita buktikan prioritas ini melalui contoh kode berikut:

import 'dart:async';

void main() {
  print('1 - Sinkron');

  // Menjadwalkan tugas pada Event Queue
  Future(() {
    print('2 - Event Queue (Pertama)');
  });

  // Menjadwalkan tugas pada Microtask Queue
  Future.microtask(() {
    print('3 - Microtask Queue (Pertama)');
  });

  // Menjadwalkan tugas pada Event Queue dengan delay
  Future.delayed(const Duration(milliseconds: 50), () {
    print('4 - Event Queue (Dengan Delay)');
  });

  // Alternatif menjadwalkan Microtask secara langsung
  scheduleMicrotask(() {
    print('5 - Microtask Queue (Kedua)');
  });

  print('6 - Sinkron');
}

Jika kita menjalankan kode di atas, urutan output yang dihasilkan adalah sebagai berikut:

  1. 1 - Sinkron dan 6 - Sinkron dicetak pertama kali karena mereka adalah instruksi sinkron yang langsung dieksekusi oleh thread utama.
  2. 3 - Microtask Queue (Pertama) dan 5 - Microtask Queue (Kedua) dicetak berikutnya karena Microtask Queue diproses segera setelah kode sinkron selesai.
  3. 2 - Event Queue (Pertama) dicetak setelah seluruh Microtask Queue kosong.
  4. 4 - Event Queue (Dengan Delay) dicetak paling akhir karena memerlukan waktu tunggu 50 milidetik sebelum masuk ke antrean aktif.
Peringatan Kritis: Jangan pernah menempatkan kalkulasi matematis atau komputasi berat di dalam Microtask Queue. Karena Microtask Queue menolak memberikan kesempatan kepada Event Queue untuk berjalan hingga dirinya kosong, menyumbat Microtask akan langsung menghentikan pemrosesan gambar layar (rendering) dan masukan pengguna. UI aplikasi kita akan membeku secara total.

Future — Representasi Nilai di Masa Depan #

Future<T> adalah objek di Dart yang mewakili hasil akhir dari suatu operasi asinkron yang nilai hasilnya (bertipe T) belum tersedia saat objek tersebut dibuat. Objek ini bertindak seperti janji (promise) bahwa data akan diberikan di masa mendatang.

Tiga Status (State) pada Future #

Setiap objek Future memiliki siklus hidup yang terbagi ke dalam tiga status utama:

flowchart TD
    Uncompleted["Uncompleted (Pending)"] -->|"Operasi Berhasil"| CompletedValue["Completed with Value (Success)"]
    Uncompleted -->|"Operasi Gagal"| CompletedError["Completed with Error (Failure)"]
  1. Uncompleted (Pending): Operasi asinkron sedang berlangsung di latar belakang. Pada tahap ini, kita belum bisa membaca nilai hasil maupun pesan kesalahan.
  2. Completed with Value (Success): Operasi asinkron selesai dengan sukses. Future menyimpan nilai hasil bertipe T yang siap digunakan oleh aplikasi kita.
  3. Completed with Error (Failure): Operasi asinkron gagal karena terjadi kesalahan (exception). Future menyimpan objek kesalahan yang harus segera kita tangani.

Konstruktor Instan Future #

Selain digunakan untuk membungkus operasi asinkron bawaan (seperti HTTP client atau pembacaan file), kita bisa membuat instansi Future secara manual menggunakan beberapa konstruktor bawaan Dart:

// 1. Future.value: Membuat Future yang langsung selesai dengan nilai sukses
Future<int> successFuture = Future.value(200);

// 2. Future.error: Membuat Future yang langsung selesai dengan kegagalan
Future<void> failedFuture = Future.error(Exception('Koneksi Ditolak'));

// 3. Future.delayed: Membuat Future yang selesai setelah jeda waktu tertentu
Future<String> delayedFuture = Future.delayed(
  const Duration(seconds: 2),
  () => 'Halo setelah 2 detik',
);

// 4. Future.sync: Menjalankan fungsi secara sinkron sebelum mengembalikan Future
Future<String> syncFuture = Future.sync(() {
  // Bagian ini dieksekusi secara sinkron segera saat baris dideklarasikan
  return 'Eksekusi Segera';
});

Callback API: then, catchError, dan whenComplete #

Sebelum Dart memperkenalkan sintaksis async/await, pengembang mengonsumsi nilai Future menggunakan metode rantai (chaining) API. Meskipun gaya ini sekarang mulai digantikan, memahaminya sangat penting karena merupakan fondasi dasar dari semua operasi asinkron Dart:

import 'dart:math';

Future<double> generateRandomNumber() {
  return Future.delayed(const Duration(seconds: 1), () {
    if (Random().nextBool()) {
      return 99.9;
    } else {
      throw Exception('Gagal membuat angka acak');
    }
  });
}

void executeTask() {
  print('Memulai tugas...');
  
  generateRandomNumber()
      .then((value) {
        // Callback jika Future berhasil (Completed with Value)
        print('Sukses: Angka yang didapat adalah $value');
      })
      .catchError((error) {
        // Callback jika terjadi error (Completed with Error)
        print('Terjadi kesalahan: $error');
      })
      .whenComplete(() {
        // Callback yang SELALU dipanggil di akhir, mirip blok 'finally'
        print('Tugas selesai diproses.');
      });
      
  print('Tugas dijadwalkan di latar belakang...');
}

Bahaya Callback Hell (Anti-Pattern) #

Kelemahan utama dari gaya pemanggilan menggunakan .then() adalah ketika kita memiliki beberapa operasi asinkron yang saling bergantung satu sama lain secara berurutan. Kode kita akan menjorok ke dalam secara ekstrem dan sangat sulit dibaca:

// ANTI-PATTERN: Callback Hell yang membuat kode tidak terbaca
void loadUserData() {
  authenticateUser().then((token) {
    fetchProfile(token).then((profile) {
      getPreference(profile.id).then((preference) {
        applyTheme(preference.theme);
      }).catchError((e) => print('Gagal memuat preferensi'));
    }).catchError((e) => print('Gagal memuat profil'));
  }).catchError((e) => print('Gagal autentikasi'));
}

async dan await — Menulis Asinkron Rasa Sinkron #

Untuk menyelesaikan masalah kompleksitas rantai .then(), Dart menyediakan kata kunci async dan await. Sintaksis ini merupakan syntactic sugar (penyederhanaan sintaksis) yang memungkinkan kita menulis kode asinkron dengan struktur alur yang persis sama dengan kode sinkron konvensional.

Deklarasi Fungsi async #

Setiap fungsi yang ingin menggunakan kata kunci await wajib ditandai dengan kata kunci async tepat setelah deklarasi parameter fungsi. Fungsi yang ditandai dengan async akan secara otomatis membungkus nilai balikan (return value) di dalamnya ke dalam objek Future.

// Tanpa kata kunci async, kita harus mengembalikan Future secara manual
Future<String> getTitle() {
  return Future.value('Judul Buku');
}

// Dengan kata kunci async, nilai balik bertipe String otomatis dibungkus menjadi Future<String>
Future<String> getTitleAsync() async {
  return 'Judul Buku'; // Otomatis dibungkus menjadi Future.value('Judul Buku')
}

Penggunaan await #

Kata kunci await digunakan di depan pemanggilan fungsi asinkron. Instruksi ini memberi tahu Dart untuk menangguhkan (suspend) eksekusi fungsi async saat ini hingga objek Future yang ditunggu selesai diproses dan mengembalikan nilainya.

Penting untuk dipahami bahwa await tidak memblokir thread eksekusi utama aplikasi. Selama eksekusi fungsi ditangguhkan, thread dibebaskan untuk memproses kejadian lain di Event Queue, menjaga agar antarmuka pengguna tetap responsif.

Mari kita tulis ulang Callback Hell sebelumnya menggunakan async/await yang bersih dan mudah dibaca:

// BENAR: Alur asinkron berurutan yang sangat bersih menggunakan async/await
Future<void> loadUserData() async {
  try {
    final token = await authenticateUser();
    final profile = await fetchProfile(token);
    final preference = await getPreference(profile.id);
    applyTheme(preference.theme);
  } catch (error) {
    // Seluruh error dari baris mana pun di atas ditangkap secara terpusat di sini
    print('Gagal memproses data pengguna: $error');
  } finally {
    // Bagian pembersihan yang pasti dieksekusi
    hideLoadingSpinner();
  }
}

Penanganan Error Spesifik #

Ketika menggunakan async/await, kita bisa memanfaatkan penanganan kesalahan bawaan Dart (try, on, catch, finally) untuk menangkap tipe kesalahan yang berbeda secara terstruktur:

Future<void> uploadPhoto(String filePath) async {
  try {
    final bytes = await readFile(filePath);
    await sendBytesToServer(bytes);
  } on SocketException catch (e) {
    // Menangkap kesalahan jaringan secara spesifik
    print('Kesalahan jaringan: ${e.message}');
  } on FileSystemException catch (e) {
    // Menangkap kesalahan pembacaan file secara spesifik
    print('File tidak dapat dibaca: ${e.message}');
  } catch (e) {
    // Menangkap kesalahan tak terduga lainnya
    print('Terjadi kesalahan yang tidak diketahui: $e');
  } finally {
    cleanTemporaryFiles();
  }
}

Mengoptimalkan Kinerja Asinkron dengan Paralelisme #

Salah satu kesalahan paling umum yang sering dilakukan oleh para pengembang saat menulis kode asinkron adalah menjalankan operasi secara berurutan (sequential) padahal operasi-operasi tersebut tidak saling bergantung. Hal ini memperpanjang durasi tunggu aplikasi secara sia-sia.

Mari kita perhatikan skenario memuat data dasbor aplikasi berikut:

// ANTI-PATTERN: Eksekusi berurutan yang lambat (Total waktu tunggu: 3s + 2s = 5 detik)
Future<void> loadDashboardSlow() async {
  final startTime = DateTime.now();

  // Memakan waktu 3 detik
  final profile = await fetchUserProfileFromServer(); 
  
  // Memakan waktu 2 detik
  final transactions = await fetchRecentTransactionsFromServer(); 

  updateDashboardUI(profile, transactions);
  
  final duration = DateTime.now().difference(startTime).inSeconds;
  print('Selesai memuat dasbor dalam $duration detik.'); // Output: 5 detik
}

Karena mengambil transaksi tidak membutuhkan data dari profil pengguna, kedua operasi di atas seharusnya dijalankan secara bersamaan (paralel) untuk menghemat waktu tunggu pengguna.

Menggunakan Future.wait #

Kita bisa memanfaatkan fungsi Future.wait untuk menjalankan sekumpulan Future secara paralel. Fungsi ini akan mengembalikan satu objek Future baru yang akan selesai setelah semua Future di dalamnya berhasil diselesaikan.

// BENAR: Menjalankan Future secara paralel (Total waktu tunggu: max(3s, 2s) = 3 detik)
Future<void> loadDashboardFast() async {
  final startTime = DateTime.now();

  // Memulai kedua operasi secara bersamaan tanpa kata kunci await di masing-masing baris
  final Future<Profile> profileFuture = fetchUserProfileFromServer();
  final Future<List<Transaction>> transactionFuture = fetchRecentTransactionsFromServer();

  // Menunggu keduanya selesai bersamaan
  final List<dynamic> results = await Future.wait([
    profileFuture,
    transactionFuture,
  ]);

  // Ekstrak hasil berdasarkan indeks array
  final profile = results[0] as Profile;
  final transactions = results[1] as List<Transaction>;

  updateDashboardUI(profile, transactions);

  final duration = DateTime.now().difference(startTime).inSeconds;
  print('Selesai memuat dasbor dalam $duration detik.'); // Output: 3 detik
}

Menggunakan Ekstensi wait pada Record (Dart 3) #

Sejak Dart versi 3, kita diberikan alternatif penulisan paralelisme yang jauh lebih bersih dan memiliki tipe data yang aman (type-safe) menggunakan fitur Record:

// BENAR: Menggunakan Tuple/Record.wait untuk tipe data yang aman (Dart 3+)
Future<void> loadDashboardTypeSafe() async {
  // Menggunakan sintaksis tuple dan destrukturisasi record
  final (profile, transactions) = await (
    fetchUserProfileFromServer(),
    fetchRecentTransactionsFromServer()
  ).wait;

  // Variabel profile otomatis bertipe 'Profile' dan transactions bertipe 'List<Transaction>'
  // Tidak memerlukan proses pengecoran tipe data (casting) manual menggunakan keyword 'as'
  updateDashboardUI(profile, transactions);
}

Menggunakan Future.any dan Future.timeout #

Dalam situasi di mana kita ingin mengoptimalkan waktu respon, Dart menyediakan fungsionalitas lanjutan:

Future.any: Mengembalikan nilai dari Future yang paling cepat selesai di antara daftar Future yang dikirimkan.

Future<String> fetchFastestServer() async {
  // Berguna jika kita memiliki beberapa server cermin (mirror) dan ingin menggunakan yang tercepat
  return await Future.any([
    requestFromServer('https://server-asia.example.com'),
    requestFromServer('https://server-europe.example.com'),
    requestFromServer('https://server-us.example.com'),
  ]);
}

Future.timeout: Membatasi durasi eksekusi dari suatu Future agar aplikasi tidak menunggu selamanya jika server bermasalah.

Future<void> loadDataWithTimeout() async {
  try {
    final data = await fetchReportFromServer().timeout(
      const Duration(seconds: 5),
    );
    displayReport(data);
  } on TimeoutException catch (_) {
    // Ditangkap jika server tidak merespons dalam 5 detik
    showAlternativeLocalData();
    print('Koneksi terputus karena batas waktu terlampaui.');
  }
}

Stream — Aliran Data Berkelanjutan #

Jika Future memegang peranan untuk mewakili satu nilai tunggal yang akan datang di masa depan, maka Stream adalah objek yang mewakili serangkaian nilai yang datang secara berkala dari waktu ke waktu.

Perbedaan model data asinkron ini dapat digambarkan sebagai berikut:

flowchart TD
    subgraph FutureModel["Model Future (Single Value)"]
        direction LR
        FStart["Mulai Request"] --> FPending["Status Pending"]
        FPending -->|"Mengembalikan Data"| FSuccess["Satu Nilai Tunggal"]
    end
    subgraph StreamModel["Model Stream (Multiple Values)"]
        direction LR
        SStart["Mulai Berlangganan"] --> SListen["Status Mendengarkan"]
        SListen -. Data 1 .-> SEmit1["Nilai Pertama"]
        SEmit1 -. Data 2 .-> SEmit2["Nilai Kedua"]
        SEmit2 -. Data 3 .-> SEmit3["Nilai Ketiga"]
        SEmit3 -->|"Stream Selesai"| SDone["Status Selesai"]
    end

Contoh analogi paling sederhana:

  • Future seperti memesan makanan secara online. Kita melakukan pesanan, menunggu beberapa saat, makanan diantarkan sekali, dan transaksi selesai.
  • Stream seperti berlangganan video streaming. Setelah kita menekan tombol putar, data terus-menerus mengalir ke perangkat kita secara bertahap hingga video selesai atau kita menutup aplikasinya.

Membuat Stream Menggunakan async* #

Cara paling umum untuk memproduksi data stream adalah dengan menggunakan generator function. Fungsi ini menggunakan penanda async* (dengan tanda bintang) dan kata kunci yield untuk mengirimkan nilai baru ke dalam saluran stream:

// Fungsi generator stream yang memancarkan angka hitung mundur setiap detik
Stream<int> startCountdown(int startValue) async* {
  for (int i = startValue; i >= 0; i--) {
    await Future.delayed(const Duration(seconds: 1));
    yield i; // Memancarkan nilai ke objek Stream yang sedang didengarkan
  }
}

Kita juga bisa membuat objek Stream instan menggunakan beberapa fungsi bawaan Dart:

// Membuat stream dari List data yang sudah ada
Stream<String> wordsStream = Stream.fromIterable(['Belajar', 'Dart', 'Asinkron']);

// Membuat stream berkala yang mengirimkan angka increment setiap 2 detik
Stream<int> timerStream = Stream.periodic(
  const Duration(seconds: 2),
  (count) => count,
);

Dua Kategori Stream: Single-Subscription vs Broadcast #

Dart mengklasifikasikan stream ke dalam dua kategori dengan karakteristik penggunaan yang sangat berbeda:

1. Single-Subscription Stream (Stream Langganan Tunggal) #

Secara standar bawaan, seluruh stream di Dart hanya mengizinkan maksimal satu pendengar (listener) aktif selama siklus hidupnya. Jika pendengar kedua mencoba berlangganan pada stream yang sama, Dart akan melemparkan kesalahan StateError.

  • Karakteristik: Menjamin bahwa seluruh data dikirimkan secara berurutan dan tidak ada data yang terlewat sejak langganan dimulai.
  • Kasus Penggunaan: Pembacaan database lokal, transfer file biner, respons HTTP yang panjang.
final Stream<int> mySingleStream = Stream.fromIterable([1, 2, 3]);

mySingleStream.listen((data) => print('Pendengar 1: $data'));

// JANGAN: Melakukan listen untuk kedua kalinya pada single-subscription stream
// mySingleStream.listen((data) => print('Pendengar 2: $data')); // ERROR: Bad state

2. Broadcast Stream (Stream Siaran) #

Broadcast Stream dirancang agar bisa didengarkan oleh banyak pendengar secara bersamaan.

  • Karakteristik: Nilai dipancarkan secara real-time. Jika ada pendengar baru yang masuk di tengah jalan, dia hanya akan menerima nilai baru yang dipancarkan setelah waktu langganannya dimulai (tidak akan menerima nilai lama).
  • Kasus Penggunaan: Sistem sensor GPS, interaksi sentuhan layar, integrasi WebSocket, penyebaran status aplikasi global.
// Mengonversi single-subscription stream menjadi broadcast stream
final Stream<int> broadcastStream = Stream.fromIterable([1, 2, 3]).asBroadcastStream();

broadcastStream.listen((data) => print('Pendengar A: $data'));
broadcastStream.listen((data) => print('Pendengar B: $data')); // VALID

Mengonsumsi Stream #

Ada dua metode utama yang paling sering digunakan untuk mengonsumsi data dari saluran stream:

1. Menggunakan await for Loop #

Metode ini sangat ideal digunakan di dalam fungsi yang ditandai dengan async. Loop akan membaca data yang masuk satu per satu dan secara otomatis berhenti ketika saluran stream selesai ditutup.

Future<void> printStreamResults() async {
  final countdownStream = startCountdown(5);

  print('Memulai pemantauan...');
  await for (final value in countdownStream) {
    // Menunggu 1 detik di setiap putaran loop secara non-blocking
    print('Nilai saat ini: $value');
  }
  print('Saluran stream ditutup.');
}

2. Menggunakan Metode listen() #

Metode .listen() lebih fleksibel karena memungkinkan kita mendaftarkan fungsi penanganan kesalahan dan fungsi penyelesaian secara eksplisit:

void monitorSensor() {
  final Stream<int> sensorStream = getSensorData();

  final StreamSubscription<int> subscription = sensorStream.listen(
    (data) {
      print('Sensor membaca: $data');
    },
    onError: (error) {
      print('Terjadi kesalahan sensor: $error');
    },
    onDone: () {
      print('Sensor dinonaktifkan.');
    },
    cancelOnError: false, // Jangan batalkan langganan meskipun terjadi error sekali
  );
}

Menguasai StreamController dan StreamTransformer #

Untuk mengimplementasikan arsitektur pengiriman data asinkron yang kompleks, kita membutuhkan alat kontrol yang lebih fleksibel daripada sekadar fungsi generator async*.

StreamController #

StreamController bertindak sebagai jembatan pengendali penuh. Objek ini memisahkan pintu masuk data menggunakan properti sink dan pintu keluar data menggunakan properti stream.

flowchart LR
    Producer["Produser Data"] -->|"Input (sink.add)"| Sink["StreamController.sink"]
    Sink -. Pengolahan .-> Stream["StreamController.stream"]
    Stream -->|"Output (listen)"| Consumer["Konsumen Data"]

Mari kita lihat implementasi penggunaan StreamController dalam skenario nyata:

import 'dart:async';

class ChatService {
  // Membuat controller untuk mendistribusikan pesan masuk
  final _messageController = StreamController<String>.broadcast();

  // Pintu Masuk Data: Digunakan oleh produser pesan
  StreamSink<String> get messageSink => _messageController.sink;

  // Pintu Keluar Data: Digunakan oleh antarmuka UI untuk mendengarkan pesan
  Stream<String> get messageStream => _messageController.stream;

  void sendMessage(String message) {
    if (message.trim().isNotEmpty) {
      messageSink.add(message); // Mengirim data ke stream
    }
  }

  // Wajib dibersihkan saat service dihancurkan
  void dispose() {
    _messageController.close(); // Menutup saluran stream
  }
}

StreamTransformer #

StreamTransformer digunakan untuk melakukan manipulasi, penyaringan, atau transformasi data secara menyeluruh sebelum data tersebut dikirimkan ke konsumen akhir:

import 'dart:async';

// Membuat transformer kustom untuk menyensor kata-kata kasar
final sensorTransformer = StreamTransformer<String, String>.fromHandlers(
  handleData: (data, sink) {
    // Mengubah kata-kata kasar menjadi sensor bintang
    final cleanData = data.replaceAll('kasar', '*****');
    sink.add(cleanData); // Kirim data bersih ke output stream
  },
  handleError: (error, stackTrace, sink) {
    sink.addError('Error Terdeteksi: $error');
  },
  handleDone: (sink) {
    sink.close();
  },
);

void runChatApp() {
  final controller = StreamController<String>();

  // Menghubungkan stream dengan transformer sebelum memanggil listen
  controller.stream
      .transform(sensorTransformer)
      .listen((cleanMessage) => print('Pesan Bersih: $cleanMessage'));

  controller.sink.add('Ini adalah kalimat kasar yang dikirim.');
  controller.close();
}

Pencegahan Kebocoran Memori (Memory Leak) #

Setiap kali kita memanggil metode .listen() pada sebuah stream, Dart akan mengalokasikan memori untuk objek StreamSubscription. Jika langganan ini tidak dibatalkan (cancelled) secara eksplisit saat objek UI atau Controller dihancurkan, referensi memori akan tetap dipegang di latar belakang. Kejadian ini memicu kebocoran memori (memory leak) yang perlahan-lahan akan menghabiskan RAM perangkat pengguna dan membuat aplikasi crash.

// ANTI-PATTERN: Membuka langganan Stream tanpa pernah membatalkannya
class BadWidgetState {
  void initConnection() {
    // Langganan ini akan terus berjalan selamanya di memori
    // bahkan jika halaman Widget ini sudah ditutup oleh user!
    gpsService.locationStream.listen((location) {
      print('Lokasi: $location');
    });
  }
}

// ====================================================================

// BENAR: Membatalkan langganan secara disiplin saat dihancurkan
class GoodWidgetState {
  StreamSubscription<Location>? _locationSubscription;

  void initConnection() {
    _locationSubscription = gpsService.locationStream.listen((location) {
      print('Lokasi: $location');
    });
  }

  void dispose() {
    // Mematikan koneksi langganan secara aman
    _locationSubscription?.cancel();
  }
}

Mengintegrasikan Stream ke dalam UI Flutter #

Flutter menyediakan widget bawaan yang sangat hebat bernama StreamBuilder. Widget ini bertugas mendengarkan suatu stream secara otomatis, melakukan rebuild (pembangunan ulang UI) setiap kali ada data baru yang masuk, dan membatalkan langganan secara otomatis saat widget tersebut dihapus dari layar (widget tree).

Berikut adalah contoh implementasi lengkap widget reaktif penunjuk hitung mundur menggunakan StreamBuilder:

import 'package:flutter/material.dart';

class CountdownWidget extends StatefulWidget {
  const CountdownWidget({super.key});

  @override
  State<CountdownWidget> createState() => _CountdownWidgetState();
}

class _CountdownWidgetState extends State<CountdownWidget> {
  // Menyimpan referensi stream agar tidak ter-recreate saat build dipanggil
  late final Stream<int> _countdownStream;

  @override
  void initState() {
    super.initState();
    _countdownStream = _createCountdownStream(10);
  }

  Stream<int> _createCountdownStream(int start) async* {
    for (int i = start; i >= 0; i--) {
      await Future.delayed(const Duration(seconds: 1));
      // Mengirimkan nilai untuk memperbarui UI
      yield i;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Hitung Mundur Asinkron'),
      ),
      body: Center(
        child: StreamBuilder<int>(
          stream: _countdownStream,
          builder: (context, snapshot) {
            // 1. Menangani jika terjadi kesalahan pada aliran data stream
            if (snapshot.hasError) {
              return Text(
                'Terjadi Kesalahan: ${snapshot.error}',
                style: const TextStyle(color: Colors.red, fontSize: 20),
              );
            }

            // 2. Memeriksa status koneksi aktif stream
            switch (snapshot.connectionState) {
              case ConnectionState.none:
                return const Text('Stream tidak terhubung.');
              
              case ConnectionState.waiting:
                // Ditampilkan saat awal koneksi sebelum data pertama dipancarkan
                return const CircularProgressIndicator();
              
              case ConnectionState.active:
                // Ditampilkan setiap kali ada data baru yang masuk (aktif)
                return Text(
                  'Sisa Waktu: ${snapshot.data}',
                  style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                );
              
              case ConnectionState.done:
                // Ditampilkan setelah saluran stream ditutup secara resmi
                return const Text(
                  'Waktu Habis! Selesai.',
                  style: TextStyle(fontSize: 32, color: Colors.green),
                );
            }
          },
        ),
      ),
    );
  }
}

Dengan menggunakan StreamBuilder, kita terhindar dari boilerplate code seperti menulis setState secara manual, membuka blok initState, dan menutup langganan di dalam blok dispose. Semua siklus hidup asinkron tersebut telah ditangani oleh Flutter dengan aman di bawah kap.

Ringkasan #

  • Model Eksekusi Dart: Dart menggunakan model single-threaded berbasis Event Loop. Operasi asinkron berjalan secara non-blocking tanpa memicu masalah sinkronisasi multi-threading tradisional.
  • Event Loop: Mengelola dua antrean: Microtask Queue (prioritas tinggi untuk tugas internal Dart) dan Event Queue (prioritas normal untuk I/O, UI rendering, dan masukan pengguna).
  • Future: Janji nilai hasil yang akan tersedia di masa mendatang. Memiliki status Uncompleted, Completed with Value, dan Completed with Error.
  • async/await: Sintaksis modern yang berfungsi sebagai penyederhanaan (syntactic sugar) di atas objek Future, membuat alur asinkron tertulis layaknya kode sinkron.
  • Paralelisme: Menjalankan operasi asinkron secara paralel menggunakan Future.wait atau tuple .wait (Dart 3+) dapat memotong total waktu tunggu aplikasi secara signifikan.
  • Stream: Aliran data berlanjut yang mengirimkan nilai berkali-kali dari waktu ke waktu. Terbagi menjadi Single-Subscription Stream (satu pendengar) dan Broadcast Stream (banyak pendengar).
  • StreamController: Alat pengendali utama yang membagi pintu masuk aliran data (sink) dan pintu keluar aliran data (stream).
  • Kebocoran Memori: Selalu panggil .cancel() pada objek StreamSubscription atau .close() pada StreamController untuk mencegah memory leaks.
  • StreamBuilder: Widget Flutter yang sangat direkomendasikan untuk mengonsumsi data Stream langsung pada Widget Tree secara otomatis dan aman.

← Sebelumnya: Null Safety   Berikutnya: Fitur Dart 3 →

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