SharedPreferences #

Penyimpanan data lokal merupakan salah satu pilar penting dalam pengembangan aplikasi mobile modern. Ketika kita membangun aplikasi menggunakan Flutter, kita sering kali dihadapkan pada kebutuhan untuk menyimpan data yang sifatnya sederhana, persisten, dan ringan. Contoh paling umum adalah menyimpan status apakah pengguna telah menyelesaikan halaman onboarding, preferensi tema aplikasi (gelap atau terang), pengaturan bahasa default, atau token registrasi push notification (FCM). Untuk skenario penyimpanan sederhana berbasis kunci-nilai (key-value pairs), SharedPreferences adalah solusi utama yang paling sering kita gunakan.

SharedPreferences bukanlah sebuah basis data relasional seperti SQLite ataupun basis data NoSQL berkinerja tinggi seperti Hive atau ObjectBox. SharedPreferences adalah sebuah pembungkus (wrapper) sederhana yang memanfaatkan API penyimpanan bawaan dari masing-masing platform target. Pustaka ini sangat efisien untuk menyimpan data berukuran kecil, namun bisa menjadi bumerang bagi performa aplikasi jika kita salah dalam memanfaatkannya. Di dalam artikel ini, kita akan membedah secara mendalam bagaimana SharedPreferences bekerja di balik layar, mengimplementasikan operasi dasar, membandingkan API lama dengan API asinkron yang baru, merancang arsitektur penyimpanan yang bersih dan aman, hingga mengintegrasikannya dengan state management.

Pendahuluan & Cara Kerja Internal #

Untuk memahami SharedPreferences dengan baik, kita harus melihat apa yang terjadi di balik layar ketika kita memanggil metode baca atau tulis di dalam kode Flutter kita. Pustaka shared_preferences di Flutter tidak membuat mesin penyimpanan baru; ia bertindak sebagai jembatan (bridge) melalui mekanisme Method Channel untuk berkomunikasi dengan pustaka penyimpanan bawaan yang ada pada sistem operasi masing-masing platform:

  • Android: Pada platform Android, SharedPreferences menggunakan kelas android.content.SharedPreferences. Sistem akan menulis data dalam bentuk berkas XML mentah (plaintext) di direktori penyimpanan internal aplikasi, tepatnya pada jalur /data/data/<package_name>/shared_prefs/<package_name>_preferences.xml.
  • iOS & macOS: Pada sistem operasi keluarga Apple ini, SharedPreferences dibungkus di atas NSUserDefaults. Data disimpan dalam bentuk berkas Property List (.plist) di dalam folder Library/Preferences di dalam sandbox aplikasi yang bersangkutan.
  • Web: Untuk platform Web, SharedPreferences akan memetakan operasi baca dan tulis ke window.localStorage milik peramban (browser).
  • Linux: Data disimpan dalam bentuk berkas konfigurasi lokal yang mengikuti spesifikasi direktori dasar XDG (biasanya terletak di bawah folder ~/.config).
  • Windows: Pustaka ini memanfaatkan penyimpanan berkas berbasis JSON lokal yang diletakkan di folder Roaming AppData aplikasi kita.

Dalam arsitektur legacy (API lama), salah satu karakteristik utama SharedPreferences adalah pemuatan seluruh data ke dalam memori (RAM caching). Saat kita memanggil SharedPreferences.getInstance(), pustaka akan melakukan operasi pembacaan berkas fisik di memori penyimpanan (disk I/O) secara asinkron satu kali untuk mengambil seluruh pasangan kunci-nilai yang ada di berkas XML atau plist, kemudian memuat semuanya ke dalam memori RAM aplikasi dalam bentuk struktur data Map Dart.

Setelah instansiasi pertama tersebut berhasil, proses pembacaan data selanjutnya (menggunakan metode get(), getString(), getBool(), dsb.) akan berlangsung secara instan dan sinkron (tanpa membutuhkan kata kunci await). Hal ini terjadi karena kode Flutter kita sebenarnya hanya membaca data dari cache RAM yang sudah dibuat sebelumnya, bukan melakukan pembacaan langsung ke penyimpanan fisik (disk). Namun, pendekatan ini memiliki dampak negatif: jika berkas SharedPreferences kita berukuran sangat besar (mencapai ratusan kilobyte atau megabyte), proses inisiasi pertama kali saat aplikasi dinyalakan dapat memakan waktu yang cukup lama dan berpotensi menghambat pemuatan antarmuka aplikasi, memicu jank (penurunan frame rate), atau bahkan memicu crash jika sistem operasi menganggap aplikasi kita membeku.


Instalasi & Konfigurasi Platform #

Untuk mulai menggunakan SharedPreferences dalam proyek Flutter kita, kita perlu menambahkan dependensi resmi yang dikelola langsung oleh tim Flutter. Kita cukup menambahkan baris berikut pada berkas pubspec.yaml di proyek kita:

dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.3.4

Setelah menambahkan pustaka tersebut, jalankan perintah flutter pub get di terminal untuk mengunduh dan mengintegrasikan pustaka tersebut ke dalam proyek.

Secara umum, SharedPreferences tidak membutuhkan konfigurasi izin (permission) khusus pada berkas manifes Android (AndroidManifest.xml) maupun berkas Info iOS (Info.plist). Penyimpanan ini sepenuhnya berjalan di dalam ruang terisolasi (sandbox) masing-masing aplikasi, sehingga sistem operasi mengizinkan aplikasi kita untuk membaca dan menulis data miliknya sendiri tanpa perlu meminta persetujuan dari pengguna.

Namun, ada beberapa aspek penting terkait konfigurasi platform yang perlu kita perhatikan:

  1. Android Auto-Backup: Secara default, Android memiliki fitur pencadangan otomatis (Auto Backup) yang akan mencadangkan data aplikasi (termasuk berkas SharedPreferences XML) ke Google Drive milik pengguna. Jika aplikasi kita menyimpan data sensitif seperti token akses sementara atau status enkripsi di dalam SharedPreferences, data tersebut akan ikut terunggah secara tidak aman. Kita bisa menonaktifkan pencadangan otomatis ini atau membatasi berkas apa saja yang boleh dicadangkan dengan mengonfigurasi aturan pencadangan pada AndroidManifest.xml menggunakan atribut android:fullBackupContent.
  2. iOS Sandbox Cleansing: Ketika pengguna menghapus aplikasi kita dari perangkat iOS mereka, sistem operasi secara otomatis akan menghapus seluruh data yang terkait dengan aplikasi tersebut, termasuk berkas NSUserDefaults (.plist). Data tidak akan tertinggal di perangkat, berbeda dengan penyimpanan eksternal Android yang terkadang memerlukan penanganan penghapusan data secara manual oleh pengguna jika tidak dikonfigurasi dengan benar.

Arsitektur Alur Data (Data Flow) #

Agar kita memiliki gambaran visual yang jelas mengenai bagaimana data mengalir dari kode Flutter kita menuju ke penyimpanan fisik di perangkat pengguna, perhatikan diagram arsitektur alur data di bawah ini. Diagram ini membedakan perilaku antara API Legacy yang menggunakan cache memori penuh dan API Async baru yang membaca data secara langsung sesuai kebutuhan.

graph TD
    Client["Aplikasi Flutter (Dart)"] --> Service["Wrapper Service (Type-safe)"]
    Service -->|Legacy API| Legacy["SharedPreferences (getInstance)"]
    Service -->|Modern API| Async["SharedPreferencesAsync"]
    
    Legacy -. "Membaca semua data ke memori saat startup" .-> Mem["Memory Cache (Synchronous Read)"]
    Async -. "Membaca secara asinkron sesuai permintaan" .-> Native["Platform Native Channel"]
    
    Mem --> Client
    
    Native --> Android["Android: SharedPreferences XML"]
    Native --> iOS["iOS: NSUserDefaults"]
    Native --> Web["Web: window.localStorage"]
    Native --> Desktop["Desktop: JSON / Registry / Plist"]

Dengan memahami diagram di atas, kita dapat menyimpulkan bahwa API Legacy lebih cepat dalam pembacaan berulang karena tidak perlu melewati Native Channel berulang kali (cukup membaca dari Memory Cache). Namun, harga yang harus dibayar adalah konsumsi memori RAM yang terus menerus untuk menahan seluruh data key-value tersebut, serta beban inisialisasi yang berat di awal aplikasi berjalan. Sebaliknya, API Async modern meminimalkan jejak memori awal dengan hanya mengambil data yang benar-benar diminta melalui saluran platform native.


Operasi CRUD Dasar #

SharedPreferences dirancang khusus untuk menyimpan tipe data primitif. Batasan ini bertujuan untuk menjaga performa baca-tulis tetap cepat. Adapun tipe data yang didukung oleh SharedPreferences meliputi:

  • int (Bilangan bulat)
  • double (Bilangan pecahan/desimal)
  • bool (Nilai boolean true/false)
  • String (Teks)
  • List<String> (Kumpulan teks)

Berikut adalah contoh implementasi lengkap untuk seluruh operasi CRUD (Create, Read, Update, Delete) dasar menggunakan API SharedPreferences konvensional (Legacy):

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(child: Text('Demo CRUD SharedPreferences')),
      ),
    );
  }
}

// Fungsi demonstrasi CRUD lengkap
Future<void> jalankanDemoSharedPreferences() async {
  // 1. Inisialisasi dan pengambilan instansiasi SharedPreferences
  // Operasi ini bersifat asinkron dan memakan waktu I/O disk di pemanggilan pertama.
  final SharedPreferences prefs = await SharedPreferences.getInstance();

  // 2. CREATE / UPDATE (Operasi Tulis)
  // Menulis data ke SharedPreferences mengembalikan nilai Future<bool> 
  // yang menandakan apakah data berhasil ditulis ke disk secara persisten.
  await prefs.setString('user_username', 'budi_developer');
  await prefs.setInt('user_login_count', 42);
  await prefs.setDouble('user_app_volume', 0.85);
  await prefs.setBool('user_is_premium', true);
  await prefs.setStringList('user_pinned_folders', ['Inbox', 'Drafts', 'Sent']);

  // 3. READ (Operasi Baca)
  // Pembacaan data bersifat sinkron karena langsung mengambil dari cache RAM.
  // Nilai yang dikembalikan bisa bernilai null jika kunci tidak ditemukan.
  final String? username = prefs.getString('user_username');
  final int? loginCount = prefs.getInt('user_login_count');
  final double? appVolume = prefs.getDouble('user_app_volume');
  final bool? isPremium = prefs.getBool('user_is_premium');
  final List<String>? pinnedFolders = prefs.getStringList('user_pinned_folders');

  // Menampilkan hasil pembacaan ke konsol
  debugPrint('Username: $username');
  debugPrint('Login Count: $loginCount');
  debugPrint('Volume: $appVolume');
  debugPrint('Premium User: $isPremium');
  debugPrint('Pinned Folders: $pinnedFolders');

  // Membaca data dengan nilai fallback (default value) jika kunci null
  final String activeTheme = prefs.getString('app_theme') ?? 'light';
  debugPrint('Active Theme: $activeTheme');

  // 4. CHECK (Verifikasi Keberadaan Kunci)
  final bool hasUsername = prefs.containsKey('user_username');
  debugPrint('Apakah kunci username ada? $hasUsername');

  // Mendapatkan semua daftar kunci yang tersimpan
  final Set<String> allKeys = prefs.getKeys();
  debugPrint('Semua kunci yang tersimpan: $allKeys');

  // 5. DELETE (Operasi Hapus)
  // Menghapus kunci spesifik secara permanen dari storage fisik dan cache RAM.
  await prefs.remove('user_app_volume');

  // Menghapus seluruh data yang ada di dalam SharedPreferences aplikasi
  // PENTING: Gunakan clear() dengan sangat hati-hati karena akan menghapus semua preferensi.
  // await prefs.clear();
}

Perlu kita ingat kembali bahwa metode penulisan seperti setString atau setInt mengembalikan nilai Future<bool>. Meskipun kita sering kali mengabaikan nilai kembalian ini, dalam aplikasi produksi yang kritis kita disarankan untuk memverifikasi nilai kembalian tersebut untuk memastikan bahwa ruang penyimpanan perangkat pengguna tidak penuh dan penulisan data benar-benar berhasil dilakukan ke memori fisik.


SharedPreferencesAsync vs SharedPreferences (Legacy) #

Sejak rilis shared_preferences versi 2.3.0, tim Flutter memperkenalkan cara baru untuk berinteraksi dengan penyimpanan lokal. Langkah ini diambil untuk mengatasi masalah klasik berupa beban inisialisasi awal (startup overhead) yang telah kita bahas sebelumnya. Di bagian ini, kita akan mempelajari perbedaan mendasar antara kedua pendekatan tersebut.

API Legacy (SharedPreferences.getInstance) #

API ini memuat seluruh data ke memori RAM saat aplikasi memanggil getInstance().

  • Kelebihan: Setelah diinisialisasi, pembacaan data sangat cepat karena bersifat sinkron. Kita tidak perlu menuliskan kata kunci await di setiap baris kode yang ingin membaca data.
  • Kekurangan: Memori RAM aplikasi terbuang untuk menampung data yang jarang dibaca. Jika data sangat banyak, inisialisasi di awal aplikasi akan membuat aplikasi terasa lambat saat dibuka pertama kali.

API Modern (SharedPreferencesAsync) #

API modern membuang konsep pemuatan seluruh data ke memori RAM di awal startup. Sebagai gantinya, setiap kali kita ingin membaca suatu nilai, kita langsung meminta data tersebut dari native secara asinkron.

  • Kelebihan: Mengurangi beban startup aplikasi secara signifikan. RAM aplikasi bersih dari penyimpanan data preferensi yang tidak digunakan secara aktif.
  • Kekurangan: Semua operasi pembacaan kini bersifat asinkron, yang berarti kita harus menggunakan kata kunci await atau menggunakan objek FutureBuilder jika ingin menampilkannya langsung di UI.

Berikut adalah contoh implementasi menggunakan SharedPreferencesAsync:

import 'package:shared_preferences/shared_preferences.dart';

Future<void> demoSharedPreferencesAsync() async {
  // Kita tidak perlu memanggil getInstance() yang memuat seluruh data.
  // Cukup instansiasi objek SharedPreferencesAsync secara langsung.
  final prefsAsync = SharedPreferencesAsync();

  // Operasi Tulis (tetap asinkron seperti biasa)
  await prefsAsync.setString('auth_token', 'xyz123abc');
  await prefsAsync.setBool('has_completed_tutorial', true);

  // Operasi Baca (sekarang wajib menggunakan await)
  final String? token = await prefsAsync.getString('auth_token');
  final bool? completedTutorial = await prefsAsync.getBool('has_completed_tutorial');

  // Menghapus kunci spesifik secara asinkron
  await prefsAsync.remove('auth_token');
}

Solusi Hibrida: SharedPreferencesWithCache #

Jika kita ingin mendapatkan kelebihan dari kedua dunia — yaitu startup yang ringan dari SharedPreferencesAsync sekaligus kecepatan pembacaan sinkron dari API Legacy — kita dapat menggunakan SharedPreferencesWithCache. Kelas ini memungkinkan kita untuk mendefinisikan secara spesifik kunci-kunci (keys) apa saja yang ingin kita masukkan ke dalam cache RAM aplikasi, sementara kunci lainnya tetap dibiarkan di dalam media penyimpanan fisik.

Berikut adalah contoh penggunaan SharedPreferencesWithCache:

import 'package:shared_preferences/shared_preferences.dart';

Future<void> demoSharedPreferencesWithCache() async {
  // Buat opsi cache untuk menentukan kunci apa saja yang akan disimpan di RAM
  final options = const SharedPreferencesWithCacheOptions(
    // Kita membatasi cache hanya untuk pengaturan UI yang sering diakses secara instan
    allowList: <String>{'app_theme', 'app_language'},
  );

  // Inisialisasi SharedPreferencesWithCache secara asinkron
  final prefsWithCache = await SharedPreferencesWithCache.create(
    cacheOptions: options,
  );

  // Operasi Tulis data
  await prefsWithCache.setString('app_theme', 'dark');
  await prefsWithCache.setString('app_language', 'id');
  await prefsWithCache.setString('api_endpoint_temp', 'https://api.example.com'); // key ini tidak masuk allowList

  // Operasi Baca data yang masuk dalam allowList dapat dilakukan secara SINKRON (tanpa await)
  final String? theme = prefsWithCache.getString('app_theme');
  final String? language = prefsWithCache.getString('app_language');

  // Namun, jika kita mencoba membaca key yang tidak terdaftar di allowList secara sinkron,
  // nilainya akan selalu menghasilkan null atau memicu pengecualian, karena data tidak di-cache.
  // Untuk mengakses key di luar allowList, kita harus kembali menggunakan SharedPreferencesAsync.
}

Dengan beralih ke SharedPreferencesAsync atau SharedPreferencesWithCache pada proyek-proyek Flutter baru, kita menulis kode yang lebih efisien dalam penggunaan memori dan memberikan pengalaman startup yang jauh lebih mulus bagi pengguna aplikasi kita.


Implementasi Wrapper Service Type-Safe #

Menulis string kunci seperti 'user_username' atau 'app_theme' secara berulang di berbagai bagian kode aplikasi kita adalah praktik yang sangat buruk (bad practice). Hal ini rawan memicu kesalahan pengetikan (typo), mempersulit proses penggantian nama kunci (refactoring), dan membuat manajemen nilai default menjadi berantakan.

Cara terbaik untuk menangani SharedPreferences adalah dengan membuat sebuah kelas pembungkus (wrapper service) yang bersifat terpusat (centralized) dan bertipe data aman (type-safe). Berikut adalah pola implementasi kelas pembungkus yang ideal untuk aplikasi Flutter berskala produksi:

// lib/core/storage/preferences_service.dart

import 'package:shared_preferences/shared_preferences.dart';

class PreferencesService {
  // 1. Definisikan semua kunci penyimpanan sebagai konstanta privat
  static const String _keyTheme = 'preferences_app_theme';
  static const String _keyLanguage = 'preferences_app_language';
  static const String _keyIsUserLoggedIn = 'preferences_is_logged_in';
  static const String _keyLastSyncTime = 'preferences_last_sync_time';
  static const String _keyNotificationEnabled = 'preferences_notif_enabled';

  // Instance SharedPreferences legacy yang akan digunakan secara internal
  final SharedPreferences _prefs;

  // Konstruktor privat untuk mencegah pembuatan instansiasi langsung secara liar
  PreferencesService._(this._prefs);

  // 2. Factory method untuk menginisialisasi service secara aman
  static Future<PreferencesService> init() async {
    final sharedPrefs = await SharedPreferences.getInstance();
    return PreferencesService._(sharedPrefs);
  }

  // ==========================================
  // GETTERS & SETTERS (Type-Safe & Default Val)
  // ==========================================

  // Pengaturan Tema Aplikasi
  // Jika data kosong, kita langsung memberikan nilai default 'system'
  String get appTheme => _prefs.getString(_keyTheme) ?? 'system';
  
  Future<bool> setAppTheme(String theme) async {
    return await _prefs.setString(_keyTheme, theme);
  }

  // Pengaturan Bahasa Aplikasi
  String get appLanguage => _prefs.getString(_keyLanguage) ?? 'id';

  Future<bool> setAppLanguage(String language) async {
    return await _prefs.setString(_keyLanguage, language);
  }

  // Status Login Pengguna
  bool get isUserLoggedIn => _prefs.getBool(_keyIsUserLoggedIn) ?? false;

  Future<bool> setIsUserLoggedIn(bool value) async {
    return await _prefs.setBool(_keyIsUserLoggedIn, value);
  }

  // Pengaturan Notifikasi
  bool get isNotificationEnabled => _prefs.getBool(_keyNotificationEnabled) ?? true;

  Future<bool> setNotificationEnabled(bool value) async {
    return await _prefs.setBool(_keyNotificationEnabled, value);
  }

  // Timestamp Sinkronisasi Terakhir
  // Contoh konversi dari tipe data primitif int (milidetik) ke objek DateTime Dart
  DateTime? get lastSyncTime {
    final int? milliseconds = _prefs.getInt(_keyLastSyncTime);
    if (milliseconds == null) return null;
    return DateTime.fromMillisecondsSinceEpoch(milliseconds);
  }

  Future<bool> setLastSyncTime(DateTime time) async {
    return await _prefs.setInt(_keyLastSyncTime, time.millisecondsSinceEpoch);
  }

  // ==========================================
  // METODE UTILITAS
  // ==========================================

  // Menghapus data spesifik berdasarkan kunci
  Future<bool> removeKey(String key) async {
    return await _prefs.remove(key);
  }

  // Menghapus seluruh preferensi tersimpan (misalnya saat pengguna logout)
  Future<bool> clearAllPreferences() async {
    return await _prefs.clear();
  }
}

Dengan menggunakan PreferencesService ini, bagian lain dari kode aplikasi kita tidak perlu lagi berurusan dengan tipe data mentah, string kunci, atau nilai null secara manual. Cukup panggil metode getter dan setter yang sudah disediakan secara terstruktur.


Integrasi State Management (Riverpod & BLoC) #

Setelah membuat kelas wrapper yang bersih, langkah berikutnya adalah mengintegrasikan kelas tersebut ke dalam sistem manajemen state (state management) yang kita gunakan di dalam aplikasi. Tujuannya adalah agar ketika nilai preferensi diubah, komponen UI yang bergantung pada nilai tersebut akan langsung terperbarui secara otomatis secara reaktif.

Di bawah ini, kita akan melihat bagaimana cara mengintegrasikan PreferencesService menggunakan Riverpod dan BLoC/Cubit, dua arsitektur state management terpopuler di komunitas Flutter.

Integrasi Menggunakan Riverpod #

Pola inisialisasi asinkron sebelum aplikasi berjalan merupakan standar industri dalam pengembangan Flutter. Kita menginisialisasi service di fungsi main(), lalu menimpa (override) provider Riverpod dengan instance yang sudah siap pakai.

// lib/core/providers/preferences_provider.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../storage/preferences_service.dart';

// Provider dasar untuk PreferencesService
// Kita membiarkannya melempar error secara default karena akan di-override di main()
final preferencesServiceProvider = Provider<PreferencesService>((ref) {
  throw UnimplementedError('PreferencesService belum diinisialisasi di main()');
});

// Notifier untuk mengelola State Tema Aplikasi secara reaktif
class ThemeNotifier extends Notifier<ThemeMode> {
  late PreferencesService _preferencesService;

  @override
  ThemeMode build() {
    // Membaca service dari provider
    _preferencesService = ref.read(preferencesServiceProvider);
    
    // Membaca status tema awal dari SharedPreferences
    final String savedTheme = _preferencesService.appTheme;
    switch (savedTheme) {
      case 'light':
        return ThemeMode.light;
      case 'dark':
        return ThemeMode.dark;
      default:
        return ThemeMode.system;
    }
  }

  // Mengubah tema dan menyimpannya secara persisten
  Future<void> changeTheme(ThemeMode themeMode) async {
    state = themeMode;
    
    String themeString = 'system';
    if (themeMode == ThemeMode.light) {
      themeString = 'light';
    } else if (themeMode == ThemeMode.dark) {
      themeString = 'dark';
    }

    await _preferencesService.setAppTheme(themeString);
  }
}

// Provider untuk ThemeNotifier
final themeProvider = NotifierProvider<ThemeNotifier, ThemeMode>(ThemeNotifier.new);

Kemudian, pada berkas main.dart, kita melakukan inisialisasi sebelum memanggil runApp():

// lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/storage/preferences_service.dart';
import 'core/providers/preferences_provider.dart';

void main() async {
  // Wajib dipanggil untuk memastikan interaksi binding native Flutter siap digunakan
  WidgetsFlutterBinding.ensureInitialized();

  // Inisialisasi PreferencesService secara asinkron sebelum aplikasi dirender
  final PreferencesService prefService = await PreferencesService.init();

  runApp(
    ProviderScope(
      overrides: [
        // Timpa nilai provider dengan instance yang sudah sukses dibuat
        preferencesServiceProvider.overrideWithValue(prefService),
      ],
      child: const MainApp(),
    ),
  );
}

class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Memantau perubahan state tema secara reaktif
    final ThemeMode activeTheme = ref.watch(themeProvider);

    return MaterialApp(
      themeMode: activeTheme,
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      home: const SettingsScreen(),
    );
  }
}

class SettingsScreen extends ConsumerWidget {
  const SettingsScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final ThemeMode themeMode = ref.watch(themeProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Pengaturan')),
      body: ListView(
        children: [
          ListTile(
            title: const Text('Tema Gelap'),
            trailing: Switch(
              value: themeMode == ThemeMode.dark,
              onChanged: (bool value) {
                ref.read(themeProvider.notifier).changeTheme(
                  value ? ThemeMode.dark : ThemeMode.light,
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

Integrasi Menggunakan BLoC/Cubit #

Jika kita menggunakan pustaka BLoC, polanya sangat mirip. Kita membuat sebuah Cubit yang menerima ketergantungan PreferencesService melalui konstruktornya, memuat data awal pada saat pembuatan, dan memicu penulisan data baru saat event dipicu.

// lib/features/settings/presentation/cubit/settings_cubit.dart

import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/storage/preferences_service.dart';

// Kelas State untuk menampung pengaturan aplikasi
class SettingsState {
  final String theme;
  final String language;

  const SettingsState({required this.theme, required this.language});
}

class SettingsCubit extends Cubit<SettingsState> {
  final PreferencesService _preferencesService;

  SettingsCubit(this._preferencesService)
      : super(SettingsState(
          theme: _preferencesService.appTheme,
          language: _preferencesService.appLanguage,
        ));

  // Aksi untuk memperbarui bahasa aplikasi
  Future<void> updateLanguage(String newLanguage) async {
    final success = await _preferencesService.setAppLanguage(newLanguage);
    if (success) {
      emit(SettingsState(
        theme: state.theme,
        language: newLanguage,
      ));
    }
  }

  // Aksi untuk memperbarui tema aplikasi
  Future<void> updateTheme(String newTheme) async {
    final success = await _preferencesService.setAppTheme(newTheme);
    if (success) {
      emit(SettingsState(
        theme: newTheme,
        language: state.language,
      ));
    }
  }
}

Dengan memisahkan logika UI dari penyimpanan fisik melalui state management dan service layer seperti ini, aplikasi kita memiliki pemisahan tanggung jawab yang jelas (separation of concerns). Kode menjadi jauh lebih mudah diuji (testable) dan mudah dirawat dalam jangka panjang.


Strategi Pengujian (Testing) #

Menguji kode yang memiliki ketergantungan (dependency) pada pustaka native seperti SharedPreferences sering kali menjadi tantangan. Tanpa penanganan khusus, pengujian unit (unit test) akan gagal karena tidak ada mesin native yang memproses permintaan Method Channel dari Flutter ketika dijalankan di lingkungan komputer lokal kita.

Beruntung, pustaka shared_preferences telah menyediakan mekanisme bawaan untuk membuat simulasi data awal (mocking) dengan sangat mudah tanpa memerlukan pustaka mocking eksternal seperti Mockito atau Mocktail. Kita bisa menggunakan metode statis SharedPreferences.setMockInitialValues(...).

Berikut adalah contoh pembuatan berkas unit test lengkap untuk menguji PreferencesService yang sudah kita rancang sebelumnya:

// test/core/storage/preferences_service_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_app/core/storage/preferences_service.dart'; // Sesuaikan dengan jalur proyek kita

void main() {
  // Menjamin inisialisasi lingkungan pengujian Flutter
  TestWidgetsFlutterBinding.ensureInitialized();

  group('Pengujian PreferencesService', () {
    // Setup awal yang dijalankan sebelum masing-masing tes berjalan
    setUp(() {
      // Mengisi nilai awal simulasi ke dalam penyimpanan SharedPreferences.
      // Ini akan menggantikan pemanggilan native sesungguhnya dengan memory storage sementara.
      SharedPreferences.setMockInitialValues({
        'preferences_app_theme': 'dark',
        'preferences_app_language': 'en',
        'preferences_is_logged_in': false,
      });
    });

    test('Harus memuat nilai default awal dengan benar saat diinisialisasi', () async {
      // Arrange & Act
      final PreferencesService service = await PreferencesService.init();

      // Assert
      expect(service.appTheme, equals('dark'));
      expect(service.appLanguage, equals('en'));
      expect(service.isUserLoggedIn, isFalse);
      // Menguji key yang tidak diset di mock, harus menghasilkan nilai default fallback
      expect(service.isNotificationEnabled, isTrue); 
    });

    test('Harus berhasil mengubah nilai tema dan menyimpannya secara persisten', () async {
      // Arrange
      final PreferencesService service = await PreferencesService.init();

      // Act
      final bool statusTulis = await service.setAppTheme('light');

      // Assert
      expect(statusTulis, isTrue);
      expect(service.appTheme, equals('light'));
    });

    test('Harus mengembalikan nilai DateTime yang valid dari representasi milidetik', () async {
      // Arrange
      final DateTime waktuSekarang = DateTime.now();
      // Set nilai mock tambahan secara langsung menggunakan representasi integer milidetik
      SharedPreferences.setMockInitialValues({
        'preferences_last_sync_time': waktuSekarang.millisecondsSinceEpoch,
      });
      final PreferencesService service = await PreferencesService.init();

      // Act
      final DateTime? waktuTersimpan = service.lastSyncTime;

      // Assert
      expect(waktuTersimpan, isNotNull);
      // Membandingkan nilai milidetik untuk menghindari perbedaan format nano-detik saat inisiasi objek
      expect(waktuTersimpan!.millisecondsSinceEpoch, equals(waktuSekarang.millisecondsSinceEpoch));
    });

    test('Harus menghapus seluruh data ketika metode clear dipanggil', () async {
      // Arrange
      final PreferencesService service = await PreferencesService.init();

      // Act
      final bool statusClear = await service.clearAllPreferences();

      // Assert
      expect(statusClear, isTrue);
      // Setelah di-clear, semua getter harus mengembalikan nilai default fallback-nya
      expect(service.appTheme, equals('system'));
      expect(service.isUserLoggedIn, isFalse);
    });
  });
}

Melalui fitur setMockInitialValues ini, pengujian unit dapat berjalan dengan sangat cepat di komputer lokal (CI/CD server) tanpa memerlukan emulator Android maupun simulator iOS, sekaligus memastikan seluruh alur bisnis yang melibatkan preferensi pengguna tetap terjaga kebenaran kodenya.


Menyimpan Data Kompleks (JSON Serialization) #

Seperti yang telah kita ketahui, SharedPreferences secara bawaan tidak dapat mengenali objek buatan kita sendiri (custom Dart class) seperti model User atau Product. Jika kita mencoba memasukkan objek tersebut ke dalam SharedPreferences secara langsung, compiler akan melemparkan error karena tipe data tersebut tidak kompatibel dengan tipe primitif native.

Solusi alternatif untuk masalah ini adalah dengan menggunakan teknik serialisasi JSON (JSON Serialization). Kita mengonversi objek Dart kita menjadi string JSON (menggunakan pustaka bawaan dart:convert), menyimpannya sebagai tipe data String, dan saat membacanya kembali, kita melakukan proses penguraian (parsing) string JSON tersebut untuk dikembalikan menjadi bentuk objek Dart semula.

Berikut adalah panduan lengkap penerapannya:

// lib/core/models/user_session.dart

import 'dart:convert';

class UserSession {
  final String userId;
  final String name;
  final String email;
  final String token;
  final DateTime expiresAt;

  UserSession({
    required this.userId,
    required this.name,
    required this.email,
    required this.token,
    required this.expiresAt,
  });

  // Konversi dari Map JSON ke objek UserSession
  factory UserSession.fromMap(Map<String, dynamic> map) {
    return UserSession(
      userId: map['userId'] as String,
      name: map['name'] as String,
      email: map['email'] as String,
      token: map['token'] as String,
      expiresAt: DateTime.fromMillisecondsSinceEpoch(map['expiresAt'] as int),
    );
  }

  // Konversi dari objek UserSession ke Map JSON
  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'userId': userId,
      'name': name,
      'email': email,
      'token': token,
      'expiresAt': expiresAt.millisecondsSinceEpoch,
    };
  }

  // Mengubah string JSON terenkode menjadi objek UserSession
  factory UserSession.fromJson(String source) => 
      UserSession.fromMap(json.decode(source) as Map<String, dynamic>);

  // Mengubah objek UserSession menjadi string JSON terenkode
  String toJson() => json.encode(toMap());
}

Sekarang kita bisa menambahkan metode untuk menulis dan membaca model UserSession ini ke dalam kelas PreferencesService kita:

// Tambahkan metode ini di dalam kelas PreferencesService kita

static const String _keyUserSession = 'preferences_user_session';

// Menyimpan objek UserSession
Future<bool> saveUserSession(UserSession session) async {
  // Konversi objek menjadi string JSON terlebih dahulu
  final String jsonString = session.toJson();
  return await _prefs.setString(_keyUserSession, jsonString);
}

// Membaca objek UserSession
UserSession? getUserSession() {
  final String? jsonString = _prefs.getString(_keyUserSession);
  if (jsonString == null) return null;
  
  try {
    // Parsing string JSON kembali menjadi objek Dart
    return UserSession.fromJson(jsonString);
  } catch (e) {
    // Tangani kemungkinan parsing gagal jika format JSON rusak
    debugPrint('Gagal mendekode UserSession: $e');
    return null;
  }
}

// Menghapus sesi pengguna
Future<bool> deleteUserSession() async {
  return await _prefs.remove(_keyUserSession);
}

[!WARNING] Peringatan Penting tentang Batas Batasan: Menggunakan teknik serialisasi JSON untuk menyimpan data objek adalah praktik yang dapat diterima jika jumlah datanya sedikit dan strukturnya sederhana (misalnya satu objek profil pengguna). Namun, jika kita mulai menyimpan daftar list yang panjang (seperti list produk keranjang belanjaan, daftar artikel favorit yang bisa bertambah tanpa batas), hal ini adalah sinyal kuat bahwa kita harus berhenti menggunakan SharedPreferences. Proses serialisasi dan deserialisasi string JSON yang panjang di thread utama akan menghambat performa aplikasi secara drastis. Untuk skenario seperti itu, beralihlah ke basis data yang didedikasikan untuk objek seperti Hive, ObjectBox, atau Drift.


Keamanan Data & Batasan Penggunaan #

Hal terpenting yang wajib kita pahami sebagai pengembang aplikasi Flutter profesional adalah: SharedPreferences sama sekali tidak memiliki lapisan keamanan enkripsi data.

Data disimpan di dalam penyimpanan lokal perangkat dalam bentuk teks polos (plaintext). Pada perangkat Android yang telah di-root atau perangkat iOS yang di-jailbreak, berkas XML/plist preferensi ini dapat dibuka dan dibaca dengan sangat mudah oleh aplikasi lain atau pihak luar menggunakan aplikasi file explorer sederhana.

Oleh karena itu, ada aturan emas yang wajib kita patuhi: Jangan pernah menyimpan data sensitif di dalam SharedPreferences. Contoh data sensitif meliputi:

  • Kata sandi (password) pengguna.
  • PIN transaksi atau kode OTP.
  • Token akses jangka panjang (seperti JWT Token auth).
  • Data informasi kartu kredit atau rekening bank.
  • Informasi pribadi yang dapat mengidentifikasi pengguna secara spesifik (PII - Personally Identifiable Information).

Alternatif Keamanan: Flutter Secure Storage #

Untuk menyimpan data sensitif, kita harus menggunakan pustaka flutter_secure_storage. Pustaka ini memanfaatkan API penyimpanan kredensial aman tingkat sistem operasi yang terenkripsi secara hardware, yaitu Keychain pada platform iOS/macOS dan Keystore dengan algoritma enkripsi AES pada Android.

Berikut adalah tabel komparasi cepat untuk membantu kita dalam memilih teknologi penyimpanan lokal yang tepat sesuai kebutuhan aplikasi kita:

Parameter KomparasiSharedPreferencesFlutter Secure StorageHive / ObjectBoxDrift (SQLite)
Kategori PenyimpananKey-Value (Sederhana)Key-Value (Aman)NoSQL Object DatabaseRDBMS (Relasional)
Keamanan (Enkripsi)Tidak ada (Plaintext)Ya (Enkripsi Hardware)Ya (Enkripsi Opsional)Ya (Melalui SQLCipher)
Kecepatan Baca-TulisSangat Cepat (RAM Cache)Lambat (Hardware Call)Sangat Cepat (Binary)Sedang (I/O Disk)
Kueri KompleksTidak MendukungTidak MendukungMendukung (Query Builder)Sangat Kuat (SQL/Join)
Kesesuaian DataPreferensi, OnboardingKredensial, Token JWTCache Data API, OfflineData Transaksional, Relasi

Kapan Kita Harus Menghindari SharedPreferences? #

Untuk merangkum batasan penggunaan SharedPreferences, hindari pemakaian pustaka ini dalam kondisi berikut:

  1. Struktur Data Kompleks: Jika data memiliki relasi satu-ke-banyak (one-to-many) atau banyak-ke-banyak (many-to-many).
  2. Kueri Data Dinamis: Jika kita perlu mencari data secara dinamis seperti memfilter data berdasarkan rentang nilai tertentu, pencarian teks parsial, atau pengurutan data yang kompleks.
  3. Data Berukuran Besar: Jika total ukuran data melebihi beberapa puluh kilobyte. Penyimpanan data yang besar di SharedPreferences akan memperlambat startup aplikasi secara signifikan dan memakan memori RAM secara sia-sia.
  4. Operasi Tulis yang Intensif: Jika aplikasi kita secara terus-menerus menulis data baru (misalnya merekam koordinat GPS pengguna setiap beberapa detik). Hal ini akan memicu bottleneck pada proses penulisan disk fisik yang lambat.

Ringkasan #

  • SharedPreferences dirancang khusus untuk menyimpan data berformat kunci-nilai (key-value) sederhana yang bersifat primitif (int, double, bool, String, List<String>).
  • Mekanisme Bawaan: Pustaka ini bekerja di atas API bawaan sistem operasi, yaitu SharedPreferences pada Android (disimpan sebagai berkas XML) dan NSUserDefaults pada iOS (disimpan sebagai berkas .plist).
  • API Modern: Mulai versi 2.3.0, gunakan SharedPreferencesAsync atau SharedPreferencesWithCache untuk menghindari overhead startup memori yang berat dari API legacy.
  • Praktik Terbaik: Selalu bungkus akses SharedPreferences ke dalam kelas wrapper yang type-safe untuk memusatkan string kunci dan menghindari kesalahan pengetikan di seluruh kode aplikasi.
  • Integrasi State: Gunakan state management seperti Riverpod atau BLoC untuk mengalirkan perubahan data preferensi secara reaktif ke UI aplikasi.
  • Pengujian Cepat: Manfaatkan SharedPreferences.setMockInitialValues untuk melakukan unit test secara independen tanpa perlu menyentuh penyimpanan fisik emulator atau perangkat nyata.
  • Keamanan Terpenting: Jangan pernah menaruh data rahasia seperti token otentikasi atau password di SharedPreferences. Gunakan flutter_secure_storage sebagai gantinya.

← Sebelumnya: Overview   Berikutnya: Hive →

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