Практическая реализация паттерна Server-Driven UI на Flutter c использованием фреймворка Duit

Итак, здравствуйте! Меня зовут Никита Синявин, я разработчик мобильных приложений в компании BetBoom. Сейчас наша команда использует Flutter для разработки некоторых своих приложений, на который мы в свою очередь успешно переехали с React Native. Мы активно развиваем свою техническую экспертизу и не боимся смелых экспериментов. Это и вдохновляет нас на создание передовых технологических решений, в том числе и Duit.

Оглавление

Введение

18 декабря 2023 года состоялся релиз первой версии SDUI фреймворка Duit. Об этом я написал небольшую статью, которая позволяет ознакомиться с тем, что из себя представляет Duit, его отличиях от аналогов и тем, какие проблемы предлагается решать с его помощью.

Сейчас же актуальная версия v1.7.0. Была проделана большая работа по расширению коллекции поддерживаемых виджетов (сейчас их 29), а так же добавлению совершенно новой функциональности, такой как Components (группы виджетов), расширение количества доступных для обработки пользовательских действий (навигация, открытие внешних ссылок), а так же новые варианты их выполнения (локальное выполнение). Так же ведется работа над написанием документации как на уровне исходного кода, так и в разделе WIKI главного репозитория проекта.

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

В статье мы затронем не только Flutter и Dart, но и Express (node js) для построения лайаута и обработки действий пользователя. Глубокое знание технологии не обязательно, но для лучшего понимания кода рекомендуется ознакомиться с базовыми концепциями Express (роутинг, мидлвары).

Подготовка

Начало работы требует от нас подготовить шаблон нового flutter-приложения, а так же шаблон приложения на Express. Для того, чтобы в полной мере ощутить преимущества Typescript, на котором написана библиотека для node js, в нашем демо-бекенде мы так же будем его использовать. Процесс инициализации проектов мы опустим и перейдем сразу к установке зависимостей.

Flutter

Для того, чтобы продемонстрировать максимальное количество фич Duit, мы установим следующий список зависимостей:

  • dio — http-клиент

  • go_router — библиотека для навигации

  • flutter_svg — библиотека для отображения SVG

  • flutter_duit — SDUI фреймворк

  • duit_kernel — зависимость flutter_duit, прямая зависимость от этой библиотеки в проекте нужна для того, чтобы использовать продвинутые возможности фреймворка (см. ниже)

Считаю нужным подробнее остановиться на том, зачем нам duit_kernel. Этот пакет содержит в себе базовые модели и общую функциональность Duit и является по своей сути фундаментом всего фреймворка. Но главное, что он предоставляет — это класс DuitRegistry. Его мы будем использовать для того, чтобы добавить возможность отрисовки кастомных виджетов и компонентов. Это можно считать продвинутой функцией фреймворка. Если ваша цель — отображение простого статичного контент (рекламные баннеры, ченджлоги и тд), то вам скорее всего не понадобится напрямую устанавливать duit_kernel. Что же касается остальных библиотек, то их применение будет рассмотрено по ходу статьи.

Backend

В этом пункте все сильно проще. Нам потребуется установить лишь одну дополнительную зависимость, помимо самого Express, Typescript и тд — duit_js.

Навигация в приложении из одного экрана

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

Flutter

Создадим наш экран с использованием Duit

import "package:flutter/material.dart";
import "package:flutter_duit/flutter_duit.dart";

//Создаем новый виджет и наследуемся от StatefulWidget
//для привязки к жизненному циклу
class DuitScreen extends StatefulWidget {
  final String path;

  const DuitScreen({
    super.key,
    //Экран принимает единственный параметр path
    //В случае, если параметр не передан, используется значение по умолчанию
    this.path = "/main",
  });

  @override
  State createState() => _DuitScreenState();
}

class _DuitScreenState extends State {
  //Создаем переменную для экземпляра DuitDriver
  late final DuitDriver _driver;

  @override
  void initState() {
    //Инициализируем DuitDriver
    _driver = DuitDriver(
      widget.path,
      //Определяем параметры подключения
      //В нашем случае используется HTTP транспорт, все запросы будут выполняться
      //в рамках этого протокола
      transportOptions: HttpTransportOptions(
        //Указываем базовые настройки транспорта
        baseUrl: "http://localhost:8999",
        defaultHeaders: {
          "Content-Type": "application/json",
        },
      ),
    );
    super.initState();
  }

  @override
  void dispose() {
    //Очищаем ресурсы драйвера
    _driver.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    //Возвращаем DuitViewHost и передаем туда экземпляр DuitDriver
    return DuitViewHost(
      driver: _driver,
      context: context,
      placeholder: const CircularProgressIndicator(),
    );
  }
}

Кратко разберем то, что происходит в коде. Мы создали наш экран, определили его параметры (роут, который будет использован по умолчанию). Переопределяем методы жизненного цикла State, инициализируем драйвер и вызываем его уничтожение в методе dispose. В методе build мы возвращаем DuitViewHost — специальный виджет, который под капотом вызываем методы инициализации драйвера (запрос начального лайаута, его обработку и отрисовку). При желании можно обойтись и без этого виджета, но с ним использование библиотеки станет проще.

Теперь мы можем создать наш простейший роутер

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

import 'duit_screen.dart';

final router = GoRouter(
  initialLocation: "/duit",
  routes: [
    ShellRoute(
        routes: [
          GoRoute(
            path: '/duit',
            builder: (context, state) {
              final path = state.extra as String?;
              return DuitScreen(
                path: path,
              );
            },
          ),
        ],
        builder: (context, state, child) {
          return Scaffold(
            body: SafeArea(
              child: child,
            ),
          );
        }),
  ],
);

Используем роутер в корне нашего приложения

import 'package:flutter/material.dart';

import 'navigation/router.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
    );
  }
}

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

class _Handler implements ExternalEventHandler {
  @override
  FutureOr handleNavigation(
    BuildContext context,
    String path,
    Object? extra,
  ) {
    final map = extra as Map;
    final duitPath = map["path"] as String;
    context.push(path, extra: duitPath);
  }

  @override
  FutureOr handleOpenUrl(String url) {
    // TODO: implement handleOpenUrl
    throw UnimplementedError();
  }
}

***

  @override
  void initState() {
    //Инициализируем DuitDriver
    _driver = DuitDriver(
      widget.path,
      //Создаем инстанс _Handler и передаем его в драйвер
      + eventHandler: _Handler(),
      //Определяем параметры подключения
      //В нашем случае используется HTTP транспорт, все запросы будут выполняться
      //в рамках этого протокола
      transportOptions: HttpTransportOptions(
        //Указываем базовые настройки транспорта
        baseUrl: "http://localhost:8999",
        defaultHeaders: {
          "Content-Type": "application/json",
        },
      ),
    );
    super.initState();
  }

На этом настройку навигации в приложении можно считать завершенной и перейти к бекенду.

Backend

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

//Запускаем наше приложение, используем роутер
const app = express();
const port = 8999;

app.use(router);
app.use(bodyParser.json());

app.listen(port, () => {
  console.log(`[server]: Server is running at http://localhost:${port}`);
});

Начнем работу над описанием двух основных роутов, которые представляют экраны нашего приложения: /main и /cart. Первый экран будет представлять собой список элементов, при нажатии на один из них будет произведено добавление элемента в «корзину». А при нажатии на кнопку мы выполним навигацию на другой экран, где он сможет увидеть выбранные элементы.

import { Router } from "express";
import { MainScreen } from "./views/screens/main";
import { CartScreen } from "./views/screens/cart";

export const router = Router();

router.get("/main", (_, res) => {
    res.status(200).send(MainScreen())
})

router.get("/cart", (_, res) => {
    res.status(200).send(CartScreen())
})

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

import { DuitView, SingleChildScrollView } from "duit_js";

export function MainScreen () {
    const builder = DuitView.builder();

    const root = SingleChildScrollView({
        attributes: {},
    });

    builder.rootFrom(root);

    return builder.build();
}

Базовый код по своей сути крайне прост и понятен. В начале вызываем DuitView.builder() и тем самым создаем специальный объект типа UiBuilder, который отвечает за правильное построение итогового JSON-объекта, его сериализацию и предоставляет полезные для работы методы. После этого мы создаем рутовый объект нашего JSON и создаем из него рут в билдере с помощью метода rootFrom . В конце мы билдим наше описание UI и возвращаем из функции.

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

Custom widgets — добавляем поддержку SVG

Фреймворк Duit развивается, его коллекция пополняется новыми виджетами, но всех их объединяет одно важное свойство — они содержатся в стандартной библиотеке Flutter. Такое решение позволяет не завязываться на конкретные реализации и не тянуть их в проект, но в то же время стандартных виджетов может не хватать для решения задачи.

В связи с этим фреймворк предоставляет пользователю возможность самостоятельно добавлять в пайплайн парсинга и отрисовки свои собственные виджеты, а так же их специфические атрибуты (на стороне flutter и бекенда). В этом разделе мы рассмотрим пример добавления поддержки рендера SVG с помощью библиотеки flutter_svg в Duit.

Backend

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

Первым важным пунктом будет выбор класса для наследованная. Duit предлагает на выбор 3 варианта: CustomTreeElement для элементов, которые никогда не содержат дочерних элементов (Checkbox, Text), CustomSingleChildWidget для элементов, которые могут содержать единственный дочерний элемент (Center, SizedBox) и CustomMultiChildWidget для элементов, которые могут содержать множество дочерних элементов.

Поскольку Svg не предполагает наличие «детей», то мы, создавая класс SvgWidget, наследуемся от CustomTreeElement.

interface SvgAttributes {
    width?: number;
    height?: number;
    content: string;
}

class SvgWidget extends CustomTreeElement {
    constructor(attrs: SvgAttributes, tag: string, id?: string) {
       super(attrs, tag, id, undefined, false);
    }
}

Здесь следует обратить внимание на несколько деталей:

  • SvgAttributes — это набор атрибутов, которые мы будем парсить на стороне flutter и присваивать свойствам виджета. Если вы используете Typescript, то я рекомендую не забывать описывать интерфейсы атрибутов для обеспечения типобезопасности вашего кода.

  • Параметр tag в конструкторе позволяет фреймворку на стороне flutter отличать один кастомный виджет от другого. Тег должен быть уникален для разных кастомных виджетов и одинаков для экземпляров виджета одного типа.

Для удобства можно обернуть создание экземпляра этого класса в функцию и использовать ее в коде.

const Svg = (attributes: SvgAttributes, id?: string) => {
    return new SvgWidget(attributes, "svg", id);
}

Flutter

Для начала разберемся в том, какие именно шаги выполняет Duit, чтобы ваш JSON превратился в виджеты Flutter.

  • Парсинг JSON. На этом этапе формируется модель duit-виджета, для нее создаются экземпляры атрибутов и контроллеров (при необходимости). Затем эта модель встраивается в свое место в дереве виджетов, которое идентично структуре исходного JSON.

  • Билд. Здесь мы обращаемся к каждой отдельной модели и создаем на ее основе виджет Flutter, куда передаем ранее созданные объекты атрибутов и контроллеров.

  • (Optional) Обновление. Каждый отдельно взятый duit-виджет, если он помечен свойством controlled=true, подписывается на обновление своих свойств. Здесь мы снова сталкиваемся с атрибутами и их копированием.

Такая система позволяет гибко модифицировать различные аспекты «жизненного цикла» duit-виджетов, не затрагивая при этом остальные домены.

В самом начале мы опишем классы, представляющие собой атрибуты нашего виджета.

class SvgWidgetAttributes implements DuitAttributes {
  final String content;
  final double? width, height;

  SvgWidgetAttributes({
    required this.content,
    this.height,
    this.width,
  });

  static SvgWidgetAttributes fromJson(Map json) {
    final w = json["width"] as num?;
    final h = json["height"] as num?;
    return SvgWidgetAttributes(
      content: json["content"] ?? "",
      height: w?.toDouble(),
      width: h?.toDouble(),
    );
  }

  @override
  SvgWidgetAttributes copyWith(other) {
    return SvgWidgetAttributes(
      content: other.content,
      height: other.height ?? height,
      width: other.width ?? width,
    );
  }
}

Остановимся и рассмотрим подробнее созданный класс. Он реализует интерфейс DuitAttributes, который обзывает нас реализовать метод copyWith. Он немного отличается от общепринятой реализации этого метода (когда в параметрах функция принимает множество nullable параметров, соответствующих полям класса). Вместо этого мы принимает объект типа SvgWidgetAttributes и создает копию на его основе.

Реализация метода fromJson не требуется. Но для удобства использования класса рекомендуется его реализовать.

Теперь опишем модель нашего виджета. В этом классе следует обратить внимание на то, что в конструкторе параметра tag должен быть идентичным с тегом на бекенде, а значение параметра type всегда устанавливается как "Custom".

final class SvgWidget extends DuitElement {
  
  SvgWidget({
    required super.id,
    required super.attributes,
    required super.viewController,
    required super.controlled,
  }) : super(
          type: "Custom",
          tag: "svg",
        );
}

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

DuitAttributes svgAttributesMapper(
  String type,
  Map? json,
) {
  return SvgWidgetAttributes.fromJson(json ?? {});
}

Widget svgRenderer(TreeElement model) {
  final data = model.attributes?.payload as SvgWidgetAttributes?;
  return Text(data?.content ?? "svg");
}

DuitElement svgModelMapper(
  String id,
  bool controlled,
  ViewAttributeWrapper attributes,
  UIElementController? controller,
) {
  return SvgWidget(
    id: id,
    attributes: attributes,
    viewController: controller,
    controlled: controlled,
  );
}

Разберем реализацию каждой из функций и рассмотрим ее назначение.

  • Функция svgAttributesMapper отвечает за, как можно догадаться, маппинг JSON, полученного с сервера, на класс атрибутов элемента интерфейса. Здесь мы для удобства используем ранее упомянутый метод fromJson, но парсить параметры можно также и в этой функции.

  • Функция svgRenderer отвечает за создание необходимого нам виджета (или композиции виджетов) и служит местом, где, как говорится, «модель натягивается на вьюху».

  • Функция svgModelMapper служит для того, чтобы создать модель нашего виджета и встроить ее в дерево элементов, а затем использовать как параметр фукнции svgRenderer

Осталось выполнить последний шаг для добавления нашего виджет — регистрация.

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  DuitRegistry.register(
    "svg",
    svgModelMapper,
    svgRenderer,
    svgAttributesMapper,
  );
  runApp(const MyApp());
}

В этом блоке кода продемонстрирован типовой вариант регистрации кастомного виджета. Поскольку класс DuitRegistry является глобальным и служит для верхнеуровневой конфигурации всех экземпляров драйверов/хост-виджетов, поэтому рекомендуется выполнять все необходимые манипуляции с ним во время инициализации приложения.

Здесь мы передаем созданную выше группу мапперов в качестве параметров метода register и не забываем о необходимости передать тег в этот метод.

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

Мы затронули достаточно большую часть функционала фреймворка, но этого все еще недостаточно для создания действительно красивого и производительного UI. Так что предлагаю перейти сразу к следующему большому разделу статьи.

Components — впихнуть невпихуемое

При построении сложного UI результирующий JSON, который мы должны будем вернуть на клиент, будет неумолимо расти, его будут раздувать большие объекты атрибутов и виджетов. Этот момент неумолимо сказывается на общей производительности всего фреймворка. Большой JSON → увеличенное время передачи данных по сети → увеличенное время парсинга → задержка первого рендера.

Поэтому в поисках решения я наткнулся на видео выступления Геннадия Евстратова про SUI, Flutter и реализацию этого паттерна от Яндекса. Я вдохновился этой идеей и, творчески ее переработав, реализовал свой вариант.

В этом разделе речь пойдет о компонентах Duit (Components) — создаваемых пользователем группах виджетов, скомпонованых в единое целое, которые от обычных виджетов отличает разметка данных в атрибутах, а так же более компактным размером JSON.

Backend

Для создания компонентов бекенд-адаптеры содержат специальные функции, такие как ComponentDescription и Component, но обо всем по порядку. Сперва создадим наш компонент.

export const ItemDescription = ComponentDescription(
    "item",
    Container({
        attributes: {
            decoration: {
                color: "#ffffff",
                borderRadius: 12,
            },
            refs: [{
                attributeKey: 'decoration',
                objectKey: "selectionColor"
            }]
        }
    }).addChild(
        Padding({
            attributes: {
                padding: 16,
            }
        }).addChild(DecoratedBox({
            attributes: {
                decoration: {
                    borderRadius: 12,
                }
            }
        })).addChild(
            Row({
                attributes: {
                    mainAxisAlignment: "spaceBetween",
                }
            }).addChildren([
                DecoratedBox({
                    attributes: {
                        decoration: {
                            borderRadius: 16,
                            border: {
                                color: "#f5c5c1",
                                width: 1.5,
                            }
                        }
                    }
                }).addChild(
                    Image({
                        attributes: {
                            type: "network",
                            src: "",
                            width: 64,
                            height: 64,
                            fit: 'fill',
                            refs: [
                                {
                                    attributeKey: 'src',
                                    objectKey: "image"
                                }
                            ]
                        }
                    })
                ),
                SizedBox({ attributes: { width: 24 } }),
                Column({
                    attributes: {
                        mainAxisAlignment: "spaceEvenly"
                    }
                }).addChildren([
                    Row({
                        attributes: {
                            mainAxisSize: 'max',
                        }
                    }).addChildren([
                        TextBold("", [
                            {
                                attributeKey: 'data',
                                objectKey: "price"
                            }
                        ]),
                        SizedBox({ attributes: { width: 24 } }),
                        Text({
                            attributes: {
                                data: "",
                                refs: [
                                    {
                                        attributeKey: 'data',
                                        objectKey: "discount"
                                    }
                                ]
                            }
                        }),
                    ]),
                    TextBold("", [
                        {
                            attributeKey: 'data',
                            objectKey: "name"
                        }
                    ]),
                    Text({
                        attributes: {
                            data: "",
                            maxLines: 3,
                            overflow: "ellipsis",
                            refs: [
                                {
                                    attributeKey: 'data',
                                    objectKey: "description"
                                }
                            ]
                        }
                    })
                ]),
                SizedBox({ attributes: { width: 24 } }),
                Column({ attributes: {} }).addChildren([
                    Svg({
                        height: 24,
                        width: 24,
                        content: "svg here"
                    }),
                ])

            ])
        )
    ),
)

Рассмотрим важные моменты в приведенном фрагменте кода:

  • Функция ComponentDescription принимает два параметра: тег (по аналогии с кастомными виджетами) и описание лайаута для нового компонента (группу виджетов).

  • В функциях, представляющих собой модели виджетов, можно увидеть свойство refs. Это свойство содержит массив специальных объектов, которые позволяют установить соответствие между ключем атрибута (attributeKey) и ключем свойства в объекте с данными для этого компонента (objectKey).

Для удобства также опишем интерфейс, представляющий собой данные для одного экземпляра компонента:

export interface ItemData {
    name: string
    description: string
    price: string
    discount: string
    image: string
}

И наконец пришло время модифицировать наш шаблон главного экрана, добавив туда наши компоненты!

export function MainScreen() {
    const builder = DuitView.builder();

    const col = Column({attributes: {}});

    const widgets: DuitElement[] = [];

    componentsData.forEach((data, index) => {
        const widget = GestureDetector({
            attributes: {
                behavior: "opaque",
                onTap: new HttpAction(`/add/${index}`, { method: "POST" }),
            }
        }).addChild(Component({
            id: `item${index}`,
            data: data,
            tag: ItemDescription.tag,
        }));

        widgets.push(widget);
        widgets.push(SizedBox({ attributes: { height: 16 } }))
    })

    col.addChildren([
        ...widgets,
        ElevatedButton({
            attributes: {}, action: new LocalExecutedAction(CreateNavigationEvent("/duit", { path: "/cart" }))
        }).addChild(Text({ attributes: { data: "Нажмите, чтобы перейти в корзину" } })),
    ])

    const res = SingleChildScrollView({
        attributes: {
            padding: 16,
        },
    }).addChild(col);

    builder.rootFrom(res);

    return builder.build();
}

В приведенном фрагменте кода видно, что мы используем функцию Component для формирования модели нашего компонента из тега и данных, которые он потребляет (выше мы написали интерфейс ItemData).

В итоге мы получаем гораздо более компактную структуру JSON. Вот что мы получаем на клиента вместо монструозного дерева виджетов:

    {
      "controlled": true,
      "action": null,
      "id": "1411d6d1-fd38-497a-8eb3-bbdaf7207b54",
      "type": "Component",
      "data": {
        "name": "Товар",
        "description": "Описание товара",
        "price": "$ 150",
        "discount": "4%",
        "image": "https://some_image_url",
        "isSelected": false,
        "selectedColor": {
          "borderRadius": 12,
          "color": "#DCDCDC"
        }
      },
      "tag": "item"
    }

Также у нас есть еще одна необязательная опция — реализовать эндпоинт, который будет возвращать на клиент массив моделей компонентов (которых может быть достаточно много). Но для чего это может нам понадобится?

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

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

Добавим в наш роутер еще один путь. В будущем мы обязательно к нему вернемся.

router.get("/components", (_, res) => {
    res.status(200).send([ItemDescription])
})

Flutter

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

Нам достаточно лишь зарегистрировать наши компоненты в DuitRegistry, причем сразу «пачкой»

DuitRegistry.registerComponents([...res.data]);

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

Transport extension — заменяем http на dio

Под капотом Duit для работы с сетью использует пакет http. Но зачастую разработчики реализуют целые сервисы, построенные на базе иных http-клиентов, добавляют логирование и retry запросов, обновление токенов и т.д. Было бы неправильным отказываться от этих вещей при работе с Duit и в этом разделе мы рассмотрим, как решить эту проблему.

Пакет http является частью реализации интерфейса Transport. А реализация этого интерфейса, в свою очередь, является важным актором в процессе работы драйвера нашего UI. Для примера я выбрал dio, как один из самых популярных пакетов с таким же назначением, как и http.

class DioTransportOptions extends TransportOptions {
  @override
  String? baseUrl;

  @override
  Map defaultHeaders;

  @override
  String type = "dio";

  final Dio dio;

  DioTransportOptions({
    required this.baseUrl,
    required this.dio,
    this.defaultHeaders = const {},
  });
}

В рамках приведенного фрагмента кода мы реализовали объект, служащий для конфигурации транспорта. Помимо обязательных для реализации свойств (baseUrl, type, defaultHeader), также доступно добавление специфичных для реализации свойств.

class DioTransport extends Transport {
  final DioTransportOptions options;

  DioTransport(super.url, this.options);

  @override
  Future?> connect() async {
    final res = await options.dio.get(url);
    return jsonDecode(res.data) as Map;
  }

  @override
  void dispose() {
    // TODO: очистка ресурсов, если требуется
  }

  @override
  FutureOr?> execute(
    ServerAction action,
    Map payload,
  ) async {
    String method = switch (action.meta) {
      null => "GET",
      HttpActionMetainfo() => action.meta!.method,
    };

    var urlString = action.event;

    if (method == "GET" && payload.isNotEmpty) {
      urlString += "?";
      payload.forEach((key, value) {
        urlString += "$key=$value";
      });
    }

    final res = await options.dio.request(
      urlString,
      options: Options(
        method: method,
      ),
      data: method == "GET" ? null : payload,
    );
    return jsonDecode(res.data) as Map;
  }
}

Рассмотрим подробнее код выше:

  • Метод connect служит для установления соединения с сервером и получение начального дерева элемента.

  • Метод execute отвечает за вызовы пользовательских действий, описанных на бекенде.

Набор этих методов позволяет создавать на основе интерфейса собственные реализации транспортов и использовать их, подменяя оригинальные реализации.

extension DioTransportExtension on UIDriver {
  void applyDioTransportExtension() {
    transport = DioTransport(
      source,
      transportOptions as DioTransportOptions,
    );
  }
}

Код итогового расширения предельно простой: мы создаем экземпляр ранее созданного нами класса DioTransport, передавая ему в качестве параметров конструктора данные из класса UIDriver (DuitDriver), а затем присваиваем свойству transport.

  @override
  void initState() {
    _driver = DuitDriver(
      widget.path ?? "/main",
      eventHandler: _Handler(),
      transportOptions: DioTransportOptions(
        //Указываем базовые настройки транспорта
        baseUrl: "http://localhost:8999",
        defaultHeaders: {
          "Content-Type": "application/json",
        },
        + dio: dio,
      ),
    + )..applyDioTransportExtension(); //Вызываем метод расширения
    super.initState();
  }

В конце добавляем вызов метода расширения. После этого этот экземпляр драйвера будет использовать реализацию транспорта, основанную на пакете dio.

Actions & Events — взаимодействие с пользователем

Мы проделали основательную работу, рассмотрев большое количество важных концепций фреймворка, но упустили кое-что важное. Поскольку Duit — технология предназначенная для создания UI, мы должны каким-то образом обрабатывать взаимодействия пользователя и реагировать на них.

Для этого Duit реализует концепцию action/event. И сейчас мы по порядку рассмотрим устройство и назначение этих вещей:

  • Action (далее действия, экшены) — специальная модель, создаваемая на бекенде и привязываемая к виджету, который подразумевает запуск пользовательских сценариев (чекбоксы, кнопки, поля ввода). Она описывает то, какие данные и откуда брать для формирования запроса на сервер и надо ли это делать, предоставляет мета-информацию для формирования запросов, устанавливает url запроса и т.д. Экшены специфичны для своего вида транспорта и не могут быть использованы для других видов транспорта.

  • Events (далее события, ивенты) — модель, представляющая собой ответ сервера. Содержит в себе информацию о том, какие атрибуты обновить (UpdateEvent), потребовать обновить layout (LayoutUpdateEvent), вызвать обработчик навигации (NavigationEvent) или открыть внешнюю ссылку (OpenUrlEvent). Событие может быть получено транспортом как в качестве ответа на действие, так и быть «запушено» со стороны сервера (в случае использования websockets). Также ивент может быть привязан к экшену — таким образом работают LocalExecutedAction .

В рамках этой концепции существует два способа обработки действия: выполнение запроса по установленному транспорту (http, ws) или локальное выполнение. Разница этих подходов состоит в том, что модель локального действия уже «зашит» ответ сервера. Это удобно использовать в случаях, когда нет явной необходимости делать запрос на бекенд, например, чтобы совершить навигацию по установленному пути.

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

        ElevatedButton({
            attributes: {}, action: new LocalExecutedAction(
                CreateNavigationEvent("/duit",
                    {
                        path: "/cart"
                    }
                )
            )
        }).addChild(Text({ attributes: { data: "Нажмите, чтобы перейти в корзину" } })),

На экране представленном функцией MainScreen мы добавляли кнопку и привязали к ней экземпляр LocalExecutedAction. В качестве параметров конструктора этот класс принимает модель ивенты, который будет выполнен без обращения к бекенду.

GestureDetector({
    attributes: {
        behavior: "opaque",
        onTap: new HttpAction(`/add/${index}`, { method: "POST" }),
    }
}).addChild(
    Component({
        id: `item${index}`,
        data: data,
        tag: ItemDescription.tag,
    })
);

На том же экране MainScreen мы мапили наши мок-данные и использовали наши ранее созданные компоненты, оборачивая их в виджет GestureDetector. Разберем подробнее действие, описанное для свойства onTap .

Мы создали экземпляр HttpAction — это действие специфичное для транспорта, реализующего взаимодействие с сервером по протоколу http и при попытке использовать его с другим видом транспорта будет получено исключение. Этот класс принимает ряд параметров:

  • Path — эндпоинт, на который будет выполнен запрос.

  • Мета — дополнительные сведения о запросе. В нашем случаем, мы явно указываем, что транспорту следует выполнить POST-запрос (по умолчанию GET).

  • ActionDependencies — массив зависимостей для конкретного действия.

На последнем пункте следует остановиться особенно внимательно. Некоторые виджеты, которые поддерживают пользовательский ввод (кнопки, поля форм, чекбоксы и радио-батоны) реализуют ряд свойств и методов, который обеспечивает возможность «собрать» из них чейндж-сет.

Объясню на примере: у нас есть форма обратной связи, на ней присутствуют несколько FormField («Электронная почта» и «Опишите проблему») , которые пользователь должен заполнить и кнопка, которая отправляет данные с формы на сервер (чтобы не усложнять пример мы не будет рассматривать изменение состояния и валидацию данных).

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

const action = new HttpAction(`/endpoint`, {
    method: "POST"
}, [
    {
        id: "email_field",
        target: "email"
    },
    {
        id: "problem_field",
        target: "problem_description"
    }
])

Описывая объекты зависимостей, мы указываем ID целевого виджета, с которого могут быть собраны данные, а так же свойство target, которое является ключом в результирующем объекте, передаваемом в качестве body http-запроса (либо преобразуется в строку query-params, если метод GET).

Это означает, что когда кнопка будет нажата, драйвер попытается найти соотвествующий контроллер по ID виджета, а затем попытается получить из него данные. В итоге сформированный объект будет передан в реализующий транспорт класс, где он в свою очередь будет передан на бекенд, где мы получим примерно такой body:

{
    "email": "somemail@gmail.com",
    "problem_description": "Lorem Ipsum"
}

Но вернемся к нашему событию для GestureDetector! Это событие не имеет зависимостей, поскольку нам не требуется сбор каких-либо данных.

Обработаем это событие в роутере:

router.post("/add/:id", (req, res) => {
    const numid = Number(req.params.id);
    const isSelected = componentsData[numid].isSelected;
    componentsData[numid].isSelected = !isSelected;
    const itemData = { ...componentsData[numid] };
    itemData.selectionColor = {
        borderRadius: 12,
        color: !isSelected ? "#88f564" : "#DCDCDC"
    }
    const keyId = "item" + numid;
    const payload = {};
    payload[keyId] = itemData;
    const update = new UpdateEvent(payload);
    res.status(200).send(JSON.stringify(update))
})

Активно симулируем бизнес-логику: меняем цвет фона нашего компонента. Формируем мапу с обновлением, где ключ это ID компонента или виджет, а значение — измененный объект атрибутов.

После этого, нажав на один из элементов списка, мы покрасим его в зеленый цвет, а если нажмем кнопку, то перейдет на экран с импровизированной «корзиной», где будут собраны все элементы, на которые было произведено нажатие.

Заключение

Мое лицо, когда дописал этот опус

Мое лицо, когда дописал этот опус

Статья получилась достаточно длинной. Заметно длиннее, чем я ожидал. В рамках статьи мы смогли ознакомиться со многими функциями и концепциями, на базе которых и функционирует фреймворк Duit.

Уже сейчас с помощью Duit можно строить приложения, которые мало чем отличаются от «обычных» flutter приложений, но при этом имеющие ряд преимуществ в виде бесшовного обновления и гибкости при разработке. Прогресс не стоит на месте и Duit продолжит пополняться новыми виджетами и полезными функциями!

Ссылки на затронутые в статье библиотеки с полезные ресурсы:

P.S. Благодарю всех и каждого, кто дочитал это до конца!

© Habrahabr.ru