Первый взгляд на переход с Xamarin Native на Flutter
Введение
Привет! Меня зовут Егор, сейчас я работаю в компании DD Planet, а разработкой мобильных приложений я занимаюсь уже более двух лет. В этой статье хочу поделиться своим опытом перехода с Xamarin Native на Flutter. Постараюсь сравнить два фреймворка с точки зрения личного опыта, расскажу о своих старых заблуждениях касательно декларативных фреймворков, которые развенчал опыт работы с Flutter, а в конце статьи порассуждаю о своем идеальном мобильном фреймворке мечты.
Эта статья, на мой взгляд, будет интересна тем разработчикам без опыта работы с декларативными фреймворками, которые (как и я до недавнего времени) все еще используют .xml- и .xib-файлы для написания интерфейса.
Кстати, вторая часть рассказа о технических подробностях перехода на новый фреймворк от лида нашей команды Виктора — уже в статье «Как мы переходили с Xamarin на Flutter»
Небольшой дисклеймер: употребляя ниже термин «декларативный UI», я имею в виду исключительно подходы Compose, Flutter и SwiftUI к построению интерфейса.
О разработке на Xamarin Native
Разработка проекта, для которого мы вынуждены были перейти на Flutter, велась примерно с 2017–2018 года. Я же присоединился в 2022-м с нулевым опытом разработки под мобильные платформы (на тот момент был опыт исключительно разработки под Desktop на WPF и AvaloniaUI). Проект представлял собой социальную сеть для соседей с чатами, сообществами, лентой, умным домом и прочими сервисами, а количество экранов превышало добрую сотню.
За основу был взят фреймворк MvvmCross — единственный фреймворк на рынке, позволяющий использовать разделяемый UI под каждую из платформ. UI под Android верстался с помощью xml- и C#-кода, а под iOS соответственно использовались xib-файлы и C#.
Команда была поделена на две части, каждая из которых занималась версткой под конкретную платформу и написанием общего кода. Однако были и товарищи, успешно реализующие интерфейс под обе платформы.
Пример кода биндингов в MvvmCross для View на iOS
var set = this.CreateBindingSet();
set.Bind(this).For("NetworkIndicator").To(vm => vm.FetchPeopleTask.IsNotCompleted).WithFallback(false);
set.Bind(_refreshControl).For(r => r.IsRefreshing).To(vm => vm.LoadPeopleTask.IsNotCompleted).WithFallback(false);
set.Bind(_refreshControl).For(r => r.RefreshCommand).To(vm => vm.RefreshPeopleCommand);
set.Bind(_source).For(v => v.ItemsSource).To(vm => vm.People);
set.Bind(_source).For(v => v.SelectionChangedCommand).To(vm => vm.PersonSelectedCommand);
set.Bind(_source).For(v => v.FetchCommand).To(vm => vm.FetchPeopleCommand);
set.Apply();
Во время работы над проектом я всячески поддерживал использование этой технологии, ведь она предоставляла все плюшки нативного UI и при этом давала нам возможность писать на любимом языке и переиспользовать огромное количество кода. Скорость разработки тоже не уступала нативной разработке. Мы использовали те же классы и модели, что и в нативной разработке, — Activity, RecyclerView, UIController, UICollectionView и т.п. Возникает проблема, ищешь решение на Java (Kotlin) или Swift и переносишь его практически один к одному на C#. Если решение — нативная библиотека, делаешь к ней обертку за час-полтора и используешь. Правда с некоторыми из них было не все так просто. Да и использовать тот же Jetpack Compose или SwiftUI мы не могли.
За год до закрытия проекта заказчик принял решение сделать глобальный редизайн приложения. Оглядываясь назад, хочу сказать, что именно эта задача прокачала меня в разработке особенно сильно. Мы разработали новые библиотеки с контролами под Android и iOS, переосмыслили многие старые экраны и переделали многое из ранее написанного. При этом все контролы мы реализовали с нуля на C# — будь то какая-то карточка, кнопка или хедер, который забеляется при подскролливании контента под него.
После выхода .NET 7 мы перешли с Xamarin на него. Во-первых, новая версия MVVM Cross перестала поддерживать классический Xamarin. Во-вторых, хотелось идти в ногу со временем, использовать последнюю версию языка и повысить перформанс за счет этого перехода.
За месяц до закрытия проекта мы перешли на .NET 8 и размышляли о включении NativeAot на iOS, чтобы приложение открывалось молниеносно. Хотя и без NativeAot, в сравнении с Android, iOS-приложение запускалось намного шустрее.
После закрытия проекта я думал, что буду дальше топить за этот подход, позволяющий писать приложения с нативным UI на одном языке. Были мысли написать свой аналог MvvmCross, с обширным включением Source Generator для генерации кода и отказа от рефлексии везде, где это возможно (DI, serialization и т. п. — не забываем про NativeAot, с которым стандартный сериализатор не работает).
Итоги по главе с Xamarin
Пункты, которые мне нравились в сравнении с классической нативной разработкой:
Обширная общая кодовая база — вся бизнес-логика, работа с базами данных и сетью.
Один язык для разработки на обеих платформах. Причем C# мне нравится гораздо больше, чем тот же Swift. Поэтому для разработки исключительно под iOS я бы посоветовал рассмотреть вариант использования .NET-iOS, если к Swift вы относитесь с предубеждением.
Удобный туллинг в работе с кодом, обширные рефакторинги C#-кода, удобный менеджер пакетов и .csproj-файлы для настроек проекта. Редактировать UI-файлы для Android было не очень удобно, но об этом расскажу в минусах.
Привычный набор библиотек из nuget, с которыми работает любой .NET-разработчик.
Обширное количество возможностей для оптимизаций перформанса, предоставляемых платформой .NET, — Span, Struct и прочие вещи, которые, на мой взгляд, позволяют делать более производительный код даже в сравнении с нативом.
Похожие абстракции при работе с MVVM для разработчиков, имеющих опыт с другими UI-фреймворками на C#. Если брать MvvmCross в качестве основы, то подход к ViewModel с INotifyPropertyChanged будет идентичным.
Но многое и не нравилось:
Неудобный тулинг при верстке UI. Автодополнение в .xml в Rider функционировало странно: показ атрибутов работал только для основных контролов, для наших же контролов подсказок не было. Однако в Visual Studio и это работало.
Коллизии при реализации фичи на одной платформе: общий код в виде моделей и ViewModel не ложился на UI второй платформы без дополнительных костылей или переделывания общего кода.
Проблемы с дженериками на Android. Из этого, например, у нас на проекте было правило — не использовать их для Activity, View и Fragment.
Проблемы с некоторыми библиотеками, работавшими на старом Xamarin, но после перехода на .NET 7 переставшими работать. Пришлось дожидаться, пока появятся форки библиотек с поддержкой .NET 7.
Проблемы с MvvmCross. У него был ряд проблем, которые не исправили до сих пор. Плюс он предоставляет собственный DI, который не удастся заменить на свою реализацию каким-то простым способом. Отсюда и проблемы с долгим стартом приложения, которые однако частично сглаживало включение AoT-компиляции.
Отсутствие поддержки написания декларативного UI. Аналога Compose и SwiftUI в Xamarin Native нет.
Отсутствие hot-reload и адекватного previewer, в отличие, например, от MAUI. В итоге единственный способ проверить, что получилось после правок разметки — запускать эмулятор и навигироваться на нужный экран.
Отсутствие profiler памяти для поиска утечек именно в .NET-части приложения.
Недостатков было немало, но я не обращал на них внимание и действительно верил в технологию, так как из альтернатив пробовал только классический натив для iOS.
Кстати говоря, в тот момент я воспринимал декларативный подход к UI просто как вынос создания контролов из файлов разметки в код и думал: в чем весь хайп? Вот я на C# делаю то же самое: создаю LinearLayout с TextView и прочими View в виде функций — чем вам не Jetpack Compose. Плюс добавлял еще абстракций для более удобного их создания в коде. А в итоге так же делал биндинги к созданным контролам, которые висели в памяти в течение всего жизненного цикла экрана.
Если вдруг вы тоже так размышляете о декларативном UI, знайте: Compose, Flutter и SwiftUI работают не так, как классический натив, в котором при старте экрана мы создаем один набор виджетов и потом мутируем их состояние путем изменения их свойств, когда на экране что-то меняется. Тут совершенно другая идеология, плюсы которой для меня теперь абсолютно очевидны.
Пример создания контролов из C#-кода вместо XML
public static View CreateView(Context context)
{
return new LayoutWrapper(RootView(context))
{
new LayoutWrapper(ContentContainer(context))
{
StickerView(context),
new LayoutWrapper(FooterContainer(context))
{
new LayoutWrapper(StatusContainer(context))
{
ProgressIndicator(context),
ErrorImageView(context)
},
new LayoutWrapper(DateContainer(context))
{
DateTextView(context)
},
}
},
};
}
В конечном итоге проект заморозили по независящим от нас причинам. Команда из 14 человек распалась, половина из них ушла в бэкенд-разработку, а я подключился к новому проекту, который решено было делать на Flutter. На момент начала работ опыта с этим фреймворком не было ни у кого из команды, поэтому обучаться нам предстояло прямо в процессе.
Начало разработки на Flutter
Хочется отметить, чтобы не вводить в заблуждение в дальнейшем: предыдущий проект и текущий совершенно не сопоставимы по объему и сложности.
Предыдущий проект был социальной сетью для соседей с дополнительными сервисами и возможностями. Дизайн приложения отражал особую философию проекта, а дизайнеры ежедневно взаимодействовали с командой в обе стороны, что накладывало на разработчика дополнительные сложности, связанные с тем, чтобы добиваться pixel-perfect с макетами в Фигме.
Новый проект — сервис для заказа услуг с возможностью сделать заказ, посмотреть текущие и активные заказы, профиль пользователя и экраны новостей и уведомлений.
Разработка началась с выбора архитектуры приложения и определения джентльменского набора библиотек для использования.
По итогу после ресерча популярных стейт-менеджеров для Flutter был выбран bloc как наиболее понятный для нас, C#-перебежчиков. Для навигации выбрали auto-router. Для логирования выбрали Talker, который я приметил в уроках его автора, когда смотрел видеоуроки по Flutter. Для DI — injectable и retrofit для генерации api-клиентов.
Касательно архитектуры была выбрана clean-архитектура с feature-first-структурой приложения. Каждый блок приложения (экран, раздел) представляет собой отдельную feature со своими слоями data, domain и presentation. Есть специальный плагин для android-студии, который по названию фичи сразу создает иерархию из папок. Особо распинаться про clean-architecture не буду, отмечу только, что это очень облегчило нам жизнь при разработке! Тем более после многолетнего опыта работы с layer-first-подходом к структуре, когда у нас на главном уровне есть папки Views, ViewModels, Models, Engines, Services, Primitives и т. д. и при разработке одного экрана или маленькой его части приходилось мотаться по всему проекту в поисках необходимых файлов и классов, в которых нужно было найти ту или иную логику.
Понятно, что можно было и ранее заложить feature-first-структуру, но этот подход редко встречается на уровне примеров в ui-фреймворках на C#. Даже при создании пустого проекта, например, в AvaloniaUI папки проекта выстроены именно в layer-first-стиле. Такой опыт с флаттером убедил меня в необходимости придерживаться clean architecture с подобной структурой и в C#-проектах.
Теперь и для проектов на Avalonia использую clean architecture + feature-first-структуру
![2c6bd49ca51e80186eb274b1dbe578a9.png](https://habrastorage.org/r/w1560/getpro/habr/upload_files/2c6/bd4/9ca/2c6bd49ca51e80186eb274b1dbe578a9.png)
Итак, мы начали верстать первые экраны, осваивать bloc, dart, да и сам flutter в целом. Сразу хочется отметить высокую скорость разработки. В сравнении с нативом экраны верстаются МГНОВЕННО, за один раз, да еще и сразу под обе платформы. Верстку для получения красивого экрана в соответствии с макетом из Фигмы я делаю буквально за 30 минут. На xml подобная работа заняла бы у меня час-полтора в лучшем случае. На iOS, из опыта коллег, выходило бы еще дольше. У меня ввиду небольшого опыта это всегда X2 ко времени относительно Android«а.
К готовой верстке прикрепляется bloc, прокидываются события, закладывается state. В блоке всю логику выносим в UseCases из слоя Domain, и наш слой presentation готов. В предыдущем проекте на Xamarin никаких UseCase не было. Вся бизнес-логика выполнялась в ViewModel и Engine, которые инкапсулировали в себе работу с бэкендом, что сейчас воспринимается чем-то неправильным без четкого разделения на слои.
Во-первых, во Flutter есть невероятно удобный hot-reload. Любое изменение мгновенно применяется на экране эмулятора при верстке. Это же касается и изменений обычных методов в ваших блоках, use-case или репозиториях. Это ускоряет разработку в разы — в сравнении с Xamarin. В MAUI, правда, завезли хот-релоад, но, как мне кажется, это не спасет его от неминуемого забвения.
Во-вторых, декларативный UI — это реально круто. Мы используем bloc- и clean-архитектуру в Flutter-проекте, и код получается максимально простым для понимания. Сразу вспоминаются какие-нибудь стабы и заглушки, которые должны были отрисовываться в нативе в случае ошибок или пустого списка. В итоге View покрывалась тонной биндингов на привязку к Visibility тех или иных контролов на экране. Поддерживать такое было непросто, постоянно вылезали баги, связанные с некорректным отображением чего-либо для каждой конкретной ситуации.
К счастью, после работы с Flutter и концепцией различных State под разные состояния экрана вспоминаешь былое как страшный сон.
Типичный код по скрытию/показу контролов из проекта на Xamarin Native
_contentChangesTrigger = new CollectionInitializedTrigger>(
state =>
{
switch (state)
{
case ContentState.Filled:
SearchView.Reveal();
ContentView.Reveal();
EmptySearchLabel.Hide();
NoContentView.Hide();
break;
case ContentState.Empty:
case ContentState.FailedToBeFilled:
AdjustStub();
bool filterAssigned = !string.IsNullOrEmpty(SearchView.Text);
SearchView.Hidden = !filterAssigned;
ContentView.Hide();
EmptySearchLabel.Hidden = !filterAssigned;
NoContentView.Hidden = filterAssigned;
break;
}
})
.LinkTo(Disposer);
В-третьих, хочется поговорить о Dart. Начну с того, что в разрезе перехода с C# меня до сих пор смущает система импортов. Раздражает наличие в каждой папке одноименного файла с экспортом всех остальных файлов в папке, которые должен генерировать плагин, но который не работает в последних версиях студии. Не нравится, что при рефакторинге переименования класса не переименовывается название файла. Также нет рефакторинга — подключения всех импортов в файле с кодом. Отсутствие такого же рефакторинга в случае kotlin в продуктах JetBrains, кстати, тоже для меня стало неприятным открытием. Прокликивать каждое место в коде, который копируешь откуда-то, чтобы подтянуть необходимые импорты, не очень приятно после работы в Rider c C#, где все это делается в одно нажатие.
Также в Dart кодогенерация работает через build runner, что совсем пока не сравнится с теми же Incremental Source Generators в C#, когда код генерируется мгновенно при написании конкретных атрибутов, от которых зависит, где и каким образом будет генерироваться код.
К чему я сразу привык, так это к отсутствию оператора «new» перед созданием объектов. Теперь, при возвращении в C#, постоянно забываю писать его перед названием класса. Еще понравилось наличие sealed-классов, позволяющих не использовать дефолтное значение при паттерн-матчинге. Такое же поведение и у здешних enum. В итоге при расширении типа новым значением мы будем ловить ошибку в compile time везде, где использовали pattern-matching.
В-четвертых, во Flutter — один файл локализации на весь проект, а официального решения для возможности разделения файла на несколько частей (например, на каждую фичу) от команды разработчиков Flutter пока нет. Держать один файл с локализованными строками совсем неудобно даже для небольшого проекта.
В целом впечатление о Flutter в сравнении с Xamarin исключительно положительное. Однако смущает Dart и его ограничения в многопоточности и прочих нюансах, описанных выше. Смущает также обширное число issues, на которые до сих пор натыкаешься при разработке, их в репозитории Flutter сейчас более 12 000, а за три месяца работы и мной было добавлено еще несколько. Основной плюс Flutter для меня — удобная система виджетов, позволяющая реализовывать экраны с невероятной скоростью. Flutter наглядно демонстрирует, что декларативный UI — это не дань моде, а неизбежное будущее. На текущий момент, Flutter — лучший кроссплатформенный фреймворк, если вы выбираете его для своих новых проектов. Возможно, в будущем его сместит с пьедестала Kotlin Multiplatform, который, на мой взгляд, еще не достиг той кондиции, чтобы тягаться с ним.
А что там кроме Flutter?
Первое, что приходит в голову разработчику, который несколько лет работал с Xamarin Native, это, конечно же, MAUI — новый-старый флагманский UI фреймворк от Microsoft со старым подходом к построению интерфейсов, перекочевавшим из Xamarin Forms. Чтобы как-то обосновать новое название, Microsoft заявила о поддержке десктопа для Windows и MacOs и в итоге тратила все силы на то, чтобы подружить старый мобильный фреймворк с жестами, поддержкой клавиатуры и прочими штуками, которые отличают десктоп от мобилок. Уже маячит .NET 9, а команда MAUI из нововведений говорит лишь о каких-то улучшениях перформанса и прочих вещах, которые не слишком интересны с точки зрения разработки. Интуиция подсказывает, что MAUI закончится через год-два, как Silverlight, WSA, UWP, VS for Mac 2022, <вставьте свой пример технологии, которую похоронили микромягкие>. И это очень обидно, учитывая мою любовь к платформе .NET.
На мой взгляд, независимый фреймворк мечты для мобильной разработки выглядит следующим образом:
Это аналог Flutter с Hot Reload, декларативным UI и поддержкой F#, который, на мой взгляд, мог бы отлично вписаться в декларативный подход. Возможно, это дало бы второе рождение F#, по аналогии с тем, как это произошло с Dart. От Microsoft мы вряд ли дождемся чего-то подобного, вслед за MAUI они, скорее всего, придумают еще один XAML-фреймворк или насовсем забросят эту нишу. Поэтому искренне надеюсь, что появятся энтузиасты, на уровне разработчиков AvaloniaUI, способные сделать тот самый фреймворк мечты.
Во время написания этой статьи я решил попробовать также Kotlin Multiplatform. Опишу свои выводы касательно этой технологии, которые можно сделать за несколько дней знакомства:
Подход с раздельным UI пока выглядит жизнеспособным. На Андроиде — Jetpack Compose, на iOS — SwiftUI. И только слои domain и data — общие для приложений на Kotlin. Попробовав создать приложение с shared UI (который для iOS еще в альфе), я понял, что в таком виде не смогу писать приложение в том же комфортном режиме, как при разработке на Flutter.
Система сборки на основе kotlin.gradle.kts-файлов выглядит дикой. Я теряюсь в количестве файлов и в том, что происходит внутри. Процесс добавления каких-то библиотек после Flutter и .NET тоже кажется неудобным: приходится копировать строки с нужной версией с сайта библиотеки и вставлять их в нужные места, разбросанные в разных .kts-файлах. После pub- и nuget-менеджеров это выглядит какой-то лютой архаикой. Возможно, мнение еще поменяется, но первое впечатление ровно такое.
Kotlin — one love, буквально с первых же примеров кода из туториалов по kmp, а некоторые конструкции мне понравились уже через призму знакомства с dart. Если бы я сразу переключился с C# на Kotlin, возможно, он понравился бы мне не так сильно.
Выводы
Вывод 1. Какой бы крутой вам не казалась технология, не нужно зажимать себя в ее рамках. Сейчас я благодарен судьбе за то, что смог вырваться из превозносимой мной разработки на Xamarin Native, к которой теперь не хочу возвращаться.
Вывод 2. Декларативный UI с нами надолго, поэтому, если вы его еще не пробовали, советую поспешить. На YouTube тысячи образовательных видео, помогающих освоить абсолютно любую технологию.
Расскажите, используете ли вы MAUI или Xamarin Native в проде. Был ли при этом у вас предшествующий опыт работы с декларативными фреймворками? Приглашаю вас к обсуждению и напоминаю о второй части рассказа про наш переход от моего коллеги Виктора.