Полтора года назад мы выпустили в опенсорс 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 для проверки можно взять отсюда.
Заключение
Это первая версия релиза, над которой мы продолжаем работать. Наша цель — сделать решение более удобным, стабильным и быстрым. Пробуйте, внедряйте, предлагайте. Мы также будем рады вкладу в технологию со стороны сообщества, ведь впереди у нас ещё много работы — анимации, переходы, триггеры видимости, оставшиеся компоненты и прочее. Продолжим развивать проект вместе!