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

от автора

Полтора года назад мы выпустили в опенсорс 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 и зависеть от изменений переменных. Текущая реализация — это временное решение, выполняющее свою задачу.

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

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

Счётчик

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

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

JSON

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

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

Для использования нужно определить список шаблонов в блоке 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_stateset_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}"     } ]

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

Карточка

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

JSON

Стейты

Стейты отвечают за отрисовку соответствующего 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, выполняет экшены в списке.

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

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

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

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

Таймер

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

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_intervaltick_action  на каждый интервал до конца duration и один вызов end_action;

  • tick_intervaltick_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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *