Ссылку на веб-версию можно найти в конце статьи.

Как здорово, что все мы здесь сегодня собрались.

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

  • о слоях и взаимосвязях в архитектуре

  • формула: 2x реактивность = Riverpod + Cardoteka

  • особенности проектирования бизнес-логики

  • лучшие паттерны для работы с Cardoteka

  • определение репозиториев и про Trivia Api

  • настройка github actions для деплоя web и релиза подписанных apk 🎁

И всё это под лязг пластмассовых катан. Прошу, вы устанете, но будет весело!

Содержание

  • Взаимосвязь слоёв

  • Стейт-менеджмент

  • Как происходит реактивное изменение состояния

  • Это лучшая архитектура?

  • Анализируем код или моё недоумение

    • Правило1: экземпляры провайдеров должны создаваться глобально

    • Правило2: для реализации контроллеров страниц Provider — не лучший выбор

    • Правило3: один Notifier класс — одна ответственность

    • Правило4: один репозиторий можно разделить на несколько

  • Улучшаем архитектуру или моё почтение

    • Проблема получения викторин из Trivia API

    • Слой domain — операция на сердце

    • Презенторы для виджетов и реактивность

    • Как организовывать репозитории и сервисы

  • Различные способы синхронной инициализации с Cardoteka

  • Бонус — деплой на github actions

    • Правильное имя артефакта — залог успеха

    • Маленький нюанс деплоя на web

  • Выводы

Для комфортного погружения в проект рекомендую клонировать репозиторий локально и по ходу чтения изучать код. Вам понадобится последняя стабильная версия Flutter (3.19.0 и Dart 3.3.0), любимая IDE и чай! Также неплохо записывать свои попутные размышления в комментарии 😏

Начнём с самого главного — Архитектуры.

Взаимосвязь слоёв

Первая архитектура приложения представляла из себя вполне рациональное решение. Вам понадобится перейти на тег v1.0.2 для изучения происходящего ниже.

Архитектура приложения представлена в виде блок-схемы:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Сейчас мы детально разберёмся в тонкостях. Прежде я скажу, что схема упрощена только лишь по количеству репозиториев, блоков, экранов и моделей. Это означает, что если есть ещё несколько ui-страниц, то все они лежат в папке ui, имеют контроллер или группу таковых, и определяются чёткой связью import 'lib/src/domain/bloc/...' с бизнес-логикой приложения.

Читаем так:

  • определено три папки — ui, domain и data. First-layer (три слоя) архитектура. В имени каждого жёлтого блока после : дано пояснение, что может лежать в этих папках.

    • ui — здесь лежит весь интерфейс + контроллеры для каждой страницы. Если интерфейс достаточно сложный, можно использовать столько контроллеров, сколько необходимо. В добавок, определено несколько папок const (хранятся интерфейсные константы) и shared (хранятся переиспользуемые виджеты).

    • domain — здесь находится вся бизнес-логика приложения (именуемая BLoC) + провайдеры, которые являются хранителями состояния и расширяются (extends) от блоков. Они также предоставляют некоторые реактивные единицы, прослушивая которые наш интерфейс может перестраиваться.

    • data — это хранилище репозиториев. По сути, осуществление доступа к базе данных и преобразование сырых данных в модели — это задача репозитория; получение конкретных данных из интернета — отдельный репозиторий; получение данных Wi-fi, Bluetooth, GPS, гироскопа и прочего — репозиторий. У каждого репозитория должны быть модели (для сложных данных), которые в mvp-проектах можно прокидывать вплоть до интерфейса (и им необязательно иметь суффикс DTO (Data Transfer Object)).

  • модели (иногда именуемые сущностями) — это простые объекты с полями, которые глупые и не имеют логики. Мы часто используем freezed или json_serializable, чтобы поддерживать иммутабельность, лёгкое изменение и, при необходимости, методы сериализации/десериализации. Не стоит путать понятие “Model”, определенное в архитектуре MVC, где таковая является держателем данных и бизнес-логики и призвана инкапсулировать взаимодействие интерфейса (View) и репозиториев (Controller). В текущем проекте есть модель CategoryDTO, которая используется всеми слоями; есть QuizDTO, которая используется в репозитории TriviaRepository, где преобразуется в Quiz, которая уже используется в интерфейсе. В случае необходимости, можно создать QuizUI, которая будет моделью слоя ui и преобразование Quiz —> QuizUI будет происходить в контроллере виджета.

Стейт-менеджмент

Теперь хотелось бы рассказать о попытке решить архитектурный вопрос хранения состояния и реактивного изменения интерфейса. Здесь используется один единственный пакет для этой цели — Riverpod, призванный решить сразу все проблемы. Что такое Riverpod и какие функции он выполняет? Ответ из документации:

Providers are a complete replacement for patterns like Singletons, Service Locators, Dependency Injection or InheritedWidgets.

И вы знаете, это действительно так. Всё ваше состояние будет храниться в контейнере ProviderContainer в ProviderScope (это StatefulWidget по большому счёту) и будет доступно любому, у кого появится ссылка типа Ref (в виджетах это WidgetRef ref). Оборачиваем ваше дерево в корне в ProviderScope и теперь все веточки могут получить доступ к состоянию. При чём, состояние провайдеров в отдельных ветках можно переопределять:

final themeProvider = Provider((ref) => MyTheme.light());  void main() {   runApp(     ProviderScope(       child: MaterialApp(         home: Home(),         routes: {           '/gallery': (_) => ProviderScope(             overrides: [               themeProvider.overrideWithValue(MyTheme.dark()),             ],           ),         },       ),     ),   ); } 

Всё, что нам нужно, это сделать столько провайдеров (Provider и иже с ним), сколько состояний в нашем приложении нужно. И для этой цели рекомендуется два способа:

  • создать провайдера на глобальном уровне (в так называемой зоне видимости верхнего уровня) — это официальный способ

  • создать статического провайдера в зоне видимости класса. Мне нравится именно этот вариант, так как в сотне существующих провайдеров проще ориентироваться и использовать их, когда они объединены в некоторую единую структуру. Таких классов может быть сколько необходимо, лишь бы каждый конкретный класс выполнял свою бизнес-логику — не нужно объединять провайдеров в одном классе, если один контролирует тему приложения, другой уведомления, а третий держит репозиторий и взаимодействует с сетью. С другой стороны, если вы так сделаете, то получите единое место внедрения всех ваших зависимостей.

Как бонус второго варианта, вы просто пишите MyClassProviders. нажимаете ctrl+пробел и получаете в автодополнении IDE всех провайдеров в этом классе.

Но не было печали, так черти накачали, и я решил пойти по третьему пути. Почему? Не нравится мне две вещи:

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

  • и в этом случае нам хочется использовать методы и поля экземпляра класса изнутри анонимной функции провайдера.

Пожалуйста, дочитайте, прежде чем что-то писать по этому поводу. Рассмотрим TriviaQuizBloc и TriviaQuizProvider.

TriviaQuizBloc является классом бизнес-логики, вся суть которого заключается в объединении под своим капотом других репозиториев или блоков. Вот как это выглядит:

class TriviaQuizBloc {   TriviaQuizBloc({     required TriviaRepository triviaRepository,     required TriviaStatsBloc triviaStatsBloc,     required GameStorage storage,     this.debugMode = false,   })  : _storage = storage,         _triviaRepository = triviaRepository,         _triviaStatsBloc = triviaStatsBloc;    final TriviaRepository _triviaRepository;   final TriviaStatsBloc _triviaStatsBloc;   final GameStorage _storage;   final bool debugMode;    // далее куча методов, которые что-то делают. Один из них    /// Get all sorts of categories of quizzes.   Future<List<CategoryDTO>> fetchCategories() async {     return switch (await _triviaRepository.getCategories()) {       TriviaRepoData<List<CategoryDTO>>(data: final list) => () async {           await _storage.set(GameCard.allCategories, list);           return list;         }.call(),       TriviaRepoError(error: final e) =>         e is SocketException || e is TimeoutException             ? _storage.get(GameCard.allCategories)             : throw Exception(e),       _ => throw Exception('$TriviaQuizBloc.fetchCategories() failed'),     };   } }

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

Здорово, все методы для управления состоянием у нас есть. Добавим провайдеров:

class TriviaQuizProvider extends TriviaQuizBloc {   TriviaQuizProvider({     required super.triviaRepository,     required super.triviaStatsBloc,     super.debugMode,     required super.storage,   });    static final instance = AutoDisposeProvider<TriviaQuizProvider>((ref) {     return TriviaQuizProvider(       triviaRepository: TriviaRepository(         client: http.Client(),         useMockData: kDebugMode,       ),       debugMode: kDebugMode,       storage: ref.watch(GameStorage.instance),       triviaStatsBloc: ref.watch(TriviaStatsProvider.instance),     );   });    late final quizzes = AutoDisposeProvider<List<Quiz>>((ref) {     ref.onDispose(() {       _quizzesIterator = null;     });     return _storage.attach(       GameCard.quizzes,       (value) => ref.state = value,       detacher: ref.onDispose,     );   });    late final quizDifficulty = AutoDisposeProvider<TriviaQuizDifficulty>((ref) {     return _storage.attach(       GameCard.quizDifficulty,       (value) => ref.state = value,       detacher: ref.onDispose,     );   });    late final quizCategory = AutoDisposeProvider<CategoryDTO>((ref) {     return _storage.attach(       GameCard.quizCategory,       (value) => ref.state = value,       detacher: ref.onDispose,     );   }); }

И здесь начинается самая жара. Что это вообще такое? Почему нельзя использовать композицию, вместо наследования? Зачем нам late?

Класс instance напоминает внедрение зависимостей. Но так как это Riverpod и никаких of(context) здесь нет, то всё внедряется “по месту жительства” и по первому требованию, а не в корне главной функции main(). Доступ ко всем провайдерам данного класса будет осуществляться только через instance и придётся писать вот так:

@override Widget build(BuildContext context, WidgetRef ref) {   final triviaQuizBloc = ref.watch(TriviaQuizProvider.instance);   final difficulty = ref.watch(triviaQuizBloc.quizDifficulty);    return ...; }

Слово late нам необходимо, чтобы иметь доступ к полям нашего родителя TriviaQuizBloc (везде нам нужен storage) и даже выполнять некоторую логику очистки после удаления всех слушателей (как в провайдере quizzes).

Важное уточнение — все провайдеры экземпляра должны иметь .autoDispose модификатор, либо же используйте непосредственно класс AutoDisposeProvider. Иначе, когда ваш instance перестроится под влиянием других провайдеров, мы потеряем доступ ко всем нашим полям-провайдерам. Добро пожаловать, утечка памяти. И кстати, мне будущему, это звоночек №1 о том, что что-то идёт не так.

Сам же instance может быть и обычным провайдером типа Provider. Его задача — иметь актуальный и единственный экземпляр TriviaQuizProvider и следить за изменениями зависимостей.

Здесь так же есть важное уточнение, относящееся ко всему Riverpod — если ваш провайдер не autoDispose то он не может слушать других провайдеров, которые являются autodispose (ошибка компиляции). Это by design, потому что логично — нет смысла прослушивать то, чьё состояние может быть удалено. (Живой не может общаться с мёртвым, если сам не мертвец. Ну вы поняли.)

Как происходит реактивное изменение состояния

Самое интересное — реактивность. Наш интерфейс должен перестраиваться с новыми данными, когда изменяется состояние провайдера.

Рассмотрим, как детально происходит изменение состояния на примере quizCategory. Это обычный некэшируемый провайдер, который хранит состояние (до тех пор, пока им кто-то пользуется) текущей выбранной категории для получения в дальнейшем по ней викторин. Он написан также выше по тексту, однако я его переписал чуть нагляднее:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

И здесь вступает в дело магия и пакет Cardoteka. Ведь в самом деле, изменить обычного провайдера извне не предоставляется возможным. И для этой цели нам пришлось бы использовать StateProvider у которого есть поле state. Однако пакетик имеет возможность прослушки всех поступающих новых значений по ключу, если зарегистрировать своего слушателя. Это как привычный нам метод addListener в классе Listenable, который позволяет зарегистрировать callback, срабатывающий каждый раз при уведомлении слушателей, только чуть навороченней.

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

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

А третий именованный параметр detacher — метод удаления слушателя, когда в этом больше нет нужды. Для этой цели я передаю ref.onDispose в качестве аргумента. Как только провайдер уничтожит своё состояние, связанные с этим событием данные внутри Cardoteka также будут удалены.

Есть ещё пару именованных параметров onRemove для уведомления о том, что значение было удалено из хранилища и fireImmediately для запуска коллбэка немедленно.

Сам класс GameStorage выглядит следующим образом:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

И это та самая наша единственная связь от storage к bloc_model на схеме. Не могу сказать, что внедрение зависимости прямо в этом классе будет хорошим тоном, однако борьба за чистоту пространства имён пока что важней.

И теперь, вот момент, заставляющий провайдера получить новое значение:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Устанавливаем новое значение -> внутри хранилища происходит синхронный вызов ранее переданных коллбэков по данной карточке -> провайдер получает новое значение и обновляет своё состояние -> (значение сохраняется в SP). Что может и как работает данный пакет, я описывал в материале:

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

Мне кажется это удобным для самых простых переключателей тем, языков, true|false и так далее. С другой стороны, можно пользоваться get и set, чтобы делать инициализацию и обновление по старинке. Наперёд скажу — в особых случаях это бывает сподручней.

Это лучшая архитектура?

Всё это была “викторина” версии 1.0. Я намеренно пропустил обсуждение слоя ui, поскольку в нём нет ничего необычного — начинаем слушать провайдеров и интерфейс обновляется. Больше интересностей есть в “контроллерах” для ui; речь о них пойдёт чуть позже.

Спустя время я выявил проблемные места данной архитектуры и предпринял попытки построить более удачную. Однако прежде чем строить заново и заниматься “улучшайзингом”, нужно детально отрефлексировать текущие подходы.

Выделю моменты, которые вызывают большие подозрения в компетентности и целесообразности (как говорится, вместо тысячи диаграмм и слов — сухие факты):

  • класс TriviaQuizBloc монолитен. Как бы я не старался сделать НЕЧТО разделённое на бизнес-логику и на само состояние — сейчас это неудачная попытка. У класса куча ролей и местами уже непонятно, точно ли он занимается только выдачей текущей викторины? А оказывается, он умеет и кэшировать викторины, и делать запрос к сети для получения новых, и управлять текущей конфигурацией (сложность и тип викторин, управление категорией). И ещё — пожалуй, это невозможно протестировать. Вот о чём я говорю, посмотрите сами.

  • подход внедрения зависимостей через создание полей у класса — утопичен. Идея TriviaQuizProvider бесперспективна. Riverpod не предназначен для такого использования. Позже я дам ответ, почему так.

  • подход с прослушкой внутри хранилища вызывает сомнения в соблюдении принципов SOLID. Да, я создал пакет Кардотеки в том числе преследуя эту цель — очень хотелось иметь быстрое внедрение реактивности в простые переключатели. Но хорошо, что это была не единственная ставка. Убираем миксин Watcher и получаем типизированную SP. Доступ только по карточкам.

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

    • либо класс обёртку, подобный GameStorage или AppStorage , в которые уже изначально заложена работа только с конкретной конфигурацией (мы передаём её через параметр config) и соответственно, карточками,

    • либо саму конфигурацию, используя которую и используя некий общий класс CardotekaBase (или CardotekaWithWatcherAndCRUD) доступа к SP, наш TokenBloc будет создавать экземпляр из класса-инструмента с параметром класса-конфигурации.

    • Оба варианта интересны и требуют проработки “временем”. Сейчас и далее я буду использовать именно первый вариант.

  • ужас, происходящий в так называемых “контроллерах ui-страниц” превзошёл мои ожидания по невыразительности и мешковатости . Использование класса Provider во всех возможных случаях — признак одержимости его кажущейся простотой. Сюда же проблема из второго пункта про Class { late final provider}.

Кроме того, я поигрался и в реализациях репозиториев. Идея была в том, чтобы использовать extension, что позволило бы разграничить работу с разными логическими частями одного апи. Это оказалось удобным, когда ты описываешь методы такого репозитория, ведь всё лежит в своём namespace. Однако передать только конкретное расширение в зависимость другому классу нельзя, поскольку тот, кто содержит TriviaRepository-переменную, имеет доступ ко всем методам во всех расширениях (кроме приватных, конечно).

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Есть место для улучшений, не так ли?

Анализируем код или моё недоумение

Начал я с того факта, чтобы узнать, как же ведёт себя провайдер внутри которого живёт другой провайдер. И выяснилось — живёт он печально. Чтобы не брать во внимание абстрактные примеры, сразу возьмём пример из v1.0.2. Есть провайдер с экземпляром класса TriviaQuizProvider. Внутри это класса живут другие late final провайдеры quizCategory, quizDifficulty и т.д. в качестве полей класса.

Когда TriviaQuizProvider запускает перестройку, то все поля этого класса больше не существуют (если конечно на них не остались ссылки в других местах). Но Provider не простой объект, а с внутренним состоянием, которое не очистится просто так. И если вы не ошиблись, и провайдер был создан как Provider.autodispose — Риверпод подчистит хвосты, НО только после того, как виджет (или другой провайдер) перестанет его слушать.

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

Правило1: экземпляры провайдеров должны создаваться глобально

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

Скрытый пример и ссылка на gist

Суть: мы сохранили fooProvider в переменную oldFoo. Повесили слушатель на barProvider, чтобы он не был утилизирован. Ошибка готова, поскольку fooProvider после чтения будет сразу же удалён (из-за модификатора autoDispose) Риверподом, а поле класса barProvider останется жить и далее.

Затем мы снова читаем fooProvider и сравниваем старое и новое значение (это хэш экземпляра FooBLoC) в barProvider. И оказывается, они ссылаются на разные FooBLoC… Это потенциально приведёт к ошибке, когда FooBLoC будет настоящим, сложным и с другими зависимостями.

Испытуемый код:

import 'package:riverpod/riverpod.dart';  final fooProvider = Provider.autoDispose((_) => FooProvider(FooBLoC()));  class FooProvider {   FooProvider(this._fooBLoC);    late final barProvider = Provider.autoDispose((_) => _fooBLoC.hashCode);    final FooBLoC _fooBLoC; }  class FooBLoC {}  Future<void> main() async {   final container = ProviderContainer(observers: [Logger()]);   final oldFoo = container.read(fooProvider);    print('---1---');    // don't let provider get disposed   container.listen(oldFoo.barProvider, (_, __) {});   await container.pump();    print('---2---');    final newFoo = container.read(fooProvider);    final hashBarOld = container.read(oldFoo.barProvider);   final hashBarNew = container.read(newFoo.barProvider);   assert(hashBarOld != hashBarNew);    print('---3---');    await container.pump();   print(container.getAllProviderElementsInOrder()); }  class Logger implements ProviderObserver {   @override   void didAddProvider(pr, v, __) => print('$pr has been added with $v');    @override   void didDisposeProvider(pr, _) => print('$pr has been disposed');    @override   void didUpdateProvider(pr, _, __, ___) => print('$pr has been updated');    @override   void providerDidFail(pr, e, st, container) => print('$pr $e $st $container'); } 

Вывод в консоль:

AutoDisposeProvider<FooProvider>#bda81 has been added with Instance of 'FooProvider' ---1--- AutoDisposeProvider<int>#87e68 has been added with 424692243 AutoDisposeProvider<FooProvider>#bda81 has been disposed ---2--- AutoDisposeProvider<FooProvider>#bda81 has been added with Instance of 'FooProvider' AutoDisposeProvider<int>#5bcd9 has been added with 688168595 ---3--- AutoDisposeProvider<FooProvider>#bda81 has been disposed AutoDisposeProvider<int>#5bcd9 has been disposed (AutoDisposeProviderElement<int>(provider: AutoDisposeProvider<int>#87e68, origin: AutoDisposeProvider<int>#87e68)) Process finished with exit code 0

https://gist.github.com/PackRuble/a07d427ab3a95a58241fab79608a2abe

С контроллерами страниц (можно понимать как “экранов”) была увлекательнейшая история. Начиная с того факта, что кажется я влюбился в другое именование — презентор страницы. Это позволяет быстро отличать глобальных контроллеров любых мастей от конкретных для интерфейса как в подсказках IDE, так и во вкладке структуры папок.

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

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

И я не про использование sealed классов — это наоборот отличнейшая штука, когда ваш интерфейс может показать много чего, в зависимости от пришедших данных. Я о StateProvider, который даёт первое состояние как “загрузка”, чтобы сработал обратный вызов в listenSelf и был сделан запрос к получению текущей викторины, и обновлено состояние. Кстати, я видел такой подход в проде, соболезную.

Правило2: для реализации контроллеров страниц Provider — не лучший выбор

Если ваш проект — простой, то совсем опустите всякие контроллеры для страниц. Вам это действительно не надо. Я буду рассматривать это как преждевременную оптимизацию, которая сильно бойлерплейтит, тратит ваше личное время и совершенно не ясно, зачем вообще нужна. Прямой вызов до бизнес-логики и дело в шляпе. Нужно немножко локального состояния? — StatefullWidget (или ConsumerStatefullWidget) с полями. Вызов диалогов — сюда же.

Если очень хочется, или же внутри будет некая специфичная логика, которая нужна только для ui — воспользуйтесь хотя бы StateNotifierProvider вместо Provider. Он очень функционален, позволит сделать вообще всё и даже больше, чем новоиспечённый NotifierProvider + подойдёт для сильно старых проектов.

Часто бывает ситуация, когда не хочется делать sealed-класс состояния для презентора. В этом случае воспользуйтесь AsyncNotifierProvider: его состояние это AsyncValue, которое может быть в 3ёх кондициях — данные, загрузка, ошибка. В интерфейсе это выглядит вполне прилично (на мой взгляд, даже приличней нового switch case expression с возможностью сопоставления), чтобы раскрыть такое состояние, вызовите метод when (или map) и на каждый случай предоставьте свой виджет.

Самое страшное было в TriviaQuizBloc который объединял под своим капотом и сериализацию объектов, и работал со статистикой, и с конфигурацией фильтров для выбора викторины, и с хранилищем работал. Некий Франкенштейн и код программиста, который забыл о разделении ответственностей. Не делайте так, не надо.

Правило3: один Notifier класс — одна ответственность

С Riverpod я вижу два основных варианта развития событий. Разговор идёт только за domain-область.

  1. Ваш класс стейт-менеджмента, наследующийся от Notifier, или подобно классу ValueNotifier, должен решать одну задачу — управлять своим состоянием — и решать её хорошо. Дайте ему методы для управления этим состоянием, дайте ему репозитории и сервисы. Дёргая за ниточки, он будет хранить своё актуальное состояние, а все зависящие (через ref.watch или ref.listen) от него провайдеры или виджеты будут всегда получать правильное состояние.

  2. Всё то же самое, что и в первом случае, однако наш MyNotifier станет слишком большим из-за сложности протекающих в нём бизнес процессов. Куча методов как приватных, так и открытых, дополнительные внутренние состояния, монолитизация ответственностей — это сигналы о том, что пора менять подход. И я предлагаю каждую связанную единичку бизнес-логики выносить либо в отдельный нотифиер со своим состоянием, либо в отдельный MyBloc, если происходят чистые вычислительные процессы. Тем самым выстраивая вертикальные связи зависимостей.

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

Репозитории пострадают меньше всего, поскольку к ним можно предъявить не самое высокое качество исполнения. Вы можете не использовать алгебраического возврата в методах, не иметь перечисления для исключений, а на каждый запрос создавать новый http-клиент… Эх, чего я только не видел, но главный вопрос — выстоит ли ваша бизнес-логика при использовании таких репозиториев? Однако маленькое правило хочется выделить.

Правило4: один репозиторий можно разделить на несколько

Не нужно включать все-все методы в один единственный класс, включая аутентификацию, подписку на какие-либо уведомления, получение данных по арбузам и автомобилям, удаления всех данных профиля… Если воспользоваться extension, то можно удачно визуально разделить методы, относящиеся к разным субстанциям. Использование extends позволит создать новый тип со своим набором методов, который удобно “предъявить” как зависимость в bloc-ах. Ну и наконец — подумайте о том, как лучше всего предоставить тестовый репозиторий.

Улучшаем архитектуру или моё почтение

Тут подошло время, когда реализация пакета cardoteka завершилась публичным релизом. Я стал потрошить старые проекты, чтобы найти незаурядный пример для неё. Это и было, и снова стало приложение викторины. Оно вполне себе продолжало работать в веб и в мобильной версии (на android через apk) уже как целых полгода. Однако даже такой выдержанный и свободный апи, как Open Trivia Database , ввёл некоторые ограничения: каждый IP-адрес может получить доступ к API только один раз каждые 5 секунд.

Отлично! — сказал Я, — ведь пришло время для рефакторинга и модернизаций. На этом этапе перейдите на тег v2.0.0, чтобы увидеть новый подход.

Проблема получения викторин из Trivia API

Мини-вступление: с этим апи есть проблема. Ты запрашиваешь 50 викторин (можно запросить от 1 до 50 за один get-вызов). Если этого количества нет на сервере (редкий фильтр, либо просто исчерпались уникальные вопросы), но есть меньшее (в диапазоне от 1 до 49) , то прилетает ошибка Code 1: No Results. Серьёзно!? То есть вместо того, чтобы отдать все доступные вопросы, вы кидаете ошибку; тогда что должен сделать программист? Правильно, писать в хелп цикл в коде, чтобы “выдоить” все викторины. И раньше я так и делал: это был цикл запросов, и если на запрос в 47 приходила ошибка, я слал новый с 23, а потом с 11 и так, пока цифра не доходила до 1. Лавочку прикрыли, а мой код не был готов к такому сюрпризу, как впрочем и ui, которое показывало обработанную ошибку без возможности сделать новый запрос.

Что ж, это только раззадорило меня.

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

  1. Когда запрашивается викторина, мы ищем её в локальном кэше. Если есть, сразу возвращаем.

  2. Независимо от предыдущего шага мы делаем проверку на количество кэшированных викторин (если меньше 10, нужны запросы) и проверку на то, нашлась ли викторина в кэше

  3. Наполняем очередь специальными моделями-запросами, в каждой из которых есть параметры для запроса

    1. “Горящий” запрос ставим первым в очереди

    2. Скрыто-кеширующие запросы добавляем в конец очереди

  4. Перебираем эту очередь из моделей-запросов и осуществляем вызов на их основе

Скрыто-кеширующие запросы составляются с интересно выведенным “желаемым числом” получаемых викторин. Пришлось ненадолго проникнуть в алгоритмы, чтобы вывести “бинарный редукционный список” (так я его называю, уверен, есть официальное определение) на основе максимального числа X. Итерация по данному списку гарантированно уменьшит число X до 0 (худшее O(log2(N)), лучшее O(1)).

Кому интересно, я составил задачку на этой основе для вас. Подтянуть здесь.

Код всё ещё сложно понимать без пол-литра свежезаваренного чая, однако он задокументирован, прологгирован и решает поставленную задачу без сбоев. Смотреть в классе QuizGameNotifier.

Слой domain — операция на сердце

Теперь об архитектуре — она изменилась. Это было сложное решение, как нам разделить стейт-менеджмент и бизнес-логику. Сейчас я решил полностью это объединить под заботливым крылом риверподного класса Notifier. Однако оставил соответствующие комментарии на этот счёт:

  • это можно сделать, и сделать это не так уж и сложно. Если у вас есть прям-прям бизнес-логика, то разместите её в отдельном классе с чистыми методами

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

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

У каждого стейт-класса появилась своя зона ответственности. Кто-то отвечает за статистику, кто-то за кэш, а кто-то за работу с токеном. Я использую Notifier и это отличное решение из Riverpod по сравнению с Provider или StateNotifier. Благодаря функции build наш класс не пересоздаётся с нуля, но всё ещё может обновлять свои зависимости.

Как и в первой версии, я постарался сделать однонаправленный поток данных и связей. Единственная стрелка, которая выбивается из колеи это Cartoteka --> bloc_models, но не переживайте, это только ссылка на модели бизнес-логики, а не зависимость, ведь кардотека упрощает взаимодействие блоков с хранилищем, беря на себя ответственность за правильную конвертацию моделей для работы с хранилищем.

Результатом вызовов между слоями являются sealed-классы и показаны пунктирными оранжевыми стрелками. Это удачное использование алгебраических типов данных позволяет обрабатывать все случаи взаимодействий между слоями, будь-то данные, исключения или ошибки. В dart 3 вы можете сконструировать нужный вам тип с составными типами с помощью sealed, что даст свойство исчерпываемости и возможность использовать switch case с конечным количеством состояний.

Я признаю тот факт, что нотифаеры типа QuizzesNotifier и TokenNotifier получились скорее марионетками в большом спектакле QuizGameNotifier‘а, однако только ради того, чтобы отдать на откуп самому пользователю решать вопрос о том, как и когда он захочет обновить свой токен или получить викторины.

Может показаться, что схема v2 мало чем отличается от v1, и большинство описаний из первой версии применимы и здесь. Если обращать на поток связей, то так оно и есть, а вот реализации сильно отличаются. Пожалуй, это абстрактная магия блок-схем 🙂

Презенторы для виджетов и реактивность

Моя логика: слово “контроллер” хочет контролировать ВСЁ. А слово “презентор” — только интерфейсную часть. Значит, HomePagePresentor вместо HomePageController. Вместе с тем, что мы воспользуемся AutoDisposeNotifier, наш код стал выглядеть так:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Мне не кажется — это правда лучше выглядит и стало проще понимать происходящее. Обратите также внимание, что наш GamePresenter сильно реактивен, и его состояние обновится тогда, когда обновится состояние QuizGameNotifier из доменной области.

Теперь смотрите, как выглядит обработка всех этих состояний в интерфейсе:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Да, спасибо команде Dart за pattern matching, sealed-классы и switch expressions. Поначалу это может показаться сложным в синтаксисе, однако позволяет писать чище. Ждём metaprogramming?

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

Долгие поиски ответа привели меня к единственно верному решению — использовать дополнительные статические поля в нашем PagePresentor. Размышления:

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

  • тогда мы могли бы указать его как поле в GamePageData, добавив тем самым реактивность. Однако в таком случае 1— наш интерфейс перестаёт перестраиваться маленькими кусочками, 2— мы не можем показать счётчик раньше, чем получим какие-либо данные по самой викторине (а ведь это несвязанные вещи), 3— в любых других состояниях наш счётчик будет исчезать (или придётся добавлять такое поле счётчика во все состояния — абсурд)

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

И здесь я понимаю, что такой вариант очень хорош:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Всё внимание на два статических провайдера в самом конце класса (впрочем, debugAmountQuizzes о том же). Они обращаются к нотифиеру статистики, получают его состояние и через select позволяют фильтровать перестройку, следя только за конкретными полями. Для краткости, stats имеет тип StatsModel и выглядит так:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Это позволяет перестраивать наш интерфейс точечно, каждый виджет зависит от тех данных, которые ему нужны. Более того, крайне удобно делать таких статических провайдеров в качестве реактивных единиц, от которых будет зависеть интерфейс — ваш код в ui будет выглядеть чище, если провайдер уже инкапсулирует в себе фильтрацию обновлений и быть может какие-то дополнительные преобразования. Посмотрите ниже, какой пример выглядит лучше?

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

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

Как организовывать репозитории и сервисы

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

  • знает, как взаимодействовать со сторонним ПО

  • умеет преобразовать сырые данные в модели и наоборот (давайте без классов маппинга, это вам не java)

  • ничего не хранит, не имеет состояний

  • не зависит от стейт-менеджеров

В зависимости от постановки задачи и качества самого API различаются и методы для взаимодействий с ним:

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

  • результат вызова метода является алгебраическим типом данных при условии такой реализации API, когда случаи данных, исключений и ошибок явно определены. Тем более с sealed-классами сделать собственную реализацию проще простого. Это позволяет не думать о try/catch в зависимых классах и красиво обрабатывать полученное состояние. В любых других случаях я предпочту либо не заморачиваться над этим и возвращать желаемый тип данных, либо на крайний случай воспользуюсь функциональным подходом обработки ошибок с результатом успеха/неудачи.

  • неплохо выделить в отдельный enum исключения API (а ещё лучше в расширенный enum с описанием ошибки и кодом исключения).

Сервисы я часто выделяю в отдельную категорию, хотя они смело лежат в папке data. Дело вкуса и потому предпочитаю думать о сервисах, как об отдельных инструментах с доступом к акселерометру, гироскопу, GPS, Bluetooth, и даже класс доступа к медиа я назову MediaService. В то время как репозитории более абстрактны, их может быть очень много и внутри для взаимодействия с данными используются одни и те же инструменты вроде протоколов http или protobuf. Остальные черты с репозиториями схожи, имеет место только технический подтекст.

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

Сейчас нужно решить то, как вы будете тестировать ваш репозиторий. Вот варианты:

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

  • использовать интерфейсы и сделать две реализации: базовый Repo c реализациями RepoImpl и RepoMock. О, как это красиво выглядит с новыми модификаторами из Dart 3! Помимо заметного разделения тестовых методов и настоящих, вы можете убрать лишнюю нагрузку с места внедрения — бизнес-логика будет видеть Repo, но не знать конкретную реализацию. А ей и не к чему. Впрочем, если всё сделать правильно, первый вариант также позволяет скрыть детали реализаций.

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

В проектах посерьёзнее я использую второй вариант, в силу надёжной типизации и разделения ответственностей. Более того, при тестировании сразу можно использовать статический тип RepoMock, чтобы заведомо не накосячить 🙂 Ах да, чуть не забыл — жи есть чистая архитектура и DDD принципы. В текущем приложении в момент тестирования апи использовал первый вариант в силу быстроты реализации и дальнейшего отсутствия потребности.

И последний шаг — подумать о разделении кучи разношёрстных методов. О чём это Я? Обратим внимание на скриншот: здесь есть базовый TriviaRepository, вариант с классическим наследованием и два варианта с использованием расширений.

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Я решил оставить оба способа, чтобы всегда иметь под рукой ссылку на то, что использование extension задумывалось для другой цели — для добавления функциональности в класс, закрытый от прямого добавления (например, это может быть пакетный класс). В данном контексте всё, чего можно добиться таким подходом — это визуально приятного разграничения (ну вы знаете вот эти // -----cool method----- разделения, когда класс огромный).

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

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Так или иначе, считаю прощупывание всех способов не завершённым, поскольку нужно обладать огромным АПИ с кучей методов и серьёзным проектом, чтобы вывести хорошее техническое решение. На данный момент с появлением extension types рождаются новые идеи на этот счёт… 🤭

Различные способы синхронной инициализации с Cardoteka

Используя “кардотеку” вы можете как жёстко привязываться методом attach, так и использовать get | set. Эта разница продемонстрирована в разнице блока кэширования и блока токена, а обработка сразу большой модели выглядит ещё интересней — блок конфигурации. Посмотрим и сравним.

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Класс QuizzesNotifier отвечает за получение викторин с сервера и их дальнейшее локальное кэширование. Метод build вызывается один раз, когда мы обращаемся к экземпляру через instance. В нём происходит обновление локальных зависимостей и прикрепление прослушки через метод attach в return. Это позволяет синхронно получить актуальное значение (список кэшированных викторин) из хранилища. Когда вызывается метод cacheQuizzes, то (это скрыто внутри _storage.set):

  1. происходит обновление состояния нотифаера (вызывается переданный в attach коллбэк (value) => state = List.of(value))

  2. значение асинхронно сохраняется в локальное хранилище

Аналогично, при вызове clearAll, сначала вызывается анонимка из attach.onRemove, что обновляет состояние нотифаера, и в последующем происходит асинхронное очищение значения в хранилище по переданному ключу.

Это реактивный способ обновлять состояние, иммутабельность которого основана на создании нового списка на основе старого (в данном случае с помощью List.of).

Следующий способ также поддерживает предыдущую концепцию с прикреплением прослушки, однако обновление состояния происходит после вызова ref.notifyListeners().

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Пожалуй, это выглядит не самым приятным способом, однако работает ровно так, как и ожидается. Избавиться от late можно (и нужно, это всего лишь маленькое дозволение в pet-проекте), создав конструктор для всех полей и переиграв с полями byDifficulty и byCategory. Важная деталь здесь в том, КАК ИМЕННО мы прослушиваем каждую желаемую карточку и обновляем состояние. Коллбэки срабатывают в тот момент, когда кто-либо изменяет значение по данному ключу в хранилище, а флаг fireImmediately позволяет немедленно вызвать коллбэк в первое прикрепление, дабы инициализировать все поля, зависящие от quizzesPlayed.

И мы всё ещё не потеряли возможность точечного обновления интерфейса:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

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

Это реактивный (с точки зрения кардотеки) способ обновлять состояние, которое основано на мутабельной модели и вызове notifyListeners().

Подобный пример, но с иммутабельной моделью:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Здесь уже всё красиво, по сравнению с предыдущим вариантом. Состояние обновляется реактивно и с точки зрения Riverpod (модель QuizConfig иммутабельная, все поля final), и с точки зрения Cardoteka (запуск коллбэков происходит в момент сохранения новых значений в хранилище по каждому из ключей).

Нижеописанный способ основан на классическом get|set. Реактивность кардотеки не всегда нужна, когда данные требуются по запросу. Для хранения состояния можно снова воспользоваться Notifier.

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Я также “отключил” реактивность хранилища, убрав из определения SecretStorage миксин реактивности WatcherImpl, получив тем самым обычную, но типизированную Shared Preferences:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

И мой самый любимый случай, которого не осталось в качестве примера в текущем репозитории:

// где-то в глобальном поле final themeModeProvider = Provider(   (ref) => ref.watch(StorageNotifiers.app).attach(         AppCard.themeMode,         (value) => ref.state = value,         detacher: ref.onDispose,       ), );  // и где-то из интерфейса ref.read(StorageNotifiers.app).set(AppCard.themeMode, mode);

Это самый короткий рассказ Хемингуэя, который растрогает любого. Однако учтите, это только для прототипирования или админок, хотя я и не настаиваю.

Сейчас же я придерживаюсь более масштабируемого и изящного варианта:

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Бойлерплейтно? Быть может, однако позволяет выполнить пируэт в любое время, просто добавив новый метод ниже и другие зависимости, вкупе усложняющие бизнес-логику ) Код останется чистым и читаемым.

Бонус — деплой на github actions

Приятное дополнение вас ждёт, господа! Пожалуй тысячи мануалов есть на просторах великого о том, как деплоить и что писать в .yml файл. Но некоторые моменты приходится выискивать и опытным путём подбирать.

Правильное имя артефакта — залог успеха

Начнём с простого — именование артефактов. История: вы скачиваете app-release.apk на внутреннюю память устройства, устанавливаете и забываете. Спустя недолгое время настаёт момент обновлений, либо момент переустановок или же вас попросили поделиться “той самой приложенькой”. В памяти устройства уже на этот момент появились и другие приложения:

  • app-release.apk

  • app-release (1).apk

  • app-release (2).apk Что из этого что? А фиг его знает, ведь ни версии, ни названия приложения нет. А то место, откуда это скачивалось — забылось, потерялось. Будем устанавливать всё подряд и по очереди в поисках той самой драгоценной.) Не спорю, что большинство этим не заморачивается — есть как другие источники приложений, так и непривередливые пользователи.

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

- run: flutter build apk --release --split-per-abi  - name: "Renaming generated apk's"   run: |      cd build/app/outputs/flutter-apk/     mv app-arm64-v8a-release.apk quiz-prize-app-${{ github.ref_name }}-arm64-v8a.apk     mv app-armeabi-v7a-release.apk quiz-prize-app-${{ github.ref_name }}-armeabi-v7a.apk     mv app-x86_64-release.apk quiz-prize-app-${{ github.ref_name }}-x86_64.apk  - name: "Upload generated apk's to artifacts"   uses: actions/upload-artifact@v4   with:     name: apk_builds     path: build/app/outputs/flutter-apk/*.apk 

Первым шагом мы создаём apk-файлы для каждого целевого ABI (Application Binary Interface), за что отвечает флаг --split-per-abi, снятие которого приведёт к компилированию единственного “толстого” apk-файла сразу для всех платформ. Хотя неплохо сбилдить и толстый apk, которым удобно обмениваться со всеми подряд, однако мы пропустим это сейчас.

Следующий шаг — переименование полученных apk-шек. Командой cd перемещаемся в целевую папку, а командой mv меняем имя каждого файла на желаемое. Я составил имя исходя из паттерна имя-приложения-версия-платформа. Здесь важно, что данный workflow запускается по запушенному тегу, поэтому номер версии можно взять по переменной github.ref_name, который выглядит вот так — v2.0.0.

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

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

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

.github/workflows/build_apk.yml

name: Build & Sign & Upload apks  on:   push:     tags:       - 'v*.*.*'  jobs:   build_apk:     runs-on: ubuntu-latest      # It’s convenient to put important variables in env     env:       JAVA_VERSION: '17'       FLUTTER_CHANNEL: 'stable'       PROPERTIES_PATH: "./android/key.properties"      steps:       - uses: actions/checkout@v4        - name: 'Setup java'         uses: actions/setup-java@v4         with:           distribution: 'liberica'           java-version: ${{ env.JAVA_VERSION }}           java-package: jdk           cache: 'gradle'        - name: 'Decoding base64 KEYSTORE into a file'         run: echo "${{ secrets.UPLOAD_KEYSTORE }}" | base64 --decode > android/app/upload-keystore.jks        - name: 'Creating key.properties file'         run: |           echo storePassword=${{ secrets.STORE_PASSWORD }} > ${{env.PROPERTIES_PATH}}           echo keyPassword=${{ secrets.KEY_PASSWORD }} >> ${{env.PROPERTIES_PATH}}           echo keyAlias=${{ secrets.KEY_ALIAS }} >> ${{env.PROPERTIES_PATH}}           echo storeFile=../app/upload-keystore.jks >> ${{env.PROPERTIES_PATH}}        - name: 'Setup Flutter'         uses: subosito/flutter-action@v2         with:           channel: ${{ env.FLUTTER_CHANNEL }}           cache: true        - run: flutter --version       - run: flutter pub get       - run: flutter build apk --release --split-per-abi        - name: "Renaming generated apk's"         run: |            cd build/app/outputs/flutter-apk/           mv app-arm64-v8a-release.apk quiz-prize-app-${{ github.ref_name }}-arm64-v8a.apk           mv app-armeabi-v7a-release.apk quiz-prize-app-${{ github.ref_name }}-armeabi-v7a.apk           mv app-x86_64-release.apk quiz-prize-app-${{ github.ref_name }}-x86_64.apk        - name: "Upload generated apk's to artifacts"         uses: actions/upload-artifact@v4         with:           name: apk_builds           path: build/app/outputs/flutter-apk/*.apk    release:     runs-on: ubuntu-latest     needs:       - build_apk     permissions:       contents: write      steps:       - uses: actions/checkout@v4        - name: 'Download all artifacts'         uses: actions/download-artifact@v4         with:           path: 'releases'           merge-multiple: true # everything will be loaded into the folder at `path`        - name: 'Show release catalog'         run: ls -R releases        - name: 'Publishing artifacts to release'         uses: ncipollo/release-action@v1         with:           artifacts: 'releases/*.apk'           body: "New release. For the full history of changes, see CHANGELOG.MD file."           #bodyFile: "body.md"           draft: true           artifactErrorsFailBuild: true           allowUpdates: true

Маленький нюанс деплоя на web

Быть может вас это обойдёт стороной, однако

Failed to load resource: server responded with status of 404

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

Приложение викторины: внедрение Cardoteka и основные паттерны проектирования с Riverpod

Однако это может не помочь в случае, когда вы деплоитесь на Github Pages и используете href отличный от имени вашего репозитория. Я уже съел на этом кактус, плакал чуть больше месяца (с перерывами, нервный срыв нам не к чему) и только упорство помогло понять причину. В остальном, используя эти две команды:

  • flutter build web --base-href='/quiz_prize_app/' --web-renderer=canvaskit

  • JamesIves/github-pages-deploy-action

всё деплоится на ура. Рабочий процесс здесь, или ниже под спойлером:

.github/workflows/deploy_web.yml

name: deploy_web  on:   workflow_dispatch:    push:     tags:       - '**' # when any tags are pushed     #branches: [deploy_on_web]     #paths:     #  - 'example/my_app'     #  - '!**.md' # ignore the readme files permissions:   contents: write  jobs:    build_and_deploy:     runs-on: ubuntu-latest      steps:       - uses: actions/checkout@v4       - uses: subosito/flutter-action@v2         with:           channel: stable           cache: true        - run: flutter --version       - run: flutter config --enable-web       - run: flutter pub get          # for deployment to Github Pages we should always use `base-href=repo_name`       - run: flutter build web --base-href='/quiz_prize_app/' --web-renderer=canvaskit --release        - name: 'deploy on Github Pages'         uses: JamesIves/github-pages-deploy-action@v4         with:           branch: deploy_web           folder: 'build/web'           single-commit: true

Выводы

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

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

  • Выделить бизнес-логику в чистый и легко тестируемый класс проблематично, но возможно. Не считаю, что я справился с этой задачей в полной мере в v2 полученного приложения. Я чувствую тропинку к успеху, но количество времени, гипотетически затраченное на её преодоление, весомо и неподвластно сейчас.

  • Хороший внешний API — залог успеха. Это очень сильно ощущается в маленьких проектах, когда вокруг чего-то единственного строится вся бизнес-суть приложения. Плохой API — время для внедрения умножай на 3, закладывай удобное тестирование и готовься морально.

  • Мне не нравится то, как сильно Watcher (который даёт нам метод attach) вплетается в слой бизнес-логики, делая его дважды реактивным — соединение хранилища реактивной нитью с реактивным менеджером состояний может выглядеть как удачная магия. С принципами тут явно есть проблемы, которыми в mvp-версиях, админках и тому подобном, можно пренебречь. И с другой стороны я восхищён, что всё работает так, как ожидается, и так, как я планировал, разрабатывая “Cardoteka”. Ещё меньше вопросов к типизированному доступу к SP — это действительно удобно, позволяет сразу мыслить в терминах бизнес-задач, не отвлекаясь на поиск правильного ключа, конвертера для преобразования данных или же разграничения доступа.

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

  • Виджеты необходимо делать максимально “глупыми”. Их задача — отображать данные. И всё. Подготовка данных — не ответственность UI; этим должен заниматься кто-то ещё (presentor, notifier, bloc, etc.). Используйте sealed в качестве состояний UI, когда они чётко определены бизнес-задачей.

  • Архитектура всего проекта зависит от задач самого проекта. Не переусложняйте, когда в этом нет необходимости. Не используйте абстракции, если вы не нуждаетесь в них. Устраняйте дублирования унификацией и делайте это в тот момент, когда испытываете желание в ctrl+c->ctrl+v. Избегайте циклических зависимостей, заменяя горизонтальные связи вертикальными. Поддерживайте чистоту в проекте.

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

  1. PackRuble/quiz_prize_app: It’s just a game – Quiz Prize 🎮 | Github — репозиторий викторины

  2. Скачать и установить приложение можно по ссылке, либо попробовать Web-версию и установить как PWA.

  3. Cardoteka | Flutter Package — типизированное хранилище с функцией реактивности, обёртка над Shared Preferences.

  4. TODO: change after — личный блог автора

  5. flutter_riverpod | Flutter package — реактивный стейт-менеджер

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

© 2022-2024 Ruble