Saltar al contenido principal

Arquitectura de almacenamiento persistente: Datos clave-valor

Guarda los datos de la aplicación en el almacenamiento de clave-valor del dispositivo del usuario.

La mayoría de las aplicaciones Flutter, sin importar qué tan pequeñas o grandes sean, requieren almacenar datos en el dispositivo del usuario en algún momento, como claves de API, preferencias del usuario o datos que deberían estar disponibles sin conexión.

En esta receta, aprenderás a integrar el almacenamiento persistente para datos clave-valor en una aplicación Flutter que utiliza el diseño de arquitectura de Flutter recomendado. Si no estás familiarizado en absoluto con el almacenamiento de datos en disco, puedes leer la receta Almacenar datos clave-valor en el disco.

Los almacenes de clave-valor se utilizan a menudo para guardar datos simples, como la configuración de la app, y en esta receta los usarás para guardar las preferencias del Modo Oscuro. Si quieres aprender a almacenar datos complejos en un dispositivo, probablemente querrás usar SQL. En ese caso, echa un vistazo a la receta del cookbook que sigue a esta, llamada Arquitectura de almacenamiento persistente: SQL.

Aplicación de ejemplo: App con selección de tema

#

La aplicación de ejemplo consiste en una sola pantalla con una barra de aplicación en la parte superior, una lista de elementos y una entrada de campo de texto en la parte inferior.

Aplicación ToDo en modo claro

En el AppBar, un Switch permite a los usuarios cambiar entre los modos de tema oscuro y claro. Esta configuración se aplica inmediatamente y se almacena en el dispositivo usando un servicio de almacenamiento de datos clave-valor. La configuración se restaura cuando el usuario inicia la aplicación nuevamente.

Aplicación ToDo en modo oscuro

Almacenar datos clave-valor de selección de tema

#

Esta funcionalidad sigue el patrón de diseño de arquitectura de Flutter recomendado, con una capa de presentación y una de datos.

  • La capa de presentación contiene el Widget ThemeSwitch y el ThemeSwitchViewModel.
  • La capa de datos contiene el ThemeRepository y el SharedPreferencesService.

Capa de presentación de selección de tema

#

El ThemeSwitch es un StatelessWidget que contiene un Widget Switch. El State del switch está representado por el campo público isDarkMode en el ThemeSwitchViewModel. Cuando el usuario toca el switch, el código ejecuta el comando toggle en el view model.

dart
class ThemeSwitch extends StatelessWidget {
  const ThemeSwitch({super.key, required this.viewmodel});

  final ThemeSwitchViewModel viewmodel;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16.0),
      child: Row(
        children: [
          const Text('Dark Mode'),
          ListenableBuilder(
            listenable: viewmodel,
            builder: (context, _) {
              return Switch(
                value: viewmodel.isDarkMode,
                onChanged: (_) {
                  viewmodel.toggle.execute();
                },
              );
            },
          ),
        ],
      ),
    );
  }
}

El ThemeSwitchViewModel implementa un view model como se describe en el patrón MVVM. Este view model contiene el State del Widget ThemeSwitch, representado por la variable booleana _isDarkMode.

El view model utiliza el ThemeRepository para almacenar y cargar la configuración del modo oscuro.

Contiene dos acciones de comando diferentes: load, que carga la configuración del modo oscuro desde el repositorio, y toggle, que cambia el State entre modo oscuro y modo claro. Expone el State a través del getter isDarkMode.

El método _load implementa el comando load. Este método llama a ThemeRepository.isDarkMode para obtener la configuración almacenada y llama a notifyListeners() para refrescar la UI.

El método _toggle implementa el comando toggle. Este método llama a ThemeRepository.setDarkMode para almacenar la nueva configuración del modo oscuro. Asimismo, cambia el State local de _isDarkMode y luego llama a notifyListeners() para actualizar la UI.

dart
class ThemeSwitchViewModel extends ChangeNotifier {
  ThemeSwitchViewModel(this._themeRepository) {
    load = Command0(_load)..execute();
    toggle = Command0(_toggle);
  }

  final ThemeRepository _themeRepository;

  bool _isDarkMode = false;

  /// If true show dark mode
  bool get isDarkMode => _isDarkMode;

  late final Command0<void> load;

  late final Command0<void> toggle;

  /// Load the current theme setting from the repository
  Future<Result<void>> _load() async {
    final result = await _themeRepository.isDarkMode();
    if (result is Ok<bool>) {
      _isDarkMode = result.value;
    }
    notifyListeners();
    return result;
  }

  /// Toggle the theme setting
  Future<Result<void>> _toggle() async {
    _isDarkMode = !_isDarkMode;
    final result = await _themeRepository.setDarkMode(_isDarkMode);
    notifyListeners();
    return result;
  }
}

Capa de datos de selección de tema

#

Siguiendo las pautas de arquitectura, la capa de datos se divide en dos partes: el ThemeRepository y el SharedPreferencesService.

El ThemeRepository es la única fuente de verdad para todas las configuraciones de tematización, y maneja cualquier posible error proveniente de la capa de servicio.

En este ejemplo, el ThemeRepository también expone la configuración del modo oscuro a través de un Stream observable. Esto permite que otras partes de la aplicación se suscriban a los cambios en la configuración del modo oscuro.

El ThemeRepository depende de SharedPreferencesService. El repositorio obtiene el valor almacenado desde el servicio, y lo almacena cuando cambia.

El método setDarkMode() pasa el nuevo valor al StreamController, de modo que cualquier componente que escuche el stream observeDarkMode

dart
class ThemeRepository {
  ThemeRepository(this._service);

  final _darkModeController = StreamController<bool>.broadcast();

  final SharedPreferencesService _service;

  /// Get if dark mode is enabled
  Future<Result<bool>> isDarkMode() async {
    try {
      final value = await _service.isDarkMode();
      return Result.ok(value);
    } on Exception catch (e) {
      return Result.error(e);
    }
  }

  /// Set dark mode
  Future<Result<void>> setDarkMode(bool value) async {
    try {
      await _service.setDarkMode(value);
      _darkModeController.add(value);
      return Result.ok(null);
    } on Exception catch (e) {
      return Result.error(e);
    }
  }

  /// Stream that emits theme config changes.
  /// ViewModels should call [isDarkMode] to get the current theme setting.
  Stream<bool> observeDarkMode() => _darkModeController.stream;
}

El SharedPreferencesService envuelve la funcionalidad del plugin SharedPreferences, y realiza llamadas a los métodos setBool() y getBool() para almacenar la configuración del modo oscuro, ocultando esta dependencia de terceros del resto de la aplicación

dart
class SharedPreferencesService {
  static const String _kDarkMode = 'darkMode';

  Future<void> setDarkMode(bool value) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_kDarkMode, value);
  }

  Future<bool> isDarkMode() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getBool(_kDarkMode) ?? false;
  }
}

Poniéndolo todo junto

#

En este ejemplo, el ThemeRepository y el SharedPreferencesService se crean en el método main() y se pasan al MainApp como dependencia de argumento del constructor.

dart
void main() {
  // ···
  runApp(
    MainApp(
      themeRepository: ThemeRepository(SharedPreferencesService()),
      // ···
    ),
  );
}

Luego, cuando se crea el ThemeSwitch, también se crea el ThemeSwitchViewModel y se pasa el ThemeRepository como dependencia.

dart
ThemeSwitch(
  viewmodel: ThemeSwitchViewModel(widget.themeRepository),
),

La aplicación de ejemplo también incluye la clase MainAppViewModel, la cual escucha los cambios en el ThemeRepository y expone la configuración del modo oscuro al Widget MaterialApp.

dart
class MainAppViewModel extends ChangeNotifier {
  MainAppViewModel(this._themeRepository) {
    _subscription = _themeRepository.observeDarkMode().listen((isDarkMode) {
      _isDarkMode = isDarkMode;
      notifyListeners();
    });
    _load();
  }

  final ThemeRepository _themeRepository;
  StreamSubscription<bool>? _subscription;

  bool _isDarkMode = false;

  bool get isDarkMode => _isDarkMode;

  Future<void> _load() async {
    final result = await _themeRepository.isDarkMode();
    if (result is Ok<bool>) {
      _isDarkMode = result.value;
    }
    notifyListeners();
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }
}
dart
ListenableBuilder(
  listenable: _viewModel,
  builder: (context, child) {
    return MaterialApp(
      theme: _viewModel.isDarkMode ? ThemeData.dark() : ThemeData.light(),
      home: child,
    );
  },
  child: //...
)