Привет,
Как вы уже, наверное, знаете, Jmix — это такая платформа для разработки корпоративных приложений, построенная на основе фреймворков Spring, Vaadin и других классных технологий с открытым исходным кодом.
Ее использование позволяет абстрагироваться от многих сложностей фронтенд-разработки. Разработчикам не обязательно учить JavaScript/TS, погружаться в особенности популярных фронтенд-фреймворков, тренироваться в верстке, чтобы иметь возможность создавать полнофункциональные веб-приложения. Достаточно просто писать код на Java и немного компоновать экраны в XML. При разработке интерфейса для Jmix под капот уходят также некоторые механики, связанные с «перекладыванием джейсонов», что открывает дополнительные возможности для написания интерактивных веб-приложений с использованием готовых компонентов и дополнений.
Сегодня мы попробуем убедиться в этом на примере, создав MVP приложения для взаимодействия пользователей.

Для начала работы нам потребуется IntelliJ IDEA или GIGA IDE с установленным плагином Jmix (https://plugins.jetbrains.com/plugin/14340-jmix).
Создадим новый проект, выбрав тип Jmix, Full-Stack Application и назовем его jmix-colab.

Добавим новый пустой экран DrawBoardView.
Экран состоит из XML-дескриптора, в котором описывается его общий дизайн (или лейаут) и Java-класса, в котором пишется бизнес-логика, относящаяся к экрану и взаимодействию с его дочерними компонентами.
Изобретать компонент для работы с Canvas мы сегодня не будем, а вместо этого просто пройдем в реестр дополнений Vaadin, в котором, кажется, есть готовые решения на любой случай, и по слову canvas найдем там вот такой вариант.
Добавим его зависимость в build.gradle.
implementation 'org.parttio:canvas-java:2.0.0'
Для начала программно добавим холст на экран. Для генерации метода-обработчика удобно использовать меню Generate Handler.

onBeforeShow и onInit — стандартный способ навесить свой функционал для экрана: загрузить, сконфигурировать, добавить в отображение и другие инициализационные операции.
На холсте мы сразу что-нибудь нарисуем, чтобы увидеть, что все заработало.
@Subscribe public void onBeforeShow(final BeforeShowEvent event) { Canvas canvas = new Canvas(800, 500); CanvasRenderingContext2D ctx = canvas.getContext(); ctx.setStrokeStyle("red"); ctx.beginPath(); ctx.moveTo(10, 10); ctx.lineTo(100, 100); ctx.closePath(); ctx.stroke(); }
В дескриптор экрана добавим контейнер для холста.
<div id="canvasContainer" />
А в коде программно добавим реализацию холста в контейнер в метод onBeforeShow.
canvasContainer.add(canvas);
Так мы «привязали» компонент, созданный в коде, к объявленному в дескрипторе экрана.
Кстати, при помощи Alt+Enter в пункте меню Inject мы можем быстро и легко добавлять ссылки на элементы из дескриптора в код.
Должна отобразиться пока что только одна красная линия.

Теперь уберем все, что касается линии, кроме стиля, и добавим обработчик движения мышью, реализовав метод класса экрана в коде.
public void onCanvasMouseMove(MouseMoveEvent event) { ctx.lineTo(event.getOffsetX(), event.getOffsetY()); ctx.stroke(); log.info("event.x: {}, event.y: {}", event.getOffsetX(), event.getOffsetY()); }
В этот раз мне пришлось написать метод вручную, потому что Generate Handler опознает только компоненты, объявленные в дескрипторе и имеющие id.
Также я добавил логгер, чтобы прямо из логов видеть, что событие обрабатывается. Его можно заинжектить из контекстного меню или добавить строчку инициализации на уровне свойств класса.
private static final Logger log = LoggerFactory.getLogger(DrawBoardView.class);
Привязывать добавленный ранее обработчик движения мыши надо будет тоже в методе onBeforeShow.
canvas.addMouseMoveListener(this::onCanvasMouseMove);
Теперь мы можем вернуться в браузер и порисовать, двигая мышью.

Но когда на любое движение мыши без остановки происходит отрисовка линии — это не очень удобно, поэтому мы будем рисовать только когда нажата левая кнопка мыши. Для этого классу экрана добавим признак, определяющий, что рисование сейчас происходит.
protected Boolean drawingEnabled = false;
А на события mousedown и mouseup добавим обработчики его включения и выключения, а также вызовы методов начала и завершения фигуры у контекста. Его следует поднять на уровень свойства класса экрана, чтобы иметь доступ из других методов класса.
canvas.addMouseMoveListener(this::onCanvasMouseMove); canvas.addMouseDownListener(this::onCanvasMouseDown); canvas.addMouseUpListener(this::onCanvasMouseUp); canvasContainer.add(canvas); } public void onCanvasMouseMove(MouseMoveEvent event) { ctx.lineTo(event.getOffsetX(), event.getOffsetY()); ctx.stroke(); log.info("event.x: {}, event.y: {}", event.getOffsetX(), event.getOffsetY()); } public void onCanvasMouseDown(MouseDownEvent event) { this.drawingEnabled = true; ctx.beginPath(); } public void onCanvasMouseUp(MouseUpEvent event) { ctx.closePath(); this.drawingEnabled = false; }
Остается добавить только проверку режима в обработчике движения мыши, и наша рисовалка станет как у людей.
Однако, мы хотим сделать многопользовательское приложение, и в этом нам поможет инструмент для работы с шиной событий uiEventPublisher.
Чтобы им воспользоваться, его надо заинжектить в класс экрана так же, как мы это проделывали с компонентом-контейнером.
Вместо рисования в текущем контексте будем отправлять событие рисующего передвижения мыши, в нашем упрощенном варианте — всем пользователям.
Теперь обработчик передвижения будет выглядеть вот так:
public void onCanvasMouseMove(MouseMoveEvent event) { if (drawingEnabled) { uiEventPublisher.publishEventForUsers(new DrawBoardMoveEvent(event), null); } log.info("event.x: {}, event.y: {}", event.getOffsetX(), event.getOffsetY()); }
Он посылает событие мыши, обернутое в событие уровня всего приложения, которое пока что просто оборачивает его.
package com.company.jmixcolab.event; import org.springframework.context.ApplicationEvent; import org.vaadin.pekkam.event.MouseMoveEvent; public class DrawBoardMoveEvent extends ApplicationEvent { protected MouseMoveEvent mouseMoveEvent; public DrawBoardMoveEvent(MouseMoveEvent event) { super(event); this.mouseMoveEvent = event; } public MouseMoveEvent getMouseMoveEvent() { return mouseMoveEvent; } }
А обработчик этого события будет уже рисовать на холсте.
@EventListener public void boardMoveEventHandler(DrawBoardMoveEvent event) { ctx.lineTo(event.getMouseMoveEvent().getOffsetX(), event.getMouseMoveEvent().getOffsetY()); ctx.stroke(); }
Теперь мы можем открыть холст в разных окнах браузера и порисовать синхронно. Работать это все может не идеально, у меня иногда «забывала» отключиться функция рисования и наблюдались некоторые задержки, что вероятно связанно с параметрами debounce для события движения мыши и особенностями сетевого обмена. Однако, мы сейчас работаем с упрощенными примерами, рассчитанными на демонстрацию возможностей, а не на оптимальные режимы работы.

Правда, получается не понятно, кто где нарисовал. Для того, чтобы стало понятно, в класс эвента добавим поле username в событие DrawBoardMoveEvent и будем заполнять его из контекста текущего пользователя в обработчике движения.
public void onCanvasMouseMove(MouseMoveEvent event) { if (drawingEnabled) { uiEventPublisher.publishEventForUsers(new DrawBoardMoveEvent(event, currentAuthentication.getUser().getUsername()), null); } log.info("event.x: {}, event.y: {}", event.getOffsetX(), event.getOffsetY()); }
А при рисовании мы будем сверять значение с текущим пользователем и менять цвет в зависимости от того сам клиент рисует или получает эвенты рисования от другого пользователя.
@EventListener public void boardMoveEventHandler(DrawBoardMoveEvent event) { ctx.setStrokeStyle(currentAuthentication.getUser().getUsername().equals(event.getUsername()) ? "red" : "green"); ctx.lineTo(event.getMouseMoveEvent().getOffsetX(), event.getMouseMoveEvent().getOffsetY()); ctx.stroke(); }
Посмотрим на результат.

Теперь наши пользователи могут различать вклад друг друга, но неплохо было бы еще добавить индикацию, когда один из них пребывает в процессе рисования. Для этого создадим эффект мигания рисунка.
Конечно, в реальном случае это, скорее всего, будет лучше сделать исключительно с помощью анимации CSS, однако у нас демонстрационный проект и мы будем мигать, просто изменяя во времени это свойство. Как это сделать?
Чтобы получился эффект мигания, мы будем изменять свойство opacity у холста, уменьшая и увеличивая его значение. Для этого нам потребуется последовательность значений в интервале от 0.6 до 1.0, изменяющаяся с шагом 0.2, при этом сначала убывая от единицы, а затем возрастая.
Чтобы это свойство изменялось у элемента последовательно во времени, нам потребуется использовать планировщик задач.
Для нашего случая подойдет однопоточный экзекьютор с возможностью выполнять отложенные и повторяющиеся задачи.
static ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
Интерфейс ScheduledExecutorService позволяет создавать отложенные и повторяющиеся с заданным интервалом задачи.
Также надо добавить код, сабмитящий в него задачи по выбрасыванию событий с новым значением opacity.
@EventListener public void boardMoveEventHandler(DrawBoardMoveEvent event) { boolean isCurrentUserDrawing = currentAuthentication.getUser().getUsername().equals(event.getUsername()); ctx.setStrokeStyle(isCurrentUserDrawing? "red" : "green"); ctx.lineTo(event.getMouseMoveEvent().getOffsetX(), event.getMouseMoveEvent().getOffsetY()); ctx.stroke(); List<String> users = new ArrayList<>() {{ add(currentAuthentication.getUser().getUsername() );}}; if (!isCurrentUserDrawing) { Iterator<Integer> it = IntStream.range(0, 5).boxed().iterator(); Stream.of(1.0, 0.8, 0.6, 0.8, 1.0).forEach((i) -> { long nextTime = Double.valueOf(it.next() * 500.0).longValue(); executorService.schedule(() -> { uiEventPublisher.publishEventForUsers(new DrawBoardOpacityChangeEvent(i), users); }, nextTime, TimeUnit.MILLISECONDS); }); } else { uiEventPublisher.publishEventForUsers(new DrawBoardOpacityChangeEvent(1.0), users); } }
А слушатель события изменения прозрачности будет, собственно, устанавливать значение.
@EventListener public void opacityChangeEventHandler(DrawBoardOpacityChangeEvent event) { canvasContainer.getElement().setAttribute("style", "opacity: " + String.valueOf(event.getOpacity())); }
Почему не сделать это прямо в задаче для ExecutorService? Дело в том, что задачи экзекьютора несмотря на то, что могут выполняться в одном реальном потоке, ведут себя как настоящие потоки. В нашем случае это значит, что они не имеют доступа к контексту выполнения пользовательского интерфейса, и тут нас снова выручает publishEventForUsers, позволяя потокам осуществлять взаимодействие с интерфейсом.
В Java начиная с версии 21 появилась возможность использовать экзекьюторы виртуальных потоков в дополнение к обычным. Cо стороны кода экзекьютор задач в виртуальных потоках имеет тот же интерфейс, что и для реальных, и отличается только реализацией.
Преимущество изоляции виртуальных потоков заключается в том, что, выполняя в них «тяжелые» задачи, мы не будем порождать блокировки интерфейса пользователей. Это особенно важно для операций ввода-вывода, таких, как запросы на сторонние сервисы, чтение и запись данных в файлы и базы. Веб-интерфейс в браузере, так же как в играх, оконных системах, десктопных тулкитах, работает в однопоточном режиме и лучшее, что мы можем сделать для обеспечения его отзывчивости — это выполнять «тяжелые» задачи в отдельных потоках, обрабатывая в UI-потоке только результаты их выполнения. Некоторые виды операций, такие как скачивание ресурсов, браузер сам выполнит в фоновых потоках, другие, например выполнение большого цикла или ресурсоемкого вычисления из JavaScript-контекста завесят интерфейс пользователя.
Говорят, виртуальные потоки как будто предназначены для эффективной работы с блокирующими операциями при высокой их интенсивности. Их создание происходит «с нулевой стоимостью». Заблокировавшись, виртуальный поток освободит контекст реального потока, в котором выполнялся для других задач, не вызвав его блокировки. Единственное исключение — когда вы имеете дело с кодом, в котором присутствует большое количество synchronized-блоков, и это не для всех случаев. В отличие от реальных потоков вы можете предполагать использование сотен и тысяч виртуальных без значительного влияния на общие системные требования приложения. Используя их, можно также перестать беспокоиться о блокировке потоков и лимитах на количество потоков в пулах. Выполнение виртуальных потоков также не привязано только к одному реальному потоку и, как следствие, процессорному ядру, как это происходит со многими имитациями асинхронного кода. Они могут стать настоящим спасением для работы в контексте синхронного кода приложений, позволив вам использовать асинхронность только там, где от нее будет практическая польза, без превращения всего приложения в спагетти обработчиков реактора. Продемонстрируем это все на примере. Добавим в нашу рисовалку возможность делать штампы картинкой с удаленного сервиса. Срабатывать оно будет по двойному клику. Повесить его на сам холст у меня не вышло, но на контейнер вполне получилось при помощи кнопки GenerateHandler. Сначала сделаем простой обработчик, добавляющий картинку урлом в src.
@Subscribe(id = "canvasContainer", subject = "doubleClickListener") public void onCanvasContainerClick(final ClickEvent<Div> event) { PendingJavaScriptResult res = canvasContainer.getElement().callJsFunction("getBoundingClientRect"); res.then((rect) -> { canvasLeft = ((JsonObject) rect).getNumber("left"); canvasTop = ((JsonObject) rect).getNumber("top"); ctx.drawImage("https://upload.wikimedia.org/wikipedia/commons/5/50/Smile_Image.png", event.getClientX() - canvasLeft, event.getClientY() - canvasTop, 100, 100); }); }
Тут не обошлось без лайфхака: для точного вычисления положения клика мыши относительно границ холоста нам надо знать его собственную позицию. Поэтому я запрашиваю у элемента значения при помощи вызова JavaScript метода getBoundingClientRect, который возвращает эти данные в актуальном виде.
Но, допустим, нам требуется скачивать картинку с удаленного сервера перед добавлением на холст. Для этого объявим для экрана HTTP-клиента.
protected HttpClient client;
И проинициализируем его в методе onBeforeShow.
client = HttpClient.newBuilder() .executor(httpExecutorService) .build();
Наш клиент будет использовать экзекьютор виртуальных потоков, который мы проинициализируем аналогичным образом.
static ExecutorService httpExecutorService = Executors.newVirtualThreadPerTaskExecutor();
Все его блокирующие запросы будут происходить как будто в отдельном потоке, не мешая при этом работе интерфейсной логики.
Чтобы убедиться в этом, добавим логирование в колбэк скачивания картинки.
client.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()).thenApply((response) -> { log.info("Image received for event.x: {}, event.y: {}", event.getClientX(), event.getClientY()); ... });
И мы увидим, что в логах маркировка текущего потока у контекста колбэка будет другой.

Тогда наш обработчик двойного нажатия вместо непосредственного рисования на холсте будет запрашивать картинку при помощи асинхронного HTTP-запроса и при ее успешном скачивании выбрасывать эвент, содержащий в себе вычисленные координаты изображения и его данные, закодированные в base64.
@Subscribe(id = "canvasContainer", subject = "doubleClickListener") public void onCanvasContainerClick(final ClickEvent<Div> event) { PendingJavaScriptResult res = canvasContainer.getElement().callJsFunction("getBoundingClientRect"); res.then((rect) -> { canvasLeft = ((JsonObject) rect).getNumber("left"); canvasTop = ((JsonObject) rect).getNumber("top"); try { HttpRequest request = HttpRequest.newBuilder(new URI("https://upload.wikimedia.org/wikipedia/commons/5/50/Smile_Image.png")) .version(HttpClient.Version.HTTP_1_1) .header("Content-type", "image/png") .build(); client.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()).thenApply((response) -> { uiEventPublisher.publishEventForUsers(new DrawBoardImageAddedEvent(response, event.getClientX() - canvasLeft, event.getClientY() - canvasTop, "data:image/png;base64," + Base64.getEncoder().encodeToString(response.body())), null); return null; }); } catch (URISyntaxException e) { throw new RuntimeException(e); } }); }
Обработчику этого события останется только отрисовать полученные данные на холсте.
@EventListener public void boardImageAddedHandler(DrawBoardImageAddedEvent event) { ctx.beginPath(); ctx.drawImage(event.getSrc(), event.getX(), event.getY(), 100, 100); ctx.closePath(); }
Теперь при двойном нажатии на холсте в точке курсора будут появляться штампы из скачанных картинок.

Итого, нам удалось достаточно легко создать приложение для коллаборации пользователей. Написание аналога при помощи традиционных стеков фронтенд и бекенд технологий могло бы потребовать значительных квалификаций и коммуникаций разработчиков разных специализаций, архитекторов, менеджеров, и возможно, целых отделов девопсов;), тогда как мы справились сами, программируя только на одном языке.
Готовый код проекта можно скачать из вот этого репозитория.
ссылка на оригинал статьи https://habr.com/ru/articles/837448/
Добавить комментарий