Этот материал уже без шуточек. Технический обзор и оценка принятых решений. На самом деле шуточки есть, но я не смог окончательно от них избавиться. Видимо, без них нет меня.
Цикл выпусков:
Я сделал Cardoteka и вот как её использовать [кто любит черпать] / Хабр
Cardoteka — техническая начинка и аналитика решений типобезопасной SP [кто любит вдаваться] (>Вы здесь<)
Содержание
Предпосылки создания
Концепция
Стадии реализации
Реализация
Как из SP синглтона сделать экземпляры
Разные экземпляры кардотеки — разное пространство имён
Как сделать типизированный доступ
Определяем место, где хранятся ключи и значения по умолчанию
Как сохранить null, если SP это не поддерживает?
Сохраняем комплексные объекты
Ассертные проверки
Реализация Watcher
Рефакторим старый код и используем прослушку
Архитектура в блок-схемах
Минимальная версия Dart SDK
Подходы к реализации конечного АПИ
Тестирование
Песочница реактивных подходов
Последствия
Предпосылки создания
Создание приложения для отслеживания погодных условий было весьма увлекательным занятием. И те или иные сложности, вставлявшие палки в колёса очередного только что мной придуманного велосипеда, были кстати: так зарождался первый практический опыт. Весьма быстро я понял, что использовать просто так shared_preferences
не получится: нужно подумать о том, где хранить ключи и значения по умолчанию, как обновлять состояния при обновлении значений и почему не получается избавиться от бойлерплейта. Проблемы были решены, написана добротная статья на этот счёт
и где-то там зародилась идея всё упростить и улучшить. Вот фраза:
Но, спешу вас обрадовать! Господа, у меня есть отличное решение 2, 3, 4 и 5 проблемы (частично, потому что есть некоторые интересности с типизацией. Я осветил данный вопрос более подробно на Stackoverflow здесь). Я не могу и не хочу на данный момент раскрывать всех карт по поводу реализации, но упорно разрабатываю пакет, призванный помочь решить данные проблемы и не только :) Спойлеры примерно такие: удобное хранение ключей и значений | слушатель изменений | несвязанные "базы" ключей | конвертеры сложных объектов. Руки чешутся и горят опубликовать статью по "благоприятному" использованию, но сейчас готовлю приложение в production, основанное полностью на вышеуказанном пакете, и пишу тесты, которые очень необходимы ;)
25 мар 2023 в 15:15
Забавно и страшно, сколько прошло времени. Оставим мюсли на закуску для 3-ей части и перейдём к обзору реальных проблем.
Действительно не ясно, как удобно мы можем хранить ключи. А если думаем о том, что хотим избавить наш код от работы с null, то и подавно.
Допустим, можно вполне удобно собрать всё под одним крылом в классе со статическими полями вида
themeModeKey
иthemeModeDefaultValue
. Так я и сделал в приложении погоды.В одарённом порядке можно просто разбросать всё по коду. Вариант так себе, особенно когда проект нужно поддерживать в дальнейшем.
В общем и целом это не выглядит как большая проблема. Однако:
имена, к которым мы вынуждены добавлять префиксы — бойлерплейт
между парой ключ-деф_значение фактически нет связи —> ошибки в использовании
при копировании пар можно ошибиться в значении ключа
Сохранять сложные объекты — сложно. Вы можете сказать, что SP не для этого, а мой ответ — класс из трёх полей, который мы не хотим дробить по ключам. А теперь представьте, что это список из таких объектов...
Дорогой дневник, мне не передать всю боль, что вынес я, когда занимался этими преобразованиями.Факт: нам может потребоваться возможность сохранить объект в SP. И хочется сделать это простой операцией.нам нужен отдельный метод для преобразования В и метод для преобразования ИЗ. Конечно мы воспользуемся
json_serializable
и такжеjsonEncode
/jsonDecode
. Но это не спасёт от бойлерплейта, а также ошибок в коде при неправильном преобразованиии это всё при условии, что архитектура выстроена таким образом, что всякий может пользоваться этими методами. А можно всё делать без методов и дублировать на каждом шагу этот код... То есть мы уже говорим про классы-мапперы. Как я попал в мир java?
Для каждого типа данных — свой метод получения и сохранения. Так устроена dart-овая часть SP. И мне показалось это слишком болезненным, ведь была нужда использовать интерфейсы и отдать их бизнес-логике. Это полезно для "смокования" и тестирования бизнес-логики в дальнейшем, да и просто для упрощения кода и отделения слоёв.
но для этого нет ни-ка-кой возможности
борьбу с этим я видел такой — для каждой бизнес-логики свой собственный интерфейс для доступа к хранилищу. С собственными методами сохранения/получения и даже методом удаления, если он нужен. Но что это за кисель на ровном месте?
к тому же единственные
get
иset
очень упростили бы код, вместо зоопарка методов.
Обновление состояния и обновление данных. Необходимость выглядит так: в тот момент, когда пользователь взаимодействует с приложением, например, указывает номер стартовой страницы, возникает новое значение. Сначала мы обновляем состояние с новым значением, а затем сохраняем это значение в хранилище.
то есть всякий раз делаем две одинаковых операции. А ещё я скажу, что при старте приложения (или при задействовании модуля) мы должны взять сохранённое значение из хранилища и присвоить его в состояние. А это уже 4 условных операции и ещё больше дублирования кода.
решением дублирования было использование примеси специального класса-апдейтера, который одной строкой кода обновлял и тут и там, и одной строкой кода загружал. Но поверьте, вы этого не хотите.
*я согласен с тем фактом, это может вылиться в сильное нарушение принципов границ слоёв. Но для true|false-переключателей хочется иметь лёгкий способ быстрого изменения и реактивности.
Проблема при riverpod-ной (и прочей) разработке — как синхронно инициализировать SP? Ведь если сделать это в условном
FutureProvider
, то устанешь потом выдумывать костыли с доступом в синхронном коде.решение такое: использовать обычный
Provider
и вернуть в нёмthrow UnimplementedError
. А вmain
асинхронно инициализировать SP и сделать переопределение провайдера с новым значением вProviderScope.overrides
сделать
FutureProvider
и инициализировать наш SP внутри него, а затем применить недавно появившийся геттерrequireValue
. Вариант неплох, признаю, однако для всех участников он строится на доверии "Отвечаю, там есть значение, клянусь табуреткой".да, это проблема того, что SP является синглтоном, экземпляр которого можно получить только через асинхронный метод
Вытекающий факт: добавление новой функциональности по типу "выбор_значения—сохранение—обновление_состояния" оказалось очень утомительным занятием, даже при использовании пакета
riverpod
. И сильно подвержено ошибкам копипаста.
Шести пунктов показалось мне достаточным, чтобы увенчать попытку отдельным проектом.
Концепция
Избушка продукта стоит на двух ногах: решение вышеуказанных проблем и удобный апи взаимодействия. Вот ряд условий:
Основано на пакете shared_preferences и используется как обёртка. "Сладко" дополняет взаимодействие, а не изменяет поведение. Доступ к оригинальным функциям. Минимум прочих зависимостей (ноль).
Гибкое апи и широкие возможности при поддержке правила "если что-то можно сделать, то только так".
Без генерации кода.
Инкапсуляция сложности работы с комплексными данными: их лёгкое преобразование и минимум бойлерплейта.
get
|set
|remove
для обычного использования и CRUD-модель взаимодействия для нуждающихся.Функциональность прослушки для реактивной реализации поведения.
Закрепляю тот факт, что всё это должно действовать совокупно и единовременно.
Стадии реализации
И вот, в октябре 22 года я подошёл к некоторым умозаключениям о реализации. Не хочется сильно вдаваться в детали, поскольку интерес представляет именно конечный результат. Однако все желающие могут проверить историю коммитов и файлик readme — и удивиться, как сильно отличается та реализация от текущей.
Есть несколько моментов, на которые мы обратим внимание.
Для полноценного production понадобилось целых 6 итераций проектирования. Именно на них я буду ссылаться всякий раз. Ниже представлены временны́е итерации со ссылками на состояние репозитория к концу итерации:
наивная реализация — 4-9 ноябрь 22г (код написан в голове с октября)
заложены основы пакета: ядро работы с SP называемое
RDatabase
, карточкаRKey
, конвертерRConverter
, миксин дляCRUD
-операций,Watcher
для прослушки значенийочень интересно выглядит
Watcher
: для хранения и обновления состояния используетсяStateNotifier
коллекция примеров в папке
example
первая документация в файле
readme.md
хочу подчеркнуть, что заложенная концепция типизированного доступа с тех пор не была изменена
3-10 марта 23г
появляются специальные проверки (в виде ассертов) для проверки корректности предоставленных карточек и конвертеров, инициализации
переименование хранилища в
CardDb
появляется
getOrNull
иsetOrNull
, а также класс конфигурацииCardConfig
интерфейс прослушки изменяется (в том числе отказ от пакета
state_notifier
) и появляется знакомый методnotify
иattach
появляются первый тесты, направленные на методы
get
иset
и на работу с riverpod
8-17апр
сильный структурный рефакторинг папок
значительное улучшение проверок конфигурации
значительное документирование кода
наконец-то теперь это любимая
Cardoteka
появляется проксирование к статическим методам SP
15-24мая
первое графическое отображение структуры проекта с помощью mermaid схем
появление набора различных конвертеров для обычных классов и коллекций
25июля-1авг
написание тестов для конвертеров и ассертов, и ядра
ощущение, что проект уже в хорошей кондиции для релиза
публичный релиз — 25ноя-22дек 23г
полное осмысление и доработка всего кода, всех частей
создание мини-примеров в
example
много тестов, документации кода
совершенно новый и обдуманный readme.md
появляется идея довести Quiz Prize до ума в качестве мини-амбассадора
подготовка первой публикации "об использовании"
В графическом представлении этапы выглядят как одинокие айсберги:
И будет очень удобно, если вместо шести этапов мы будем рассматривать всего лишь 3 основных стадии:
Наивная реализация (закладывание основы ядра — левый айсберг)
Совершенствование (удобство апи, корректная работа — кучка айсбержков в середине)
Оттачивание (подготовка к релизу — правый айсберг)
Реализация
Удивительно, как много времени понадобилось для создания казалось бы простейшей обёртки. Но стоит учитывать подбор удобного апи для взаимодействия, обдумывание dart-концепций, желание выпустить полноценный законченный продукт и природную лень человека. Всё это купно очень страшные вещи, оттягивающие релиз перфекциониста (насколько это слово применимо) до невнятных сроков.
Что же, давайте обратимся к тем участкам кода, которые способны решить наши задачи.
Как из SP синглтона сделать экземпляры
Пожалуй это не такая уж сложная задачка, достаточно поместить наш синглтон в поле класса, экземпляры которого можно создавать. Однако, какой в этом смысл? Более удобная инициализация и пространство имён. Получаем какой-то код: (тут и далее я буду удалять незначимые детали из кода, дабы убрать отвлекающий шум)
Моя особая любовь к букве R
не давала покоя: у нас были ключи RKey
, хранилище RDatabase
и конвертеры RConverters
.., не то, чтобы всё это было категорически необходимо в создании пакета, но если уж начал брендировать, то к делу надо подходить серьёзно. Это я отвлёкся; обратите внимание на способ инициализации. Да, без слёз не взглянешь, но имейте ввиду — эти слёзы превратились в крепкий опыт, который в свою очередь стал частью меня. И мне не стыдно. Метод initSync
действительно ничего не делает(1), поскольку он возвращает ровно тот же экземпляр, метод которого вызывается. А вот асинхронный init
делает что-то важное: принимает нужные аргументы (2) и инициализирует саму SP, присваивая экземплярному(3) полю ссылку.
Но в середине айсбергов я постепенно понимаю, что подход нужен иной. Например, вот такой:
RDatabase
преобразовался в Cardoteka
, всюду убран ненужный префикс R
, в late
теперь нет нужды.
Если что-то (1) не делает ничего полезного — от этого можно избавиться. Важные аргументы (2) стоит собрать в класс-конфигуратор и передать прямо в конструктор Cardoteka
— местоположение зависимостей в шапке конструктора показалось мне хорошей идей. А экземпляр класса CardotekaConfig
легко перебрасывать ассертным поверочным функциям, а также удобно добавлять в него новые поля.
Теперь _prefs
(3) является статичным полем, собственно как и единственный метод инициализации init()
. Это весьма логично, потому что инициализацию нужно провести один раз, а экземплярам кардотеки нет необходимости иметь метод init()
.
Есть ещё маленькая хитрость: карточки лежат в UnmodifiableListView
, чтобы никто не смог их изменить извне (да и изнутри тоже).
Сейчас я вижу здесь одно желаемое упрощение: разрешить создание экземпляров Cardoteka
. Это упростит жизнь тем пользователям, которым не нужны дополнительные функции (прослушка, доступ к SP и т.д.). Issue добавил.
Разные экземпляры кардотеки — разное пространство имён
Это просто необходимо, иначе нет ровным счётом никакого смысла в создании множества экземпляров (ну может быть только ради разделения интерфейсов). В общем, идентичность каждого экземпляра складывается из двух вещей:
имя в конфигурационном классе
префикс в ключе (если ключи основаны на
Enum.name
)
Вот как выглядит конфигурационный класс:
Он хранит имя экземпляра, сами карточки и набор конвертеров. В будущем сюда может упасть класс по миграции данных, кто знает