Flutter'da Local Storage ve Offline: shared_preferences, flutter_secure_storage, SQLite/drift ve Cache Stratejisi
Flutter'da küçük ve gizli olmayan ayarlar shared_preferences'a, token'lar flutter_secure_storage'a (iOS Keychain, Android Keystore), sorgulanan veri drift ile SQLite'a yazılır. Bookstore'un sepetini drift'e taşıyor, migration ve reactive query'yi deniyor, katalogu fresh cache, ağ ve stale cache sırasıyla internet yokken de gösteriyoruz; ViewModel'ler değişmeden.
Sürüm notu: Bu yazıdaki örnekler Flutter 3.47.8 (Dart 3.13.7), shared_preferences 2.5.6, flutter_secure_storage 11.2.0, drift 2.35.4 (drift_dev 2.35.3, sqlite3 3.5.2), drift_flutter 0.3.1 ve build_runner 2.17.1 ile denenmiştir. Çıktılar flutter test koşularından; testlerde veritabanı geçici bir dosyada ya da bellekte, platform plugin'leri ise test için sağlanan bellek içi karşılıklarıyla çalıştı. Keychain, Android Keystore ve cihazda uygulamayı kapatıp açmak cihaz gerektirdiği için çalıştırılmadı; o kısımlar dokümana göre.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) mobil uygulamasında kullanıcı sabah kataloğa baktı. 35 dakika sonra metroda, internet yokken uygulamayı tekrar açıyor. Önceki yazının sonundaki kodla ekranda ne görür: boş bir liste mi, "Try again" hatası mı, sabahki kitaplar mı? Bu yazının sonunda üçüncüsünü, ama kullanıcıya bunun eski veri olduğunu söyleyerek görecek.
Ne Nerede Saklanır?
Cihazda veri tutmanın üç yaygın yolu var ve her biri farklı bir iş için:
| Veri | Paket | Cihazda nerede | Bookstore'da |
|---|---|---|---|
| Küçük, gizli olmayan ayar | shared_preferences | iOS: NSUserDefaults, Android: DataStore Preferences, web: localStorage | son giriş yapılan e-posta |
| Gizli küçük değer | flutter_secure_storage | iOS: Keychain, Android: Keystore anahtarıyla şifrelenmiş depolama | access ve refresh token |
| Yapılandırılmış veri, sorgu | drift (SQLite) | uygulamanın kendi klasöründe bir SQLite dosyası | katalog cache'i ve sepet |
Kural basit: arayacağın, birleştireceğin, sayfalayacağın veri veritabanına; tek bir değer ayara; çalınırsa zarar verecek değer secure storage'a. Token'ı shared_preferences'a yazmak en sık görülen hata: Android'de bu dosya şifresiz bir XML ya da DataStore dosyası, root'lu bir cihazda ya da yedekte okunabilir.
shared_preferences: Küçük ve Gizli Olmayan Değerler
Paketin üç API'si var. Eski SharedPreferences class'ı dokümana göre ileride deprecated olacak; yeni kodda SharedPreferencesAsync (her okuma platforma gider) ya da SharedPreferencesWithCache (açılışta bir kez okur, sonra senkron getter) öneriliyor. Paketin README'si ayrıca yazmanın diske ulaşmasının garanti olmadığını, bu yüzden kritik veri için kullanılmaması gerektiğini söylüyor. Bookstore'da yalnız son giriş yapılan e-postayı tutuyoruz:
// Gizli değil: yalnız kimin giriş yaptığını göstermek ve formu doldurmak için
final SharedPreferencesAsync _prefs;
static const _emailKey = 'last_email';
String? _email;
String? _lastEmail;
@override
String? get lastEmail => _lastEmail;
/// Açılışta bir kez çağrılır: cihazda token varsa kullanıcı giriş yapmıştır.
Future<void> restore() async {
_lastEmail = await _prefs.getString(_emailKey);
if (await _api.tokens.read() != null) _email = _lastEmail;
}
main artık async: plugin'ler runApp'ten önce çağrılacağı için önce WidgetsFlutterBinding.ensureInitialized(), sonra await auth.restore(). Böylece router ilk redirect'ini doğru giriş durumuyla çalıştırıyor; açılışta bir an giriş sayfası görünüp kaybolmuyor.
flutter_secure_storage: Token'lar
Önceki yazıdaki TokenStore interface'inin ikinci implementasyonu:
/// Token'lar iOS'ta Keychain'de, Android'de Keystore ile şifrelenmiş depoda.
class SecureTokenStore implements TokenStore {
SecureTokenStore([this._storage = const FlutterSecureStorage()]);
final FlutterSecureStorage _storage;
static const _access = 'access_token';
static const _refresh = 'refresh_token';
@override
Future<Tokens?> read() async {
final access = await _storage.read(key: _access);
final refresh = await _storage.read(key: _refresh);
if (access == null || refresh == null) return null;
return (accessToken: access, refreshToken: refresh);
}
ApiClient ve ApiAuthRepository değişmedi; main'de InMemoryTokenStore yerine SecureTokenStore() veriliyor. Testte plugin'in platform tarafı yok; FlutterSecureStorage.setMockInitialValues({}) ve InMemorySharedPreferencesAsync ile "uygulamayı kapatıp açmayı" yeni object'lerle aynı depoyu okuyarak taklit ettik:
before restore: false
after restore: true, ada@example.com
stored keys: [access_token, refresh_token]
after sign-out: false, form prefilled with ada@example.com
Paketin dokümanına göre Android'de 10.0'dan beri veriyi bir AES (simetrik şifreleme standardı) anahtarı şifreliyor, o anahtarı da Keystore'daki bir RSA (açık anahtarlı şifreleme algoritması) anahtarı koruyor; minimum Android SDK 23. Web'de ise durum farklı: WebCrypto ile şifreleyen deneysel bir implementasyon, anahtarlar localStorage'da ve yalnız HTTPS ya da localhost'ta çalışıyor. Yani web'de "secure" kelimesi Keychain'in verdiği korumayı vermiyor. Web build'imiz açılışta token okuyup katalog listesini Chromium'da sorunsuz yükledi, ama web'de token güvenliği için doğru yer token'ı tarayıcıya hiç vermeyen BFF (Backend for Frontend; arayüze özel sunucu katmanı) yaklaşımı; Authentication ve Authorization serisinin BFF yazısında anlatılıyor.
drift: Tablolar, Join ve EF Core Karşılığı
drift, SQLite üstünde tip güvenli sorgular için bir library; tabloları Dart class'ı olarak yazıyorsun, build_runner sorgu kodunu üretiyor. Entity Framework Core (EF Core) bilen biri için eşleme şöyle: AppDatabase bir DbContext, tablo getter'ları DbSet, sorgu builder'ı LINQ'in (Language Integrated Query; C#'ın sorgu syntax'ı) karşılığı, schemaVersion ve onUpgrade ise migration'lar. En büyük fark change tracking olmaması: her yazma açık bir insert/update/delete çağrısı.
/// Uygulamanın gördüğü kitaplar: katalog cache'i ve sepetteki kitaplar.
@DataClassName('CachedBook')
class Books extends Table {
TextColumn get isbn => text()();
TextColumn get title => text()();
TextColumn get author => text()();
IntColumn get priceKurus => integer()();
IntColumn get publishedYear => integer()();
// Şema sürümü 2'de eklendi
TextColumn get description => text().nullable()();
// Kitabın katalogdaki yeri; başka yerden geldiyse null
IntColumn get catalogPage => integer().nullable()();
IntColumn get catalogPosition => integer().nullable()();
DateTimeColumn get fetchedAt => dateTime()();
@override
Set<Column> get primaryKey => {isbn};
}
@DataClassName('CartLineRow')
class CartLines extends Table {
TextColumn get isbn => text().references(Books, #isbn)();
IntColumn get quantity => integer()();
@override
Set<Column> get primaryKey => {isbn};
}
@DataClassName('CartLineRow') bir ad çakışmasından: drift varsayılan olarak CartLines tablosu için CartLine adında bir satır class'ı üretiyor, bizim domain'deki CartLine record'uyla aynı ad. dart run build_runner build ilk koşuda "Built with build_runner/aot in 17s" dedi; üretilen app_database.g.dart'ı repoya koyuyoruz ki proje build_runner çalıştırmadan derlensin. Sepet artık iki tablodan join ile okunuyor:
@override
Future<Result<List<CartLine>>> getLines() async {
// SELECT ... FROM cart_lines INNER JOIN books ON books.isbn = cart_lines.isbn
final query = _db.select(_db.cartLines).join([
innerJoin(_db.books, _db.books.isbn.equalsExp(_db.cartLines.isbn)),
])..orderBy([OrderingTerm.asc(_db.books.title)]);
final rows = await query.get();
return Result.ok([
for (final row in rows)
(
book: bookFromRow(row.readTable(_db.books)),
quantity: row.readTable(_db.cartLines).quantity,
),
]);
}
DriftCartRepository, CartRepository'nin üç method'unu aynı sözleşmeyle yazıyor; CartViewModel ve sepet ekranı değişmedi. Testte veritabanını geçici bir dosyada açıp kapattık ve yeniden açtık:
after reopen: Dune x2, Emma x1
Testlerdeki NativeDatabase gerçek SQLite kullanıyor. sqlite3 paketi native kütüphaneyi build hook ile sağlıyor: varsayılan ayarda paketin GitHub sürümlerinde yayımlanan ve sha256 ile doğrulanan derlenmiş SQLite ilk build'de indiriliyor. Cihazda veritabanını drift_flutter'ın driftDatabase(name: 'bookstore')'u uygulamanın veri klasöründe açıyor.
Migration ve Reactive Query
Uygulama bir kez yayınlandıktan sonra şema değişikliği, cihazlardaki mevcut veriyi bozmadan yapılmalı. Kullanıcı sürüm 1'den sürüm 2'ye güncellediğinde onun SQLite dosyası hâlâ eski şemada. drift bunu SQLite'ın user_version değeriyle takip ediyor:
@override
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
// Sürüm 1'de description kolonu yoktu
if (from < 2) await m.addColumn(books, books.description);
},
beforeOpen: (details) => customStatement('PRAGMA foreign_keys = ON'),
);
Testte veritabanını sürüm 1'in oluşturacağı tablolar, bir satır ve PRAGMA user_version = 1 ile hazırlayıp AppDatabase'i üstünde açtık:
user_version: 2, columns: isbn, title, author, price_kurus, published_year, catalog_page, catalog_position, fetched_at, description
kept row: Dune, description: null
EF Core'daki Migrate()'in karşılığı, ama migration kodunu elle yazıyorsun. drift_dev'in şema dosyalarını kaydedip migration'ları her eski sürümden test eden araçları var; birden fazla sürüm yayınlandıktan sonra bunlar elle yazılan if'lerden daha güvenli.
drift'in EF Core'da doğrudan karşılığı olmayan yanı reactive query: watch() ile açılan sorgu, ilgili tablo her değiştiğinde yeni sonuç yayınlayan bir Stream. Sepetteki toplam kitap sayısı:
/// Sepetteki kitap sayısını şimdi ve her değişiklikten sonra yayınlar.
Stream<int> watchCartCount() {
final total = cartLines.quantity.sum();
return (selectOnly(
cartLines,
)..addColumns([total])).map((row) => row.read(total) ?? 0).watchSingle();
}
cart counts: [0, 2, 5, 0]
İki Dune, üç Emma ve clear(): her yazmadan sonra stream yeni toplamı verdi. Bookstore'un ViewModel'leri Future dönen repository'lerle çalıştığı için bu stream'i şimdilik yalnız testte kullandık; birden fazla ekranın aynı veriyi canlı göstermesi gerektiğinde repository'nin Stream dönmesi doğal bir sonraki adım.
Offline Katalog: Fresh Cache, Ağ, Stale Cache
Katalog için CachedBookRepository, ApiBookRepository'yi sarıyor ve yine BookRepository olarak görünüyor. Strateji üç adım: cache 10 dakikadan yeniyse ağa hiç gitme; değilse API'yi dene ve sonucu kaydet; ağ yoksa eski kayıtları göster ve bunu bildir.
@override
Future<Result<List<Book>>> getPage(int page) async {
final cached = await _cachedPage(page);
final fetchedAt = cached.isEmpty ? null : cached.first.fetchedAt;
if (fetchedAt != null && _now().difference(fetchedAt) < maxAge) {
_status.showingSaved(null);
return Result.ok([for (final row in cached) bookFromRow(row)]);
}
switch (await _remote.getPage(page)) {
case Ok(:final value):
await _savePage(page, value);
_status.showingSaved(null);
return Result.ok(value);
// Yalnız ağ hatası cache'e düşer; 500 ya da bozuk JSON hata olarak kalır
case Failure(error: NetworkException()) when cached.isNotEmpty:
_status.showingSaved(fetchedAt);
return Result.ok([for (final row in cached) bookFromRow(row)]);
case Failure(:final error):
return Result.failure(error);
}
}
Constructor'da Dart 3.12'nin private named parameter'larını kullandık: required this._remote yazılıyor, çağıran taraf remote: diyor. "Önce Tahmin Et"in cevabı, saati elle ilerleten testte:
remote calls after 5 min: [0]
offline after 35 min: 20 books, first Dune, status offline: true, saved at 2026-11-15 10:00:00.000Z (read back as UTC: false)
500 with a cache: Server error
Beşinci dakikada API'ye ikinci istek gitmedi. 35. dakikada ağ yoktu ve kullanıcı sabahki 20 kitabı gördü; OfflineStatus bildirim yaptı ve alt menünün üstünde "Offline: showing books saved on ..." banner'ı çıktı (widget testi: banner: 1). 500 hatası ise cache'in arkasına saklanmadı: sunucu hatası ile bağlantı yokluğu farklı şeyler ve ilkini gizlemek sorunu fark etmeyi geciktirir.
Bu tam bir stale-while-revalidate (eski veriyi hemen gösterip arka planda tazelemek) değil: Future tek bir değer döndürebildiği için eski veriyi gösterip ardından yenisini aynı çağrıyla iletemiyoruz. Gerçek stale-while-revalidate için repository'nin Stream dönmesi ve ViewModel'in onu dinlemesi gerekir; drift'in watch()'u bunun için hazır.
Üretimde Dikkat
Web'de drift ayrıca sqlite3.wasm ve bir worker dosyası istiyor. Seri Android ve iOS'a odaklı olduğu için main web'de veritabanı açmıyor (kIsWeb); katalog ve sepet orada bellekte kalıyor.
Android'in otomatik yedeklemesi. flutter_secure_storage dokümanı, yedekten geri yüklenen şifreli verinin yeni cihazda anahtarı olmadığı için InvalidKeyException verebileceğini ve android:allowBackup="false" ya da yedek kurallarıyla bu dosyaların dışarıda bırakılmasını öneriyor. iOS'ta ise Keychain kayıtları uygulama silinip yeniden kurulduğunda kalabiliyor; ilk açılışta eski token'ları temizlemek için shared_preferences'ta bir "ilk kurulum" işareti kullanılabilir. İkisini de cihazda denemedik.
Zaman damgaları. drift DateTime'ı varsayılan olarak Unix zamanı olarak saklıyor ve yerel saat olarak geri veriyor; çıktıdaki read back as UTC: false bu. Karşılaştırma doğru çalışır, ama loglarda ya da API'ye geri gönderirken toUtc() unutulursa saat dilimi farkı görünür. drift'in build ayarlarında tarihleri 2026-11-15T10:00:00.000Z gibi metin olarak saklama seçeneği de var.
Cache'i sınırla. Katalog sayfaları catalogPage'e göre üzerine yazılıyor ama kitap satırları hiç silinmiyor; aylarca kullanılan bir uygulamada eski satırları açılışta temizlemek gerekir.
Sık Düşülen Tuzaklar
Birinci tuzak, token'ı shared_preferences'a yazmak. Çalışır, testten geçer ve şifresiz durur.
İkinci tuzak, WidgetsFlutterBinding.ensureInitialized()'ı unutup runApp'ten önce plugin çağırmak; uygulama açılışta hata verir.
Üçüncü tuzak, şemayı değiştirip schemaVersion'ı artırmamak ya da yalnız onCreate'i güncellemek. Yeni kurulumlarda her şey çalışır, güncelleme yapan kullanıcıların uygulaması "no such column" ile çöker.
Dördüncü tuzak, her hatada cache'e düşmek. Kullanıcı eski fiyatları görür, ekip de sunucu hatasını geç fark eder.
Küçük Bir Deneme
★☆☆ maxAge'i 10 dakikadan 1 dakikaya düşür ve testteki "remote calls after 5 min" satırının nasıl değiştiğini gör.
★★☆ Kitap detay sayfası da cache'e yazsın: getByIsbn başarılı olunca kitabı description ile birlikte Books tablosuna kaydet ve internet yokken deep link ile açılan detay sayfasının çalıştığını testle göster.
★★★ Sepet rozetini watchCartCount()'a bağla: CartRepository'ye Stream<int> watchItemCount() ekle, CartViewModel bu stream'i dinlesin ve dispose'da aboneliği kapatsın. Aynı anda iki ekran sepeti değiştirdiğinde rozetin doğru kaldığını widget testiyle doğrula.
Bir sonraki yazıda testlere toplu bakıyoruz: fake repository'ler ve mocktail, provider ve go_router ile widget testleri, integration_test paketiyle uçtan uca akış ve golden test'lerin platform farkı.