Ссылку на веб-версию можно найти в конце статьи.
Как здорово, что все мы здесь сегодня собрались.
Если очень хочется создать викторину, то почему бы и да! Но на пути будет много увлекательных происшествий. Эта статья на гране сумбурного изыскания лучших паттернов проектирования. Вот что рассмотрено:
-
о слоях и взаимосвязях в архитектуре
-
формула:
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
для изучения происходящего ниже.
Архитектура приложения представлена в виде блок-схемы:
Сейчас мы детально разберёмся в тонкостях. Прежде я скажу, что схема упрощена только лишь по количеству репозиториев, блоков, экранов и моделей. Это означает, что если есть ещё несколько 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. Ведь в самом деле, изменить обычного провайдера извне не предоставляется возможным. И для этой цели нам пришлось бы использовать StateProvider
у которого есть поле state
. Однако пакетик имеет возможность прослушки всех поступающих новых значений по ключу, если зарегистрировать своего слушателя. Это как привычный нам метод addListener
в классе Listenable
, который позволяет зарегистрировать callback, срабатывающий каждый раз при уведомлении слушателей, только чуть навороченней.
Первый позиционный аргумент — это так называемая карточка (по большому счёту это улучшенный ключ), чтобы получить значение из хранилища (под капотом там Shared Preferences). Cardoteka позволяет работать с хранилищем только по заранее определённым типизированным карточкам.
Второй позиционный параметр в методе attach
это как раз обратный вызов — каждый раз, когда изменяется значение в хранилище, мы устанавливаем новое состояние в провайдере. Обращаю также ваше внимание, что вызов слушателей происходит синхронно.
А третий именованный параметр detacher
— метод удаления слушателя, когда в этом больше нет нужды. Для этой цели я передаю ref.onDispose
в качестве аргумента. Как только провайдер уничтожит своё состояние, связанные с этим событием данные внутри Cardoteka также будут удалены.
Есть ещё пару именованных параметров onRemove
для уведомления о том, что значение было удалено из хранилища и fireImmediately
для запуска коллбэка немедленно.
Сам класс GameStorage
выглядит следующим образом:
И это та самая наша единственная связь от storage
к bloc_model
на схеме. Не могу сказать, что внедрение зависимости прямо в этом классе будет хорошим тоном, однако борьба за чистоту пространства имён пока что важней.
И теперь, вот момент, заставляющий провайдера получить новое значение:
Устанавливаем новое значение -> внутри хранилища происходит синхронный вызов ранее переданных коллбэков по данной карточке -> провайдер получает новое значение и обновляет своё состояние -> (значение сохраняется в 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
-переменную, имеет доступ ко всем методам во всех расширениях (кроме приватных, конечно).
Есть место для улучшений, не так ли?
Анализируем код или моё недоумение
Начал я с того факта, чтобы узнать, как же ведёт себя провайдер внутри которого живёт другой провайдер. И выяснилось — живёт он печально. Чтобы не брать во внимание абстрактные примеры, сразу возьмём пример из 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
). Но уж очень дурно это состояние обновлялось.
И я не про использование 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-область.
-
Ваш класс стейт-менеджмента, наследующийся от
Notifier
, или подобно классуValueNotifier
, должен решать одну задачу — управлять своим состоянием — и решать её хорошо. Дайте ему методы для управления этим состоянием, дайте ему репозитории и сервисы. Дёргая за ниточки, он будет хранить своё актуальное состояние, а все зависящие (черезref.watch
илиref.listen
) от него провайдеры или виджеты будут всегда получать правильное состояние. -
Всё то же самое, что и в первом случае, однако наш
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, которое показывало обработанную ошибку без возможности сделать новый запрос.
Что ж, это только раззадорило меня.
План остался тем же: мы будем доить, ведь другого варианта нет. Но только теперь чуть иначе.
Алгоритм примерно такой:
-
Когда запрашивается викторина, мы ищем её в локальном кэше. Если есть, сразу возвращаем.
-
Независимо от предыдущего шага мы делаем проверку на количество кэшированных викторин (если меньше 10, нужны запросы) и проверку на то, нашлась ли викторина в кэше
-
Наполняем очередь специальными моделями-запросами, в каждой из которых есть параметры для запроса
-
“Горящий” запрос ставим первым в очереди
-
Скрыто-кеширующие запросы добавляем в конец очереди
-
-
Перебираем эту очередь из моделей-запросов и осуществляем вызов на их основе
Скрыто-кеширующие запросы составляются с интересно выведенным “желаемым числом” получаемых викторин. Пришлось ненадолго проникнуть в алгоритмы, чтобы вывести “бинарный редукционный список” (так я его называю, уверен, есть официальное определение) на основе максимального числа X. Итерация по данному списку гарантированно уменьшит число X до 0 (худшее O(log2(N))
, лучшее O(1)
).
Кому интересно, я составил задачку на этой основе для вас. Подтянуть здесь.
Код всё ещё сложно понимать без пол-литра свежезаваренного чая, однако он задокументирован, прологгирован и решает поставленную задачу без сбоев. Смотреть в классе QuizGameNotifier
.
Слой domain — операция на сердце
Теперь об архитектуре — она изменилась. Это было сложное решение, как нам разделить стейт-менеджмент и бизнес-логику. Сейчас я решил полностью это объединить под заботливым крылом риверподного класса Notifier
. Однако оставил соответствующие комментарии на этот счёт:
-
это можно сделать, и сделать это не так уж и сложно. Если у вас есть прям-прям бизнес-логика, то разместите её в отдельном классе с чистыми методами
-
а когда настанет время её использовать, воспользуйтесь композицией и поместите в приватное поле в ваш стейт-класс. В примере выше отлично будет вынести бизнес-логику формирования запросов и итерирования по локальным данным
У каждого стейт-класса появилась своя зона ответственности. Кто-то отвечает за статистику, кто-то за кэш, а кто-то за работу с токеном. Я использую Notifier
и это отличное решение из Riverpod
по сравнению с Provider
или StateNotifier
. Благодаря функции build
наш класс не пересоздаётся с нуля, но всё ещё может обновлять свои зависимости.
Как и в первой версии, я постарался сделать однонаправленный поток данных и связей. Единственная стрелка, которая выбивается из колеи это Cartoteka --> bloc_models
, но не переживайте, это только ссылка на модели бизнес-логики, а не зависимость, ведь кардотека упрощает взаимодействие блоков с хранилищем, беря на себя ответственность за правильную конвертацию моделей для работы с хранилищем.
Результатом вызовов между слоями являются sealed-классы и показаны пунктирными оранжевыми стрелками. Это удачное использование алгебраических типов данных позволяет обрабатывать все случаи взаимодействий между слоями, будь-то данные, исключения или ошибки. В dart 3 вы можете сконструировать нужный вам тип с составными типами с помощью sealed, что даст свойство исчерпываемости и возможность использовать switch case с конечным количеством состояний.
Я признаю тот факт, что нотифаеры типа QuizzesNotifier
и TokenNotifier
получились скорее марионетками в большом спектакле QuizGameNotifier
‘а, однако только ради того, чтобы отдать на откуп самому пользователю решать вопрос о том, как и когда он захочет обновить свой токен или получить викторины.
Может показаться, что схема v2 мало чем отличается от v1, и большинство описаний из первой версии применимы и здесь. Если обращать на поток связей, то так оно и есть, а вот реализации сильно отличаются. Пожалуй, это абстрактная магия блок-схем 🙂
Презенторы для виджетов и реактивность
Моя логика: слово “контроллер” хочет контролировать ВСЁ. А слово “презентор” — только интерфейсную часть. Значит, HomePagePresentor
вместо HomePageController
. Вместе с тем, что мы воспользуемся AutoDisposeNotifier
, наш код стал выглядеть так:
Мне не кажется — это правда лучше выглядит и стало проще понимать происходящее. Обратите также внимание, что наш GamePresenter
сильно реактивен, и его состояние обновится тогда, когда обновится состояние QuizGameNotifier
из доменной области.
Теперь смотрите, как выглядит обработка всех этих состояний в интерфейсе:
Да, спасибо команде Dart за pattern matching, sealed-классы и switch expressions. Поначалу это может показаться сложным в синтаксисе, однако позволяет писать чище. Ждём metaprogramming?
Есть ещё одна важная деталь в разговоре о презенторах. Часто нашему виджету требуется отобразить что-то помимо основной логики. В текущем случае это счётчик викторин на верхнем баре, который показывает количество успешно и неуспешно отвеченных викторин. И встаёт вопрос, куда и как это можно поместить, сохранив при этом реактивность обновления данного счётчика.
Долгие поиски ответа привели меня к единственно верному решению — использовать дополнительные статические поля в нашем PagePresentor
. Размышления:
-
допустим можно разместить счётчик как поле класса презентора, независящее от состояния. Однако, каким образом мы сделаем его реактивным?
-
тогда мы могли бы указать его как поле в
GamePageData
, добавив тем самым реактивность. Однако в таком случае 1— наш интерфейс перестаёт перестраиваться маленькими кусочками, 2— мы не можем показать счётчик раньше, чем получим какие-либо данные по самой викторине (а ведь это несвязанные вещи), 3— в любых других состояниях наш счётчик будет исчезать (или придётся добавлять такое поле счётчика во все состояния — абсурд) -
то есть нам нужна какая-то реактивная единица. И она должна быть независимой в обновлениях от других данных, потому что не связана с ними.
И здесь я понимаю, что такой вариант очень хорош:
Всё внимание на два статических провайдера в самом конце класса (впрочем, debugAmountQuizzes
о том же). Они обращаются к нотифиеру статистики, получают его состояние и через select
позволяют фильтровать перестройку, следя только за конкретными полями. Для краткости, stats
имеет тип StatsModel
и выглядит так:
Это позволяет перестраивать наш интерфейс точечно, каждый виджет зависит от тех данных, которые ему нужны. Более того, крайне удобно делать таких статических провайдеров в качестве реактивных единиц, от которых будет зависеть интерфейс — ваш код в ui будет выглядеть чище, если провайдер уже инкапсулирует в себе фильтрацию обновлений и быть может какие-то дополнительные преобразования. Посмотрите ниже, какой пример выглядит лучше?
Я всегда стараюсь сделать виджет как можно чище и проще. От сложной вёрстки итак можно огрести. Но чем глупее виджет, чем меньше обязанностей он на себя берёт, тем проще в обслуживании, легче тестируем и семантически опрятней.
Как организовывать репозитории и сервисы
Сначала стоит обсудить, что же это такое. В моём понимании это торговый пункт на окраине города для взаимодействия с внешним миром. Здесь решается вопрос о том, как именно мы будем брать и преобразовывать приходящие данные, а также о том, в каком виде эти данные пойдут далее по цепочке связей. Чтобы понять репозиторий, выделим его основные черты:
-
знает, как взаимодействовать со сторонним ПО
-
умеет преобразовать сырые данные в модели и наоборот (давайте без классов маппинга, это вам не 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
, но не знать конкретную реализацию. А ей и не к чему. Впрочем, если всё сделать правильно, первый вариант также позволяет скрыть детали реализаций.
В проектах посерьёзнее я использую второй вариант, в силу надёжной типизации и разделения ответственностей. Более того, при тестировании сразу можно использовать статический тип RepoMock
, чтобы заведомо не накосячить 🙂 Ах да, чуть не забыл — жи есть чистая архитектура и DDD принципы. В текущем приложении в момент тестирования апи использовал первый вариант в силу быстроты реализации и дальнейшего отсутствия потребности.
И последний шаг — подумать о разделении кучи разношёрстных методов. О чём это Я? Обратим внимание на скриншот: здесь есть базовый TriviaRepository
, вариант с классическим наследованием и два варианта с использованием расширений.
Я решил оставить оба способа, чтобы всегда иметь под рукой ссылку на то, что использование extension
задумывалось для другой цели — для добавления функциональности в класс, закрытый от прямого добавления (например, это может быть пакетный класс). В данном контексте всё, чего можно добиться таким подходом — это визуально приятного разграничения (ну вы знаете вот эти // -----cool method-----
разделения, когда класс огромный).
Классический extends
позволяет создать новый тип, который смело передаётся отдельной зависимостью “куда-надо”, не выставляя на показ любые другие методы репозитория. Вот нотифаер для работы с токеном имеет такую зависимость:
Так или иначе, считаю прощупывание всех способов не завершённым, поскольку нужно обладать огромным АПИ с кучей методов и серьёзным проектом, чтобы вывести хорошее техническое решение. На данный момент с появлением extension types рождаются новые идеи на этот счёт… 🤭
Различные способы синхронной инициализации с Cardoteka
Используя “кардотеку” вы можете как жёстко привязываться методом attach
, так и использовать get
| set
. Эта разница продемонстрирована в разнице блока кэширования и блока токена, а обработка сразу большой модели выглядит ещё интересней — блок конфигурации. Посмотрим и сравним.
Класс QuizzesNotifier
отвечает за получение викторин с сервера и их дальнейшее локальное кэширование. Метод build
вызывается один раз, когда мы обращаемся к экземпляру через instance
. В нём происходит обновление локальных зависимостей и прикрепление прослушки через метод attach
в return. Это позволяет синхронно получить актуальное значение (список кэшированных викторин) из хранилища. Когда вызывается метод cacheQuizzes
, то (это скрыто внутри _storage.set
):
-
происходит обновление состояния нотифаера (вызывается переданный в
attach
коллбэк(value) => state = List.of(value)
) -
значение асинхронно сохраняется в локальное хранилище
Аналогично, при вызове clearAll
, сначала вызывается анонимка из attach.onRemove
, что обновляет состояние нотифаера, и в последующем происходит асинхронное очищение значения в хранилище по переданному ключу.
Это реактивный способ обновлять состояние, иммутабельность которого основана на создании нового списка на основе старого (в данном случае с помощью List.of
).
Следующий способ также поддерживает предыдущую концепцию с прикреплением прослушки, однако обновление состояния происходит после вызова ref.notifyListeners()
.
Пожалуй, это выглядит не самым приятным способом, однако работает ровно так, как и ожидается. Избавиться от late
можно (и нужно, это всего лишь маленькое дозволение в pet-проекте), создав конструктор для всех полей и переиграв с полями byDifficulty
и byCategory
. Важная деталь здесь в том, КАК ИМЕННО мы прослушиваем каждую желаемую карточку и обновляем состояние. Коллбэки срабатывают в тот момент, когда кто-либо изменяет значение по данному ключу в хранилище, а флаг fireImmediately
позволяет немедленно вызвать коллбэк в первое прикрепление, дабы инициализировать все поля, зависящие от quizzesPlayed
.
И мы всё ещё не потеряли возможность точечного обновления интерфейса:
Можно не создавать отдельный провайдер, однако это уже ранее обсуждённый вопрос. Используйте select
, чтобы отслеживать желаемое поле в вашей модели.
Это реактивный (с точки зрения кардотеки) способ обновлять состояние, которое основано на мутабельной модели и вызове notifyListeners()
.
Подобный пример, но с иммутабельной моделью:
Здесь уже всё красиво, по сравнению с предыдущим вариантом. Состояние обновляется реактивно и с точки зрения Riverpod (модель QuizConfig
иммутабельная, все поля final
), и с точки зрения Cardoteka (запуск коллбэков происходит в момент сохранения новых значений в хранилище по каждому из ключей).
Нижеописанный способ основан на классическом get
|set
. Реактивность кардотеки не всегда нужна, когда данные требуются по запросу. Для хранения состояния можно снова воспользоваться Notifier
.
Я также “отключил” реактивность хранилища, убрав из определения SecretStorage
миксин реактивности WatcherImpl
, получив тем самым обычную, но типизированную Shared Preferences:
И мой самый любимый случай, которого не осталось в качестве примера в текущем репозитории:
// где-то в глобальном поле 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);
Это самый короткий рассказ Хемингуэя, который растрогает любого. Однако учтите, это только для прототипирования или админок, хотя я и не настаиваю.
Сейчас же я придерживаюсь более масштабируемого и изящного варианта:
Бойлерплейтно? Быть может, однако позволяет выполнить пируэт в любое время, просто добавив новый метод ниже и другие зависимости, вкупе усложняющие бизнес-логику ) Код останется чистым и читаемым.
Бонус — деплой на 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
при сборке.
Однако это может не помочь в случае, когда вы деплоитесь на 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
. Избегайте циклических зависимостей, заменяя горизонтальные связи вертикальными. Поддерживайте чистоту в проекте.
И пусть этот сумбурный список не стесняет Вас: дополняйте, уничтожайте — комментарии приветствуются! Хорошего дня, закрепляю полезные ссылки:
-
PackRuble/quiz_prize_app: It’s just a game – Quiz Prize 🎮 | Github — репозиторий викторины
-
Скачать и установить приложение можно по ссылке, либо попробовать Web-версию и установить как PWA.
-
Cardoteka | Flutter Package — типизированное хранилище с функцией реактивности, обёртка над Shared Preferences.
-
TODO: change after — личный блог автора
-
flutter_riverpod | Flutter package — реактивный стейт-менеджер
-
Я сделал Cardoteka и вот как её использовать [кто любит черпать] ( ENG )
© 2022-2024 Ruble