На нынешней стадии мобильная разработка меня не слишком интересует из-за тесной связи с IoT и неопределенности тамошнего победителя: JavaScript, Dart, Kotlin или же какой-нибудь другой язык программирования станет доминирующим, а может они поделят рынок между собой - пока не слишком ясно. Есть неопределенность с Fuchsia, со свободными операционными системами, которые с тем же F-Droid на каких-то неспецифических и простых задачах вполне могут заменить собой доминирующие операционки и обойтись без официальных маркетов приложений. Разными могут быть стратегии в отношении конкурентов и воздействия на них через инструменты: например, есть определенные вопросы, насколько уютно HarmonyOS будет чувствовать себя во Flutter.

Кроме того, мобильный\IoT стек должен быть выгоден для электроники или же иметь какую-то дополнительную полезность, но в части Dart проект Dartino немножко умер, замечены странности с фреймкорками (вымирание Aqueduct, непонятная ситуация с AngularDart), что намекает на определенную осторожность.

Может так оказаться, что JavaScript (с бонусом Espruino) или тот же Kotlin (c бонусом от связи с Java) будет иметь большую совокупную полезность для стека, хорошо перекрывая и часть задач вне мобайла\IoT. Можно вспомнить и о TinyGo, хотя пока еще непонятно, чем обернется для него такой огромный список поддерживаемых платформ, но вместе с мобайлом (например, в Fyne заявлена его поддержка) Go также может оказаться наиболее выгодным, покрывая большую часть кейсов на разные устройства. Поэтому пока я не определился окончательно со стеком и платформами, пытаясь нащупать наиболее выгодное сочетание инструментов.

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

Dart без Flutter вполне может работать как скрипт или же как native (как тот же Dart Sass), а так как десктопное приложение часто является частным случаем консольного, то задача формируется сама собой: попробовать сделать сначала консольное приложение, потестировать, а потом натянуть на него десктопный Flutter. Если с последним не выйдет, то все равно Dart можно где-нибудь использовать.

После перебора разных претендентов победителем вышел парсер картинок для Reddit. Задача достаточно простая: нет каких-то специфических библиотек, асинхронность в Dart может быть полезна для распараллеливания загрузок, а сам по себе Reddit имеет удобное и относительно простое api.

Прототип под Linux получился таким:

flutter linux application

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

Наверное, главная из них - это наличие асинхронных вариантов методов и проблемы со случайным пропуском await, которые IDE никак особо не подсвечивает. В таком случае код, который должен выполняться синхронно, например, создание краш-файла, пойдет по аcинхронному варианту и приложение закончит свою работу раньше, чем файл будет создан. Поскольку вызов асинхронного метода вполне легален, то такие логические ошибки довольно трудно отследить, а за счет сторонних пакетов, большая часть которых ориентирована на асинхронность, код повсеместно пропитывается ей, что повышает количество возможных проблем в сравнении с менее асинхронными тулкитами.

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

Объектность языка позволяет легко разделить код на универсальную логику фреймворка и специфическую предметную, тут никаких претензий нет. Архитектура легко подгоняется под любой другой объектный фреймворк, разве что с небольшими поправками на асинхронность. Как вывод, Dart (в отличие, например, от того же Go) позволяет реиспользовать уже протестированную на других языках архитектуру с минимальными правками.

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

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

Есть, конечно, определенные вопросы к консистентности названий типов, странному преобразованию перечислений в строку, что приводит к появлению более удобных библиотек и прочим мелочам, которые выглядят очень странно, учитывая возможности и ресурсы команды Dart. Например, String не содержит удобного аналога IsNullOrWhiteSpace из C# или же isBlank из Java, хотя это очень распространенная проверка.

А вот резкий переход к sound null safety заставил потратить достаточно много времени на рефакторинг. Все это вызывает определенные сомнения и одни проблемы с null поменялись на другие проблемы без null. Сразу инициализированные объекты можно получить только в очень простых случаях (обычно так и делается), а у более сложных компонентов, которые трудно собирать и создавать на полях все равно будет null, разве что late, но особо ничего не меняется, от null уйти проблематично. Для таких объектов невозможно использовать конструкторы, как и делить их на более мелкие, что сразу же усложнит сборку и управление самими компонентами. С одной стороны, IDE и компилятор начинают подсказывать места, в которых нужны проверки, с другой стороны, этих проверок может стать настолько много, что пользоваться апи неудобно, код зашумляется, юзабилити его понижается, а ошибки рантаймовые там все равно остаются, но здесь трудно сказать, какой вариант лучше - c null, без или что-то среднее, везде свои достоинства и недостатки.

Первые значительные неприятности подкрались со стороны рефлексии, а вернее маппинга реддитовского json на объекты. Это очень просто и удобно в пару строчек кода делается через рефлексию в dart:mirrors, но Flutter так не думает. Простая логика маппинга в cli-приложении должна быть заменена на кодогенерацию или конвертирование объектов вручную.

Создание методов-конвертеров кодогенерацией выглядит неоправданным для такой простой задачи, обратной стороной будет завязывание кода на очень специфический инструмент, множество побочных эффектов и разделение багов между ним и самим кодом. Кроме того, само наличие dart:mirrors, который может работать в cli-приложениях, создает некоторые подозрения в изначальной продуманности инструментов и терять время из-за чужих ошибок и недоделок выглядит странным. Для больших моделей преимущества может быть и будут покрывать недостатки, но не факт.

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

Если конвертеры (методы) работают с типом Map<String, dynamic>, то их можно сделать более универсальными, подводя под специализированный интерфейс с мапой, убирая то, что связано с JSON под капот. Тот же маппинг в базу данных тоже можно построить через структуру ключ-значение и если приложение переключается с json на бд или использует их одновременно, то для более универсального Map правки могут не потребоваться. Но поскольку в конвертерах есть привязка к полям конвертируемого класса, а их может быть сколь угодно много, то масштабирование и поддержка тут не очень.

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

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

Также возникли небольшие проблемы с вводом-выводом: стабильно работающие в консольном варианте программы stdout.writeln и stderr.writeln внезапно отказались работать в vscode, но тут трудно сказать, кто виноват, IDE или же фреймворк.

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

Настройка ширины окна оказалась задачей нетривиальной и потребовала отдельного плагина desktop_window.

Изначально трея не было, но позже его завезли. Не тестировал, но судя по коду для Linux используется libappindicator, а эта библиотека очень "своеобразная" в плане юзабилити, только для самых простых случаев. С другой стороны, она используется повсеместно, в том же Electron и т.п.

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

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

Например, конструктор IconButton содержит около 20 аргументов, причем сам по себе не наследуется от какой-либо Button, чего интуитивно можно было бы ожидать. Есть мнение, что увеличение количества аргументов конструктора (и не только) после определенного значения очень сильно усложняет сборку объекта, чем и обусловлена известная проблематика инжекции зависимостей через конструктор и замены её на сеттеры ценой иммутабельности. Здесь же выбран DSL-путь и описание разметки в общем случае нельзя сделать последовательной, как в других тулкитах, где контролы мутабельны и могут настраиваться в разных участках кода.

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

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

Создавать код дедовским ручным способом в 2021 выглядит такой себе идеей, но поскольку в разметку все равно проникает какая-то логика, то использование билдеров вроде Flutter Studio или его аналогов становится несколько проблематичным. Здесь можно вспомнить GUI-тулкиты первых поколений без всяких xml-разметок. Для их мутабельных контролов, которые описываются кодом, в билдерах можно получить общую структуру, а уже в самом приложении донастраивать, что уменьшает зависимость от билдера и такой макет все еще можно более-менее сносно редактировать.

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

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

Поскольку в блоге есть Groovy, то можно провести аналогии с его DSL для Swing через SwingBuilder. Я возьму пример из документации и простейшее окошко с кнопкой можно вывести вот так:

import groovy.swing.SwingBuilder
import java.awt.*

new SwingBuilder().edt {
frame(title: 'Frame', size: [250, 75], show: true) {
	borderLayout()
	textlabel = label(
		text: 'Click the button!', 
		constraints: BorderLayout.NORTH
	)
	button(
		text:'Click Me',
		actionPerformed: { println "hello world"},
		constraints: BorderLayout.SOUTH
	)
	}
}

Выглядит идеологически очень похоже, разве что DSL формируется с помощью Closure. Трудно сказать, насколько тут улучшается читаемость в сравнении с обычным Swing, где можно последовательно настраивать контролы или же отделить структуру макета от его поведения.

С другой стороны, главный контроллер приложения совсем необязательно должен что-то знать о Flutter, задача вырождается в обмен событиями между главным контроллером и всеми остальными, в роли которых может выступать презентер\вью Flutter, как и что-нибудь другое. Механизм обмена можно абстрагировать в понятие канала, которых минимально нужно два: события из контроллера и события в контроллер. Без IoC контейнера в игру вступает ограничения инжекции через конструктор - количество передаваемых зависимостей должно быть небольшим, да и нужно пробросить все основные службы - логгер, конфиг и т.п.. Кроме того, в них легко запутаться, поэтому нужны классы агрегаторы.

В итоге, как самый простой способ можно попробовать создать два класса-агрегатора каналов, которые заинжектить в конструктор, что-то вроде:

class FromMainControllerChannelsCollector {
	final StreamController<RedditPost> postProcessChannel = StreamController();
	final StreamController<ParserProcessingEvent> parserEventsChannel = StreamController();
}

при получении событий от контроллера и его брата, для получении событий в контроллер:

class ToMainControllerChannelsCollector {
	Function()? onRun;
	Function()? onStop;
	Function()? onExit;
	Function(RedditPost)? onFavoritePost;
}

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

Можно поиграться и с неймингом полей, но разделение на классическое имя коллбэка onX и XChannel (или XListener?) для стрима выглядит более удобным. Имя postProcess скорее всего неудачно из-за мимикрии под повсеместно распространеный префикс post\pre, так что сугубо пример. Конечно же, то же самое можно реализовать и через более продвинутый наблюдатель - шину сообщений (event bus). При наличии рефлексии можно лишь зарегистрировать подписчика в шине, а далее автоматически вызывать нужные методы под определенные события.

Если в роли вью выступает потомок State, то в initState можно подписаться на события главного контроллера, как и освободить слушатели в dispose. Контроллер, в свою очередь, ловит события от вью, при пересоздании вью снова происходит инжекция каналов в конструктор. Наибольшая проблема тут появляется (как и для любого наблюдателя) в более сложном обмене между ними, что может быстро превратить все в хаос из событий и коллбэков. Ну а более сложные случаи навроде взаимозависимости событий друг от друга или же их ретрансляции скорее всего потребуют другое решение.

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

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

Теоретически, если использовать что-то вроде Integration Patterns, то можно сократить количество полей и обратных вызовов, но усложнится обработка событий - ими нужно как-то управлять и маршрутизировать. Для такой простой задачи эта дополнительная сложность выглядит не слишком обоснованной. Ну и поскольку никаких ссылок на контроллер в событиях не передается, то эта модель напоминает выталкивающий (push) Наблюдатель, а значит в более специфических случаях может потребоваться pull-модель, например, для ленивых вычислений.

Сама интеграция Fluter в существующее приложения достаточно проста, разве что жизнь может осложнить StatelessWidget, можно попробовать сделать так:

abstract class DesktopApplication<C extends MainController> extends StatelessWidget with CliApplication<C> { }

Из-за наследования класс базового приложения должен быть миксином, но в общем случае эти классы проблематично сделать иммутабельными: на ранних этапах жизни приложения службы\сервисы еще не существуют, они формируются поэтапно. Первым может создаваться конфиг, на основе конфига - логгер и т.д. Требования иммутабельности для StatelessWidget можно соблюсти через композицию, например, так:

abstract class DesktopApplication<C extends MainController> with CliApplication<C> { }

в котором уже можно создать и запустить Flutter:

abstract class FlutterDesktopApplication<S extends StatefulWidget> extends StatelessWidget { }

Тогда класс вне core-кода может наследоваться от DesktopApplication, переопределять создание контроллера и стратегии сборки служб, а также создать FlutterDesktopApplication, что-то вроде:

class App extends DesktopApplication<MainController> {

@override
Future<void> start(List<String> args) async {
	await super.start(args); //где-то в глубине происходит запуск CliApplication
	var app = FlutterDesktopApplication<MainScreen>(() {
	return MainScreen(
		mainController.fromMainControllerChannelsCollector,
		mainController.toMainControllerChannelsCollector);
	});
	runApp(app);
}
}

Таким образом, сборка все также проходит по этапам cli-приложения, создаются все необходимые службы, которыми комплектуется главный контроллер, каналы которого инжектятся через конструктор в вью Flutter. Если снова нужно переделать десктопное приложение в CLI-приложение, то нужно наследоваться от CliApplication и если донастроек не требуется, то просто удалить этот метод вообще. Каналы в контроллере так и остаются - они особо ни на что не влияют (кроме усложнения и влияния на производительность, разумеется).

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

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

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

Вероятно, иммутабельность имеет свои сильные стороны в мобильных (или веб) интерфейсах, поскольку мобильные операционные системы более жестко управляют приложением, но это не снимает вопроса адаптации Flutter для десктопных программ с более-менее сложной логикой.

Сам Dart показал себя очень хорошо в качестве консольного приложения, язык частично унифицирован со многими объектными языками и реиспользование кода очень высокое. Есть определенные вопросы к стратегии развития языка, вносимым изменениям и живучести сторонних библиотек, но тут покажет время. Однозначно, сам Dart мне интересен как язык, хотя есть проблема в библиотеках, часть из них сильно завязаны на Flutter. Это завязка инфраструктуры на один фреймворк может потенциально осложнить использование на других задачах.

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