Pull to refresh

Cardoteka — техническая начинка и аналитика решений типобезопасной SP [кто любит вдаваться]

Level of difficultyHard
Reading time28 min
Views491

Этот материал уже без шуточек. Технический обзор и оценка принятых решений. На самом деле шуточки есть, но я не смог окончательно от них избавиться. Видимо, без них нет меня.

Цикл выпусков:

Содержание

Предпосылки создания

Создание приложения для отслеживания погодных условий было весьма увлекательным занятием. И те или иные сложности, вставлявшие палки в колёса очередного только что мной придуманного велосипеда, были кстати: так зарождался первый практический опыт. Весьма быстро я понял, что использовать просто так shared_preferences не получится: нужно подумать о том, где хранить ключи и значения по умолчанию, как обновлять состояния при обновлении значений и почему не получается избавиться от бойлерплейта. Проблемы были решены, написана добротная статья на этот счёт

и где-то там зародилась идея всё упростить и улучшить. Вот фраза:

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

25 мар 2023 в 15:15

Забавно и страшно, сколько прошло времени. Оставим мюсли на закуску для 3-ей части и перейдём к обзору реальных проблем.

  1. Действительно не ясно, как удобно мы можем хранить ключи. А если думаем о том, что хотим избавить наш код от работы с null, то и подавно.

    • Допустим, можно вполне удобно собрать всё под одним крылом в классе со статическими полями вида themeModeKey и themeModeDefaultValue. Так я и сделал в приложении погоды.

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

    • В общем и целом это не выглядит как большая проблема. Однако:

      • имена, к которым мы вынуждены добавлять префиксы — бойлерплейт

      • между парой ключ-деф_значение фактически нет связи —> ошибки в использовании

      • при копировании пар можно ошибиться в значении ключа

  2. Сохранять сложные объекты — сложно. Вы можете сказать, что SP не для этого, а мой ответ — класс из трёх полей, который мы не хотим дробить по ключам. А теперь представьте, что это список из таких объектов... Дорогой дневник, мне не передать всю боль, что вынес я, когда занимался этими преобразованиями. Факт: нам может потребоваться возможность сохранить объект в SP. И хочется сделать это простой операцией.

    • нам нужен отдельный метод для преобразования В и метод для преобразования ИЗ. Конечно мы воспользуемся json_serializable и также jsonEncode/jsonDecode. Но это не спасёт от бойлерплейта, а также ошибок в коде при неправильном преобразовании

    • и это всё при условии, что архитектура выстроена таким образом, что всякий может пользоваться этими методами. А можно всё делать без методов и дублировать на каждом шагу этот код... То есть мы уже говорим про классы-мапперы. Как я попал в мир java?

  3. Для каждого типа данных — свой метод получения и сохранения. Так устроена dart-овая часть SP. И мне показалось это слишком болезненным, ведь была нужда использовать интерфейсы и отдать их бизнес-логике. Это полезно для "смокования" и тестирования бизнес-логики в дальнейшем, да и просто для упрощения кода и отделения слоёв.

    • но для этого нет ни-ка-кой возможности

    • борьбу с этим я видел такой — для каждой бизнес-логики свой собственный интерфейс для доступа к хранилищу. С собственными методами сохранения/получения и даже методом удаления, если он нужен. Но что это за кисель на ровном месте?

    • к тому же единственные get и set очень упростили бы код, вместо зоопарка методов.

  4. Обновление состояния и обновление данных. Необходимость выглядит так: в тот момент, когда пользователь взаимодействует с приложением, например, указывает номер стартовой страницы, возникает новое значение. Сначала мы обновляем состояние с новым значением, а затем сохраняем это значение в хранилище.

    • то есть всякий раз делаем две одинаковых операции. А ещё я скажу, что при старте приложения (или при задействовании модуля) мы должны взять сохранённое значение из хранилища и присвоить его в состояние. А это уже 4 условных операции и ещё больше дублирования кода.

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

    • *я согласен с тем фактом, это может вылиться в сильное нарушение принципов границ слоёв. Но для true|false-переключателей хочется иметь лёгкий способ быстрого изменения и реактивности.

  5. Проблема при riverpod-ной (и прочей) разработке — как синхронно инициализировать SP? Ведь если сделать это в условном FutureProvider, то устанешь потом выдумывать костыли с доступом в синхронном коде.

    • решение такое: использовать обычный Provider и вернуть в нём throw UnimplementedError. А в main асинхронно инициализировать SP и сделать переопределение провайдера с новым значением в ProviderScope.overrides

    • сделать FutureProvider и инициализировать наш SP внутри него, а затем применить недавно появившийся геттер requireValue. Вариант неплох, признаю, однако для всех участников он строится на доверии "Отвечаю, там есть значение, клянусь табуреткой".

    • да, это проблема того, что SP является синглтоном, экземпляр которого можно получить только через асинхронный метод

  6. Вытекающий факт: добавление новой функциональности по типу "выбор_значения—сохранение—обновление_состояния" оказалось очень утомительным занятием, даже при использовании пакета riverpod. И сильно подвержено ошибкам копипаста.

Шести пунктов показалось мне достаточным, чтобы увенчать попытку отдельным проектом.

Концепция

Избушка продукта стоит на двух ногах: решение вышеуказанных проблем и удобный апи взаимодействия. Вот ряд условий:

  • Основано на пакете shared_preferences и используется как обёртка. "Сладко" дополняет взаимодействие, а не изменяет поведение. Доступ к оригинальным функциям. Минимум прочих зависимостей (ноль).

  • Гибкое апи и широкие возможности при поддержке правила "если что-то можно сделать, то только так".

  • Без генерации кода.

  • Инкапсуляция сложности работы с комплексными данными: их лёгкое преобразование и минимум бойлерплейта.

  • get|set|remove для обычного использования и CRUD-модель взаимодействия для нуждающихся.

  • Функциональность прослушки для реактивной реализации поведения.

Закрепляю тот факт, что всё это должно действовать совокупно и единовременно.

Стадии реализации

И вот, в октябре 22 года я подошёл к некоторым умозаключениям о реализации. Не хочется сильно вдаваться в детали, поскольку интерес представляет именно конечный результат. Однако все желающие могут проверить историю коммитов и файлик readme — и удивиться, как сильно отличается та реализация от текущей.

Есть несколько моментов, на которые мы обратим внимание.

Для полноценного production понадобилось целых 6 итераций проектирования. Именно на них я буду ссылаться всякий раз. Ниже представлены временны́е итерации со ссылками на состояние репозитория к концу итерации:

  1. наивная реализация — 4-9 ноябрь 22г (код написан в голове с октября)

    • заложены основы пакета: ядро работы с SP называемое RDatabase, карточка RKey, конвертер RConverter, миксин для CRUD-операций, Watcher для прослушки значений

    • очень интересно выглядит Watcher: для хранения и обновления состояния используется StateNotifier

    • коллекция примеров в папке example

    • первая документация в файле readme.md

    • хочу подчеркнуть, что заложенная концепция типизированного доступа с тех пор не была изменена

  2. 3-10 марта 23г

    • появляются специальные проверки (в виде ассертов) для проверки корректности предоставленных карточек и конвертеров, инициализации

    • переименование хранилища в CardDb

    • появляется getOrNull и setOrNull, а также класс конфигурации CardConfig

    • интерфейс прослушки изменяется (в том числе отказ от пакета state_notifier) и появляется знакомый метод notify и attach

    • появляются первый тесты, направленные на методы get и set и на работу с riverpod

  3. 8-17апр

    • сильный структурный рефакторинг папок

    • значительное улучшение проверок конфигурации

    • значительное документирование кода

    • наконец-то теперь это любимая Cardoteka

    • появляется проксирование к статическим методам SP

  4. 15-24мая

    • первое графическое отображение структуры проекта с помощью mermaid схем

    • появление набора различных конвертеров для обычных классов и коллекций

  5. 25июля-1авг

    • написание тестов для конвертеров и ассертов, и ядра

    • ощущение, что проект уже в хорошей кондиции для релиза

  6. публичный релиз — 25ноя-22дек 23г

    • полное осмысление и доработка всего кода, всех частей

    • создание мини-примеров в example

    • много тестов, документации кода

    • совершенно новый и обдуманный readme.md

    • появляется идея довести Quiz Prize до ума в качестве мини-амбассадора

    • подготовка первой публикации "об использовании"

В графическом представлении этапы выглядят как одинокие айсберги:

И будет очень удобно, если вместо шести этапов мы будем рассматривать всего лишь 3 основных стадии:

  1. Наивная реализация (закладывание основы ядра — левый айсберг)

  2. Совершенствование (удобство апи, корректная работа — кучка айсбержков в середине)

  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)

Вот как выглядит конфигурационный класс:

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

Пока я это пишу, то задаю себе возможный вопрос читателя "А как происходит удаление пар из SP через экземпляр кардотеки?". Да, мы не можем вызвать clear(), поскольку это удалит вообще все данные из SP. Поэтому вот:

Где сам ключ готовится вот так:

Такое разделение пространства имён позволяет в каждый класс бизнес-логики поместить уникальный интерфейс, который бы мог затронуть только те данные, к которым имеет доступ.

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

Интерфейсом можно представить как конкретного наследника класса Cardoteka, так и сам конфигурационный класс. Что я хочу сказать, то напишу кодом:

interface class NotesCardoteka extends Cardoteka {
  NotesCardoteka(List<Card> cards)
      : super(
          config: CardotekaConfig(
            name: 'Notes',
            cards: cards,
          ),
        );
}

class NotesBloc {
  NotesBloc(this._notesCardoteka);

  final NotesCardoteka _notesCardoteka;

  void breakingAction() {
    _notesCardoteka.remove(cardFromAnotherCollection);
    // or
    _notesCardoteka.removeAll();
  }
}

main() {
  NotesBloc(NotesCardoteka(NotesCards.values));
}

Код очень условный, поскольку не решает конкретной задачи, но показывает одну из возможных реализаций разделения интерфейсов для разных блоков. И вот что здесь главное: если в каком-то экземпляре кардотеки мы захотим прочитать или удалить все данные, то решающее слово будет за CardotekaConfig.name (именно поэтому я сразу в интерфейсе указал имя). Соответственно, ничего не получится. Конечно, мы можем сохранить какие-то данные по неправильному ключу, но это не страшней удаления на мой взгляд.

Гипотетичность вымышленных ситуаций не исключает их реальность. И если говорить совсем простым языком, то, например, мы не хотим блоку заметок дать возможность считывать и изменять данные аутентификации. Разное пространство имён решает эту проблему.

Как сделать типизированный доступ

Всё, что мы хотели бы использовать — это get и set, а не набор из getBool, getStringList, getInt setDouble, setStringList и прочих. В голове выстраивается такой апи:

  • get(key)

  • set(key, value)

Вопрос лишь в том, как внутри дёргать правильные методы SP исходя из этих данных и как обеспечить корректный возвращаемый тип?

Пожалуй, это очень непростой вопрос. У меня есть пара решений:

  • определять тип динамически на основе is

  • определять тип на основе сравнения значений

Рассмотрим первый вариант. Функция определения типа будет основана на взаимной подтипизации:

bool sameTypes<S, V>() {
  void func<X extends S>() {}
  return func is void Function<X extends V>();
}

Чтобы понять почему недостаточно простого value is вот пример:

bool sameTypes<S, V>() {
  void func<X extends S>() {}
  return func is void Function<X extends V>();
}

void set1<T extends Object>(String key, T value) {
  print(value.runtimeType);    // int
  print(sameTypes<T, num>());  // false
  print(sameTypes<T, int>());  // true
  print(value is num);         // true
  print(value is int);         // true
}

void main() {
  const numb = 1; // inferred type "int"
  set1('numb_key', numb);
}

И даже с учётом того, что функция sameTypes вполне надёжно обрабатывала определение вложенного типа коллекций (например, List<String>) полагаться на неё я не предпочёл, поскольку пришлось бы разобраться в крайне непростой спецификации по типизации, которая могла бы измениться в следующей версии sdk.

Второй вариант мне показался проще и безопасней, хотя и требовал небольшое количество бойлерплейта. Смысл в том, чтобы дополнительно указывать тип передаваемых данных, исходя из которого остаётся правильно применить as.

Вот как это выглядит в простом случае:

void set1<T extends Object>(String key, T value, String type) {
  if (type == 'int') {
    prefs.setInt(key, value as int);
  } else if (type == 'bool') {
    prefs.setBool(key, value as bool);
  } else {
    // и далее...
  }
}

void main() {
  const numb = 1;
  set1('numb_key', numb, 'int');
}

Но если так указывать тип, то во-первых, можно запросто ошибиться, а во-вторых, просто послать всё к чертям — какой здесь профит от вызова конкретного метода в SP? И правда, так и есть. А ещё ведь появляется потребность в значении по умолчанию, обработке null, конвертерах... И решение состоит в том, чтобы объединить под одним крылом ключ, значение по умолчанию и тип данных. И если дважды воспользоваться enum, то всё окажется вполне тривиальным.

Для начала укажем допустимые типы, которые сможет принимать наше хранилище при запросах:

знаю-знаю, нам не нужны конструкторы (issue)
знаю-знаю, нам не нужны конструкторы (issue)

Всё, теперь на основе свойства исчерпываемости switch case легко вызывать правильный метод:

Я также знаю, что с новой возможность switch-выражений этот код будет выглядеть очень ёмким. Теперь мы можем использовать карточку для доступа к значению в SP. Самое время понять, как выглядят эти карты.

Определяем место, где хранятся ключи и значения по умолчанию

Сначала это было незаурядной задачей, и не очень понятно, как хранить ключи:

  • может быть использовать коллекцию из строковых ключей? А как тогда их непосредственно использовать?

  • хранить в качестве статических полей? Вроде удобно, но как собирать их в коллекцию?

  • или определить базовый класс IKey, и все ImplKey обязаны реализовать контракт. А после собрать их ручками в список и отправить в кардотеку...

  • вспомнить о том, что есть такой класс enum, в котором легко определять ключи в виде значений, и использовать Enum.values для быстрой генерации коллекции всех ключей

  • и если ещё взять enhanced enums, то можно добавить свои собственные поля

Так я понял, что идеальным вариантом будет создать интерфейс "ключа" с желаемыми полями, а с помощью enum пользователь легко реализует статичные пары "ключ-значение_по_умолчанию—тип_данных". Абстрактный класс выглядит вот так:

Я называю это "карта" или "карточка". Мы можем использовать обычные классы, но тогда потеряем возможность "встроенного коллекционирования" и "авто-ключа", да и синтаксис окажется сильно бойлерплейтным ИЛИ можем использовать перечисление. Тогда типичное определение всех ключей-карт:

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,
   };
}

Когда вам понадобится новая карта-ключ, просто добавьте её в любом месте — порядок не имеет значения. Такой подход даёт ряд преимуществ:

  • ключ генерируется на основе поля EnumName.name (этот подход может быть опасен: изменение имени в последующем повлечёт потерю доступа к значению в SP, поскольку имя является частью ключа. Поэтому лучше использовать константный строковый ключ для каждого enum-значения и указывать его через конструктор)

  • коллекция всех карточек генерируется на основе enum.values

В итоге вопрос определения и хранения ключей решён раз и навсегда. Однако появляется новый вопрос — значение по умолчанию должно быть строго константным. А что если наше значение таковым не является? Это можно решить с помощью null.

Как сохранить null, если SP это не поддерживает?

Ответ может быть только один — никак, и фактически это так. Однако мы можем сымитировать такое поведение. Достаточно условиться, что если пара отсутствует в SP (метод containsKey()), то значение при получении будет равно null в случае, если карточка является nullable-типом. А если мы сохраняем null-значение, то по факту удаляем его из хранилища. Звучит сложно и запутанно, но сейчас объясню.

В наивной реализации я действительно пытался использовать всего лишь два метода get|set и играться с nullable-дженериками. Потом оказалось, что главная проблема заключается в типе возврата. Когда мы используем non-nullable карточку, то при get-запросе ожидаем получить либо значение из хранилища, либо значение по умолчанию. Результат точно не будет равен null. И всё ломается, если карточка может допускать значение по умолчанию равное null.

Предпринята попытка решить это, добавив параметр ifAbsent:

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

С set всё немного иначе. В наивной реализации ровно в то же время, что и скриншот выше, функция сохранения выглядела так:

Видите, сразу прописано, что value не может быть равным null. То есть никакой работы с null в наивной реализации не предусматривалось, и у всех карточек значения по умолчанию строго не должны быть равными null. А это значит, что предыдущий пункт с enum-карточками невыполним при таком подходе.

Подумав *дцать месяцев, решение оказалось вполне тривиальным: для обычных (имеется ввиду non-null) карточек используем get и set, а для nullable карточек используем getOrNull и setOrNull. После этого решения жизнь сразу наладилась, ввиду того, что между наивным и финальным вариантом было много попыток "игры в дженерики" и жонглирование количеством методов (условно, есть get и getOrNull и единственный set). Теперь это выглядело так:

Что здесь из примечательного?:

  • оба метода используют единственный подкапотный метод

  • get и getOrNull различаются только тем, что при отсутствии значения в хранилище первый всегда возвращает значение по умолчанию, а второй вернёт null

  • set и setOrNull похожи, с той лишь разницей, что второй допускает "сохранение" null — значение будет удалено из хранилища.

  • внимательный читатель заметит, что флаг toNotify не имеет смысла, ведь если мы попадём внутрь условия if (value == null), то выполнение функции завершится вызовом remove. Разработчики — удивительнейшие люди, и поэтому я оставил этот флаг-страховку на будущее, если вдруг слово return исчезнет или что-то поменяется местами. Вероятно, лучше это переписать на if-else. Об этой истории я рассказал в разделе "Тестирование".

Есть даже специальная табличка по совместимости карточек с разными методами:

Сохраняем комплексные объекты

Как я уже подмечал ранее, SP создана не совсем для этого. Однако, запретный плод сладок и нет никаких проблем это реализовать. Под комплексным объектом я подразумеваю экземпляр класса с полями, который должен конвертироваться В тип, поддерживаемый SP, и деконвертироваться ИЗ этого типа в тот же экземпляр до преобразований. В общем, обычная сериализация/десериализация. И то, как конкретно это будет происходить, я оставил на откуп пользователю.

Первая наивная реализация предполагала несколько вариантов. Наряду с классическими типами по лекалам SP, есть и другие возможные типы:

  • например вида DataType.color или DataType.date. Мы выполняем операцию и передаём карточку с датой, условно, объект типа DateTime, а внутри через switch проверяем указанный DataType. Если это DataType.date мы уже знаем, какие именно преобразования необходимо сделать. Правда ведь?

    • оказывается что нет, не знаем. И ладно, если мы ещё как-то условимся принимать определённый паттерн для сохранения даты в строковом формате...

    • но ведь дату можно сохранить и как int. А как пользователь должен указать это? Через дополнительные флаги/enum/что-то ещё?

  • добавить универсальный тип DataType.custom. Внутри отлавливаем его и точно знаем, что к этому типу нужно применять прилагаемый конвертер.

    • оказалось, что это противоречит принципам перечислений в DataType, которые явно показывают все допустимые типы для SP

Поэтому было решено остановится на 5 допустимых типах: bool, int, double, String, List<String>. Это соответствует 5-ти одноименным set-методам в SP. Именно в них должно преобразовываться всё, что вы хотите сохранить в хранилище.

Теперь идея заключается в том, чтобы указать интерфейс конвертера. Вот как он выглядит:

Юзеру достаточно реализовать этот интерфейс и указать методы from и to. Я посчитал, что можно немного облегчить жизнь всем, и самостоятельно реализовал конвертеры для некоторых популярных типов. И всё объединил в класс со статическими полями:

Такое объединение показалось мне правильным по следующим соображениям:

  • не засоряется пространство имён при автодополнениях в IDE

  • не нужно вспоминать название конкретного конвертера — остаётся запомнить комбинацию Converters. + ctrl+пробел и выбрать нужный из списка.

Что ещё из интересностей? Как правило нужно сохранить не один объект, а их коллекцию: список товаров, таблицу покупок и т.д. Решение есть — конвертер коллекций!

На его основе реализованы IterableConverter, ListConverter и MapConverter. Пользователю необходимо расшириться и реализовать методы objFrom и objTo.

На примере создания конвертера коллекции чайных кружек:

class TeaCup {
  const TeaCup({
    required this.material,
    required this.capacity,
    required this.dishwasherSafe,
  });

  final String material;
  final double capacity;
  final bool dishwasherSafe;

  factory TeaCup.fromMap(Map<String, dynamic> map) => TeaCup(
        material: map['material'] as String,
        capacity: map['capacity'] as double,
        dishwasherSafe: map['dishwasherSafe'] as bool,
      );

  Map<String, dynamic> toMap() => {
        'material': material,
        'capacity': capacity,
        'dishwasherSafe': dishwasherSafe,
      };
}

class TeaCupsConverter extends IterableConverter<TeaCup> {
  const TeaCupsConverter();

  @override
  TeaCup objFrom(String element) =>
      TeaCup.fromMap(jsonDecode(element) as Map<String, dynamic>);

  @override
  String objTo(TeaCup object) => jsonEncode(object.toMap());
}

Как правило вместо fromMap/toMap вы используете fromJson/toJson и генерацию из пакета json_serializable. И если это так, то для сериализации/десериализации можно воспользоваться jsonDecode/jsonEncode из встроенной в Flutter SDK библиотеки dart.convert.

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

Остаётся вопрос, как передать конвертеры и где их хранить. Передаём мы их через файл конфигурации CardotekaConfig, а хранить можно в любом удобном месте. Я предпочитаю использовать статический геттер converters в enum-карточке, чтобы избежать бойлерплейта в указании имени перечисления:

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

Ассертные проверки

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

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

Да, тут есть место для улучшений. К примеру, можно собрать сразу все дубликат-ключи и выдать их одним сообщением. Или сделать кастомные ошибки вроде DuplicateCardError. Приятные задачки, чтобы отправить их в бэклог.

Кстати, здесь есть финт ушами. Почему бы не использовать AssertionError напрямую? Так вот этот класс использует под капотом метод Error.safeToString(message), который не даёт мне минимальные функции форматирования текста, игнорируя перенос строки и т.д. Поэтому было решено использовать свою реализацию:

Далее, все проверки собираются в одну единственную функцию checkConfiguration, которая принимает файл-конфигурации. И всё это помещается в assert в конструкторе кардотеки и проверяется один раз:

Да, про этот интересный комментарий со ссылкой "Allow run-time-only assertion checking in constant constructors · Issue #2581 · dart-lang/language". Всё-таки я стремился сделать конструктор кардотеки константным. Однако из-за ассерта, внутри которого содержится неконстантное выражение, это невозможно пока что. Быть может когда-то появится что-то вроде runtimeAssert, которые позволят магии осуществиться. Ну или метапрограммирование, я жду)

Раз уж разговор зашёл про ассертные проверки, то есть ещё одна интересная проверка _assertCheckInit. Она не позволяет обратиться к хранилищу до тех пор, пока не была выполнена асинхронная инициализация SP.

И да, вот прямо сейчас вместе с вами нашёл критический рудимент из наивной реализации — сообщение лжёт, ведь необходимо вызвать статический метод await Cardoteka.init(), а не экземплярный. Исправим, господа.

Ассерты удобны тем, что выпиливаются при релизной компиляции. И метод, который содержит внутри себя только ассерт, тоже удалится.

И последнее, самое важное — ассертные проверки покрыты тестами. Это очень важно, поскольку их некорректная работа сделает невозможным использование пакета. И тогда человек скажет "да лучше бы их не было, ** **** **** .

Реализация Watcher

Что же, это был принципиальный момент и слишком важный для данного пакета. При проектировании погодного приложения Weather Today на базе riverpod я столкнулся с неприятными моментами обновления состояния. Вам необходимо быть знакомым с этим пакетом в общих чертах, чтобы понять суть происходящего:

  • есть некое состояние, которое мы заключаем в StateProvider, чтобы иметь возможность изменять его извне. Provider использовать не получится

  • в качестве первого состояния мы возвращаем значение по умолчанию

  • есть метод set...(newValue), который делает две вещи — обновляет состояние и сохраняет новое значение в хранилище

  • теперь представьте, что состояние у провайдера не простой тип, а комплексный. Значит нужно применять метод для конвертации.

  • так как наше хранилище имеет синхронный доступ, то совсем неуместно использовать FutureProvider, что обязательно усложняет получение значения в виджетах (подход с AsyncValueX.requireValue мне не нравится семантически)

  • а ещё мы хотим заполнить состояние провайдера актуальным значением из SP, которое ранее мог указать пользователь

Изначально я не сильно понимал, что делаю. Обезопасив себя от высказываний, лучше даже сказать, что "У меня был какой-то план и я его придерживался". При проектировании прослушки была часть того, что я хотел увидеть со стороны пользователя, и часть непонятного. Наивный код выглядел мягко говоря, странным, хотя уже имел все задатки будущего подхода — основан на миксине для конкретного класса, есть абстрактный интерфейс и метод прикрепления прослушки, называемый listen, основанный на коллбэках. Запустить уведомления можно было с помощью actualizeValue, что логически соответствует notify в последующих ревизиях.

StateNotifier??? Да, использовался для хранения состояния "карточка-прослушки". Что самое интересное — метод getSavedValue, он же метод get из супер-класса. Всё это вынужденные меры из-за разделения вотчера на две части в попытках инкапсуляции.

В процессе этапа совершенствования пришло понимание того, что никакой _WatcherNotifer нам не нужен и хранить всё можно в приватной переменной внутри самого вотчера. Необходимость в прокидывании get исчезла. Метод listen был заменён на attach и весьма усовершенствован внутри. Внутренний апи упрощён — теперь достаточно реализовать notify.

На этом этапе я подошёл к необходимости тестирования и поэтому был реализован класс WactherImplDebug с полезными методами отслеживания состояния.

Теперь остаётся самое интересное — рассмотреть устройство вотчера на стадии оттачивания кода. Фактически, улучшено именование, процессы и всё сдобрено с лихвой подробной документацией. В метод attach добавлен коллбэк "при удалении" и флаг fireImmediately для немедленного вызова коллбэка "уведомления".

Процессы реализации такие: если мы хотим прослушивать изменения значения конкретной карточки, то вызываем метод attach.

  • Внутри создаётся newCallback на основе переданного callback и сохраняется в поле _watchers таким образом, что одной карточке соответствует список из всех коллбэков, когда либо привязанных (и всё ещё активных).

  • Далее вызывается функция detacher, аргументом в которую нужно передать анонимную функцию с удалением данного коллбэка из списка прослушек. В дальнейшем эта функция будет вызвана Вашим менеджером состояний, когда в прослушке пропадёт нужда.

  • И изюминка на торте — получаем актуальное значение из хранилища и при необходимости вызываем немедленно коллбек с этим самым значением. Завершаем метод возвратом актуального значения.

Метод notify — внутренний, предназначен для использования кардотекой для уведомления о поступлении/удалении значения. Именно он и запускает переданные ранее коллбэки, итеративно перебирая каждый по заданной карте. Магия и никакого мошенничества.

Всё происходит синхронно, что является большим преимуществом. Данный WatcherImpl.attach можно использовать с любым типом карт и с любой кардотекой. Гибкость апи заключается в том, что всякий пользователь может использовать интерфейс Wacther и написать свою реализацию, например, на стримах. Более того, именно менеджер состояния решает, когда прекратить прослушку и вызвать Detacher. У каждого своя зона ответственности.

На словах это может выглядеть сложнее, чем есть на деле...

Рефакторим старый код и используем прослушку

Изменим участок кода из вышеописанного приложения так, чтобы соответствовать самым новым требованиям. Важное замечание при рефакторинге — клиенты (тут это виджеты) не должны увидеть изменений.

Бойлерплейт был серьёзным преступлением в проекте; вот ссылка, чтобы это оценить. Суть я покажу ниже.

И представьте, что таких состояний порядка 10... А 50? Объединить их в одну модель желания большого нет, чтобы потом не мучаться с select, пытаясь избежать чрезмерно-частой перестройки виджетов.

И даже это — уже улучшенный вариант, поскольку часть нагрузки забирает на себя миксин-апдейтер Updater:

Надеюсь, вам уже плохо и везде мерещатся дженерики в эпилептических приступах. Ещё был метод saveAndUpdate, который принимал функцию конвертации, чтобы всё сделать в один вызов.

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

Я перепишу текущий код, чтобы показать, чего удалось достичь с помощью Cardoteka и миксина Watcher. Извините, но будут скриншоты, чтобы сохранить аналогичную цветовую составляющую.

Сначала определим карточки и сам класс кардотеки:

Я также добавил ещё несколько ключей-карточек для разнообразия. Вместе с определением провайдера для AppThemeTeka, наш преобразованный класс бизнес-логики AppTheme теперь выглядит так:

Никаких методов инициализации (а его между прочим необходимо не забыть вызвать из main и всё это положить в отрывной ProviderContainer), класс не знает ничего о конвертации объектов-состояний, минимум бойлерплейта в бизнес-логике. Да, сейчас я воспользовался бы Notifier-классами, в каждом из которых лежала логика, ответственная за свою область, будь-то изменение цвета темы или шрифта. Однако вышеуказанный рефакторинг гарантирует (!), что никакой из слоёв даже не заметит текущих изменений. Как виджет раньше подписывался на изменения через ref.watch(AppTheme.typography), так это сделает и сейчас. То же самое для изменения состояния — ref.read(AppTheme.instance).setTypography(newValue). Также этот способ позволяет не плодить излишнее количество классов с крайне простой функцией "хранения состояния", но при этом обладает гибкостью селективной слежки за изменениями. Ещё одно преимущество в том, что состояние провайдера теперь нельзя изменить извне, как это было со StateProvider. Мы фактически заставляем писать эту логику ровно там, где ей и место — в domain слое.

Архитектура в блок-схемах

Для начала хочу напомнить и изобразить взаимосвязи между объектами в UML нотации с некоторыми адаптациями к dart и собственным нуждам (укажите, если есть проблема):

Некоторые пояснения:

  • В Dart есть только одиночное наследование, которое можно сделать с помощью слова extends.

  • Используйте модификатор interface, если хотите явно указать, что данный класс является интерфейсом. Если нужно запретить создание экземпляров, то используйте abstract interface.

  • Зависимость может выражаться по разному. Когда мы используем пакет в pubspec.yaml это зависимость нашего проекта от чужой разработки. Когда мы в текущем файле делаем импорт или экспорт другого файла, то данный файл зависит от импортированных реализаций, конечно при условии, что мы их используем. Когда один класс использует другой, то фактически зависит от него. Абстрактный термин и быстрая пунктирная линия.

  • В момент агрегации главный объект получает ссылку на побочный объект. Но, будьте внимательны. В Dart примитивные типы всегда передаются по значению. Ситуацию я описывал здесь. Если кратно, используйте обёртки, чтобы получить ссылку.

  • Если миксины используются как инструмент другим классом (with), то будут обозначаться стрелкой композиции. Если же миксин реализует другой миксин, то это заключение контракта (implements). Есть и ситуация агрегации, когда класс содержит ссылку на экземпляр миксина, который может быть подмешан.

Предупреждение: в будущем диаграммы могут не соответствовать действительности. Текущие блок-схемы иллюстрируют состояние пакета для v1.0.1. В случае разногласий код имеет бóльшую силу.

Ниже представлена основа для всех действий — класс Cardoteka. Этот класс содержит весь основной функционал. Обратите внимание, что Watcher добавлен через агрегацию, поэтому внутри примеси WactherImpl мы переопределяем геттер watcher => this. Это изящное решение позволяет навешивать необходимый инструментарий на ваш MyCardoteka используя миксины.

Сама карточка представляет из себя абстрактный класс. Хотите новый ключ — реализовываете класс Card. Ваш MyCard может быть обычным классом, хотя это и не очень удобно. Все такие карточки придётся ручками собрать в список и передать в CardConfig. Чтобы дело заспорилось быстрее, стоит использовать расширенный enum и его встроенные возможности по "генерации списка из значений на лету".

Вся фишка конвертеров — это единственный абстрактный класс Converter, который используется в core как интерфейс. По умолчанию предоставлено много разнообразных конвертеров, включая Color, Uri, Enum и DateTime разных способов, Num... Их все я собрал в едином классе Converters, чтобы не загрязнять пространство имён и одновременно с этим обеспечить лёгкий выбор всех имеющихся в пакете конвертеров.

Не обошёл стороной и возможность конвертации коллекций, определив базовый класс CollectionConverter для этой цели. Забавен тот факт, что под капотом это всё тот же Converter. Отсутствие модификаторов сильно удручает в таких проектах, но с dart 3 появилась возможность гибко настраивать работу с классами. Теперь можно смело сделать MapConverter и прочие abstract base классом, доступными только для расширения (extends).

Как расширить функциональность класса? Воспользоваться композицией? Можно круче — использовать миксины. Я так проникся with, что остановить меня было сложно. Что получилось, вы видите на схеме:

Нужен доступ к вспомогательным методам тестирования? Используйте миксин CardotekaUtilsForTest. Использовать хранилище как "базу данных" — используйте миксин CRUD! И конечно же, если нужна прослушка или доступ к чистому SP — начните использовать миксины :) Словно панацея, эти зелёные маленькие существа захватили мой код. С другой стороны, какие есть способы ещё? Заранее определить классы кардотек в разных комбинациях? — их количество велико. Перевернуть некоторые зависимости таким образом, чтобы CRUD имел конструктор, а его полем стал экземпляр кардотеки? — сомнительно.

Таким образом, структура получилась вполне линейной, простой в использовании, выверенной практическим путём. Фактически это хорошее достижение.

Минимальная версия Dart SDK

Нижняя граница версии Dart SDK подобрана опытным путём. Ровно в версии 2.17.0 были добавлены расширенные enum (а также указание супер параметров в конструкторах через super. и расположение именованных аргументов в любом месте от позиционных), с помощью которых создаются все мои карточки. От такого было отказаться невозможно даже в тестах (и тем более!), хотя сам код в папке lib готов к опусканию границы.

С другой стороны Dart версии 2.17.0 используется в стабильной версии Flutter 3.0.0, и граница ниже этой в конечном счёте повлекла бы за собой совсем устаревшие зависимости. Если увлечься этим, то предполагаю, краеугольной стала бы версия Dart 2.12.0 с поддержкой null safety.

С другой стороны верхняя версия определена границей в 4.0.0, что весьма удобно, поскольку обеспечивает очень широкий спектр допустимых sdk. Всё, за что мы любим dart 3.0, доступно! А это необъятный матчинг, модификаторы для классов, switch выражения, сеалед классы, теперь вот уже и расширение типов, продвижение приватных полей...

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

Подходы к реализации конечного апи

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

  1. Сидим внутри классов своего пакета, думаем исходя из соображений "это может выглядеть так" и не понимаем, что хотим получить в конечном счёте. В наивной стадии я неспециально шёл этим маршрутом, и поэтому возникали ситуации с непонятной экземплярной инициализацией SP, использованием StateNotifier в Watcher и мучениями с дженериками в get|set. Неясно было, куда я хочу прийти в результате этих манипуляций — это код без оглядки на само использование, почти что "делаем ради дела" и "пишем, чтобы написать". Пожалуй, так выглядит написание кода без конкретного технического задания, что часто можно встретить в ситуациях бизнес-требований "хочу то, не знаю что".

  2. Думаем о том, какие данные у нас есть на руках, и чего мы хотим получить, когда ими воспользуемся. В стадии оттачивания этот подход раскрылся в приятном свете. Он означает, что необходимо посмотреть на пакет со стороны пользователя, оценить его потребности и исходя из этого создать необходимые методы с понятными сигнатурами. Это можно назвать методом индукции, когда мы опираемся на частные ситуации и выводим простые методы, решающие конкретную задачу. В дальнейшем легко преобразовать код для широкого захвата всевозможных случаев.

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

Здесь можно развить тему далее, но оставлю это на откуп отдельной статьи. Для себя подытожу, что второй вариант сработал с невероятной ясностью кода и оказался удачным вариантом для исследований, которые точно приведут к результату.

Тестирование

Есть две вещи, о которых хочется рассказать.

Первое — я был очень приятно шокирован, когда в какой-то момент написания тестов поймал сразу две ошибки (одной истории) неправильной работы кода в моём проекте! Это было потрясающе, я тысячу раз всё проверял глазами, обдумывал в голове, компилировал там же, но такой банальной ошибки не видел. И бац, вот она раз и два — двойное уведомление слушателя. Только представьте, написание тестов смогло предотвратить неправильную работу кода. Если Вы не удивлены, значит не подходите к тестам с позиции "тяп-ляп, тесты, ну пару юнитов, никто и проверять не будет, просто сдам проект и дело в шляпе" или "а что тут проверять то, всё чётко, проверено в голове тыща четыреста раз, какие ошибки, э".

Второе — вопрос итерирования по коллекции разных конфигов. Это оказалось непростой задачей, однако создание собственной setUp-tearDown-функции выправило ситуацию. Вы можете сделать так:

@isTest
Future<void> testWith(
  Object description,
  dynamic Function() body, {
  dynamic Function()? setUp,
  dynamic Function()? tearDown,
}) async {
  test(description, () async {
    await setUp?.call();
    await body.call();
    await tearDown?.call();
  });
}

и добавить необходимых параметров, чтобы заставить всё выполняться в нужной последовательности. Не исключаю, что данный подход может быть некорректным с точки зрения идеологии пакета flutter_test, но полностью рабочий и выполняющий поставленную задачу.

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

Вывод:

  • написание тестов отнимает много времени

  • но они могут предотвратить глупые и не очень ошибки

  • тесты заставляют вас мыслить другими категориями, думать об архитектуре и подходах

  • могут быть отличным примером использования вашей библиотеки

  • это весело, когда у вас есть свободное время

Песочница реактивных подходов

Короткий бонусный пункт. Я сделал маленькое приложение "Reactive Domain Playground", где можно потренироваться в разных способах взаимодействия domain слоя, интерфейса и данных. Суть в том, что необходимо предоставить три отдельных виджета в параметры базовому ExperimentPage, которые определённым образом связаны со своим состоянием из Notifier (bloc, controller или что-там у вас). Три виджета существуют не просто так: каждый из них даёт возможность селективно перестраивать интерфейс и содержит методы для изменения цвета темы, режима темы и "того и другого" одновременно. При первом отображении страницы Notifier должен получить актуальное значение из хранилища, конечно, с помощью Cardoteka.

На самой страничке встроен логгер с автоскроллом, с помощью которого можно видеть текущие перестроения виджетов и состояний. Уже есть 5 примеров того, как можно организовать эту связь :) Пул-реквесты приветствуются, дополнительная информация в readme проекта.

Цель — выследить оптимальные решения для построения связи ui->domain->data с помощью пакета cardoteka и различных менеджеров состояний.

Ссылка: https://github.com/PackRuble/reactive_domain_playground

Последствия

Разработка этого пакета произвела на меня большое впечатление. Сделать что-то сложное качественно и для широкой публики за одну итерацию оказалось нетривиальной задачей. Необходим большой архитектурный опыт, полублестящие языковые навыки и творческий потенциал. И глядя на проект и витиеватую дорожку, я поражён — как это сложно!

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

© 2022-2024 Ruble

Tags:
Hubs:
Total votes 4: ↑4 and ↓0+4
Comments0

Articles