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

Здравствуйте, товарищи 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, я подробно написал в статье <Почему анимированная погода – это код из конфигуратора или История одного грустного пакета>

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

Вводные:

class KeyStore {
  KeyStore._();
  
  static const String showIntro = 'isIntro';
  static const bool showIntroDefault = true; // покажем интро по умолчанию

<- в виджете:

final bool showIntro = db.get(DbStore.showIntro, DbStore.showIntroDefault);

if (showIntro) {
  return constIntroPage();
}

-> в момент нажатия на кнопку <ГОТОВО>:

onTapDone: () async => db.set<bool>(KeyStore.showIntro, false);
  1. Давайте рассмотрим что-нибудь посложней, например, как получить/сохранить список избранных местоположений. У пользователя есть такая возможность: добавлять в избранное места, для которых он хочет узнавать погоду. Можно даже заметку о местоположении оставить и заодно посмотреть координаты. А выделенным отображается текущее место, по которому определяется погода на данный момент.

Вводные:

class KeyStore {
  KeyStore._();
  
  static const String savedPlaces = 'savedPlaces';
  static const List<String> savedPlacesDefault = <String>[];
}

Опс, там же список строк.! Правильно, потому что количество типов, которые мы можем сохранить, ограничено SharedPreferences. В данном случае удобно воспользоваться json_serializable, чтобы преобразовать наш объект в строку и обратно.

Наша модель выглядит так (используется также пакет freezed, помогающий делать объект иммутабульным и вселяющий прочие штуки, вроде copyWith(). Сейчас не акцентируйте на этом внимание):


part 'place_model.freezed.dart';
part 'place_model.g.dart';

// ignore_for_file: invalid_annotation_target

/// Местоположение и его характеристики.
///
@freezed
class Place with _$Place {

  @JsonSerializable(fieldRename: FieldRename.snake)
  const factory Place({
    /// Название местоположения.
    required String? name,

    /// Широта местоположения.
    required double? latitude,

    /// Долгота местоположения.
    required double? longitude,

    /// Название страны.
    required String? country,

    /// Заметка об этом месте.
    String? note,
    
  }) = _SavedPlace;

  /// Внутренний метод десериализации json.
  factory Place.fromJson(Map<String, dynamic> map) => _$PlaceFromJson(map);
}

<- Преобразование списка строк в объект List<Place>:

List<Place> getPlacesFromDb() {
  final List<String> listJson = db.get(KeyStore.savedPlaces, KeyStore.savedPlacesDefault);
  
  return listJson.map((String str) {
    return Place.fromJson(json.decode(str) as Map<String, dynamic>);
  }).toList();

}

-> И обратное преобразование для сохранения в бд:

Future<void> savePlacesInDb(List<Place> places) async =>
		db.set(KeyStore.savedPlaces, places.map((e) => jsonEncode(e.toJson())).toList());

Некоторые выводы

Умозаключения, к которым я пришёл в гости:
(SP - SharedPreferences)

  1. Помните, что SP хранит все ваши данные в оперативной памяти (буквально в Map), когда вы используете приложение. Большие данные засунуть можно, но не советую. По приколу (и просто из удобства), я собирал туда логи в List<String>, где при достижении 1000 объектов список обрезал. Естественно, если что-то падало, stackTrace также попадал туда.

  2. SP не может хранить значения null. Периодически, в этом может возникнуть необходимость; например, null может указывать, что значения ещё не существует. Обыграйте это либо значениями по умолчанию, либо используйте проверку на наличие ключа в SP (метод SharedPreferences.containsKey(String key) выбросит false, если ключа не существует)

  3. Использование класса KeyStore с ключами (и со значениями по умолчанию) более чем уместно. Это лучше, чем разбросать ключи и значения по всему коду. Я расскажу небольшую историю. Участвовал я как-то в решении некоторой проблемы в проекте. И вот беда, нужно было воспользоваться методом сохранения нового значения по имеющемуся ключу. А сервиса базы данных (обычно такое кладут в папку data_source/local_source) не было и не существовало в принципе. Ключи были разбросаны не просто хотя бы в контроллерах виджетов, а тупо по всему коду: в виджетах, в их initState'ax, в утилитах, контроллерах, репозиториях... просто мрак. Когда разрабу нужно было что-то закинуть в бд, он делал следующее:

    final db = await SharedPreferences.getInstance();
    
    // когда нужно получить значение
    db.getInt('name_key_fizz_buzz_O8') ?? 45;
    
    // когда нужно сохранить значение
    db.setInt('name_key_fizz_buzz_08', value);

    А правильно ли он скопировал ключ из другого места или нет – видимо, и так сойдёт! А вишенкой на торте было то, что к SP могли обращаться разные пакеты-обертки, коих было достаточно. Ну ещё там было пару оберток над SQL. Эй ребята, ключи не попутаете? Ну и как финал, никаких вам интерфейсов. И если вдруг вы захотите только подумать (!) о смене бд – идите, собирайте ваши ключи, обертки и значения по умолчанию по всему коду ????

  4. Недостаток подхода с простыми set() и get() методами – это производительность в большом проекте. И проблемы с типизацией! Волшебная палочка sameTypes() может сломаться в любой момент от очередного обновления типизации в dart. Ну благо можно тесты написать.

  5. Ещё одна проблема заключается в том, что при использовании метода set() без указания типа мы не можем гарантировать, что мы когда-нибудь не ошибёмся с передачей параметра правильного типа. Это означает вот что:

    // наш метод сохранения значения в бд
    Future<void> set<T>(String key, T value);
    
    // правильный тип -> int
    final int countApple = get('key_count_apple', 5);
    
    // попробуем сохранить всё что душе угодно:
    set('key_count_apple', true);
    set('key_count_apple', 5.555);
    set('key_count_apple', ['wdw']);
    
    // однако, если мы заставим себя указывать тип (который ещё и помнить нужно)
    // то такие фокусы не пройдут
    set<int>('key_count_apple', true); // mistake
    set<int>('key_count_apple', 5); // correct

Напутствие: используйте SharedPreferences для хранения простых настроек и не храните там объекты (тем более большие). В любом другом случае, когда у вас есть сложные запросы (фильтрация и т.п.), когда данных много (или планируется такое количество) и когда вы просто хотите надежно что-то сохранить – используйте SQL и обертки над ней для flutter. Это проверенное и надежное решение. Или, если тип продукта располагает и позволяет - храните просто файлы .json.

Но, спешу вас обрадовать! Господа, у меня есть отличное решение 2, 3, 4 и 5 проблемы (частично, потому что есть некоторые интересности с типизацией. Я осветил данный вопрос более подробно на Stackoverflow здесь). Я не могу и не хочу на данный момент раскрывать всех карт по поводу реализации, но упорно разрабатываю пакет, призванный помочь решить данные проблемы и не только :) Спойлеры примерно такие: удобное хранение ключей и значений | слушатель изменений | несвязанные "базы" ключей | конвертеры сложных объектов. Руки чешутся и горят опубликовать статью по "благоприятному" использованию, но сейчас готовлю приложение в production, основанное полностью на вышеуказанном пакете, и пишу тесты, которые очень необходимы ;)

Исходный код доступен ниже:

© 2023 Ruble

Комментарии (4)


  1. MiT_73
    00.00.0000 00:00

    Почему бы не сделать нормальную проверку?

      @override
      Future<void> set<T>(String key, T value) async {
        if (value is bool) {
          return _prefs.setBool(key, value);
        }
    
        ...
    
        throw Exception('Wrong type for saving to database');
      }
    

    Метод get тоже не безопасный, из-за каста через as. Понятно что там есть try, но все же, раз вы сделали сохранение безопасным, то почему бы не сделать и получение безопасным?


    1. PackRuble Автор
      00.00.0000 00:00

      ошибся с веткой ответа, никак не могу привыкнуть к комментариям на Хабре :) С использованием Markdown не могу расширить поле для ввода текста и не знаю, как закрепить картинку. А когда пытаюсь переключиться обратно – сбрасывается ответ с ветки


  1. PackRuble Автор
    00.00.0000 00:00

    Постараюсь аргументировать свой выбор. Функция sameTypes() это "улучшенный" вариант value is bool. Если вы попробуете сделать что-то такое value is List<String>, то потерпите фиаско. Определение sameTypes() в настоящее время тоже самое, что и взаимная подтипизация (mutual sub-typing). Повторюсь, как это выглядит:

    bool sameTypes<S, V>() {
      void func<X extends S>() {}
      // В спецификации Dart говорится, что это верно только в том случае, 
      // если S и V одного типа.
      return func is void Function<X extends V>();
    }
    

    По поводу get. Так как всё завернуто в try-catch, всё вполне безопасно. SP может выдать только известные заранее типы. Если мы укажем неправильный тип значения по-умолчанию (второй аргумент), то так или иначе получим ошибку (и вернем этот тип).
    Замечу, что под капотом у SP все эти "безопасные" getBool, getInt и т.д. это тот же самый каст через as.

    Смотрите


  1. NowebNolife
    00.00.0000 00:00

    Последний раз использовали SP в 2019 году, т.к. в одном из крупных проектов это приводило к большому кол-ву memory leak.

    На текущий момент 9 / 10 проектов хранит все локальные данные c помощью Isar /