Comments 21
В нашей статье мы не разрабатывали новый StateManager, а использовали один из наиболее популярных - Bloc, с небольшой доработкой SingleResult для приближения к MVI. Но ваша идея хорошая, постараемся в будущем разработать статью, сравнивающую разные StateManagment решения, возможно в окружении нашего “шаблона“.
По нашему опыту обилие StateManager-решений характерно и для нативной разработки. Если говорить про Flutter-решения, то их обилие косвенно связанно с разным бэкграундом разработчиков, переходящих во Flutter, также у каждого StateManager есть свои плюсы, минусы и ограничения, так что тема действительно актуальная, будем стремиться ее раскрыть. Благодарим за обратную связь!
я остановился на связке MobX & Provider, и очень ей доволен, bloc кажется неоправданно перегруженным, хотя может там чтото поменялось за последнее время.
Ага, и лично у меня после ознакомления с этим зоопарком возникло одно простое чувство: "а гори они все!.. В принципе, и setState сам по себе довольно хорош."
огого, вот это статьища! здорово развернул ту коротенькую новость с прошлой пятницы)
Благодарим за обратную связь, мы всегда рады услышать мнение других и понять движемся ли мы в правильную сторону!
Хотим обратить внимание, что мы просто делимся опытом решения, которое выработали за длительное время и с проведением экспериментов конкретно под наши задачи. Мы не настаиваем, что оно единственно верное и все проекты должны быть такими, поэтому просим не расценивать ответы на тезисы, как вступление спор:
1. Const - подразумевают исключительно константы. Если называть их util, подразумеваются утилитарные вещи - константы / общие функции без домена / экстеншены над dart core. У нас был такой опыт, утилиты стали доходить до нескольких тысяч строк кода, поэтому мы приняли решение сделать более жесткое сужение.
2. Про arch вы действительно правы, мы указали, что в идеальном мире они должны быть вынесены в отдельные pub-package, но на этапе, пока решение не задокументировано и покрыто тестами, его лучше не выливать в package для других внутренних/внешних потребителей. Сделать arch саб-модулем решение хорошее, мы к нему тоже пришли. Про свой опыт модуляризации, в том числе пакета arch, планируем рассказать в будущем, это достаточно неоднозначная тема.
3. mappers - вещь опциональная, даже на уровне статьи мы нигде не указываем ее, как обязательную часть нашей структуры директорий. Хотим заметить, что их область использования чуть шире, чем fromJson и toJson, потому что вы можете работать не только с REST, а также в Mapper вы можете добавить проверки соблюдения контракта с бекендом, аналитику нарушений контрактов и тд. Из-за этого логика маппинга начинает распухать и раздувать модели, что, по-нашему мнению, удобнее вынести в изолированный объект. Вполне возможно наше восприятие было искажено длительной Kotlin/Java разработкой, и поэтому мы приходим к таким выводам.
4. Вы правы, SingleResult фактически и есть StreamController, развёрнутый над Bloc, и StreamListener, развёрнутый в SrBlocBuilder, поэтому не очень понятно, в чем состоит костыльность абстракции. Ввод дополнительной абстракции, полностью совместимой с оригинальным Bloc, мы обосновываем экономией кода в каждом отдельном Bloc, но самое важное - мы можем строить инфраструктуру вокруг этой абстракции: централизованное логирование SR, аналитика, реализация TimeMachineDebuger с учетом SR.
5. Мы пробовали строить приложение исключительно на Provider, но скорость разработки значительно уменьшалась при росте используемых объектов. Когда проект разрастается и подходит к 30, 50 тысячам строк кода, собирать объекты руками становится достаточно проблематично. А для понимания ЖЦ объекта приходится обращаться к дереву виджетов, что не всегда очевидно, плюс иногда было необходимо обращаться к дереву без контекста.
Мы не исключаем, что мы просто не смогли его приготовить, но решение GetIt + Injectable позволило решить все наши боли без видимых потерь и повысить скорость работы. Мы не сделали абсолютно все объекты на GetIt, а использовали Provider(BlocProvider) из-за не стандартного ЖЦ объекта (factory/singleton), BLoC должен вести себя как Scoped-объект. А наши тесты использования Scope в GetIt показали, что проект начинает усложняться и пока она нужна только для StateManagment-объектов, нам удобнее использовать BlocProvider.
6. Мы упомянули в статье, что ThemeData + ColorScheme + TextTheme не бился с дизайн-токенами, которые были сформированы в нашей компании задолго до начала разработки на Flutter и поэтому поделились свои решением, которое позволяет решить эту проблему. Нам показалось, что это достаточно полезно для команд в похожей ситуации. Также решение в виде централизованного Bloc над сменой темы позволяет включать всю инфраструктуру Bloc (логирование, аналитика и так далее) в этот процесс, а также менять тему без контекста. Хотим заметить, что мы сделали минимально необходимый mapping нашего объекта темы в стандартную тему.
Сейчас в проекте у нас сгенерированый файл с деревом зависимостей приложения занимает 600 строк, но это не человеко-читаемый код, потому что он генерируется, если бы мы его писали руками и с использованием Provider, он скорее всего бы дошел до 1000-1500 строк. Плюс проект у нас пока достаточно молодой, если апроксимировать цифры на наш основной нативный проект, то это была бы история про 10000+ строк.
Мы не утверждаем, что это колосальная проблема или что Provider не применим для реальных проектов. Также скорее всего придумать решение по разбиению зависимостей на модули, чтобы работа с зависимостями была организована проще и удобнее, не очень сложно.
Однако для нашей команды намного привычнее концепция GetIt для управления зависимостями, весь инфраструктурный код генерируется, а ЖЦ каждого объекта наглядно виден в коде самого объекта. Это досточно серьезные плюсы для нашей команды, и при этом мы не видим каких-то минусов или проблем, создаевамых из-за использования GetIt+Injectable.
Отличная статья.
Когда я пару месяцев назад переходил с Android на Flutter, начал исследование возможных комбинаций для реализации своих проектов и собрал набор решений, которые на 90% сходятся с вышепредставленными: от похожей структуры папок до теммирования приложения и набором библиотек.
Но изыскания продолжаются и люди в комментариях дают дельные советы, к которым, как минимум, я постараюсь прислушаться и вразумиться :)
статья отличная, но вот до сих пор не могу понять почему используют Either? не проще отлавливать исключения в нужном месте?
Благодарим вас за ответ!
Выбрасывание ошибок и блок try-catch ломает последовательность выполнения действий и его использование может быть схоже с операцией go-to, что выглядит не очень чисто, а также часто это вырождается в цепочку обработок исключения в разных слоях с последующим пробросом новой ошибки.
Вторая проблема читабельность контракта, если вы в одной части кода выбрасывается ошибку, а ловите ее в другой, у вас нет четкого контракта, какая именно ошибка может возвращаться (это не следует из сигнатуры метода)
Следовательно, мы разделяем для себя 2 вида ошибок:
– Критические ошибки, такие выбрасываются через throw и не обрабатываются в бизнес логике, потому что это какое-то критическое исключение.
- Обрабатываемые ошибки, такие являются корректным результатом вызова и являются типизированной частью ответа = веткой Either, их мы обрабатываем в бизнес логике и на их базе строим какое-то поведение.
Таким образом Either позволяет сделать наш код чище и сильно проще для восприятия, а также вы неизбежно будете обрабатывать ветку left и не забудете об обработке ошибок.
спасибо за такой развернутый ответ =). попробую на проекте использовать подобное
Мы согласны, что throw является допустимым подходом для обработки ошибок и исключений, особенно с учетом разделения Error и Exception в Dart. Но давайте я попробую расшифровать, какие структурные проблемы решает Either в нашем примере использования.
В Дарте есть разбор ошибок по типам:
Это больше история про реализацию, а не про контракт. Например, у вас есть интерфейс:
abstract class AuthInteractor {
Future<bool> authorize();
}
Пока вы не обратитесь к его реализации, вы не сможеет узнать, выбрасывает он UnAuthorizedError или нет (может он вообще ходит в сеть и выбрасывает Http-ошибки), а если реализаций несколько, то все еще хуже, ошибки могут быть разные, а ваш код об этом даже не узнает. В java, например, было ключевое слово 'throws', позволяющее указать на уровне интерфейса, какие ошибки выбрасывает метод, также оно заставляет вас обработать эти ошибки в месте вызова. В Dart, кажется, такой функциональности нет.
А если говорить об Either, то вы четко на уровне контракта описываете допустимые ошибки:
abstract class TimeApiService {
Future<Either<CommonResponseError, TimeResponse>> getTime();
}
При вызове такой функции вы получите Either, а не результат TimeResponse, что гарантированно заставит вас обработать ошибку (CommonResponseError). В случае, если тип ошибки изменится, то типизация вам подскажет все места, где сломался контракт.
Типичный кейс: делаем сетевой вызов, ловим ошибку на уровне контроллера, выводим лог, зануляем какие-то переменные, отправляем в Catcher, перевыбрасываем. Снова ловим на уровне виджета, показываем снэкбар с ошибкой.
Этот случай как раз и показывает проблемы, которые могут возникать при перевыбрасывании. Например, если ваш интерактор обращается к 3 репозиториям, каждый из которых выбрасывает ошибки со своими типами, то, перевыбрасывая ошибки этих типов, его контракт раздувается и становится очень сложен для восприятия, а это очень частый кейс при работе с throw. Потом эта ошибка начинает протекать во все слои вашего приложения и, при смене контракта, вносить правки становится очень тяжело.
Хотел заметить, что ошибки, которые генерируюстя не в UI-слое, архитектурно находятся за пределами UI-слоя, поэтому проникновение обработки ошибок в UI нарушает чистоту архитектуры. Еще одна проблема - завязывание виджета на ошибки делает его плохо переиспользуемым, потому что для воспроизведения этих кейсов в других местах, придется выбрасывать такие же ошибки.
Это нормальная правильная обработка ошибок, вы к ней просто не привыкли.
После досточного длительного опыта работы в нативной разработке с Java/Kotlin, где сильно распространены throw/try/catch, довольно сложно к ней "не привыкнуть" :)
Спасибо за классную статью!
Вопрос про базы - а зачем вы используете и SharedPreferencies и отдельную базу?
Не проще ли было бы создать в основном хранилище таблицу, например, UserSettings и там хранить настройки и заодно через API синхронизировать настройки на разных дивайсах?
Привет. Спасибо за вопрос.
Обычно в мобилках используется большая "тяжелая" база для серьезной бизнес-логики ( в таком случае нам необходим весь функционал работы с большими базами, например, какие-нибудь хитрые выборки) и легковесные базы, умеющие просто сохранять данные по ключу.
В нашем примере Moor -«тяжелая» база со всеми возможностями но и с тяжелым и ресурсоемким движком. SharedPreferences - легковесная база (базируется на нативных легковесных решениях, с урезанным функционалом) но с легким запуском движка. Использование разных решений - это вопрос оптимизации мобилок. Бывают случаи, когда нет необходимости поднимать движок "тяжелой" базы, и сохранить какой-либо признак достаточно в легковесной базе.
И key-value и реляционные хранилища имеют свои преимущества в разных кейсах использования, которые актуальны для мобилок. Также отмечу, что использование 2 разных каналов общения с хранилищами, чуть повышает отказоустойчивость, а особенно в нашем самом проблемном кейсе - изолятах (бэкграунд сервис). Чем чаще выполняются параллельные запросы в Moor, тем больше она сбоит (что мы наблюдали в крашлитике), а SharedPreferences себя хорошо показывают в этом кейсе (за счёт нативной safe-multi-thread реализации)
Секреты запуска Flutter в production. Создаем IT-верфи