Привет! Самое время в предновогоднее настоящее поделиться с вами опенсУрс проектом :) Встречайте -> Cardoteka <- строгая типизированная обёртка над Shared Preferences (SP) в мире Flutter. Этот материал будет коротким, с рекламными нотками (а точнее, приглашающий к дискуссии в issues и в комментарии) и readme-подтекстом. Так или иначе, это заслуженная метка "Обзор".

https://github.com/PackRuble/cardoteka

Обозначу в первую очередь пару вещей:

  1. У проекта есть подробный README, который я намерен продублировать в некоторой степени

  2. Есть самодокументированный код. Серьёзно, над этим велась работа - это не пустой текст ради баллов в pub.dev

  3. Есть тесты, если вы любите такое познание

Подробный анализ "зачем и почему" готовится в виде отдельной публикации - технической, объёмной и серьёзной (насколько позволяет моя серьёзность). Но чтобы заинтересовать читателя и оправдать появление текущей статьи, а также указать на важность существования этой "обёртки над SP", заявляю: (с префиксом "теперь мы можем")

  • упорядоченно храним ключ и значение по умолчанию. Легко создаём новую пару, точно уверены в её типах

  • вытекающий приятный бонус в использовании всего лишь двух методов get|set (или CRUD методов), чтобы получить/сохранить значение любого типа

  • nullable значения - не помеха! Имитируем поведение и используем методы getOrNull|setOrNull

  • умеем слушать поступление/удаление значения из хранилища и реактивно обновлять состояния

????Добро пожаловать, всё расскажу!

Содержание

  • Начало использования

    • Карта/карточка - это пара "ключ-значение"

    • Свой экземпляр cardoteka - свои правила

    • `get` и `set` - ловись рыбка большая и маленькая

  • Маленькое FAQ

    • А всё ли Я указал правильно?

    • Хочется использовать методы CRUD?

    • Мне нужна сырая Shared Preferences!

  • Один приятный бонус - реактивная прослушка

  • Заключение

Начало использования

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

Карта/карточка - это пара "ключ-значение"

В первую очередь у нас есть место, где хранятся все пары ключ-значение_по_умолчанию_для_данного_ключа - это можно представить в виде enum или class:

import 'package:cardoteka/cardoteka.dart';
import 'package:flutter/material.dart' hide Card;

enum SettingsCards<T extends Object> implements Card<T> {
   userColor(DataType.int, Color(0x00FF4BFF)),
   themeMode(DataType.string, ThemeMode.light),
   isPremium(DataType.bool, false),
   ;

   const SettingsCards(this.type, this.defaultValue);

   @override
   final DataType type;

   @override
   final T defaultValue;

   @override
   String get key => name;

   static Map<SettingsCards, Converter> get converters => const {
      themeMode: EnumAsStringConverter(ThemeMode.values),
      userColor: Converters.colorAsInt,
   };
}

Пожалуй, самая важная строка здесь

  • enum SettingsCards<T extends Object> implements Card<T> {} потому что в совокупности с полем final T defaultValue; это позволяет использовать всю силу дженериков и способность анализатора выводить корректный тип из переданного значения умолчанию.

Здесь ключ выводится на основе имени перечисления, однако это может быть подвержено ошибкам: ваше имя после использования не должно измениться. Если изменится - вы потеряли доступ к вашей паре (имя вашего enum-значения это ключ в хранилище. Изменится имя -> изменится ключ -> старое значение становится недоступным).

Варианты:

  • добавьте дополнительный обязательный параметр в конструктор вашего enum - вероятность данной ошибки будет меньше. Самый надёжный способ.

  • можете использовать позиционный необязательный параметр custom_key и сделать напоминание в шапку класса "При переименовании обязуюсь установить кастомный ключ - старое имя перечисления". Вариант опасный, потому что рефакторинг из другого места проекта никто не отменял

Далее, DataType - это перечисление доступных типов, в которое (to) сконвертируется ваше значение внутри кардотеки. Тут всё просто - укажите значение по умолчанию и подумайте, в какой тип из доступных оно может быть преобразовано. Если типы совпадают, то ничего делать дополнительно не нужно. В примере SettingsCards.isPremium имеет тип bool, а значит конвертация не требуется. Доступные типы соответствуют оригинальным методам из SP и могут быть следующими:

Но не переживайте, сохраним всё! Есть куча конвертеров, чтобы преобразование доставляло радость (или по крайней мере - не доставляло хлопот). Достаточно где-то завести переменную (в моём случае это статический геттер converters в классе перечисления, что удобно, поскольку убирает бойлерплейт имени класса), где указать пару "карта-конвертер". Все доступные конвертеры вы можете увидеть ниже:

Стоит обратить внимание, что для использования конвертеров коллекций необходимо создать собственный экземпляр класса с расширением от желаемого конвертера и указать тип элемента. Одна строка кода - и далее используйте встроенные возможности quick fix от dart-анализатора. Остаётся указать, как мы хотим [де]сериализовывать объект (например в json-формат):

Приятен тот факт, что заложенная гибкость позволяет использовать полностью свой класс-конвертер. Просто реализуйте интерфейс класса Converter или расширьтесь от CollectionConverter для коллекций (под капотом всё тот же Converter).

Хотя и всё это подразумевалось как мини-обзор, укажу для полноты руководства про значение по умолчанию и про nullable, потому что это одна из важных единиц функциональности библиотеки.

Если мы используем перечисление для создания списка карт, то значение по умолчанию должно быть константой (всякие трюки с геттерами и switch не рассматриваю). Если желаемый класс, который мы хотим сохранить, не имеет константного конструктора, то это может стать проблемой. Однако есть пару возможностей для преодоления этого барьера:

  • превратите ваше значение в тип, допускающий null. По большому счёту, просто передайте null в качестве значения по умолчанию. Это потребует также указать тип дженерика Object? для всех карт и тип в конкретной карточке (для примера, пусть будет garageCar):

    • enum SettingsCards<T extends Object?> implements Card<T> {}

    • garageCar<Car?>(DataType.string, null), - если этого не сделать, то мы потеряем тип карточки (будет dynamic)

  • используйте обычный class для создания коллекции. Это в целом более гибкий способ объявить список из любых карт (главное, чтобы каждая отдельная карта реализовывала Card), хотя и теряется исчерпываемость (exhaustive) карт - ведь список из всех карт теперь нужно составлять вручную.

Свой экземпляр cardoteka - свои правила

Это интересное место, поскольку определяет ваш стиль игры :) Вы можете знать, что оригинальный класс SharedPreferences - это синглтон. Обёртка в виде класса Cardoteka позволяет вам иметь свои экземпляры, где с одной стороны хранилище всё ещё остаётся синглтоном (статичное приватное поле _prefs внутри), а с другой стороны конфигурационный класс CardotekaConfig не позволяет влиять разным экземплярам кардотеки друг на друга.

Вау, что за архитектура! А потому что "костылирование, инкостыляция и поликостылизм" наше всё! Шучу конечно, я старался не использовать эти технологии. Ладно, вместо долгих снов, вам нужно расшириться от Cardoteka и... всё!:

class SettingsCardoteka extends Cardoteka with WatcherImpl {
  SettingsCardoteka({required super.config});
}

Дополнительно скажу, что можно приправить наш собственный класс вкусняшками через with (да, вместо добавления функциональности привычной агрегацией я проникся миксинами. Есть и техническая сторона вопроса, расскажу во второй части):

Пока что оставляю это без особого (и заслуженного!) внимания и перехожу к параметру config. Лучшим вариантом будет рассмотреть его в процессе инициализации самой кардотеки. Сколько бы экземпляров кардотеки вы не планировали создать, инициализацию достаточно сделать один раз (не забываем, SP синглтон ведь):

main() async {
  await Cardoteka.init();
  final cardoteka = SettingsCardoteka(
    config: CardotekaConfig(
      name: 'settings',
      cards: SettingsCards.values,
      converters: SettingsCards.converters,
    ),
  );
  
  // ... и теперь пользуемся
}

Конфигурационный файл принимает имя (в конечном счёте, это ещё одна зона разделения самих экземпляров кардотеки - имя используется в качестве префикса для ключей), коллекцию карточек и конвертеры для них. То есть в самом SP ключ будет выглядеть так - settings.SettingsCards.имя_значения_перечисления, а если для каждой карточки использовать кастомный ключ, то settings.кастомный_ключ. Да и всё, что тут ещё говорить - давайте делать запросы!

get и set - ловись рыбка большая и маленькая

В двух словах, логика работы с кардотекой: чтобы получить значение, вам потребуется передать карточку. Если в хранилище ничего не было, то вернётся значение по умолчанию. Так или иначе, вы точно не получите null, а ваш тип будет соответствовать установленному типу в карточке для значения по умолчанию. Анализатор Dart всё выведет сам ????

Чтобы установить значение, также передаём карточку И новое значение для этой карточки. Если всё пройдёт успешно, вернётся bool в значении true. Здесь есть важный нюанс, который поможет избежать runtime-ошибок: когда вы делаете set или setOrNull, указывайте дженерик тип. Это защита от самого себя - если вы укажете один тип карты (имеется ввиду тип именно значения по умолчанию для этой карты), а сохранить по ней попытаетесь значение другого типа, то произойдёт ошибка. Если указываем generic, то схитрить уже не получится - анализатор будет требовать от аргументов указанный тип.

main() async {
  // инициализация cardoteka...

  ThemeMode themeMode = cardoteka.get(SettingsCards.themeMode);
  print(themeMode); // будет возвращено значение по умолчанию -> ThemeMode.light

  await cardoteka.set(SettingsCards.themeMode, ThemeMode.dark);
  themeMode = cardoteka.get(SettingsCards.themeMode);
  print(themeMode); // ThemeMode.dark

  // вы можете использовать правильный generic-тип, чтобы предотвратить возможные
  // ошибки, когда случайно указываются аргументы разных типов.
  await cardoteka.set<bool>(SettingsCards.isPremium, true);
  await cardoteka.set<Color>(SettingsCards.userColor, Colors.deepOrange);

  await cardoteka.remove(SettingsCards.themeMode);
  Map<Card<Object?>, Object> storedEntries = cardoteka.getStoredEntries();
  print(storedEntries);
  // {
  //   SettingsCards.userColor: Color(0xffff5722),
  //   SettingsCards.isPremium: true
  // }

  await cardoteka.removeAll();
  storedEntries = cardoteka.getStoredEntries();
  print(storedEntries); // {}
}

Остальные операции соответствуют SP, разве что вместо ключа вы передаёте карточку, где это требуется: удаление - remove, удаление всего - removeAll, проверить наличие пары в хранилище - containsCard, получить хранимые карты - getStoredCards, получить хранимые пары - getStoredEntries. Эти операции индивидуальны для каждого экземпляра кардотеки (если в передаваемой конфигурации разное имя ИЛИ в том числе и разное имя класса перечисления, я думаю о возможности добавить assert) и никак не затрагивают другие экземпляры.

Всё немного интересней, когда мы касаемся работы с null. SP не поддерживает сохранение null-значений. Всё, что мы можем сделать, это сымитировать такую работу. Поэтому, если ваша карточка может содержать null-значение, то воспользуйтесь методами getOrNull и setOrNull. Работает это так:

  • getOrNull - если пара отсутствует в хранилище, то получим null

  • setOrNull - если сохраняем null, то пара удалится из хранилища

И тот и другой метод вы можете использовать и с не-null-евыми карточками. Резонный вопрос: с какими типами карточек может работать метод получения/сохранения? Проще всего представить это в виде таблицы:

По большому счёту, чаще всего вы будете использовать get/set, а когда необходима симуляция работы с null, либо же в момент отсутствия пары хочется получить null (а не значение по умолчанию) - используем getOrNull/ setOrNull.

Маленькое FAQ

А всё ли Я указал правильно?

Этот вопрос, как и другие подобного характера:

Корректные ли типы я указал? Нужен ли для этой карточки конвертер? А правильно ли он конвертирует? Есть ли дубликаты ключей?

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

Хочется использовать методы CRUD?

Легко, дополните ваш класс Cardoteka миксином CRUD и вы получите любимые read, create, update, delete. При этом вашем распоряжении остаются и стандартные методы для работы с хранилищем.

Мне нужна сырая Shared Preferences!

Вероятно вам нужен доступ для динамического изменения ключей или же вы мягко вкатываетесь в типизированную структуру карточек... Используйте миксин AccessToSP и получайте доступ к "сырому" SharedPreferences prefs.

Если вы в процессе тестирования, используйте CardotekaUtilsForTest. Там есть привычный setMockInitialValues и ещё немножко полезных методов.

Один приятный бонус - реактивная прослушка

Скажу без сомнений, что реализация этой штуки была одной из самых времязатратных и важных для меня. Хотя кажется, что нижеописанная концепция вредит архитектуре, но с другой стороны она позволяет быстро связать бизнес-состояние с хранилищем реактивной нитью. Что должно быть тепло воспринято в мире MVP.

Покажу самый банальный пример на основе ChangeNotifier - это то, что входит в Flutter SDK "с завода". Наша цель проста: при сохранении значения в хранилище мы хотим реактивного обновления состояния нотифаера.

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

import 'package:cardoteka/cardoteka.dart';
import 'package:flutter/material.dart' hide Card;

class OrderNotifier with ChangeNotifier, Detachability {
  final _orders = <String>[];

  void addOrder(String value) {
    _orders.add(value);
    notifyListeners();
    print('New order: $value');
  }
  
  void removeAll() {
    _orders.clear();
    print('_orders has been cleared!');
  }
}

Да, кстати, пожалуйста, перестаньте расширяться от ChangeNotifier. В этом нет необходимости, но так сложилось исторически: ChangeNotifer появился в библиотеке раньше, чем появились миксины в языке. Для полного погружения в ситуацию смотрите этот issue - Allow mixins in "extends" clauses · Issue #1942 · dart-lang/language.

Теперь самое время продемонстрировать работоспособность решения. Первым делом нам понадобится экземпляр Cardoteka с примиксованным WatcherImpl. Этот класс даёт нам полезный метод attach, с помощью которого можно передать callback для прослушивания новых значений:

class CardotekaImpl = Cardoteka with WatcherImpl;

Future<void> main() async {
  await Cardoteka.init();
  // ignore_for_file: definitely_unassigned_late_local_variable
  // to☝️do: процесс инициализации опущен
  late CardotekaImpl cardoteka;
  late Card<String> lastOrderCard;

  final notifier = OrderNotifier();
  cardoteka.attach(
     lastOrderCard,
     notifier.addOrder,
     onRemove: notifier.removeAll,
     detacher: notifier.onDispose,
  );

  await cardoteka.set(lastOrderCard, '#341');
  // 1. значение было сохранено в хранилище
  // 2. console-> New order: #341
  
  await cardoteka.remove(lastOrderCard);
  // 3. значение было удалено из хранилища
  // 4. console-> _orders has been cleared!
}

Из кода должно быть всё ясно.., кроме Detachability, который на данный момент существует только в моей голове. Его реализация видится мне такой:

import 'package:flutter/foundation.dart' show VoidCallback;

mixin Detachability on ChangeNotifier {
  List<VoidCallback>? _onDisposeCallbacks;

  void onDetach(void Function() cb) {
    _onDisposeCallbacks ??= [];
    _onDisposeCallbacks!.add(cb);
  }

  @override
  void dispose() {
    _onDisposeCallbacks?.forEach((cb) => cb.call());
    _onDisposeCallbacks = null;
		
		super.dispose();
  }
}

Надеюсь, вы окончательно запутаны! Если нет, то поздравляю, с архитектурой вы на очень плотное ТЫ. Суть этой примеси в том, что необходимо предоставить возможность любому классу хранить список из detach-callback'ов, которые он вызовет, когда экземпляр ChangeNotifier станет ненужным. Это очистит связанные ресурсы внутри WatcherImpl.

Реализация Detachability функциональности подлежит всеобщему обсуждению в этих issues:

Соединения с другими BLoC-хранящими классами (ChangeNotifier, ValueNotifier, Cubit из пакета bloc, Provider из пакета riverpod) есть в readme проекта в этой главе. Пока что вы можете просто скопировать вышеуказанный код и использовать в своём проекте. Как только реализация будет стандартизирована и протестирована, обновление не заставит себя ждать.

И да, если WactherImpl не нравится по тем или иным причинам, то можно сделать свой собственный BlackjackWatcher на стримах... или на чём позабористей.

Заключение

Хочу выразить искреннюю благодарность тем, кто был со мной ментально в момент создания этой маленькой обёртки для нужд всех желающих. Это была первая часть, маленькая user-frendly шалость, обзор на ночь для крепкого сна.

Я буду нескрываемо рад, если сообщество примет участие в обсуждении дальнейшего развития проекта: поделится мнением об удобстве использования, расскажет о технических нюансах или просто пожелает что-нибудь к Новому Году ????

Код доступен под лицензией Apache-2.0 ⭐

С наступающим Новым Годом, друзья! ????

© 2022-2024 Ruble


Ссылки:

  1. Issues · PackRuble/cardoteka

  2. PackRuble/cardoteka: The best type-safe wrapper over SharedPreferences

  3. cardoteka | Flutter Package

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