Полтора года назад мы выпустили в опенсорс DivKit — фреймворк для отрисовки интерфейсов из ответа сервера. На тот момент он уже прошёл проверку временем внутри компании и применялся в приложении Яндекс, Алисе, Маркете, Едадиле и других сервисах. С тех пор инструмент прошёл длинный путь. И сегодня у нас по-настоящему важная новость: мы выпускаем в свободный доступ долгожданный клиент для Flutter.
В статье расскажем об особенностях вёрстки в DivKit и нашей реализации UI. Вы узнаете, какие фичи и компоненты Flutter поддерживаются во фреймворке на текущий момент. Покажем, как начать пользоваться клиентом уже сейчас.

Что и как мы можем рисовать и делать
Рассмотрим фичи и компоненты детально. На всех схемах оранжевым будет обозначен presentation, зелёным — domain, а синим — data. Основные протоколы экспортированы и находятся в divkit/lib/src/core/protocol/…
Рендеринг
Рендеринг в DivKit имеет следующие особенности:
-
Графический элемент разделён на две части: базовую (общую для всех компонентов) и специфическую.
-
Графический компонент инициализирует модель на основе данных DTO и подписывается на её стрим обновления. Отрисовывает по полученным данным.
-
В самой модели происходят асинхронные вычисления свойств в виде выражений, в которых могут использоваться ранее заданные переменные. Графические элементы через их модели зависят от значений переменных.
-
Рисуем все компоненты асинхронно, по мере доступности данных. Реактивно перерисовываем компоненты только при изменении свойств.
Особенности вёрстки в DivKit и нашей реализации UI
Протокол DivKit позволяет клиентам собирать блоки UI любой сложности от простых текстовок до полноценных экранов. Вёрстка основана на вложенности элементов с помощью элемента container, реализацией которого во Flutter являются flex, wrap и stack в зависимости от свойств расположения потомков в контейнере.
Свойства компонентов поддерживают три вида размеров — fixed, match-parent, wrap-content. Для Flutter эти категории оказались не самыми подходящими, однако для задания размеров компонентов мы смогли обойтись стандартными виджетами: SizedBox, Flexible и Expanded. Для правильного позиционирования и изменяемых размеров (match-parent, wrap-content) стали оборачивать их в row, column и stack.
Расширяя функциональность библиотеки и поддерживая всё новые и новые свойства, мы столкнулись с плохой совместимостью API коробочных виджетов и тем, что есть у DivKit на нативных платформах. Например, BorderSide во Flutter считается внутри размера компонента, а в нативе — снаружи. Далеко не все подобные моменты задокументированы и нашли своё решение, поэтому мы будем очень благодарны сторонним разработчикам, которые будут участвовать в жизни библиотеки и вносить свой вклад в исправление таких багов.
Стандартные компоненты
В самом DivKit есть достаточно обширный набор стандартных компонентов, позволяющий верстать практически всё. Сейчас доступны только самые базовые и основные:

Они не на 100% реализуют спецификацию DivKit. Чтобы узнать, что именно поддерживается, можно посмотреть в справочнике элементов в доке.
Работа с кастомными компонентами
Если вам не хватает стандартных компонентов, можно добавить свои. Для этого в DivKit есть механизм кастомов.
Для обработки кастомных компонентов с помощью Flutter-клиента можно реализовать интерфейс DivCustomHandler:
Код
/// Handles specific div-custom. Creates Flutter Widget from custom model abstract class DivCustomHandler { /// Returns TRUE if custom widget can be handled. /// [type] — DivCustom.customType, custom alias to handle. Bool isCustomTypeSupported(String type); /// Returns Widget to use for div-custom. /// [div] — div-custom model. Widget createCustom(DivCustom div); factory DivCustomHandler.none() => _DefaultDivCustomHandler(); } class PlaygroundAppCustomHandler implements DivCustomHandler { late final factories = { 'new_custom_card_1': _createCustomCard, 'new_custom_card_2': _createCustomCard, 'new_custom_container_1': _createCustomContainer, }; @override Widget createCustom(DivCustom div) { final child = factories[div.customType]?.call(div) ?? (throw Exception( 'Unsupported DivCustom with custom_type: ${div.customType}')); return child; } @override bool isCustomTypeSupported(String type) => factories.containsKey(type); Widget _createCustomCard(DivCustom div) { const gradientColors = [ -0xFF0000, -0xFF7F00, -0xF00F00, -0x00FF00, -0x0000FF, -0x2E2B5F, -0x8B00FF, ]; return Material( child: Container( width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomLeft, end: Alignment.topRight, colors: gradientColors.map((item) => Color(item)).toList(), ), ), alignment: Alignment.centerLeft, padding: const EdgeInsets.only( left: 12, top: 12, right: 12, bottom: 12, ), child: _ChronographWidget(), ), ); } Widget _createCustomContainer(DivCustom div) { return Column( children: _createItems(div.items ?? []), ); } List<Widget> _createItems(List<Div> items) { return items.map((item) => DivWidget(item)).toList(); } } class _ChronographWidget extends StatefulWidget { @override State<StatefulWidget> createState() => _ChronographState(); } class _ChronographState extends State<_ChronographWidget> { late final timer = Timer.periodic( const Duration( seconds: 1, ), (timer) { if (mounted) { setState(() {}); } else { timer.cancel(); } }, ); @override Widget build(BuildContext context) => Text( '${(timer.tick ~/ 60).toString().padLeft(2, '0')}:${(timer.tick % 60).toString().padLeft(2, '0')}', style: const TextStyle( fontSize: 20, color: Colors.black, backgroundColor: Colors.transparent, ), ); }
Собственную реализацию CustomHandler к DivKitView можно подключить так:
DivKitView( data: data, customHandler: MyCustomHandler(), )
isCustomTypeSupported и createCustom будут вызываться последовательно во время отрисовки UI, а если компонент оказывается неподдерживаемым — библиотека по умолчанию ничего не нарисует, но можно включить placeholder с помощью флага showUnsupportedDivs у DivKitView.
Модели как прослойка между DTO и Flutter
Сейчас JSON парсится в DTO-дерево сущностей, свойства которого — вычисляемые выражения или константы. Выражения решаются асинхронно, и сами параметры представляют сырые данные, которые нужно преобразовать в сущности стандартной вёрстки фреймворка Flutter. Этой задачей занимается модель виджета.
Разберём на примере div-text:
class DivTextModel with EquatableMixin { final String text; final int? maxLines; const DivTextModel(this.text, this.maxLines); static Stream<DivTextModel> from(BuildContext context, DivText data) { final variables = DivKitProvider.watch<DivContext>(context)!.variableManager; return variables.watch<DivTextModel>((context) async { return DivTextModel( await data.text.resolveValue(context: context), await data.maxLines?.resolveValue(context: context), ); }).distinct(); // The widget is redrawn when the model changes. } @override List<Object?> get props => [text, maxLines]; }
Главная задача модели — хранить свойства, которые будут использоваться в виджете, и быть сравнимой, для правильного контроля перерисовок. Также нужно уметь собирать её из DTO и зависеть от изменений переменных. Текущая реализация — это временное решение, выполняющее свою задачу.
Поддерживаемые фичи и примеры их использования
Мы считаем, что все фичи нужно разбирать на практике. Увидев возможности клиента, гораздо проще оценить потенциальные выгоды для своих проектов.
Счётчик
Начнём с примера реализации счётчика с двумя кнопками. Для него нам понадобятся шаблоны, переменные, вычисляемые выражения.
Вот есть задача сверстать такой экран, с чего можно начать?
Темплейтинг и шаблоны
Шаблоны нужны для удобства и сокращения объёма пересылаемых по сети данных. В идеале они версионируются и хранятся в закэшированном виде в клиенте, используются и обновляются по мере необходимости.
Для использования нужно определить список шаблонов в блоке templates или передать его отдельно, если отходить от схемы.
Можно делать именованные наборы свойств, наследоваться и делать композици. Стоит заметить, что родительские свойства шаблона переопределяются значениями из дочерних. В вёрстке раздела card же определяются максимально приоритетные свойства.

В примере, который мы разбираем, всего две кнопки, но очень похожих компонентов может быть и больше. Тогда описание вёрстки раздувается на ровном месте и всё будет долго грузиться. Чтобы этого избежать, можно вынести общие свойства в шаблон, например в нашем случае напрашивается circle_button:

Как видим, шаблон существенно сократил объём самой вёрстки. Но можно сделать ещё лучше — переименовать названия полей, используя синтаксис:
«$real_prop_name»: «new_prop_name»
В вёрстке следует использовать уже новое обращение. Оно позволяет более лаконично и понятно определять параметры графических элементов.
Кнопка у нас всегда имеет полную заливку цветом, так что можно определить color в background новым именем bg_color (значение просто подставляется).

Ну с этим разобрались. Более детально можно почитать в разделе Шаблоны. А мы пойдём дальше.
Переменные
Переменные — это очень мощная и гибкая основа для реактивности графических элементов. Если меняются переменные и результат самого выражения, то графический элемент перерисовывается.
Чтобы использовать переменные, нужно объявить их в отдельном блоке variables в «card«-секции JSON-файла вёрстки:
{ "card": { "log_id": "main_screen", "variables": [ { "type": "integer", "name": "count", "value": 0 }, { "type": "number", "name": "alpha", "value": 0.5 } ] } }
Вернёмся к примеру, объявим непосредственно count = 0, а как дополнительное свойство пусть будет alpha = 0.5.
Переменные также можно создать программно. Это позволяет внедрять в DivKitView значения из хостовой среды. Делается всё согласно протоколу DivVariableStorage:
abstract class DivVariableStorage { /// The parent storage. There is full access to it. DivVariableStorage? get inheritedStorage; /// A list of variable names under the control of the current storage. Set<String> get names; /// Update an existing variable. /// If successful, it will return true bool update(DivVariable variable); /// Update if it exists and will create otherwise. void put(DivVariable variable); /// The current snapshot of the raw representation of the storage. Map<String, DivVariable> get value; /// The raw data stream of storage changes. Stream<Map<String, DivVariable>> get stream; /// Safely destroy storage. Future<void> dispose(); }
В составе библиотеки есть default-реализация. Она сеансовая, так как не сохраняет значения на устройство. Начальные значения можно передать двумя способами: списком и методом put. Хранилища можно вложить друг в друга, изменение родительского хранилища обновляет дочерние. При этом имена уже существующих переменных заменяются новыми (разрывается связность). Круто, что изменения переменных в таких хранилищах влияют на DivKitView.
Ниже пример наследования хранилищ, идёт разделение данных на уровни. View-хранилище можно передать уже в DivKitView и использовать все объявленные переменные:
final system = DefaultDivVariableStorage( variables: [ DivVariable(name: 'isDark', value: false), ], ); final page = DefaultDivVariableStorage( inheritedStorage: system, variables: [ DivVariable(name: 'token', value: "..."), ], ); final view = DefaultDivVariableStorage( inheritedStorage: page, variables: [ DivVariable(name: 'id', value: '...'), ], );
Выражения
Выражения добавляют интерактивности и позволяют творить чудеса, особенно если учитывать, что большинство полей в схеме вёрстки их поддерживают. Они объявляются с помощью включений вида «@{…}». Далее, чтобы вычислить значение, они должны пройти через «Вычислитель выражений». Это сейчас достаточно дорого, поэтому мы сразу разделяем параметры на выражения и константы на этапе построения DTO-моделей.
В DivKit поддерживается вся базовая математика, также есть встроенные функции.
Немного стоит сказать про использование переменных в выражениях. Всё просто, обращение идёт по имени:
-
«@{isDark ? 1 : 0}» → 0
-
«url?token=@{token}» → url?token=…
-
«set_state?state_id=@{id}» → set_state?state_id=…
В нашем случае выведем на экран значение счётчика (count) с помощью текста и будем менять его прозрачность (alpha):
{ "type": "text", "font_size": 100, "alpha": "@{min(1.0, max(alpha, 0.0))}", "width": {"type": "wrap_content", "constrained": true}, "text_alignment_horizontal": "center", "alignment_horizontal": "center", "alignment_vertical": "center", "text": "@{count}" }
Более детально можно посмотреть в документации, раздел Вычисляемые выражения.
Обработчик экшенов
Обработчик экшенов позволяет выполнять прописанные в вёрстке экшены (ивенты с данными). Экшены есть двух видов: «диплинк» и типизированный. Первые хранят в себе всю нужную информацию для обработки, например из реализованных set_state, set_variable, timer. У вторых есть дополнительный объект с информацией, например из реализованных — DivActionFocusElement и DivActionSetVariable.
Все их обработчики реализуют протокол DivActionHandler, который проверяет и затем исполняет экшен. Есть стандартная реализация DefaultDivActionHandler. Если хотим, чтобы стандартные экшены работали, нужно наследоваться от default или вызывать у себя отдельно:
/// Handles any div-action abstract class DivActionHandler { const DivActionHandler(); /// Returns TRUE if action can be handled. /// [action] — Action to handle. Maybe typed or contain a non-null url /// [context] — DivContext to use variables and access stateManager bool canHandle(DivContext context, DivAction action); /// Returns TRUE if action was handled. /// [action] — Action to handle. Maybe typed or contain a non-null url /// [context] — DivContext to use variables and access stateManager FutureOr<bool> handleAction(DivContext context, DivAction action); }
Переопределяется на уровне DivKitView:
DivKitView( data: DefaultDivKitData.fromJson(data), actionHandler: CustomDivActionHandler(navigator: navigatorKey), )
Более детально можно посмотреть в доке — раздел Действия с элементами.
Для обновления значения переменных из вёрстки можно воспользоваться стандартным диплинком. В нём указываются имя и новое значение, которое должно быть того же типа, что и переменная. Можно указать и выражение:
"actions": [ { "log_id": "action_id", "url": "div-action://set_variable?name=count&value=@{count - 1}" }, { "log_id": "action_id", "url": "div-action://set_variable?name=alpha&value=@{alpha - 0.1}" } ]
Более детально об этом написано в документации в разделе Изменение значений переменных.
Карточка
Карточки умеют переключаться при тапе на кнопку. Тут мы познакомимся со стейтами и триггерами.
Стейты
Стейты отвечают за отрисовку соответствующего div (из предопределённого в вёрстке набора) по stateId. Предположим, у нас есть главный стейт DivKitView, который индексируется числом, и стейты, созданные с помощью компонента div-state.
На практике они позволяют программно менять контент в определённом месте дерева вёрстки.

Для примера нам потребуется два стейта, группу назовём sample, и будет только два варианта divkit или flutter. Таким нехитрым образом мы добавили интерактивности на уровне компонентов.
Для переключения стейта используется стандартный action:
div-action://set_state?state_id=0/sample/flutter
На изменения состояния подписываются только card и div-state. По мере построения дерева компонентов в менеджер регистрируются стейты и выбранные значения сохраняются в хранилище.
Стоит отметить, сейчас не поддерживаются вложенные div-state в верстке, из-за того что все дерево представлений заранее не просчитывается, и мы не можем переключить стейты, которые не отображаем. Этот является ключевым вектором для улучшений.
Более детально можно посмотреть в доке — раздел Наборы состояний.
Триггеры переменных
Триггеры нужны, чтобы выполнять любые экшены по условию от переменных. Чтобы начать использовать, нужно добавить описание триггера в блоке variable_triggers:
"card": { "variables": [ { "name": "change_state", "type": "string", "value": "none" }, { "name": "block_state", "type": "string", "value": "divkit" } ], "variable_triggers": [ { "condition": "@{change_state == 'block' && block_state == 'divkit'}", "mode": "on_variable", "actions": [ { "log_id": "update change_state", "url": "div-action://set_variable?name=change_state&value=none" }, { "log_id": "update block_state", "url": "div-action://set_variable?name=block_state&value=flutter" }, { "log_id": "update state", "url": "div-action://set_state?state_id=0/sample/flutter" } ] }, { "condition": "@{change_state == 'block' && block_state == 'flutter'}", "mode": "on_variable", "actions": [ { "log_id": "update change_state", "url": "div-action://set_variable?name=change_state&value=none" }, { "log_id": "update block_state", "url": "div-action://set_variable?name=block_state&value=divkit" }, { "log_id": "update state", "url": "div-action://set_state?state_id=0/sample/divkit" } ] } ] }
У триггера указывается само условие срабатывания и режим (на каждое обновление переменной on_variable или на смену результата условия on_condition).
По реализации представляет собой просто наблюдатель над контекстом переменных. При каждом обновлении проверяет прохождение условия: если true, выполняет экшены в списке.
Как видно из схемы, мы не застрахованы от рекурсивного срабатывания триггера (когда мы обновляем переменную и триггеримся на её обновление снова). С этим нужно быть осторожным, в рассматриваемом примере для избежания данной ситуации значение стейта не меняется напрямую, и кнопка отправляет именованный ивент.
Более детально можно узнать в разделе Выполнение действий при изменении значений переменных.
Таймер
Таймеры достаточно сложные, поэтому разберём пример, использующий возможности таймера по максимуму, — тикающий таймер.
По своей сути он позволяет выполнять действия отложено, периодически (бесконечно или конечно). Для этого добавьте описание триггера в блоке timers:
{ "timers": [ { "id": "ticker", "duration": "@{timer_duration}", "tick_interval": "@{timer_interval}", "value_variable": "timer_value", "tick_actions": [ { "log_id": "tick", "url": "div-action://set_variable?name=timer_state&value=ticking" }, { "log_id": "tick", "url": "div-action://set_variable?name=tick_count_value&value=@{tick_count_value+1}" } ], "end_actions": [ { "log_id": "end", "url": "div-action://set_variable?name=timer_state&value=ended" } ] } ] }
Таймеру задаётся длительность и интервал. Может работать в трёх режимах:
-
duration — один вызов end_action;
-
duration и tick_interval — tick_action на каждый интервал до конца duration и один вызов end_action;
-
tick_interval — tick_action на каждый интервал без конца.
Таймеры управляются по id с помощью экшена:
div-action://timer?id=ticker&action=start/stop/reset/pause/resume/cancel
У механизма есть три основные сущности. TimerManager — прослойка для инициализации таймеров, обработки внешних экшенов и взаимодействия с Scheduler, который хранит набор Timers (DTO) и может управлять их Clockworks по id. Сам Clockwork нужен для вызова колбэков в определённые моменты — отложено или периодически.
Если подробнее, то Clockwork позволяет вызывать onEnd, отложенный на duration, а также позволяет задать промежуточный interval, на который будет вызываться onTick до истечения основного duration. Сам по себе Clockwork — это простейшая стейт-машина с тремя состояниями: started/stopped/paused.
Что осталось
Контекст DivKitView

Связующая сущность, которая ссылается на все публичные внутренние механизмы. Используется в кастомных экшенах и onInit-колбэке DivKitView. У главного контекста есть доступ к хранилищу переменных, стейтам, таймерам. Также здесь можно запустить выполнение экшена с помощью ActionHandler. Наконец, можно менять фокусы FocusNode.
Вычислитель выражений и контекст переменных
Вычислитель выражения — это продвинутый калькулятор (отвечает за получение значения из выражения). Для своей работы он требует контекст переменных (буквально значения и имена переменных в момент вычисления), также он помогает выделить изменение в данных. Само выражение в момент создания анализирует строку и ищет все имена используемых переменных. Комбинируя оба значения, вычислитель считает новое только при изменении и возвращает предыдущее значение в противном случае. Это помогает сократить количество вычислений, которые происходят достаточно долго.
На данный момент в клиенте используются нативные реализации вычислителей — плагин div_expressions_resolver (основанный на Platform Channel). Практически всё работает в рамках спецификации, но производительность не потрясающая.
Для iOS вычислитель пока не может сам вывести тип результата, поэтому гоняем строки, из-за чего есть проблемки в типах внутри коллекций:

Для Android, на момент написания, полный интероп типов без промежуточного представления. Последняя версия вычислителя не опубликована, поэтому падают новые тесты:

Самая большая текущая проблема в том, что идёт жёсткое ограничение полноценно поддерживаемых платформ — только iOS и Android, на других не будут работать выражения. В ближайших планах плавно переписать его на Dart/C++ (Rust).
Первый запуск Flutter-клиента
Инструкция
1. Добавляем зависимость в pubspec.yaml:
dependencies: divkit: any
2. Импортируем библиотеку:
import 'package:divkit/divkit.dart';
3. Строим данные вашего элемента с помощью DivKitData из JSON:
final data = DefaultDivKitData.fromJson(json); // Map<String, dynamic>
Также можно отдельно собрать по схеме:
final data = DefaultDivKitData.fromScheme( card: json['card'], // Map<String, dynamic> templates: json['templates'], // Map<String, dynamic>? );
4. Используйте DivKitView внутри вашего дерева виджетов, передав данные вёрстки (data):
DivKitView( data: data, )
Убедитесь что Directionality-виджет есть в вашем дереве.
DivKitView поддерживает глубокую кастомизацию с помощью определения новых обработчиков кастомов, действий или внешнего хранилища:
DivKitView( data: data, customHandler: MyCustomHandler(), // DivCustomHandler? actionHandler: MyCustomActionHandler(), // DivActionHandler? variableStorage: MyOwnVariableStorage(), // DivVariableStorage? )
Важно! Если вам нужно использовать стандартные действия, то нужно наследоваться от DefaultDivActionHandler.
Вот так всё просто, а JSON для проверки можно взять отсюда.
Заключение
Это первая версия релиза, над которой мы продолжаем работать. Наша цель — сделать решение более удобным, стабильным и быстрым. Пробуйте, внедряйте, предлагайте. Мы также будем рады вкладу в технологию со стороны сообщества, ведь впереди у нас ещё много работы — анимации, переходы, триггеры видимости, оставшиеся компоненты и прочее. Продолжим развивать проект вместе!
ссылка на оригинал статьи https://habr.com/ru/articles/814187/
Добавить комментарий