Последний год я так или иначе пишу приложения на Flutter для iOS и Android. До этого у меня был и есть 5 летний опыт работы с Xamarin. Это были замечательные 5 лет. Благодаря Xamarin и моей любви к этому фреймворку я, в принципе, перешел в стан разработчиков, этот инструмент помог заработать мне немалых денег, знаний и найти замечательных коллег. Так почему же сейчас я пишу на Flutter? Короткий ответ, потому что Flutter покрывает все потребности кросс-платформенной разработки.
Немного истории
Поправьте меня если я не прав, но 2009 год был во многом ключевым для мобильной разработки в целом и кроссплатформенной разработки в частности. В 2009 вышел iPhone 3gs, который позволял запускать сторонние приложения из AppStore. Впервые эта возможность появилась в годом ранее в iPhone 3g, но по настоящему массовым, «народным» айфоном стал 3gs. Опять же, годом ранее, в сентябре 2008 Android был представлен публике и в 2009 многие производители телефонов стали пробовать Android для своих новый моделей телефонов. Весной 2009 компания Nitobi представила PhoneGap — новый фреймворк для создания кроссплатформенных приложений на основе HTML5, CSS и JS. В том же году, в сентябре компания Ximian выпустила MonoTouch, который позволял писать iOS приложения с использованием Mono и C#. В том же 2009, в декабре, компания Rovio Entertainment выпустила игру для iOS и, на минуточку, Maemo, которая во многом положила начало индустрии мобильных игр — Angry Birds. Последний пример здесь не случайно.
Первым кросс-платформенным фреймворком «для народа» можно считать PhoneGap (Qt разработчики, не кидайте камнями). Это была замечательная и вполне очевидная идея — принести веб в мир мобильной разработки. К 2009 году, возможности веба уже начали распространяться за пределы браузера (привет node.js), при этом писать приложения для веба на JS было делом довольно простым. Второй, не менее важный момент — рендер UI. То как происходит рендер лежит на движке браузера и все эти движки более или менее следуют стандартам W3C по HTML, CSS и DOM. Любой веб-разработчик сверставший сайт ожидает что его сайт бyдет выглядеть почти идентично в любом браузере, на любой платформе. Это, по моему мнению, наиболее важная сторона веба как открытой платформы. Зачем мне учить новый язык/фреймворк для рисования UI для каждой из платформ, если уже давно есть стандарт по моделирования UI для разных браузеров.
После этого, от PhoneGap'a отпочковалась Cordova, а от нее Ionic. Казалось бы, это идеальный фреймворк, но было 2 момента: производительность и интеграция с OS. Одной из главных целей или, если хотите, бенчмарков приложений, написанный на кроссплатформенный решениях были их «нативность». Т.е. в идеале, 100% пользователей должны считать, что твое кроссплатформенное приложение — нативное. А это значит, что оно должно выглядеть как нативное, работать как нативное и иметь все возможные интеграции с ОС. В начале все эти пункты для PhoneGap были недостижимы, мощностей смартфонов 10-летней давности не хватало для 60 fps'ного рендера UI, интеграция с ОС была минимальной. Сейчас существует довольно много приложений на Ionic которые трудно отличить от нативных, но мимикрировать под нативное приложение все еще остается задачей, а не данностью как таковой. Давайте подведем небольшие итоги. Писать веб-приложения, а точнее hybrid-приложения на iOS и Android можно и удобно. Удобно потому что механизм рендера UI всецело лежит на WebView платформы, плюс есть уже обученный пласт программистов, которые хорошо разбираются в веб. Однако в hybrid-приложениях производительность и интеграция с ОС могут хромать.
Одновременно с PhoneGap в 2009 году вышел на свет MonoTouch, что в дальнейшем было переименовано в Xamarin.iOS. Также, в этом же году вышел в свет Titanium, который также в свою очередь позволял писать приложения для iOS на javascript. По началу Titanium работал абсолютно в такой же парадигме как PhoneGap — опираясь на WebView. Но потом они переняли Xamarin подход. Что же это за подход? Его можно рассмотреть как нечто посередине. Подход Xamarin/Titanium/React.Native заключается в том, что вместо того, чтобы пытаться создать/перенести свой/существующий UI рендер, фреймворк просто интегрируется с существующим, нативным.
Вместо того, чтобы рисовать формочку в HTML, Xamarin вызывает нативный UI элемент для этого (UITextField, TextEdit, etc). Действительно, зачем изобретать велосипед? Все нужные UI элементы существуют в нативных SDK и рантаймах, нужно просто наyчиться коммуницировать с ними из своих VM (mono, v8, etc). При этом, как вы уже догадались, можно использовать свой любимый C#, JS, TS, F#, Kotlin, etc и при этом код, который непосредственного не взаимодействует с UI — 100% кроссплатформенный. Можно иди еще дальше. Те же UITextField и TextEdit концептуально идентичные сущности, они имеют довольно много схожих свойств и интерфейсов взаимодействия, а посему, можно создать абстрактный Entry (привет Xamarin.Forms) и работать только с ним, за редким (не очень) исключением спускаясь до платформенного UI элемента. Я уже не упоминаю, что если твоя vm может работать с UI нативно, скорее всего твоя vm может вызывать любые платформенные API. Кажется, это идеальный вариант. Нативный UI, нативная производительность (привет Bridges в React.Native), 100% интеграция с ОС. Неужели это идеальный вариант? Скорее всего — нет, и проблема заключается в том, что на самом деле эти решения не решают проблему кроссплатформенной разработки — единый UI. Они ее маскируют. Я хочу write once, run everywhere. Это далеко не лучшее motto для всех типов программ и проблем, но это хорошо ложится на UI. Я хочу писать UI одинаково для всех, независимо от платформы. Почему веб-разработчик может позволить себе использовать HTML и CSS для написания какого-либо сайта, который потом будет одинаково отображаться в Safari на iOS и в Chrome на Android а нативный разработчик нет?
На самом деле, программисты давно пишут высокопроизводительный UI c общей кодовой базой для iOS и Android. Этих программистов зовут гейм девелоперы. Angry Birds была написана на Cocos2d-x движке, Cuphead на Unity, а Fortnite на Unreal Engine. Если гейм движки в состоянии показывать умопомрачительные сцены на твоем телефоне, то кнопочки да списки с плавной анимацией точно смогут. Так почему никто их не использует в данном ключе? Ответ просто и банален, они не предназначены для этого. Когда ты открываешь игру, тебе абсолютно до фонаря насколько UI похож на нативный, тебе практически никогда не нужно взаимодействовать с геолокацией, пушами, видеокамерой и тд. Пока ты играешь ты живешь другую жизнь в своем маленьком мире который рендерится через Canvas в твоем UIViewController/Activity. Поэтому в игровых движках относительно слабая интеграция с ОС, поэтому же нет (или я не видел) мимикрирования нативного UI поверx движка.
Промежуточные итоги
Для идеального кроссплатформенного фреймворка нам нужны:
- Нативное отображение UI
- Нативная производительность UI
- 100% возможность вызвать любой API OS, как будто это нативное приложение
Вы сейчас думаете что я начну подводить под Flutter, но я уже слышу гневные комментарии «А где Qt!? Он все это может!». Действительно, Qt в той или иной степени подходит под эти критерии. Хотя я сильно сомневаюсь в первом из них. Но главная проблема Qt не в сложности написания нативного UI, главная проблема — C++. Тут я уже вытираю лицо от плевков тру-кодеров на плюсах. Плюсы это швейцарский нож на анаболиках, на плюсах можно делать все. Но мне, как frontend разработчику, не нужно это все. Мне нужен простой и понятный язык который работает с UI и I/O. Итак, к нашим трем пунктам выше добавилось:
- Легкий в освоении и достаточно выразительный язык
- Рантайм, который хорошо вписывается во frontend парадигму разработки
Что ж, теперь когда мы выделили некоторые метрики хорошего кроссплатформенного инструмента для разработки мобильных приложений, мы можем пробежаться по каждой из них и посмотреть как это реализовано во Flutter.
Нативное отображение UI
Как мы выяснили ранее, существуют два противоположных подхода к работе с UI в кроссплатформенных фреймфорках. Это рендер UI с помощью WebView или нативные вызовы элементов UI на каждой из платформ. У каждого подхода есть преимущества и недостатки. Но они не покрывают весь спектр потребностей разработчика: выглядеть неотличимо от нативного UI + нативная производительность. Flutter покрывает все эти потребности с головой. Команда Flutter потратила некоторое количество ресурсов на создание «нативных» элементов в самом фреймфорке. Все виджеты во Flutter разделены на три большие категории:
Если вы зайдете в купертино раздел, то вы увидите что эти виджеты неотличимы от нативных iOS элементов. Как разработчик, который использует Flutter некоторое время я могу подтвердить, они неотличимы. Если вы воспользуетесь CupertinoDatePicker, например, то при скролле вы почувствуете абсолютно такой же, приятный фидбек от Taptic/Haptic engine на вашем iPhone как будто это и есть нативный элемент нативного приложения. Скажу больше, периодически я открываю приложение сайта realtor.com на моем iPhone и до последнего времени я понятия не имел, что оно написано на Flutter (или на чем-то не нативном).
Flutter не только позволяет использовать «нативные» виджеты для 2 платформ, но и создавать свои, и это очень легко! Вся парадигма что everything is widget работает. Вы можете создавать удивительно сложные UI элементы и анимации в короткие сроки. Прелести и премудрости подхода работы с UI во Flutter недавно были описаны в этой статье на хабре, рекомендую к прочтению. Т.к. все это работает на едином графическом движке, который непосредственно рендерит все это для каждой из платформ (о нем поговорим позже), вы можете быть уверены, что все будет отображаться так, как вы планировали.
Еще один довольно удивительный момент. Flutter поддерживает платформы начиная с iOS 8 и Android API v16. С точки зрения рендера UI, Flutter'у по большому счету без разницы какие API доступны на конкретной платформе. Ему бы возможность работать с Canvas и какое-никакое взаимодействие с графической подсистемой. А это значит что мы можем рисовать самые последние UI элементы из AndroidX, например, на телефоне 8ми летней давности. Тут конечно есть вопрос производительности такого подхода на самых старых поддерживаемых платформах, но это другой вопрос.
Нативная производительность UI
Как вы могли понять, подход Flutter к рендеру UI ближе к подходу hybrid-приложений, таких как Ionic. У нас есть единый движок для отрисовки UI на всех платформах, это Skia Graphics Library. Google купила Skia как продукт в 2005 году и превратила его в Open Source проект. Это как минимум говорит о том, что это достаточно зрелый продукт. Некоторые особенности производительности Skia:
- Copy-on-write для графических элементов и других типов данных
- Использование стек-памяти где только это возможно, для уменьшения фрагментации
- Thread-safety, для лучшего распараллеливания
Я не нашел убедительных тестов производительности Skia по сравнению со схожими библиотеками (см. Cairo), но некоторые тесты говорят о 50% выигрыше в производительности в среднем, за исключением некоторых специфичных ситуациях. Да это и не особо важно, потому как эти тесты основаны на использовании OpenGL на десктопах, а…
Skia может взаимодействовать с множеством GPU backends. С недавнего времени на iOS, с 11 версии, Flutter по умолчанию использует Metal в качестве GPU бэкэнда. На Android начиная с API 24 — Vulkan. Для версий ниже — OpenGL. Все это дает нам очевидный выигрыш в производительности. На остальных «хардварных» платформах, насколько я понимаю, Skia/Flutter использует OpenGL что в принципе не мешает нам писать приложения с достаточной графической производительностью.
Особняком стоит Web. На текущий момент весь UI рендер по прежнему ложится на связку Canvas/HTML. Поэтому Skia никоим образом не участвует в этом процессе. Плюс, Dart VM не взаимодействует непосредственно c DOM. Сначала идет преобразование в js. Все это не лучшим образом сказывается на производительности и это прям заметно невооруженным глазом. Однако ведутся работы по внедрению СanvasKit во Flutter, что в свою очередь позволит использовать Skia в браузерах через WebGL.
Напоследок, C# программисты относительно давно использует SkiaSharp — обертку над Skia для Mono/.Net x. А Xamarin сообщество использует эту либу для рисования кастомных UI элементов и это очень популярная библиотека. Если это не победа, то я не знаю что это.
100% возможность вызвать любой API OS
Во Flutter существует 2 принципа взаимодействия со «внешним» миром:
Platform Channels позволяют взаимодействовать с нативным runtime/API посредством системы сообщений. С архитектурной точки зрения на это можно посмотреть следующим образом. Визуально, Flutter это просто Canvas, который растянут на весь экран в единственной Activity/UIViewController вашего нативного приложения. Это абсолютно такой же подход, что использую гейм девелоперы (гейм-движки). Т.е. вы можете открыть iOS/Android проект вашего приложения и добавить любую другую функциональность на Swift/Kotlin/etc. Проблема в том, что нативный рантайм и Dart VM не будут ничего знать об друг друге (помимо того, что нативный рантайм будет знать что в приложении есть Canvas и там что-то отображается). Далее, если вы, например, откроете файл MainActivity.kt вашего Android проекта ты вы увидите что-то вроде этого:
class MainActivity: FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GeneratedPluginRegistrant.registerWith(this)
}
}
Вы уже заметили, что ваша Activity наследуется от FlutterActivity? Это и дает нам возможность настроить механизм отправки сообщений непосредственно во Flutter/DartVM. Для этого нам нужно переопределить метод configureFlutterEngine и определится с названием вызываемого метода и названием канала для отправки асинхронных сообщений. Все. Это дает возможность писать нам любой нативный код и вызывать любые нативные API! При этом уже сейчас существует большое количество плагинов (packages), которые избавляют вас от написания нативного кода, можно использовать только Dart. Это же просто замечательно! Пишешь UI отдельно и один раз для любой платформы, используешься DartVM для работы с UI, I/O и просто как вычислительный компонент, используешь плагины, которые реализуют нативные возможности и которые покрывают 99% всего функционала. А если этого недостаточно, пишешь нативно и коммуницируешь посредством механизма сообщений. Сказка.
Второй механизм это Foreign function interface или FFI. Это довольно распространенный термин для обозначения механизма итеропа с другими языками, в основном с Си. В .Net мире этот механизм носит имя P/Invoke, для JVM это JNI. Если кратко, это возможность взаимодействия с библиотеками написанными на С/С++/etc. В момент появления .Net Framework, например, не было никакого ПО написанного на C# и подавляющее большинство ПО было написано на C/C++, поэтому нужен был механизм для работы с этими библиотеками. Тоже самое применимо и к JVM, Python, you name it. FFI так или иначе используется во всех кроссплатформенных мобильных фреймворках. С недавнего времени DartVM также начал поддерживать FFI для интеропа с Си и JavaScript! Пока эта возможность находится в бэта-ветке, но уже доступна для пользования (на свои страх и риск).
Как вы можете видеть, Flutter и DartVM покрывают 100% возможностей на нативных платформах, и даже больше.
Легкий в освоении и достаточно выразительный язык
Я признаюсь честно, пока Dart для меня остается не самым лучшим языком на свете. Тут нет строгой системы типов, тут нет функциональных плюшек, типа Pattern Matching или возможности Immutability (вроде скоро завезут), etc. О системе типов, Dart изначально задумывался как «без типовой» язык, аля JS, но для нормальной поддержки AOT компиляции пришлось все таки приводить систему типов к более строгой, хоть и не до конца, я бы сказал. Меня по-прежнему раздражает работать с сигнатурами методов, а именно с аргументами. Все эти скобочки, @required
почему-то бесят. Но dart очень легкий в освоении язык. По синтаксису это что-то среднее между Java и JS для меня. Dart прощает очень многое, как JS. В общем, это довольно простой в освоении язык, я не испытал каких-то существенных проблем.
Рантайм, который хорошо вписывается во frontend парадигму разработки
Теперь поговорим о Dart VM. Вообще Dart VM включает в себе множество вещей, от GC до Profiler и Observatory. Здесь я хочу поговорить только о GC и условном Runtime. Вы можете ознакомится с тем, как работает рантайм и из чего он состоит здесь. Я не специалист в этой области, но для себя я отметил некоторые преимущества Dart VM, которые постараюсь описать. До этого, я бы хотел отметить что Dart и соответствующая VM изначально разрабатывалась как замена JS, что как бы намекает на ориентированность на frontend разработку.
Isolates
В Dart VM есть концепт Isolate. Isolate это комбинация одного main thread, который непосредственно бежит по Dart коду и изолированной куче, где собственно аллоцируются объекты из Dart кода. Это упрощенная структура. В Isolate также есть вспомогательные/системные треды, есть ОС треды, которые могут входить и выходить в Isolate, и т.д. Стэк также присутствует в Isolate но вы, как пользователь, не оперируете им. Главное что тут нужно подчеркнуть, что если смотреть на один Isolate, то это single-thread окружение. По умолчанию, Flutter использует один дэфолтный Isolate. Ничего не напоминает? Да это же JS окружение. Точно также как и в JS, программисты Dart не могут работать с multithreading. Кто-то может подумать, что это непорядок, упрощение и ущемление прав настоящих разработчиков, но я считаю что при работе с UI, когда ты оперируешь условным DOM (а не вырисовываешь полигоны на экране), тебе не нужно, опасно оперировать несколькими тредами.
Тут я конечно слукавил, если очень хочется, то вы можете использовать отдельно запущенные Isolate для выполнения параллельных задач (привет WebWorkers) Тут можно подробно посмотреть как можно работать с дополнительными Isolate во Flutter. Вообще, Isolates, как можно догадаться из названия — не знают друг о друге ничего, не держат ссылки друг на друга и общаются посредством системы сообщений.
Помимо single-thread подхода, тот факт, что для каждого Isolate выделяется отдельная куча, без возможности манипулирования стэком этого треда, по моему тоже очень неплохой подход. Если вы пишете серверное приложение, которое манипулирует огромным количеством строк, например, и эти строки хранятся в куче, где появляются и исчезают с огромной скоростью, при этом фрагментируют память и добавляют работы GC, любой вариант переноса этих строк, или хотя бы части, из кучи в стек сэкономят ресурсов и повысят производительности. Пример так себе но вы меня поняли. Но при работе с UI, где есть, возможно, достаточное количество UI элементов, которые могут иметь короткое время жизни (при анимации например), но при этом всего один клиент и количество обрабатываемых данных ничтожно мало по сравнению с серверным приложением, возможность напрямую работать со стэком просто ненужна. Я уже не говорю про boxing/unboxing, который мог бы быть в этом случае и который абсолютно бессмысленен. Причем надо отметить, что объекты в Dart VM аллоцируются довольно часто. Даже для вывода суммы double из метода Dart VM отдельно аллоцирует кусок в куче. Как же GC справляется с этой нагрузкой? Давайте посмотрим.
Young Space Scavenger (и Parallel Mark Sweep)
Во-первых, как и все GC, GC в Dart VM имеет поколения. Также GC в Dart VM можно разделить по принципу работы на 2 составные части: Young Space Scavenger и Parallel Mark Sweep. На последнем принципе я не буду останавливаться, это довольно популярный принцип очистки памяти, который реализован практически везде и не дает Flutter особого преимущества. Нам интересен первый. Принцип работы Young Space Scavenger хорошо проиллюстрирован на следующей картинке:
Она достаточно наглядно демонстрирует преимущества этого подхода. Young Space Scavenger работает для самых новых объектов в памяти, можно сказать что для первого/нулевого поколения объектов. Зачастую, и это характерно для Flutter/Dart VM, большинство новых объектов имеют непродолжительную жизнь. В ситуации, когда вы аллоцируете множество объектов которые живут не долго, память может быть очень фрагментирована. В этом случае вам придется заплатить либо объемами памяти либо процессорным временем для устранения проблемы (хотя такими способами устранять проблему не стоит). Young Space Scavenger решает это проблему. Если посмотреть на картинку выше, то 6 шага на самом деле нет, вам не нужно очищать первый чанк памяти, по дэфолту мы считаем что этот чанк пустой после копирования объектов во второй. Ну и при копировании выживших объектов во второй чанк, мы естественно, ставим их один за другим, не создавая фрагментации. Все это позволяет VM аллоцировать множество новых объектов за довольно низкую цену.
Idle Time GC
Как вы понимаете, команды Flutter и Dart VM тесно сотрудничают между собой и результатом этого сотрудничества можно рассматривать Idle Time GC. Как можно понять из названия, это сборка мусора в момент когда ничего не происходит. В контексте Flutter, в момент когда приложение визуально ничего не меняет. Нет никакой анимации, скроллинга и взаимодействия с пользователем. В эти моменты Flutter отправляет сообщения в Dart VM о том, что сейчас, в принципе, неплохой момент чтобы начать сбору мусора. Далее сборщик мусора решает, стоит ли ему начать свою работу. Разумеется, сборка мусора в этом плане происходит для более старых объектов которые управляются через Parallel Mark Sweep, что само по себе довольно дорогой процесс и Idle Time GC в этом плане очень полезный механизм.
Есть еще такие вещи как Sliding Compaction и Compressed Pointers. Первый это механизм дефрагментации памяти после работы Parallel Mark Sweep. Это также дорогой процесс и работает он только если есть Idle Time. Второй подход, Compressed Pointers, сжимает 64-разрядные поинтеры в 32 разряда, что позволяет сэкономить памяти (я думаю это намного полезнее в серверной среде, чем в мобильной).
Итоги
Если вы дочитали до этой строчки, то, во-первых, поздравляю, во-вторых, я должен сказать что у меня нет никакого опыта написания статей, поэтому я не вполне понимаю, получилось ли у меня донести свою мысль. А мысль проста, когда вы пишете мобильное приложение с Flutter, оно получается нативным. А еще в виде бонуса вам прилетает очень приличная скорость разработки приложения. Hot Reload/Restart просто незаменимая вещь во Frontend разработке. Вы можете представить какого-нибудь верстальщика, которому при каждом изменении цвета какой-либо кнопки нужно было бы собирать/компилить весь проект для каждого браузера, например? Конечно нет. Вообще Hot Reload/Restart заслуживает отдельной статьи. Но я отвлекся.
Мой опыт работы с Flutter говорит мне, что этот фреймворк будет доминирующим в ближайшем будущем. Периодически, я прохожу собеседования на позицию Flutter девелопера и в половине случаев, компании, которые ищут Flutter девелопера на самом деле имеют штат мобильных нативных разработчиков. Они просто попробовали Flutter на внутренних/side проектах, остались довольны/в восторге и потихоньку перемещаются к Flutter. Это настоящая победа, как мне кажется. Чего не скажешь о Xamarin, к сожалению. Довольно часто решение выбрать Xamarin обусловлено просто тем, что весь остальной стек написан на .Net, а это скользкий путь.
Подводя итог хочется сказать, что если вы в раздумьях о том, с какого боку подойти к разработке вашего нового мобильного приложения, посмотрите на Flutter.
Автор: Денис Гордин