İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Flutter: SharedPreferences ile Yerel Veri Saklama

Ahmet Balaman

7 dk okuma

FlutterSharedPreferencesYerel DepolamaTemaAsyncAyarlar
Flutter: SharedPreferences ile Yerel Veri Saklama

Kullanıcı karanlık temayı seçti, uygulamayı kapattı, tekrar açtı ve her şey açık temaya döndü. Bu, bir ayarın yalnızca bellekte tutulduğunun en tipik belirtisidir. Uygulama kapanınca kaybolmaması gereken küçük değerler için Flutter ekibinin yayımladığı shared_preferences paketi ilk başvurulan araçtır. Bu yazıda paketin bugün önerilen iki API'sini (SharedPreferencesAsync ve SharedPreferencesWithCache), eski SharedPreferences.getInstance() yolunu, tema tercihini kalıcı yapan tam bir örneği ve en az bunlar kadar önemli olan konuyu, yani orada neyi saklamaman gerektiğini anlatıyorum.

SharedPreferences Nedir?

Paket, her platformun kendi basit anahtar-değer deposunu tek bir Dart arayüzüyle sarar: iOS ve macOS'ta NSUserDefaults, Android'de yeni API'lerle varsayılan olarak DataStore Preferences (eski API'de Android SharedPreferences), web'de tarayıcının localStorage'ı. Saklayabildiğin tipler sınırlıdır: int, double, bool, String ve List<String>.

Paketin dokümanı bir uyarıyı açıkça yazar: yazma işlemleri diske asenkron aktarılabilir ve metot döndükten sonra verinin diske yazılmış olduğu garanti edilmez; bu yüzden paket kritik veriler için kullanılmamalıdır. Yani burası tema, dil, "tanıtım ekranı görüldü mü" gibi kaybolsa da dünyanın yıkılmayacağı tercihlerin yeridir.

Kurulum

flutter pub add shared_preferences

Paketin pub.dev sayfasındaki tabloya göre Android'de en az SDK 24, iOS'ta en az 13.0 gerekir. Kurulumdan sonra uygulamayı hot reload ile değil, baştan derleyerek çalıştır; yeni eklenen native kod ancak böyle devreye girer.

Üç API: Hangisini Seçmeli?

Paket 2.3.0 sürümünden beri üç ayrı API sunuyor. Dokümana göre eski SharedPreferences ileride kullanımdan kaldırılacak (deprecated) ve yeni kod için diğer ikisi öneriliyor:

API Okuma Önbellek Ne zaman?
SharedPreferencesAsync Her okuma await ister Yok, her seferinde platformdan okur Veri başka bir izolattan ya da native koddan değişebiliyorsa; en güvenli varsayılan
SharedPreferencesWithCache Açılışta bir kez yüklenir, sonra senkron Var, allowList ile sınırlandırılır Değerleri build içinde senkron okumak istiyorsan
SharedPreferences (eski) getInstance() sonrası senkron Var, tüm anahtarlar Mevcut projeler; yeni kodda tercih etme

Önbellekli API'lerde bir sorun çıkabilir: arka planda çalışan bir izolat (örneğin bildirim eklentilerinin açtığı ayrı bir motor) ya da native kod aynı depoya yazarsa senin önbelleğin eski kalır. SharedPreferencesAsync bu durumda hep güncel değeri okur; SharedPreferencesWithCache kullanıyorsan okumadan önce reloadCache() çağırman gerekir.

Karar veremiyorsan şu kural çoğu projede iş görür: değeri arayüzde senkron okuman gerekiyorsa (tema, dil, yazı boyutu) SharedPreferencesWithCache; değeri yalnızca belirli anlarda, örneğin açılışta bir kez ya da bir butona basıldığında okuyup yazıyorsan SharedPreferencesAsync. İkisi aynı platform deposunu kullandığı için aynı projede yan yana durabilirler.

SharedPreferencesAsync ile Temel Kullanım

import 'package:shared_preferences/shared_preferences.dart';

Future<void> example() async {
  final prefs = SharedPreferencesAsync();

  // Yazma
  await prefs.setBool('onboarding_done', true);
  await prefs.setInt('launch_count', 3);
  await prefs.setString('last_tab', 'profile');
  await prefs.setStringList('recent_searches', ['flutter', 'dart']);

  // Okuma: anahtar yoksa null döner
  final bool onboardingDone = await prefs.getBool('onboarding_done') ?? false;
  final int launchCount = await prefs.getInt('launch_count') ?? 0;
  final String? lastTab = await prefs.getString('last_tab');

  // Silme
  await prefs.remove('last_tab');
  await prefs.clear(allowList: {'onboarding_done', 'launch_count'});
}

Her getter null dönebilir, çünkü uygulama ilk kez açıldığında hiçbir anahtar yoktur. ?? ile makul bir varsayılan vermek alışkanlık hâline gelmeli. clear() çağrısına allowList vermek de önemli: parametresiz clear(), başka paketlerin ya da native kodun aynı depoya yazdığı değerleri de silebilir; paketin dokümanı bu yüzden listeyi vermeyi şiddetle öneriyor.

SharedPreferencesWithCache: Senkron Okuma

Tema gibi bir değeri her build'de await ile okumak pratik değildir. SharedPreferencesWithCache açılışta izin verdiğin anahtarları bir kez belleğe alır; sonrasında okumalar senkron, yazmalar ise hem önbelleğe hem diske gider:

final prefs = await SharedPreferencesWithCache.create(
  cacheOptions: const SharedPreferencesWithCacheOptions(
    allowList: {'onboarding_done', 'launch_count'},
  ),
);

final count = prefs.getInt('launch_count') ?? 0; // await yok
await prefs.setInt('launch_count', count + 1);

allowList bir güvenlik kemeri gibi çalışır: listede olmayan bir anahtarı okumaya ya da yazmaya çalışırsan ArgumentError alırsın. Yeni bir ayar eklediğinde listeye eklemeyi unutmak, bu API'de en sık karşılaşacağın hatadır. allowList hiç verilmezse bütün anahtarlar önbelleğe alınır, ama doküman bunu önermiyor.

Eski API: SharedPreferences.getInstance()

İnternetteki örneklerin çoğu hâlâ bu API ile yazılmış, o yüzden tanıman gerekiyor:

final prefs = await SharedPreferences.getInstance();
await prefs.setInt('launch_count', 1);
final count = prefs.getInt('launch_count') ?? 0;

Mevcut bir projede çalışıyorsa acele etmene gerek yok. Ancak yeni API'ye geçerken dikkat: Android'de yeni API'ler varsayılan olarak DataStore kullanır, yani eski API'nin yazdığı dosyadan farklı bir depo. Sadece sınıf adını değiştirirsen, uygulamayı güncelleyen kullanıcıların kayıtlı ayarları "kaybolmuş" gibi görünür. Paket bunun için bir geçiş fonksiyonu sunuyor:

import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';

Future<void> migratePreferences() async {
  final legacy = await SharedPreferences.getInstance();
  await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
    legacySharedPreferencesInstance: legacy,
    sharedPreferencesAsyncOptions: const SharedPreferencesOptions(),
    migrationCompletedKey: 'prefs_migration_done',
  );
}

Bu fonksiyonu açılışta, yeni API'yi kullanmadan önce çağırırsın. Geçişin tamamlandığını verdiğin anahtarla işaretler; migrationCompletedKey değişmediği sürece her açılışta çağrılması veri kaybına yol açmaz.

Tanıtım Ekranını Yalnızca İlk Açılışta Göstermek

En sık karşılaşılan kullanım, "kullanıcı tanıtım ekranlarını gördü mü?" sorusudur. Değer açılışta bir kez okunur ve ilk ekrana karar verilir:

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

const onboardingDoneKey = 'onboarding_done';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final done =
      await SharedPreferencesAsync().getBool(onboardingDoneKey) ?? false;
  runApp(MaterialApp(home: done ? const HomePage() : const OnboardingPage()));
}

// OnboardingPage'in State sınıfında, son sayfadaki "Başla" butonu:
Future<void> _finish() async {
  await SharedPreferencesAsync().setBool(onboardingDoneKey, true);
  if (!mounted) return;
  Navigator.of(context).pushReplacement(
    MaterialPageRoute(builder: (context) => const HomePage()),
  );
}

pushReplacement tanıtım sayfasını yığından çıkarır, böylece geri tuşu kullanıcıyı tanıtıma döndürmez; sayfa geçişlerinin ayrıntısı Navigator yazısında. await sonrasındaki mounted kontrolü de, yazma sürerken sayfa kapanmışsa geçersiz bir context kullanılmasını engeller.

Mini Senaryo: Tema Tercihini Kalıcı Yapmak

Provider yazısındaki tema örneğinde tercih yalnızca bellekte tutuluyordu. (Açık ve koyu temanın kendisini ThemeData ile kurmayı Theme ve ThemeData yazısında anlattım; burada yalnızca seçimi saklıyoruz.) Şimdi aynı fikri kalıcı hâle getirelim. Üç parça var: depoyla konuşan küçük bir sınıf, temayı tutan bir ChangeNotifier ve uygulamanın kendisi.

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

class SettingsStore {
  SettingsStore._(this._prefs);

  static const _themeModeKey = 'theme_mode';

  final SharedPreferencesWithCache _prefs;

  static Future<SettingsStore> create() async {
    final prefs = await SharedPreferencesWithCache.create(
      cacheOptions: const SharedPreferencesWithCacheOptions(
        allowList: {_themeModeKey},
      ),
    );
    return SettingsStore._(prefs);
  }

  ThemeMode get themeMode {
    final saved = _prefs.getString(_themeModeKey);
    return ThemeMode.values.asNameMap()[saved] ?? ThemeMode.system;
  }

  Future<void> saveThemeMode(ThemeMode mode) =>
      _prefs.setString(_themeModeKey, mode.name);
}

class ThemeController extends ChangeNotifier {
  ThemeController(this._store) : _mode = _store.themeMode;

  final SettingsStore _store;
  ThemeMode _mode;

  ThemeMode get mode => _mode;

  Future<void> setMode(ThemeMode mode) async {
    if (mode == _mode) return;
    _mode = mode;
    notifyListeners(); // Önce arayüz güncellensin
    await _store.saveThemeMode(mode); // Sonra kalıcı kayıt
  }
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final store = await SettingsStore.create();
  runApp(MyApp(themeController: ThemeController(store)));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key, required this.themeController});

  final ThemeController themeController;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: themeController,
      builder: (context, child) => MaterialApp(
        theme: ThemeData(colorSchemeSeed: Colors.indigo),
        darkTheme: ThemeData(
          colorSchemeSeed: Colors.indigo,
          brightness: Brightness.dark,
        ),
        themeMode: themeController.mode,
        home: SettingsPage(themeController: themeController),
      ),
    );
  }
}

class SettingsPage extends StatelessWidget {
  const SettingsPage({super.key, required this.themeController});

  final ThemeController themeController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Ayarlar')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('Tema'),
            const SizedBox(height: 8),
            SegmentedButton<ThemeMode>(
              segments: const [
                ButtonSegment(value: ThemeMode.system, label: Text('Sistem')),
                ButtonSegment(value: ThemeMode.light, label: Text('Açık')),
                ButtonSegment(value: ThemeMode.dark, label: Text('Koyu')),
              ],
              selected: {themeController.mode},
              onSelectionChanged: (selection) =>
                  themeController.setMode(selection.first),
            ),
          ],
        ),
      ),
    );
  }
}

Bu kodda dört karar var:

  • Değer runApp'ten önce okunuyor. Tema ilk karede doğru geliyor; önce açık tema görünüp yarım saniye sonra koyuya geçme (titreme) olmuyor. runApp öncesinde bir eklentiye erişmek için WidgetsFlutterBinding.ensureInitialized() şart.
  • Enum, adıyla saklanıyor. mode.name "dark" gibi bir metin üretir, asNameMap() de onu geri çevirir. Sıra numarasını (index) saklamak, enum'a ileride araya değer eklendiğinde yanlış temanın açılmasına yol açar. Enum'larla çalışmanın ayrıntıları için Dart enum yazısına bakabilirsin.
  • Kaydedilmiş değer bozuksa ya da yoksa ?? ThemeMode.system devreye giriyor; uygulama asla hata vermiyor.
  • Önce arayüz, sonra disk. notifyListeners() yazma işleminden önce çağrılıyor, böylece kullanıcı bekleme hissetmiyor.

Burada ListenableBuilder kullandım ki örnek ek paket gerektirmeden çalışsın. Projende Provider varsa aynı ThemeControllerChangeNotifierProvider ile ağaca verip context.watch ile okursun; Riverpod kullanıyorsan SettingsStore'u bir provider'dan sağlarsın. Switch, SegmentedButton ve Slider'lı ayarlar ekranındaki "kalıcı kayıt" adımı da tam olarak bu yapıya karşılık geliyor.

Küçük Bir Nesneyi Saklamak

Desteklenen tipler arasında nesne yok, ama küçük bir nesneyi JSON metnine çevirip String olarak saklayabilirsin:

import 'dart:convert';

class SearchFilter {
  const SearchFilter({required this.query, required this.onlyFree});

  final String query;
  final bool onlyFree;

  Map<String, dynamic> toJson() => {'query': query, 'onlyFree': onlyFree};

  factory SearchFilter.fromJson(Map<String, dynamic> json) => SearchFilter(
        query: json['query'] as String,
        onlyFree: json['onlyFree'] as bool,
      );
}

Future<void> saveFilter(SharedPreferencesAsync prefs, SearchFilter filter) =>
    prefs.setString('last_filter', jsonEncode(filter.toJson()));

Future<SearchFilter?> loadFilter(SharedPreferencesAsync prefs) async {
  final raw = await prefs.getString('last_filter');
  if (raw == null) return null;
  return SearchFilter.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}

fromJson ve toJson kalıbı, API'den gelen veride kullandığınla aynıdır; HTTP istekleri yazısında ayrıntısıyla anlattım. Bu yöntem son kullanılan filtre gibi tek bir küçük nesne için uygundur. Yüzlerce kayıtlık bir listeyi tek bir JSON metni olarak saklamaya başladıysan yanlış araçtasın: tek bir kaydı değiştirmek için bütün listeyi okuyup yeniden yazman gerekir, filtreleme ve sıralamayı elle yaparsın, SharedPreferencesWithCache kullanıyorsan da o metnin tamamı sürekli bellekte durur. Bir veritabanı ise tek satırı günceller ve sorguyu senin yerine çalıştırır.

Nereye Ne Saklanır?

Veri Doğru yer
Tema, dil, bildirim tercihi, "tanıtım görüldü" bayrağı shared_preferences
Oturum token'ı, şifre, API anahtarı flutter_secure_storage
Not, sipariş, mesaj gibi çok sayıda yapılandırılmış kayıt sqflite ya da drift gibi bir veritabanı
Resim, PDF, büyük dosyalar Dosya sistemi (path_provider ile uygulama klasörü)

Token'ın neden burada durmaması gerektiği önemli: shared_preferences veriyi şifrelemez. Android'de uygulamanın veri klasöründeki bir dosyada şifrelenmeden durur, web'de ise tarayıcının geliştirici araçlarından okunabilen localStorage'dadır. flutter_secure_storage ise iOS'ta Keychain'i, Android'de şifrelenmiş bir depolamayı kullanır ve kullanımı neredeyse aynıdır: await storage.write(key: 'token', value: token) ve await storage.read(key: 'token').

Sık Yapılan Hatalar

1. runApp'ten önce ensureInitialized'ı unutmak

Belirti: Uygulama açılır açılmaz Binding has not yet been initialized. hatası. main içinde runApp'ten önce bir eklentiye erişiyorsan, ilk satır WidgetsFlutterBinding.ensureInitialized(); olmalı.

2. Anahtarları kodun her yerine string olarak dağıtmak

Bir yerde 'themeMode', başka yerde 'theme_mode' yazarsan değer asla okunmaz ve hata da almazsın, sadece varsayılan değer gelir. Anahtarları tek bir sınıfta sabit olarak tut; SharedPreferencesWithCache kullanıyorsan aynı sabitleri allowList için de kullan. Yayındaki bir uygulamada anahtarın adını sonradan değiştirmek de aynı sonucu doğurur: mevcut kullanıcıların değeri eski adın altında kalır ve bir daha okunmaz. Anahtar adını bir kez belirle, gerekiyorsa eski adı okuyup yeni ada taşıyan küçük bir geçiş kodu yaz.

3. Eski API'den yeni API'ye geçip veriyi "kaybetmek"

Belirti: Güncellemeden sonra kullanıcıların ayarları sıfırlanmış görünüyor. Android'de eski ve yeni API farklı depolara yazar. Geçişi yukarıdaki migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary ile yap.

4. Hassas ya da kritik veriyi burada tutmak

Token ve şifre şifrelenmeden saklanır; bir ödeme kaydı gibi kaybolmaması gereken veri için ise yazmanın diske ulaştığı garanti edilmez. İlki için güvenli depolama, ikincisi için veritabanı ve mümkünse sunucu tarafında kayıt gerekir.

5. Değeri her ekranda FutureBuilder ile yeniden okumak

Her ekranın açılışta await prefs.getBool(...) için bir FutureBuilder kurması hem kod tekrarı hem de her açılışta kısa bir yükleniyor anı demektir. Uygulama genelindeki ayarları açılışta bir kez yükleyip yukarıdaki gibi bir controller'da tut; ekranlar oradan okusun.

Sık Sorulan Sorular

Değer değişince ekran kendiliğinden güncellenir mi?

Hayır. shared_preferences bir değişiklik akışı ya da dinleyici sunmaz; sadece yazar ve okur. Ekranın tepki vermesini istiyorsan değeri bir ChangeNotifier (ya da Provider, Riverpod) içinde tut, değişiklikte hem dinleyicilere haber ver hem de depoya yaz. Yukarıdaki ThemeController tam olarak bunu yapıyor.

Uygulama silinip yeniden kurulunca kayıtlar gider mi?

Genelde evet, ama iki istisnaya dikkat et. Android'de Auto Backup açıksa (varsayılan olarak açıktır) uygulama verisi Google hesabına yedeklenebilir ve yeniden kurulumda geri yüklenebilir; "tanıtım ekranı görüldü" bayrağı yeni kurulumda da true gelebilir. iOS'ta ise flutter_secure_storage'ın kullandığı Keychain kayıtları uygulama silindikten sonra da kalabilir. Bu davranışlara güvenerek ya da bunları yok sayarak mantık kurma; yeniden kurulumda ne olmasını istediğini açıkça tasarla.

Widget testlerinde SharedPreferences nasıl kullanılır?

Testte gerçek platform deposu yoktur. Eski API için SharedPreferences.setMockInitialValues({}) ile bellekte bir depo kurulur. Yeni API'ler için shared_preferences_platform_interface paketini dev bağımlılığı olarak ekleyip test başında SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); satırını çalıştırırsın; SharedPreferencesAsync ve SharedPreferencesWithCache bundan sonra bellekteki bu depoyu kullanır.

Yorumlar