Start Debugging

shared_preferences vs un archivo JSON con path_provider para guardar ajustes en Flutter

Usa shared_preferences (SharedPreferencesWithCache) para un puñado de flags planos y un archivo JSON en getApplicationSupportDirectory para un objeto de ajustes estructurado. Cubre el patrón de escritura atómica, la condición de carrera de los guardados concurrentes, la trampa de la carpeta Documentos en Windows y Linux, el soporte web y costos de escritura medidos en Flutter 3.44.8.

Si tus ajustes son una docena de valores planos e independientes (tema, un flag de “onboarding visto”, la última pestaña seleccionada), usa shared_preferences con la API SharedPreferencesWithCache: lecturas síncronas, soporte web y nada de manejo de archivos. En cuanto los ajustes se conviertan en un solo objeto estructurado con mapas anidados, listas o un esquema que necesites migrar, cambia a un único archivo JSON escrito de forma atómica (archivo temporal más rename, serializado) en getApplicationSupportDirectory(), no en getApplicationDocumentsDirectory(). Todo lo que sigue se probó en Flutter 3.44.8 (Dart 3.12.2) con shared_preferences 2.5.6 y path_provider 2.1.6, ejecutándose como una build de depuración real de macOS en un Apple M4.

La comparación de un vistazo

shared_preferences 2.5.6Archivo JSON vía path_provider 2.1.6
Tipos de valorbool, int, double, String, List<String>Todo lo que maneja jsonEncode (mapas anidados, listas, nulls)
API de lecturaSíncrona (SharedPreferencesWithCache) o asíncrona (SharedPreferencesAsync)Carga asíncrona una vez, luego tu propio objeto en memoria
Durabilidad de escritura”No hay garantía” de que las escrituras lleguen al disco al retornar (README)Tú la controlas: flush: true + rename atómico
Actualizaciones parcialesPor clave, integradasReescritura del archivo completo (bien por debajo de unos cientos de KB)
Migración de esquemaPor clave, manualUn campo schemaVersion, una función de migración
WebSí (localStorage)No: path_provider no tiene implementación web
Almacenamiento en AndroidDataStore Preferences (APIs nuevas) o SharedPreferences (legacy)Un archivo en el directorio de datos privado de la app
Almacenamiento en iOS/macOSplist de NSUserDefaultsUn archivo en Application Support
Legible por código nativo / widgetsSí, es el almacén de la plataformaSolo si el código nativo lee tu archivo
Trampas de concurrenciaLas cachés por isolate quedan desactualizadasLos guardados concurrentes corrompen el archivo temporal si no se serializan
Código que mantienesCasi nadaUnas 60 líneas

Ninguno de los dos es “la base de datos”. Si guardas contenido del usuario, listas que crecen o cualquier cosa que consultes, quieres SQLite (drift, sqflite), no ninguno de estos.

Cuándo elegir shared_preferences

Esta es la versión que publicaría hoy. Usa SharedPreferencesWithCache con una allow list, que el README recomienda para código nuevo en lugar de la clase legacy SharedPreferences:

// Flutter 3.44.8, Dart 3.12.2, shared_preferences 2.5.6
import 'package:shared_preferences/shared_preferences.dart';

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

  final SharedPreferencesWithCache _prefs;

  static const _themeMode = 'themeMode';
  static const _fontScale = 'fontScale';
  static const _onboardingDone = 'onboardingDone';

  static Future<PrefsSettingsStore> open() async {
    final prefs = await SharedPreferencesWithCache.create(
      cacheOptions: const SharedPreferencesWithCacheOptions(
        allowList: {_themeMode, _fontScale, _onboardingDone},
      ),
    );
    return PrefsSettingsStore._(prefs);
  }

  // Synchronous reads after open(): safe to call from build().
  String get themeMode => _prefs.getString(_themeMode) ?? 'system';
  double get fontScale => _prefs.getDouble(_fontScale) ?? 1.0;
  bool get onboardingDone => _prefs.getBool(_onboardingDone) ?? false;

  Future<void> setThemeMode(String value) => _prefs.setString(_themeMode, value);
  Future<void> setFontScale(double value) => _prefs.setDouble(_fontScale, value);
  Future<void> completeOnboarding() => _prefs.setBool(_onboardingDone, true);
}

La allow list no es decoración. Con ella, un error tipográfico como getString('themMode') lanza un ArgumentError (“themMode is not included in the PreferencesFilter allowlist”) en lugar de devolver null en silencio, y clear() solo elimina tus claves en vez de todo lo que el plugin puede ver.

Cuándo elegir un archivo JSON

El almacén de abajo es el que uso. Fíjate en los tres detalles que la mayoría de los tutoriales omiten: el directorio, la escritura atómica y la cola de escrituras.

// Flutter 3.44.8, Dart 3.12.2, path_provider 2.1.6
import 'dart:convert';
import 'dart:io';

import 'package:path_provider/path_provider.dart';

class AppSettings {
  const AppSettings({
    this.themeMode = 'system',
    this.fontScale = 1.0,
    this.notifications = const {'replies': true, 'digest': 'weekly'},
  });

  final String themeMode;
  final double fontScale;
  final Map<String, Object?> notifications;

  static const schemaVersion = 2;

  AppSettings copyWith({String? themeMode, double? fontScale}) => AppSettings(
        themeMode: themeMode ?? this.themeMode,
        fontScale: fontScale ?? this.fontScale,
        notifications: notifications,
      );

  Map<String, Object?> toJson() => {
        'schemaVersion': schemaVersion,
        'themeMode': themeMode,
        'fontScale': fontScale,
        'notifications': notifications,
      };

  factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
        themeMode: json['themeMode'] as String? ?? 'system',
        fontScale: (json['fontScale'] as num?)?.toDouble() ?? 1.0,
        notifications:
            (json['notifications'] as Map<String, dynamic>?) ?? const {},
      );
}

class JsonSettingsStore {
  JsonSettingsStore._(this._file);

  final File _file;
  Future<void> _tail = Future<void>.value();

  static Future<JsonSettingsStore> open() async {
    // Application Support, not Documents: see the Windows/Linux gotcha below.
    final dir = await getApplicationSupportDirectory();
    await dir.create(recursive: true);
    return JsonSettingsStore._(File('${dir.path}/settings.json'));
  }

  Future<AppSettings> load() async {
    if (!await _file.exists()) return const AppSettings();
    try {
      final json = jsonDecode(await _file.readAsString()) as Map<String, dynamic>;
      return AppSettings.fromJson(_migrate(json));
    } on FormatException {
      // Truncated or hand-edited file: keep a copy for diagnostics, start fresh.
      await _file.copy('${_file.path}.corrupt');
      return const AppSettings();
    }
  }

  Future<void> save(AppSettings settings) {
    // Chain every write so two saves never share the temp file.
    return _tail = _tail.then((_) => _writeAtomic(settings.toJson()));
  }

  Future<void> _writeAtomic(Map<String, Object?> data) async {
    final tmp = File('${_file.path}.tmp');
    await tmp.writeAsString(jsonEncode(data), flush: true);
    await tmp.rename(_file.path);
  }

  static Map<String, dynamic> _migrate(Map<String, dynamic> json) {
    final version = json['schemaVersion'] as int? ?? 1;
    if (version < 2) {
      // v1 stored a bool 'darkMode'; v2 stores a 'themeMode' string.
      json['themeMode'] = (json.remove('darkMode') as bool? ?? false) ? 'dark' : 'light';
    }
    return json;
  }
}

Cárgalo una vez al arrancar, mantén el AppSettings en tu capa de gestión de estado (un ValueNotifier, un Notifier de Riverpod, lo que ya uses) y llama a save() en cada cambio. Las lecturas nunca tocan el disco después del arranque, que es el mismo intercambio que SharedPreferencesWithCache hace por ti.

La condición de carrera de los guardados concurrentes que rompe los almacenes JSON ingenuos

La versión más común de este patrón en internet es writeAsString a un archivo .tmp y luego rename, sin cola. Parece atómico. Lo es, para un solo llamador. Conéctalo al onChanged de un slider y obtienes muchos guardados superpuestos que comparten la misma ruta temporal. Lancé 50 guardados sin await a la vez en Dart 3.12.2 puro:

// Dart 3.12.2
await Future.wait([
  for (var i = 0; i < 50; i++)
    writeAtomic(file, {'counter': i}).catchError((Object e) { errors++; }),
]);

Resultado: 49 de las 50 escrituras lanzaron PathNotFoundException: Cannot rename file ... (OS Error: No such file or directory, errno = 2). El primer rename movió el archivo temporal y cada uno de los demás guardados intentó renombrar un archivo que ya no existía. Según el timing también puedes acabar con los bytes de un guardado renombrados a su lugar mientras otro sigue escribiendo en la misma ruta. Con la cadena _tail = _tail.then(...) del almacén de arriba, los mismos 50 guardados produjeron 0 errores y el archivo contenía el último valor. En mi ejecución el archivo final era correcto incluso en el caso roto, que es justamente por lo que este bug sobrevive a la revisión de código: solo aparece como excepciones en tu reporte de crashes.

Si guardas en cada pulsación de tecla o movimiento del slider, aplica también un debounce (300 ms es suficiente). La cola hace que los guardados concurrentes sean seguros; el debounce los hace baratos.

Lo que cuestan realmente las escrituras

Ambos enfoques son lo bastante rápidos como para que el rendimiento no decida, pero “lo bastante rápido” merece un número. Medí la ruta de escritura del almacén JSON (codificar, escribir el archivo temporal, rename) con un payload de ajustes de 927 bytes (unas 40 claves, un mapa anidado, una lista de 20 elementos), 200 iteraciones de calentamiento y luego 2 000 iteraciones cronometradas, con Stopwatch bajo un dart run JIT (Dart 3.12.2) en un Apple M4, macOS, APFS:

OperaciónMedianap95p99
Codificar + escribir + flush: true + rename177 µs235 µs318 µs
Codificar + escribir + rename (sin flush)155 µs247 µs328 µs
Leer + jsonDecode62 µs69 µsn/a

Dos advertencias. En el SSD del M4 el flush agregó solo unos 20 µs a la mediana; en almacenamiento flash de Android de gama baja espera que cueste bastante más, otra razón para aplicar debounce. Y no medí las escrituras de shared_preferences, porque pasan por un platform channel hacia NSUserDefaults o DataStore y el costo depende de la plataforma, no del plugin. Ninguno de los dos aparecerá en el presupuesto de un frame con payloads del tamaño de unos ajustes. El enfoque del archivo solo se vuelve un problema si los “ajustes” crecen hasta megabytes de datos en caché, y en ese punto ya no debería ser un archivo de ajustes.

Las trampas que deciden por ti

getApplicationDocumentsDirectory() es la carpeta Documentos del usuario en escritorio. En Windows, path_provider_windows la mapea a WindowsKnownFolder.Documents. En Linux es el directorio de usuario XDG DOCUMENTS. Tu settings.json termina junto a las declaraciones de impuestos del usuario, OneDrive lo sincroniza y la gente que ordena sus carpetas lo borra. En iOS queda expuesto al usuario en cuanto activas UIFileSharingEnabled. La documentación de path_provider dice lo mismo a su manera: Documents es para datos generados por el usuario y, si no es el caso, conviene considerar getApplicationSupportDirectory(). Application Support se mapea a AppData roaming en Windows, a XDG_DATA_HOME/<app id> en Linux y a Library/Application Support/<bundle id> dentro del sandbox en macOS (confirmé que settings.json terminó en ~/Library/Containers/com.example.settingsDemo/Data/Library/Application Support/com.example.settingsDemo/ en la build de macOS). Todo tutorial de ajustes en JSON que usa Documents está mal en escritorio.

La web descarta el archivo. Si la app apunta a la web, la decisión está tomada: usa shared_preferences y guarda tu objeto estructurado como un solo string generado con jsonEncode bajo una única clave si necesitas anidación. Pierdes la garantía de durabilidad pero conservas el modelo de un solo objeto.

Los isolates y engines en segundo plano ven cachés desactualizadas. El README de shared_preferences es explícito: SharedPreferences y SharedPreferencesWithCache mantienen una caché por isolate, así que un valor escrito desde un handler en segundo plano de firebase_messaging o desde un segundo engine es invisible para el isolate principal hasta que llames a reload(). Si varios isolates escriben los mismos ajustes, usa SharedPreferencesAsync, que se salta la caché. Un archivo JSON tiene el mismo problema en una forma peor: dos isolates, cada uno con su propia cola de escritura, todavía pueden intercalarse, así que mantén todas las escrituras en un solo isolate y envíale mensajes desde los demás. Si ya haces trabajo pesado en un isolate en segundo plano para tareas intensivas de CPU, no dejes que sea el dueño del archivo de ajustes.

Las claves legacy llevan prefijo, las nuevas no. La clase legacy SharedPreferences guarda cada clave con el prefijo flutter.. SharedPreferencesWithCache y SharedPreferencesAsync no: en mi build de macOS el plist contenía una clave themeMode sin prefijo. En Android las APIs nuevas usan DataStore por defecto, un archivo completamente distinto. Así que cambiar de API en una app ya publicada es una migración de datos, no un refactor. Ejecuta migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary de package:shared_preferences/util/legacy_to_async_migration_util.dart al arrancar, o tus usuarios perderán sus ajustes al actualizar.

El backup de Android aplica a ambos. Auto Backup incluye por defecto los archivos privados de la app y sus almacenes de preferencias, así que pasar a un archivo JSON no te saca del backup en la nube. Si un ajuste es específico del dispositivo (una URL de servidor en caché, un toggle de depuración), exclúyelo en tus reglas de backup en cualquiera de los dos casos.

Lo que recomiendo

Empieza con shared_preferences y SharedPreferencesWithCache para flags planos. Es el valor por defecto correcto para la mayoría de las apps, funciona en todas las plataformas a las que apunta Flutter y es la única opción en la web. En el momento en que te sorprendas inventando nombres de clave con puntos, escribiendo tres claves relacionadas que deben cambiar juntas o queriendo una versión de esquema, mueve la parte estructurada a un único archivo JSON en getApplicationSupportDirectory(), escrito con flush, rename y una cola serializada. Muchas apps terminan con ambos: unos pocos flags en shared_preferences que necesitan el código nativo o el primer frame, y un settings.json para el objeto de ajustes real.

Si los datos no son ajustes en absoluto (contenido del usuario, historial, cualquier cosa que crezca), sáltate ambos y usa SQLite. Expliqué cómo mover los datos locales de una app de esa manera en migrar una app de Flutter de Hive a drift.

Para leer después

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver