Я сделал Cardoteka и вот как её использовать [кто любит черпать]

Привет! Самое время в предновогоднее настоящее поделиться с вами опенсУрс проектом :) Встречайте ->Cardoteka <- строгая типизированная обёртка над Shared Preferences (SP) в мире Flutter. Этот материал будет коротким, с рекламными нотками (а точнее, приглашающий к дискуссии в issues и в комментарии) и readme-подтекстом. Так или иначе, это заслуженная метка "Обзор".
https://github.com/PackRuble/cardoteka
Обозначу в первую очередь пару вещей:
У проекта есть подробный README, который я намерен продублировать в некоторой степени
Есть
самодокументированный код. Серьёзно, над этим велась работа - это не пустой текст ради баллов в pub.devЕсть тесты, если вы любите такое познание
Подробный анализ "зачем и почему" готовится в виде отдельной публикации - технической, объёмной и серьёзной (насколько позволяет моя серьёзность). Но чтобы заинтересовать читателя и оправдать появление текущей статьи, а также указать на важность существования этой "обёртки над 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:
Way to remove a callback using
Watcher.attach
+ChangeNotifier
· Issue #9 · PackRuble/cardotekaWay to remove a callback using
Watcher.attach
+Cubit
· Issue #10 · PackRuble/cardoteka поскольку одна голова хорошо, а без головы - плохо!
Соединения с другими BLoC-хранящими классами (ChangeNotifier
,ValueNotifier
,Cubit
из пакетаbloc
,Provider
из пакетаriverpod
) есть в readme проектав этой главе. Пока что вы можете просто скопировать вышеуказанный код и использовать в своём проекте. Как только реализация будет стандартизирована и протестирована, обновление не заставит себя ждать.
И да, еслиWactherImpl
не нравится по тем или иным причинам, то можно сделать свой собственныйBlackjackWatcher
на стримах... или на чём позабористей.
Заключение
Хочу выразить искреннюю благодарность тем, кто был со мной ментально в момент создания этой маленькой обёртки для нужд всех желающих. Это была первая часть, маленькая user-frendly шалость, обзор на ночь для крепкого сна.
Я буду нескрываемо рад, если сообщество примет участие в обсуждении дальнейшего развития проекта: поделится мнением об удобстве использования, расскажет о технических нюансах или просто пожелает что-нибудь к Новому Году ?
Код доступен под лицензией Apache-2.0 ⭐
С наступающим Новым Годом, друзья! ?
© 2022-2024 Ruble
Ссылки:
Моя литера Т стремится к равнозначности линий
Публикации
Истории
Работа
Ближайшие события








