SharedPreferences отличное хранилище для вашего flutter-приложения. Но есть нюансы…

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Настрой: плавный, недвусмысленный, решительный

Здравствуйте, товарищи flutter-щики. Сегодня я поделюсь скромным рецептом использования NOSQL решения для хранения данных в flutter-приложении. Не будем томиться, пора приступать. Данный материал целиком и полностью описывает приложение погоды – Weather Today.

Ситуация: вы пишите простое приложение (блокнот, погодка, калькулятор, будильник!?), и появляется небольшое количество пользовательской информации, которую необходимо не просто сохранить, но и подтянуть при старте приложения (или по требованию). Это означает, что вам нужно хранилище – место, где будут храниться эти самые данные. Сразу оговорюсь, что SharedPreferences это всего лишь Map<String, dynamic> и базой данных не является! Использование сокращения, именуемого бд, в ниже представленном тексте – нежелание автора использовать длинные слова (хранилище, карта ключ-значение!?), и желание упростить понимание текста.

Данный материал входит в цикл статей о создании приложения Weather Today (Google Play) – лаконичного и бесплатного продукта для мониторинга погодных условий в вашем смартфоне.


С чего всё началось?

Контекст: необходимо сохранить небольшое количество информации, которую условно можно разделить на два типа:

  1. Пользовательские настройки

    1. Единицы измерения всякого

    2. Цветовое решение: различные темы (прикиньте, сейчас их 52 + можно поменять местами Primary и Secondary цвета (!), а в темном режиме ещё и Main и Container цвета. Цветомизируй меня полностью, ДА), оттенки черного, и даже про OLED не забыл – выключите ваши лапочки :)

    3. Подтверждения: первого запуска, пользовательского соглашения, просмотренного интро, всплывающих окон и т.д. (обычно, это простые bool)

    4. Визуальное оформление: шрифт и его размер, вариант типографики, и даже физика скролла (Вопрос: зачем это всё приложению погоды? Ответ: потому что сейчас я "тащусь" от кастомизации UI)

    5. Ну и всякое: стартовая страница, язык приложения (RU и EN, можно быстро добавить новый. Об успешной локализации данного приложения уже написан материал здесь, на Хабре)

  2. Пользовательские данные

    1. Некоторые настройки для запроса данных с погодного сервиса

    2. Избранные локации

    3. Погодные данные

    4. Последнее выбранное местоположение

Main / Container | Primary / Secondary swap colors. Каждый найдёт себе что-то по вкусу.
Main / Container | Primary / Secondary swap colors. Каждый найдёт себе что-то по вкусу.

Данных немного, сложные запросы делать не нужно, да и расширяться во что-то более крупное не планировалось, поэтому в качестве базы данных выбрана SharedPreferences.

Простое key-value хранилище, а по сути wrapper над платформенными реализациями. Ничего нам не обещает

Data may be persisted to disk asynchronously, and there is no guarantee that writes will be persisted to disk after returning, so this plugin must not be used for storing critical data.

, однако применяется повсеместно и пользуется большим спросом из-за своей простоты. Поддерживается flutter.dev командой.

Удобство, которое пришло на ум первым

Правда, пришлось сделать ряд манипуляций, чтобы работа с ней была ещё удобней.

  1. Простое key-value хочется использовать просто. Вот тебе ключик, дай-ка/положи-ка вот это значение, и при этом использовать get(String key) и set(String key, value). Но не всё так просто в этом типизированном мире. Под капотом мы имеем setInt, setBool, setDouble, setString, setStringList и getInt, getBool и т.д. Однако, код ниже потенциально решает эту проблему:

bool sameTypes<S, V>() {
  void func<X extends S>() {}
  return func is void Function<X extends V>();
}
  1. Мы не хотим получить null в качестве ответа get, когда значения не существует. При этом нет желания по всему коду разбрасываться значениями по умолчанию, что-то вроде:

final String currentPlace = db.get('currentPlace') ?? 'Mosсow';

Самое простое решение - создать класс KeyStore, где хранить и наши ключи, и значения по умолчанию. Всё в одной корзине. (воспользуйтесь //====== в качестве визуального разделителя разных данных. Помним, что у нас крайне простое приложение и разделять всё это по разным файликам не имеет смысла)

class KeyStore {
  KeyStore._();
  
  static const String currentPlace = 'currentPlace';
  static const String currentPlaceDefault = '';

  static const String useMaterial3 = 'useMaterial3';
  static const bool useMaterial3Default = true;

  static const String appLocale = 'appLocale';
  static const String appLocaleDefault = 'ru';

  static const String textScaleFactor = 'textScaleFactor';
  static const double textScaleFactorDefault = 1.1;
}

и делать вот такой запрос:

final currentPlace = db.get(KeyStore.currentPlace, KeyStore.currentPlaceDefault);
  1. Иметь общий интерфейс. Это крайне полезно, когда мы захотим использовать другое NoSQL решение или заменить MockDataBase; тогда не придется шерстить весь код и исправлять связи с SharedPreferences. Поверьте, это больно и я это пробовал :)

Общий интерфейс:

/// Abstract interface for the App Settings and user data.
abstract class IDataBase {
  /// Implementations can override this method to perform
  /// the necessary initialization and configuration.
  Future<void> init();

  /// Loads a setting from service, stored with `key` string.
  Future<T> get<T>(String key, T defaultValue);

  /// Save a setting to service, using `key` as its storage key.
  Future<void> set<T>(String key, T value);

  /// Здесь могут быть представлены другие полезные методы.
  /// Например, метод полной очистки бд, удаление конкретной записи и т.д
  ...
}

Реализации этого интерфейса будут выглядеть так:

class DataBasePrefs implements IDataBase {
  DataBasePrefs();

  late final SharedPreferences _prefs;

  @override
  Future<void> init() async => _prefs = await SharedPreferences.getInstance();
  
  @override
  Future<T> get<T>(String key, T defaultValue) => ...;

  @override
  Future<void> set<T>(String key, T value) => ...;
}

// другая реализация
class OtherDB implements IDataBase {
  OtherDB();

  @override
  Future<void> init() async => ...;

  @override
  Future<T> get<T>(String key, T defaultValue) => ...;

  @override
  Future<void> set<T>(String key, T value) => ...;
}

Когда нам нужно воспользоваться бд:

final IDataBase db = DataBasePrefs();
// или, когда нам понадобится использовать другую бд: 
final IDataBase db = OtherDB();

await db.init(); // для SharedPreferences это необходимо

final data = db.get(KeyStore.currentPlace, KeyStore.currentPlaceDefault);
final data = db.set(KeyStore.currentPlace, 'Костомукша');

В совокупности, мы имеем следующий код внутри метода set() (вспоминайте, что я говорил про sameTypes()):

  @override
  Future<void> set<T>(String key, T value) async {
    if (sameTypes<T, bool>()) {
      return _prefs.setBool(key, value as bool);
    }

    if (sameTypes<T, int>()) {
      return _prefs.setInt(key, value as int);
    }

    if (sameTypes<T, double>()) {
      return _prefs.setDouble(key, value as double);
    }

    if (sameTypes<T, String>()) {
      return _prefs.setString(key, value as String);
    }

    if (sameTypes<T, List<String>>()) {
      return _prefs.setStringList(key, value as List<String>);
    }

    if (value is Enum) {
      return _prefs.setInt(key, value.index);
    }

    throw Exception('Wrong type for saving to database');
  }

Здесь нужно обратить внимание на несколько вещей:

  1. Функция sameTypes() корректно обрабатывает List<String>> и не нужно дополнительно делать cast (используя as или же list.cast<String>().toList()), итерируя каждый элемент списка.

  2. Мы можем кинуть наши любимые Enum (а на самом деле и DateTime, и Color и ещё много чего) внутрь метода set(), радостно примечая, что это всё через один единственный метод.

В реальности структура этого метода ещё сложнее: внутри есть logger и обработка ошибок (try ... catch (e, s)).

Метод get() выглядит так (не учитывая logger и минифицируя try-catch):

  @override
  Future<T> get<T>(String key, T defaultValue) async {
    Object? value;
    try {
      if (sameTypes<T, List<String>>()) {
        value = _prefs.getStringList(key);
      } else {
        value = _prefs.get(key);
      }

      // значения ещё нет в бд
      if (value == null) {
        return defaultValue;
      }

      return value as T;
    } catch (e, s) {
	  ...
      return defaultValue;
    }
  }

Для спасения души и избавления от лишнего кода в библиотеке SharedPreferences есть метод Object? get(String key) => _preferenceCache[key];. Мы его смело используем, обрабатывая List<String>> отдельно. Если не воспользоваться методом getStringList(), получим следующее:

Error: type 'List<dynamic>' is not a subtype of type 'List<String>' in type cast

А всё потому, что под капотом этого метода вот такие преобразования:

List<String>? getStringList(String key) {
    List<dynamic>? list = _preferenceCache[key] as List<dynamic>?;
    if (list != null && list is! List<String>) {
      list = list.cast<String>().toList();
      _preferenceCache[key] = list;
    }
    // Make a copy of the list so that later mutations won't propagate
    return list?.toList() as List<String>?;
  }

Нокаковакрасота? (Жаль, что нельзя прикрутить switch ... case в set() методе). Однако, у такой красоты есть ряд недостатков:

  1. Необходимо потратить время на грамотное написание тестов для этих двух методов (благо, мы же его сэкономили недавно)

  2. if в совокупности с sameTypes() уменьшает быстродействие и, вероятно, для крупного проекта с большим количеством ключей это может стать проблемой.

Как этим пользоваться?

Всё очень просто. Рассмотрим некоторые варианты. (Замечание: код приведен таким образом, чтобы показать взаимодействие с бд, минуя некоторые слои и абстрагируясь от управления состоянием.)

  1. Есть кнопка смены темы – это тройной переключатель (светлая тема | режим устройства | темная тема), который встроен в trailing виджета ListTile().

Вводные:

class KeyStore {
  KeyStore._();
  // enum
  static const String themeMode = 'themeMode';
  static const int themeModeDefault = 0; // system

<- Получаем значение так:

// db.get(ключ, значение_по_умолчанию)
final themeModeIndex = db.get(KeyStore.themeMode, KeyStore.themeModeDefault);
final themeMode = ThemeMode.values[themeModeIndex];

-> Сохраняем новое значение:

// [ThemeMode] - это перечисление (Enum); доступно прямиком из flutter
ListTile(
  title: ...,
  subtitle: ...,
  trailing: ThemeModeSwitch(
	themeMode: themeMode, // текущий режим
    onChanged: (ThemeMode newMode) async => 
					    db.set<int>(KeyStore.themeMode, newMode.index),
  ),
);

То есть мы просто сохраняем индекс перечисления, а когда необходимо, превращаем этот индекс в правильный объект ThemeMode.

  1. При самом первом запуске приложения есть интро: буквально четыре странички красиво анимированной графики и приятных текстовых обоснований :)

 Здесь нет анимации, это png. Но вы можете пощупать онлайн, используя конфигуратор. О том, как создать такую анимацию, не прибегая к помощи gif/rive, я подробно написал в статье <Почему анимированная погода – это код из конфигуратора или История одного грустного пакета>
Здесь нет анимации, это png. Но вы можете пощупать онлайн, используя конфигуратор. О том, как создать такую анимацию, не прибегая к помощи gif/rive, я подробно написал в статье <Почему анимированная погода – это код из конфигуратора или История одного грустного пакета>

После того, как пользователь посмотрел интро, мы больше не будем этим беспокоить и сохраним новое значение в бд. При каждом запуске приложения считываем значение и убеждаемся – пользователь уже знает о всех великих преимуществах

Источник: https://habr.com/ru/post/724706/


Интересные статьи

Интересные статьи

Привет! Меня зовут Максим Чижов, я уже третий год работаю бэкенд-инженером в Авито. Когда только пришёл в компанию, я столкнулся с проблемой хранения больших объёмов информации. О том, как её решить, ...
У меня никода не было мотивации заниматься физкультурой. Даже в детстве я предпочитал спорту киберспорт. С началом профессиональной деятельности поменялся только характер запускаемых прог...
Уроки от фильма Netflix “Социальная дилемма”.Социальные сети делают вас другим человеком. Они меняют то, что вы делаете, меняют ход ваших мыслей и, в конечном итоге, меняют вас. И ка...
В детстве я, наверное, был антисемитом. И все из-за него. Вот он. Он меня всегда раздражал. Я просто обожал великолепный цикл рассказов Паустовского про кота-ворюгу, резиновую лодку и т. д...
Многим геймерам по всему миру, заставшим эпоху Xbox 360, очень знакома ситуация, когда их консоль превращалась в сковороду, на которой можно было жарить яичницу. Подобная печальная ситуация в...