Привет! Сегодня я поведаю вам историю создания супер-пупер движка для Server Driven UI во Flutter, являющегося составной частью супер-пупер CMS (именно так её создатель, то есть я, её позиционирует). У вас, конечно же, может быть другое мнение и я с удовольствием обсужу его в комментариях.

Эта статья - первая из двух (уже трёх) в цикле. В ней мы рассмотрим непосредственно Nui, а в следующей - то, как глубоко Nui интегрируется - Nanc CMS, а между этой и следующей будет еще одна статья с огромным количеством информации о производительности Nui.

В конце данной статьи вы не найдете никаких ссылок на телеграм-каналы, но будет много интересного про Server Driven UI, возможности Nui (Nanc Server Driven UI) историю проекта, шкурные интересы и Доктора Стренджа**. Ах да, еще будут ссылки на GitHub и pub.dev, так что если вам понравится и будет не жалко 1-2 минут своего времени - буду рад принять звезду в грудь и лайк в ребро.


Оглавление

  1. Интро

  2. Причины разработки

  3. Proof of concept

  4. Синтаксис

  5. IDE-редактор

  6. Производительность

  7. Компоненты и создание UI

  8. Playground

  9. Интерактивность и логика

  10. Передача данных

  11. Документация

  12. Бонус: DivKit

  13. Дальнейшие планы

Небольшое интро

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

Что же такое Nanc? Это CMS общего назначения, которая не тащит с собой свой собственный бэкенд. При этом это и не что-то вроде React Admin, где чтобы создать что-то, надо написать тонны кода. Для того, чтобы начать использовать Nanc достаточно:

  1. Описать структуры данных, которыми вы хотите управлять через CMS с помощью DSL

  2. Написать слой API, реализующий связь CMS с вашим бэком

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

  1. Вы используете Firebase

  2. Или вы используете Supabase

  3. Или вы хотите поиграться и запустить Nanc без привязки к реальному бэку - с локальной базой данных (пока эту роль играет JSON-файлик или LocalStorage)

Таким образом, при некоторых сценариях, вам не придется писать ни строчки кода, чтобы получить CMS для управления любым вашим контентом и данными. В дальнейшем, количество этих сценариев будет увеличиваться, скажем - плюс GraphQL и RestAPI. Если у вас есть идеи, для чего еще можно было бы реализовать SDK - буду рад прочитать предложения в комментариях.

Nanc оперирует сущностями - aka моделями, которые на уровне слоя хранения данных можно представить в виде таблицы (SQL) или документа (No-SQL). У каждой из сущностей есть поля - репрезентация колонок из SQL, либо таких же "полей" из No-SQL.
И одним из возможных типов поля является так называемый тип "Screen". То есть вся эта статья - это текст всего-лишь про одно поле из CMS. При этом архитектурно это выглядит так - есть полностью выделенная библиотека (на самом деле несколько библиотек), которые совместно реализуют Server Driven UI Engine, именуемый Nui. И этот функционал интегрирован в CMS, поверх которого накручено еще много дополнительных возможностей.

На этом вводную часть, посвященную непосредственно Nanc, я заканчиваю и начинаю рассказ про Nui.

С чего все началось

Дисклеймер: Все совпадения случайны, история выдумана и мне это приснилось.

Я работал в одной большой компании сразу над несколькими приложениями. Они были во многом схожи, но было и множество различий.

Но что было в них полностью идентичным - так это, как я его могу назвать, движок статей. Он представлял собой несколько (5-10-15, я уже точно не помню) тысяч строк довольно скомканного кода, обрабатывающего JSON'чики с бэкенда. Эти JSON'чики в конце концов должны были превратиться в UI, а точнее - в статью для прочтения в мобильном приложении.

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

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

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

Если последнее - разработчик всегда присутствовал на созвонах в хорошем настроении, а запах... – запах камера не передает.

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

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

Глядя на все это меня озарила невероятная мысль - а не сделать ли общее решение этой проблемы? Такое решение, которое позволит больше не растить монстра и стачивать клавиши для расширения админки и приложения на каждый новый элемент, а вот так, чтобы раз - и навсегда! И вот тут на сцену приходит...

Шкурный интерес

Моя шкура
Моя шкура

Я подумал - "я могу решить эту проблему. я могу сэкономить компании многие десятки, если не сотни тысяч денег; но идея может быть слишком ценной для компании, чтобы просто так её подарить".

Под подарком я имею в виду то, что соотношение потенциального value для компании отличается от того, что компания заплатит мне в виде зарплаты на порядки. Это как если бы вы пошли работать в стартап на начальной стадии, но за ЗП меньше, чем вам предлагают в каком-нибудь крупняке, и без доли в компании. А потом стартап становится единорогом, а вам говорят - "ну чувак, мы тебе зарплату платили". И ведь были бы правы!

Я люблю аналогии, но мне часто говорят, что они - не являются моей сильной стороной. Это как рыбке нравится плавать в океане, но она - телапия.

И тогда - я решил сделать proof of concept (POC), в свое свободное время, чтобы не обкакаться, предлагая какие-то там идеи, которые реализовать то не факт что получится.

Proof of concept

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

Я потратил, вот прям буквально, 40 часов, считая с вечера пятницы и до утра понедельника, на то, чтобы проверить эту гипотезу - насколько эта библиотека расширяема для новых возможностей, насколько все вообще работает хорошо, и главное - способно ли это решение быть им в задаче свержения с трона пресловутого движка. Гипотеза подтвердилась - после разбора библиотеки по косточкам и небольшого патчинга, появилась возможность регистрации любых UI-элементов по ключевым словам или конструкциям, это все можно было несложно расширять, и самое главное - это действительно могло бы заменить движок статей. К этому состоянию, на самом деле, я пришел где-то через 15 часов. Остальные 25 я потратил на финализацию POC.

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

Для целей POC, я посчитал, что было бы достаточно сделать просто редактор. Выглядел он примерно вот так:

Редактор UI
Редактор UI

Спустя 40 часов я имел - работающий редактор кода, состоящего из бурной смеси markdown и кучи кастомных xml-тегов (например - <container>), превью, отображающее UI из этого кода в реальном времени, а также самые большие мешки под глазами, которые только видел этот мир. Также стоит отметить, что используемый "редактор кода" - еще одна библиотека, умеющая в подсветку синтаксисов, но вот беда - в подсветку markdown она может, в подсветку xml - тоже, а подсветка из смеси ежа и ужа постоянно ломается. Так что к 40 часам можно добавить еще парочку на манки-кодинг химеры, которая обеспечит подсветку и того и другого в одном флаконе. Самое время спросить - что было дальше?

Первое демо

Давно не смотрел этих молчунов производства LABELCOM, но и у нас не юмористическое шоу, поэтому отвечаем серьезно. Дальше было демо. Я собрал пару вышестоящих менеджеров, объяснил им свое видение решения проблемы, то, что я это видение подтвердил на практике, и показал что и как работает, и какие возможности у этого есть.
Работа ребятам понравилась. И было желание использовать. Но был и зуд. Зуд шкуры. Моей шкуры. Не мог же я взять и просто так подарить это компании? Конечно нет. Но я и не планировал. Демо было частью дерзкого плана, в ходе которого я шокирую их своей поделкой, они просто не могут устоять и готовы будут пойти на любые условия, лишь бы использовать эту невероятно, исключительно и изумительную приблуду. Не буду раскрывать всех деталей этой выдуманной (!) истории, но скажу лишь, что я хотел денег. Денег и отпуск. Оплачиваемый отпуск в один месяц, а также денег. Сколько именно денег - не так важно, важно лишь, что число денег коррелирует с моей зарплатой и цифрой 6. Но я не был совсем уж обезбашенным дерзилой. Я пришел договориться!** И договор был в следующем - я работаю полные две недели в своем режиме (спишь 4 часа, 20 часов работаешь), допиливая POC до состояния "можно использовать для наших целей", и паралелльно с этим реализую новую фичу в приложении - целый экран, с помощью этой убер-пупы (на который эти две недели изначально и отводились). А по окончанию двух недель мы проводим еще одно демо. Только собираем на него уже побольше народу, можно даже руководство компании, и если увиденное их впечатлит, и они захотят это использовать - сделка выполнена, я получаю свои хочушки, компания - супер-пушку. Если же они ничего из этого не захотят - я готов на то, что эти две недели я работал бесплатно.

Pedra Furada
Pedra Furada

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

Хотя нет...кажется есть: 1, 2, 3, 4, 5.

Посмотрев все эти фильмы, я решил что это знак! И что так даже лучше - обидно продавать столь многообещающую разработку за какие-то там плюшки (кого я обманываю???) и я продолжу и дальше разрабатывать свой проект. И продолжил. Но уже не по 40 часов за выходные, но часов 15-20 в неделю, в относительно спокойном темпе.

Будет уже про код или нет?

Ломать 4ю стену - непростая задача. Ровно как и пытаться придумывать интересные заголовки, которые заставят читателя продолжать чтение и ждать, чем же закончится история с компанией. Историю я завершу во второй статье. А сейчас, кажется, пора переключиться на реализацию, функциональные возможности, и вот это всё, что, по идее, и должно сделать эту статью технической, а Хабр - тортом!

Синтаксис

Первое, о чем мы поговорим - это синтаксис. Изначальная идея со скрещиванием ежей и ужей была пригодной для POC, но как показала практика - markdown не то чтобы прям сильно простой. Плюс совмещение каких-то родных markdown-элементов с сугубо-Flutter'овскими (есть же такое слово? есть?) - не то чтобы прям консистентное.

Самый первый вопрос - картинка будет ![Описание](Ссылка) или же <image>? Если первое - куда пихать кучу параметров? Если второе - зачем тогда первое? Вопрос второй - тексты. Возможности Flutter по стилизации текстов буквально безграничные. Возможности markdown - ну такое. Да, можно пометить текст жирным или курсивом, и даже были мысли использовать эти конструкции ** / __ для стилизации. Потом были мысли пихать посреди <color="red"> текста </color> теги, но это такая кривота и крипота что кровь из глаз идет. Получить некое подобие HTML, со своим маргинальным синтаксисом совсем не хотелось. Плюс задумка была такой, что этот код смогут писать даже менеджеры без технических знаний.

Шаг за шагом, из химеры удалили ужа, и получили markdown-ежа. То есть получилась пропатченная библиотека для отрисовки markdown, но напичканная кастомными тегами и без поддержки markdown. То есть как бы получился XML.

Я сел думать и экспериментировать, какие еще есть простые синтаксисы? JSON - шлак. Заставлять человека писать JSON в кривом Flutter-редакторе - это получить маньяка, который будет хотеть тебя убить. Да и дело не только в этом, мне в принципе не кажется, что JSON пригоден для набора человеком, особенно для UI - он все время растет вправо, куча обязательных "", нет комментариев. YAML? Нууу может быть. Но тоже ведь код будет ползти вширь. Из интересного есть ссылки, но многого с их только помощью не добьешься. TOML? Пфф.

Окей, остановился я, все таки, на XML. Мне показалось, да и сейчас все еще кажется, что это довольно "плотный" синтаксис, очень неплохо подходящий для UI. В конце концов HTML-верстальщики до сих пор существуют, а тут все будет даже проще чем в вебе (наверное).

Дальше встал вопрос - было бы неплохо получить возможность какой-то подсветки / дополнения кода. А также логических конструкций, а-ля {{ user.name }}. Тогда я начал экспериментировать с Twig, Liquid, смотрел и какие-то другие шаблонизаторы, которые я уже и не помню. Но столкнулся с другой проблемой - вполне можно реализовать часть задуманного на стандартном движке, скажем, Twig, но всё реализовать точно не получится. И да, хорошо, что будет автодополнение и подсветка, но они будут только мешать, если поверх стандартного Twig накрутить своих новых фич, которые будут нужны для Flutter. В итоге - с XML все получалось очень даже неплохо, эксперименты с Twig / Liquid не давали каких-то выдающихся результатов, а в определенные моменты я даже упирался в невозможность реализации некоторых фич. Поэтому выбор так и остался за XML. О фичах мы еще поговорим, а сейчас давайте остановимся на автодополнении и подсветке, которые так подкупали в Twig / Liquid.

IDE

Первое, что хочется сказать - во Flutter реально кривые текстовые инпуты. Они хорошо работают в мобильном формате. Хорошо и в десктопном, когда речь идет о чем-то, ну максимум на 5-10 строк в высоту. Но когда речь идет о полноценном редакторе кода, где этот редактор реализован на Flutter - без слез не взглянешь. В Trello, где я веду учет всех задач, пишу заметки и идеи, есть вот такая "тасочка":

Задача на смену редактора UI кода
Задача на смену редактора UI кода

По сути, практически с самого начала работы над проектом я держал в голове идею замены редактора Nui-кода на что-то более адекватное. Скажем - встраивать web view с Open Source частью от VS Code. Но пока руки до этого так и не дошли, к тому же, в голову пришло, хоть и костыльноватое, но все же рабочее решение проблемы кривоты этого редактора - использовать вместо него свою собственную среду разработки.

Достигается это следующим образом - создаем файл с "версткой", в идеале с расширением .html / .twig, открываем этот же файл через CMS - Web / Desktop / Локальная / Задеплоенная - не важно. И открываем этот же файл через любую IDE, хоть через web-версию VS Code. И вуаля - вы можете редактировать этот файл в любимом инструменте, и иметь предпросмотр в реальном времени прямо в браузере или где угодно.

Nanc + IDE Sync
Nanc + IDE Sync

В таком сценарии даже можно докрутить полноценное автодополнение. В VS Code есть возможность реализации оного через кастомные HTML-теги. Однако, я не пользуюсь VS Code, мой выбор - IntelliJ IDEA и для этой IDE такого простого решения уже нет (ну по крайней мере не было, или по крайней мере я его не нашел). Но есть более общее решение, сработающее и там и там - XML Schema Definition (XSD). Я потратил где-то 3 вечера на то, чтобы разобраться с этим монстром, но успех так и не пришел, и в итоге я забросил это дело, оставив до лучших времен.

Еще интересно то, что в итоге, после множества итераций экспериментов, обновлений, скажем так, движка, отвечающего за преобразование XML в виджеты, получилось такое решение, для которого язык то особо и не важен. Просто в качестве носителя информации о структуре вашего UI в итоге выбор пал на XML, но при этом, спокойно можно скармливать ему и JSON, и даже бинарную форму - "скомпилированный Protobuf". И это подводит нас к следующей теме.

Производительность

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

Nalmart Screen Demo
Nalmart Screen Demo

то было необходимо создать точно такой же экран нативно (в контексте Flutter, разумеется). В итоге это заняло больше 3х недель, очень много переписываний одного и того же, улучшений процесса тестирования, и получения все более и более интересных цифр. А размер только этого раздела перевалил за 3000 слов. Поэтому я пришел к мысли, что имеет смысл написать отдельную статью, которая будет целиком и полностью посвящена производительности Nui, как частности, и той дополнительной цене, которую вам придется заплатить, если вы решите использовать Server Driven UI, как подход.

Но я сделаю небольшой спойлер: было два главных сценария оценки производительности, которые я рассматривал - время первичного рендеринга. Оно важно, если вы решили реализовать целый экран на Server Driven UI, и этот экран будет где-то в вашем приложении открываться. Так вот если этот экран очень тяжелый, то даже нативный Flutter экран будет долго отрисовываться, поэтому при переходе на такой экран, особенно если этот переход сопровождается анимацией, будут видны тормоза. Второй сценарий - время кадра (FPS) при динамическом изменении UI. Изменились данные – нужно перерисовать какой-то компонент. Вопрос в том, как сильно это повлияет на время отрисовки, повлияет ли это настолько, что при обновлении экрана пользователь будет видеть лаги? А вот и спойлер - в большинстве случаев вы не сможете узнать, что экран, который вы видите, полностью реализован на Nui. Если вы встроите Nui-виджет в обычный, нативный Flutter-экран (скажем, какая-то область экрана, которая должна очень динамично изменяться в приложении) - вы гарантированно не сможете этого распознать. Просадки в производительности, конечно же, есть. Но они такие, что не влияют на FPS даже при частоте кадров на уровне 120FPS - то есть время одного кадра практически никогда не превысит 8ms. Это справедливо для второго сценария. Касательно же первого - все зависит от уровня сложности экрана. Но даже здесь разница будет такой, что не повлияет на восприятие и не заставит ваше приложение быть бенчмарком для пользовательских смартфонов.

Ниже показаны три записи с экрана Pixel 7a (Tenso, частота обновления экрана была установлена в 90 кадров (максимум для этого девайса), частота записи видео - 60 кадров в секунду (максимум для настроек записи). Каждые 500мс происходит рандомизация положения элементов в списке, из данных которого строятся первые 3 карточки, а еще через каждые 500мс происходит переключение статуса заказа в последующий.
Сможете ли вы угадать, какой из этих экранов реализован полностью на Nui?

P.S. Скорость загрузки изображений не зависит от реализации, так как на этом экране, при любой реализации, очень много svg-изображений - все иконки, а также логотипы брендов. Лежат все svg (как и обычные картинки) на GitHub, как хостинге, поэтому могут грузиться довольно долго, что и наблюдается в некоторых экспериментах.

Variant 1
Variant 2
Variant 3

P.S. Если честно, я просмотрел эти видео несколько раз, и они все кажутся мне немного тормознутыми. Возможно дело в том, что они всего в 60FPS, а реальная частота кадров была 90. Поэтому в следующей статье я буду записывать видео-сравнения вживую.

Доступные компоненты - как создавать UI

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

То же применимо и к параметрам виджетов - как скалярным, вроде String, int, double, enum и т.д., что, как параметр, не настраивается само и в рамках Nui называется argument (аргументы). Так и к сложным параметрам-классам, вроде decoration у виджета Container, называемым property (свойства). Это правило не абсолютно, посколько некоторые свойства ну уж слишком многословны, поэтому их названия были упрощены. Также, для некоторых виджетов был расширен список доступных параметров. Например - сделать квадратный SizedBox или Container можно передав всего один кастомный параметр size, вместо двух одинаковых width + height.

Не буду приводить полный список реализованных виджетов, так как их довольно много (53 данный момент). Кратко - реализовать можно практически любой UI, для которого бы имело смысл применять Server Driven UI как подход в принципе. Включая сложные эффекты скроллинга, связанные со Slivers.

Gif со списком реализованных виджетов
Реализованные виджеты
Реализованные виджеты

Также, касательно компонентов стоит отметить и следующее - entrypoint или виджет, в который и предстоит передавать облачную верстку. На данный момент таких виджетов два - NuiListWidget и NuiStackWidget.

Первый, по задумке, стоит использовать если необходимо реализовать целый экран. Под капотом это CustomScrollView, содержащий все виджеты, которые будут разобраны из исходного кода верстки. При этом разбор, можно сказать, "интеллектуальный": так как содержимым CustomScrollView должны являться slivers, то возможным решением было бы обернуть каждый из виджетов в потоке в SliverToBoxAdapter, но это бы крайне негативно сказалось на производительности. Поэтому виджеты встраиваются в своего родителя следующим образом - начиная с самого первого мы идем вниз по списку до тех пор, пока не встретим настоящий sliver. Как только мы встретили sliver - все предыдущие виджеты мы добавляем в SliverList, и уже его добавляем в родительский CustomScrollView. Таким образом производительность именно отрисовки всего UI будет максимально возможной, так как количество именно slivers будет минимальным. Почему же плохо иметь очень много slivers в CustomScrollView? Ответ здесь.

Второй виджет - NuiStackWidget тоже может быть использован как полноценный экран - в таком стоит иметь в виду, что все, что вы создаете, в таком же порядке будет встроено в Stack. А также будет необходимо явным образом использовать slivers - то есть, если хотите список slivers - придется добавить CustomScrollView и уже внутри него реализовывать список.
Второй же сценарий - реализация маленького виджета, который может быть встроен в нативные компоненты. Скажем - сделать некую карточку товара, которая будет полностью настраиваемой по инициативе сервера. Видится очень интересным сценарий, в котором можно реализовывать все компоненты в библиотеке компонентов с помощью Nui, и использовать их как обычные виджеты. При этом всегда будет возможность полностью изменить их без обновления приложения.

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

Вот как выглядит counter app, если бы он создавался с помощью Flutter:

Counter App
import 'package:flutter/material.dart';
import 'package:nui/nui.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Nui App',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Nui Demo App'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    required this.title,
    super.key,
  });

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: NuiStackWidget(
          renderers: const [],
          imageErrorBuilder: null,
          imageFrameBuilder: null,
          imageLoadingBuilder: null,
          binary: null,
          nodes: null,
          xmlContent: '''
<center>
  <column mainAxisSize="min">
    <text size="18" align="center">
      You have pushed the button\nthis many times:
    </text>
    <text size="32">
      {{ page.counter }}
    </text>
  </column>
</center>
''',
          pageData: {
            'counter': _counter,
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

А вот еще один пример, только полностью на Nui (включая логику):

Counter App with logic
import 'package:flutter/material.dart';
import 'package:nui/nui.dart';

void main() {
  runApp(const MyApp());
}

final DataStorage globalDataStorage = DataStorage(data: {'counter': 0});

final EventHandler counterHandler = EventHandler(
  test: (BuildContext context, Event event) => event.event == 'increment',
  handler: (BuildContext context, Event event) => globalDataStorage.updateValue(
    'counter',
    (globalDataStorage.getTypedValue<int>(query: 'counter') ?? 0) + 1,
  ),
);

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return DataStorageProvider(
      dataStorage: globalDataStorage,
      child: EventDelegate(
        handlers: [
          counterHandler,
        ],
        child: MaterialApp(
          title: 'Nui App',
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
            useMaterial3: true,
          ),
          home: const MyHomePage(title: 'Nui Counter'),
        ),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    required this.title,
    super.key,
  });

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: NuiStackWidget(
          renderers: const [],
          imageErrorBuilder: null,
          imageFrameBuilder: null,
          imageLoadingBuilder: null,
          binary: null,
          nodes: null,
          xmlContent: '''
<center>
  <column mainAxisSize="min">
    <text size="18" align="center">
      You have pushed the button\nthis many times:
    </text>
    <dataBuilder buildWhen="counter">
      <text size="32">
        {{ data.counter }}
      </text>
    </dataBuilder>
  </column>
</center>

<positioned right="16" bottom="16">
  <physicalModel elevation="8" shadowColor="FF000000" clip="antiAliasWithSaveLayer">
    <prop:borderRadius all="16"/>
    <material type="button" color="EBDEFF">
      <prop:borderRadius all="16"/>
      <inkWell onPressed="increment">
        <prop:borderRadius all="16"/>
        <tooltip text="Increment">
          <sizedBox size="56">
            <center>
              <icon icon="mdi_plus" color="21103E"/>
            </center>
          </sizedBox>
        </tooltip>
      </inkWell>
    </material>
  </physicalModel>
</positioned>
''',
          pageData: {},
        ),
      ),
    );
  }
}
Отдельно UI-код, чтобы была подсветка
<center>
  <column mainAxisSize="min">
    <text size="18" align="center">
      You have pushed the button\nthis many times:
    </text>
    <dataBuilder buildWhen="counter">
      <text size="32">
        {{ data.counter }}
      </text>
    </dataBuilder>
  </column>
</center>

<positioned right="16" bottom="16">
  <physicalModel elevation="8" shadowColor="black" clip="antiAliasWithSaveLayer">
    <prop:borderRadius all="16"/>
    <material type="button" color="EBDEFF">
      <prop:borderRadius all="16"/>
      <inkWell onPressed="increment">
	    <prop:borderRadius all="16"/>
        <tooltip text="Increment">
          <sizedBox size="56">
            <center>
              <icon icon="mdi_plus" color="21103E"/>
            </center>
          </sizedBox>
        </tooltip>
      </inkWell>
    </material>
  </physicalModel>
</positioned>
Gif с UI
Nui Counter App with logic
Nui Counter App with logic
Nui Counter App
Nui Counter App

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

Nanc playground

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

Сделать это можно перейдя по ссылке, а затем кликнув по полю Page Interface / Screen. Открывшийся экран и можно будет использовать в качестве Playground, а по нажатию на кнопку Sync синхронизировать Nanc с вашей IDE посредством файла с исходниками, а вся документация доступна по нажатию кнопки Help.

P.S. эти сложности связаны с тем, что я так и не нашел времени на то, чтобы сделать явную отдельную страницу с документацией по компонентам в Nanc, а также отсутствием возможности вставить прямую ссылку на эту страницу.

Интерактивность и логика

Было бы слишком бессмысленно создавать обычный маппер XML на виджеты. Это, конечно, тоже может быть полезным, но кейсов применения будет намного меньше. То ли дело - полностью интерактивные компоненты и экраны, с которыми можно взаимодействовать, которые можно гранулярно обновлять (то есть не все и сразу - а по частям, которые и нуждаются в обновлении). Также, этот UI нуждается в данных. Которые, с учетом наличия буквы S в фразе Server Driven UI, можно подставлять прямо в верстку на сервере, но можно же делать и красивее. Да и не таскать на каждое изменение в UI новую порцию верстки с бэка (Nui - не машина времени, привносящая лучшие практики JQuery во flutter).

Начнем с логики: в верстку можно подставлять переменные и вычисляемые выражения. Скажем, виджет, определенный как <container color="{{ page.background }}"> будет извлекать свой цвет непосредственно из переданных в "родительский контекст" данных, хранящихся в переменной background. А <aspectRatio ratio="{{ 3 / 4}}"> задаст соответствующее значение соотношения сторон для своих потомков. Есть встроенные функции, сравнение и многое другое, что можно использовать для построения UI с применением некоторой логики.

Второй пункт - шаблонизация. Вы можете определить свой собственный виджет прямо в UI коде посредством тега <template id="your_component_name"/>. При этом все внутренние компоненты данного шаблона будут иметь доступ к передаваемым в этот шаблон аргументам, что позволит гибко параметризировать кастомные компоненты и затем переиспользовать их с помощью тега <component id="your_component_name"/>. Внутрь шаблонов можно передавать не только атрибуты, но и другие теги / виджеты, что дает возможность создавать переиспользуемые компоненты любой сложности.

Пункт третий - "форычи". В Nui есть встроенный тег <for> позволяющий использовать перечисления, чтобы отрисовать один и тот же (или множество) компонент несколько раз. Это удобно, когда есть набор данных, из которых надо создать список / строку / колонку виджетов.

Четвертый - условная отрисовка. На уровне верстки реализован тег <show> (была мысль назвать его <if>), который позволяет рисовать вложенные компоненты, или вообще не встраивать их в дерево по различным условиям.

Пункт пятый - экшены. Некоторые компоненты, с которыми пользователь может взаимодействовать, умеют отправлять события. Которыми вы можете полностью, как угодно управлять. Скажем, <inkWell onPressed="something"> - при таком объявлении данный виджет становится кликабельным, а ваше приложение, а вернее, некий EventHandler, сможет обработать этот ивент и сделать что-то. По задумке, все, что касается логики - должно быть реализовано непосредственно в приложении, но реализовать можно что-угодно. Сделать некие generic-обработчики, которые могут обрабатывать группы действий, вроде "переход к экрану" / "вызов метода" / "отправка аналитического события". В планах есть реализация и динамического кода, но тут есть свои нюансы. Для Dart есть способы исполнения произвольного кода, но это влияет на производительность, а кроме того, интероперабельность этого кода с кодом приложения едва ли будет 100%. То есть, создавая логику в этом динамическом коде вы постоянно будете сталкиваться с какими-то ограничениями. Поэтому данный механизм нуждается в очень тщательной проработке, чтобы быть действительно применимым и полезным. К слову - не так давно вышла интересная статья на эту тему.

Шестой пункт - локальное обновление UI. Это возможно благодаря тегу <dataBuilder>. Этот тег (Bloc под капотом) может "смотреть" на определенное поле, и при его изменении будет перерисовывать свое поддерево. Работает это следующим образом (щас будет лекция на 20 минут).

Данные

Изначально я пошел по пути двух сторов для данных - "родительский контекст", упомянутый выше. А также "data" - данные, которые можно определить прямо в UI, посредством тега <data>. Честно говоря, я не могу сейчас вспомнить аргументацию, почему было нужно реализовывать два способа хранения и передачи данных в UI, но и не могу как-то жестко себя же за такое решение покритиковать.

Работают они следующим образом - "родительский контекст" является объектом типа Map<String, dynamic>, передаваемым непосредственно в виджеты NuiListWidget / NuiStackWidget. Доступ к этим данным возможен по префиксу page - <something value="{{ page.your.field }}"/>. Обращаться можно к чему угодно, на какую угодно глубину, в том числе к массивам - {{ page.some.array.0.users.35.age }}. Если данного ключа / значения не будет, получите null. По спискам можно итерироваться с помощью <for>.

Второй же способ - "data" является глобальным хранилищем данных. На практике это некий Bloc, размещенный на более высоком уровне в дереве, чем NuiListWidget / NuiStackWidget. При этом ничто не мешает организовать их использование в локальном стиле, передавая свой собственный инстанс DataStorage посредством DataStorageProvider.

При этом, первый способ не является реактивным - то есть при изменении данных в page никакой UI обновляться сам не будет. Так как это, по сути, просто аргументы вашего StatelessWidget. Если же источником данных для page будет, скажем, ваш собственный Bloc, который будет отдавать набор значений Nui...Widget - то как и с обычным StatelessWidget он будет полностью перерисован с новыми данными.

Второй способ для работы с данными - реактивный. Если изменить данные в DataStorage, используя API данного класса - метод updateValue то это вызовет метод emit Bloc-класса, и если в вашем UI будут активные слушатели этих данных - теги <dataBuilder> то их содержимое будет соответствующим образом изменено, но остальная часть UI не будет тронута.

Таким образом мы получаем два потенциальных источника данных - очень простой page, и реактивный data. Кроме логики обновления данных в этих источниках и реакции UI на эти обновления, никакой разницы между ними нет.

Документация

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

Кратко перечислю некоторые возможности, не охваченные в данной статье, но доступные вам:

  • Создание своих собственных тегов / компонентов, с возможностью создания для них точно такой же интерактивной документации, ровно как и для их аргументов и свойств с live preview. Именно таким образом реализован, например, компонент для отрисовки SVG-изображений. Нет никакого смысла пихать его в ядро движка, потому что он нужен далеко не всем, но как расширение, доступное к использованию при передаче всего одной переменной - легко и просто. Непосредственно - пример реализации.

  • Огромная встроенная библиотека иконок, которые можно расширять, добавляя свои собственные (тут я оказался непоследовательным, и "запихал", логика была такой, чтобы сделать доступными к использованию сразу как можно больше иконок и не было необходимости обновлять приложение, чтобы использовать новые иконки). Из коробки доступны: fluentui_system_icons, material_design_icons_flutter и remixicon. Просмотреть все доступные иконки можно с помощью Nanc, Page Interface / Screen -> Icons

  • Кастомные шрифты, включая Google Fonts из коробки

  • Преобразование XML в Json / Protobuf и использование уже их в качестве "исходников" для UI

Все это и многое другое можно изучить в документации.

Яндекс's DivKit

Я начал работу над Nanc осенью 2022 года, и каково же было мое удивление, когда я случайно обнаружил, где-то спустя пол года с начала работ, что Яндекс уже зарелизил что-то подобное. У меня были (очень мимолетные, но все таки они были) мысли про то, не стоит ли забросить то, что я делаю. Но довольно быстро я пришел к выводу, что "нет". Как минимум - я должен довести проект "до конца" и эта статья является определенной точкой (одной из трех, повторюсь), в этом финале. Как максимум - я должен завиралить это решение, потому что считаю его крайне подходящим для определенного спектра задач. Можно рассматривать это как Open Source, Free, Software, созданное одним человеком, с амбициями превратить это в продукт, который будет востребован. Время покажет. Но это было лирическое отступление.

В общем я продолжал идти к намеченной цели - сделать Nanc + Nui пригодными для использования в production не только мной лично, к этой цели я пришел, и встала задача дать знать людям, что есть вот такое решение - я начал писать эту статью. Суммарно, от начала и до настоящего момента прошло 4 недели / 50-60 часов, и тут произошла забавная ситуация - Яндекс написал статью-продолжение про то, что DivKit вышел и для Flutter. И тут мне стало прям очень интересно. Прямой конкурент от компании-гиганта с командой (по любому) из десятка-другого разработчиков.

И честно говоря, я не впечатлился. На этот раз у меня не появилось даже и мимолетной мысли, что я делаю Nanc / Nui зря. Если поставить себя на место инженера, которому нужно реализовать фичу с Server Driven UI, то оценивая DivKit, первое, что мне бы претило - полностью "свой" нейминг. Попытка скрестить все три платформы с их разными, как минимум, названиями, как максимум - логикой построения layout, ведут к тому, что созданный инструмент будет одинаково неудобен для разработчиков всех трех платформ. Второе - невнятная оценка производительности, но есть очень веселые комментарии от пользователей. Третье - кажется, что набор реализованных компонентов настолько мал, насколько он достаточен для самого Яндекса. И я, как тот, кому нужно что-то сделать на DivKit, буду должен переносить всю библиотеку виджетов из Flutter в DivKit? Неплохой ход - доработать свое решение с помощью сообщества (привет, народная карта).

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

Всё, написанное в этой главе - просто мысли завистника. И я действительно завидую - мне бы такую команду маркетинга, что соберет 26 лайков при 0 комментариев (уже 3). А на самом деле DivKit - топ ?.

Что дальше?

Главное - это проработка возможности динамически исполнять код с логикой. Это очень крутая фича, которая позволит очень серьезно расширить возможности Nui. Также, можно (и нужно) добавить оставшиеся редко-используемые, но иногда очень важные виджеты из стандартной библиотеки Flutter. Осилить XSD, чтобы в IDE появилось авто-дополнение по всем тегам (есть мысль генерировать эту схему напрямую из документации тегов, тогда можно будет и для кастомных виджетов её легко создавать и она всегда будет актуальной, а еще есть мысль сделать генерируемый DSL на Dart, который затем можно будет преобразовывать в XML / Json / Protobuf). Ну и дополнительная оптимизация производительности - она неплохая уже сейчас, очень неплохая, но может быть еще лучше, еще ближе к нативному Flutter.

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

Если вам стало интересно попробовать Nui или узнать поближе - прошу к документационному столу. А также, если не затруднит, то прошу поставить звезду на GitHub и лайк на pub.dev - вам не сложно, а мне, одинокому гребцу на этой огромной лодке - невероятно полезно.

P.S. Поскольку статья писалась в течение почти месяца, со временем оригинальный стиль "хихи-хаха" немного сместился в более серьезный уклон, возможно даже немного депрессивный. Судя по всему на это влияет состояние человека в конкретный момент времени. Однако мне интересно ваше мнение касательно стиля, и касательно Server Driven UI, поэтому ниже будет несколько опросов.

Комментарии (4)


  1. romanitalian_net
    24.05.2024 06:36

    Полезно! В закладки.


  1. SergeiShaikin
    24.05.2024 06:36

    Очень захватывающая история. Читал не отрываясь. Спасибо!


  1. realbot
    24.05.2024 06:36
    +2

    "В конце концов HTML-верстальщики до сих пор существуют" нуну )

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

    Картинка конечно смешная, но ситуация страшная. Видимо совсем магазины заматали нативщиков.

    Вангую что астрологи предскажут неделю новых react-программистов из перегоревших на BDUI мобильных нативщиков


    1. alphamikle Автор
      24.05.2024 06:36

      Есть целые миры разработки, которые не имеют даже близко чего-то общего. В одной крайности до сих пор перемещают DOM на экране с помощью jQuery, получая за это 30-40 тысяч рублей в месяц. А в другой - разработчики не будут даже открывать полное описание вакансии, если где-то в сообщении от рекрутера проскочит слово на j. Так что, я думаю, и чистые верстальщики до сих пор существуют.

      Касательно стоашности - безусловно я бы лично предпочёл обновить код приложения, дождаться отработки CI/CD и начать считать количество пользователей с новой фичей, но есть нюанс - даже если бы сторы делали ревью моментально - далеко не все пользователи обновляют приложения. Их можно заставлять, блокируя старые версии, но вы же не будете блокировать сразу предыдущую версию, при выпуске новой - есть риск, что юзер просто перестанет пользоваться приложением. И, по моему опыту, это главная причина, почему Server Driven UI имеет смысл - показывать новый контент всем пользователям сразу.