DivKit теперь и для Flutter. Рассказываем об особенностях BDUI-фреймворка Яндекса

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

В статье расскажем об особенностях вёрстки в DivKit и нашей реализации UI. Вы узнаете, какие фичи и компоненты Flutter поддерживаются во фреймворке на текущий момент. Покажем, как начать пользоваться клиентом уже сейчас.

956580b1e54fa59c1115d5f7dbe7a406.png

Что и как мы можем рисовать и делать

Рассмотрим фичи и компоненты детально. На всех схемах оранжевым будет обозначен 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 есть достаточно обширный набор стандартных компонентов, позволяющий верстать практически всё. Сейчас доступны только самые базовые и основные:

9516077b5f69eb19b3720b36bbc99f29.png

Они не на 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 _createItems(List
items) { return items.map((item) => DivWidget(item)).toList(); } } class _ChronographWidget extends StatefulWidget { @override State 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 from(BuildContext context, DivText data) {
        final variables = DivKitProvider.watch(context)!.variableManager;
        return variables.watch((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 get props => [text, maxLines];
}

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

Поддерживаемые фичи и примеры их использования

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

Счётчик

Начнём с примера реализации счётчика с двумя кнопками. Для него нам понадобятся шаблоны, переменные, вычисляемые выражения. 

Вот есть задача сверстать такой экран, с чего можно начать?

JSON

Темплейтинг и шаблоны

Шаблоны нужны для удобства и сокращения объёма пересылаемых по сети данных. В идеале они версионируются и хранятся в закэшированном виде в клиенте, используются и обновляются по мере необходимости.

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

Можно делать именованные наборы свойств, наследоваться и делать композици. Стоит заметить, что родительские свойства шаблона переопределяются значениями из дочерних. В вёрстке раздела card же определяются максимально приоритетные свойства.

28b4c8c0dbbb634198525cd5c0aec531.png

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

d710505985b15c3c48d6899f549d84b2.png

Как видим, шаблон существенно сократил объём самой вёрстки. Но можно сделать ещё лучше — переименовать названия полей, используя синтаксис:

»$real_prop_name»: «new_prop_name»

В вёрстке следует использовать уже новое обращение. Оно позволяет более лаконично и понятно определять параметры графических элементов. 

Кнопка у нас всегда имеет полную заливку цветом, так что можно определить color в background новым именем bg_color (значение просто подставляется).

a8e70723f2e15d445f8e42ac4aab0a74.png

Ну с этим разобрались. Более детально можно почитать в разделе Шаблоны. А мы пойдём дальше.

Переменные

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

Чтобы использовать переменные, нужно объявить их в отдельном блоке 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 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 get value;

 /// The raw data stream of storage changes.
 Stream> get stream;

 /// Safely destroy storage.
 Future 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 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}"
    }
]

Более детально об этом написано в документации в разделе Изменение значений переменных.

Карточка

Карточки умеют переключаться при тапе на кнопку. Тут мы познакомимся со стейтами и триггерами. 

JSON

Стейты

Стейты отвечают за отрисовку соответствующего div (из предопределённого в вёрстке набора) по stateId. Предположим, у нас есть главный стейт DivKitView, который индексируется числом, и стейты, созданные с помощью компонента div-state.

На практике они позволяют программно менять контент в определённом месте дерева вёрстки.

b1cb36b8fa0941e0670ca11a8c969fee.png

Для примера нам потребуется два стейта, группу назовём 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, выполняет экшены в списке.

Примерная архитектура триггеров переменных

Примерная архитектура триггеров переменных

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

Более детально можно узнать в разделе Выполнение действий при изменении значений переменных.

Таймер

Таймеры достаточно сложные, поэтому разберём пример, использующий возможности таймера по максимуму, — тикающий таймер.

JSON

По своей сути он позволяет выполнять действия отложено, периодически (бесконечно или конечно). Для этого добавьте описание триггера в блоке 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

100fd16a187d8f9e36413494f39ad18d.png

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

Вычислитель выражений и контекст переменных

Схематический процесс вычисления

Схематический процесс вычисления

Вычислитель выражения — это продвинутый калькулятор (отвечает за получение значения из выражения). Для своей работы он требует контекст переменных (буквально значения и имена переменных в момент вычисления), также он помогает выделить изменение в данных. Само выражение в момент создания анализирует строку и ищет все имена используемых переменных. Комбинируя оба значения, вычислитель считает новое только при изменении и возвращает предыдущее значение в противном случае. Это помогает сократить количество вычислений, которые происходят достаточно долго.

На данный момент в клиенте используются нативные реализации вычислителей — плагин div_expressions_resolver (основанный на Platform Channel). Практически всё работает в рамках спецификации, но производительность не потрясающая. 

Для iOS вычислитель пока не может сам вывести тип результата, поэтому гоняем строки, из-за чего есть проблемки в типах внутри коллекций:

8db885e9ecdcb287532ed4f7ec727f4f.png

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

29f8a7a86b4f426a7b8301ac1ca6794f.png

Самая большая текущая проблема в том, что идёт жёсткое ограничение полноценно поддерживаемых платформ — только 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

Также можно отдельно собрать по схеме:

 final data = DefaultDivKitData.fromScheme(
       card: json['card'], // Map
       templates: json['templates'], // Map?
   );

4. Используйте DivKitView внутри вашего дерева виджетов, передав данные вёрстки (data):

 DivKitView(
       data: data,
   )

Убедитесь что Directionality-виджет есть в вашем дереве.

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

DivKitView(
     data: data,
     customHandler: MyCustomHandler(), // DivCustomHandler?
     actionHandler: MyCustomActionHandler(), // DivActionHandler?
     variableStorage: MyOwnVariableStorage(), // DivVariableStorage?
   )

Важно! Если вам нужно использовать стандартные действия, то нужно наследоваться от DefaultDivActionHandler.

Вот так всё просто, а JSON для проверки можно взять отсюда.

Заключение

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

© Habrahabr.ru