Реалізація спільного рисування в реальному часі в мобільному додатку
Спільне рисування — це ще більш вимогливша задача, ніж whiteboard з фігурами. Тут кожен штрих кисті — це потік точок з тиском, нахилом та швидкістю. Затримка мережі сприймається гостріше: користувач очікує, що рисування іншої людини з'являється одночасно з її рухом, а не через півсекунди.
Розділення local та remote stroke
Ключове архітектурне рішення: завжди рисуй локальний штрих відразу, в обхід мережі. Синхронізація — для інших клієнтів, не для відправника.
// Flutter: локальний штрих
class DrawingBloc extends Bloc<DrawingEvent, DrawingState> {
void onPointerDown(PointerDownEvent e) {
currentStroke = Stroke(id: uuid(), points: [e.localPosition]);
emit(state.copyWith(activeStroke: currentStroke));
_syncService.beginStroke(currentStroke.id, color, brushSize);
}
void onPointerMove(PointerMoveEvent e) {
currentStroke.points.add(e.localPosition);
emit(state.copyWith(activeStroke: currentStroke));
_syncService.appendPoints(currentStroke.id, [e.localPosition]);
}
void onPointerUp(PointerUpEvent e) {
_syncService.finalizeStroke(currentStroke.id);
}
}
Синхронізація йде паралельно — локальний рендер не чекає мережі.
Транспорт: батчинг точок
60fps на мобілі = 60 pointerMove подій у секунду. При RTT 100ms batch за 100ms = ~10 точок. Відправляємо batch кожні 50–80ms — баланс між затримкою та трафіком.
Формат повідомлення (бінарний, а не JSON — економія в 3–5x по розміру):
[strokeId: 16 bytes UUID][pointCount: uint8][x1:f32][y1:f32][p1:f16][x2:f32]...
Float16 для тиску (pressure) — 0.0–1.0 з точністю 0.001 достатньо. Float32 для координат (субпіксельна точність потрібна для масштабування). Разом ~10 байтів на точку проти ~30 байтів у JSON.
На WebSocket: бінарні frames (ArrayBuffer у JS, Uint8List у Dart, ByteBuffer у Kotlin).
Алгоритм сглажування на стороні отримувача
Видалені точки приходять батчами з затримкою та дискретно. Проста відрисовка ліній між точками — ступенчасто. Потрібне сглажування:
Catmull-Rom Spline — проходить через всі контрольні точки. Для кожної пари сусідніх точок генерує проміжні. Підходить для рисування, не вимагає offline-вычислення.
Perfect Freehand (бібліотека Steve Ruiz) — симулює форму кисті з урахуванням тиску та швидкості, генерує SVG-path з точок. Працює у Dart, JS, Swift. Результат — органічна крива, а не просто лінія з round cap.
Для видаленого stroke: застосовуємо Perfect Freehand до всього накопленого масиву точок при кожному обновленні. Path перерисовується повністю. Це дорожче за CPU, ніж інкрементальне додавання, але візуально правильніше (сглажування враховує весь контекст шляху).
Шари та порядок об'єктів
Рисування без шарів — примітивно. Базова модель: кожен stroke має z-index (timestamp створення). При конкурентному рисуванні в одній області — хто нарисував останнім, той зверху.
Шари (layers) — опціональна фіча. Кожен шар — окремий Y.Array об'єктів. Користувач вибирає активний шар. Видимість/блокування шару — поле у Y.Map шару.
При рендеринґу: Canvas рисує шари знизу вверх. Кожен шар — окремий offscreen canvas (iOS: UIGraphicsImageRenderer, Android: Bitmap з Canvas). Шари кешируються та перерисовуються тільки при зміні.
Ластик: спеціальний інструмент
Ластик не рисує білим — він видаляє піксели. Два варіанти:
- Object-level eraser — видаляє весь stroke при перехресті. Просто у реалізації, відповідає моделі об'єктів.
- Pixel-level eraser — розрізає stroke на частини. Вимагає геометричних вычислень (clip polygon by path).
Для collaborative: object-level eraser простіше синхронізувати (delete(strokeId) — атомарна операція). Pixel-level — потрібно розрізати stroke та створити нові об'єкти, що складніше у CRDT-контексті.
Apple Pencil та Android Stylus
Apple Pencil через UITouch.type == .pencil:
-
force— тиск 0.0–1.0 -
altitudeAngle— кут нахилу (0 = горизонтально, π/2 = вертикально) -
azimuthAngle(in:)— напрямок нахилу -
predictedTouches(for:)— передбачені майбутні точки
Flutter: PointerEvent.pressure, PointerEvent.tilt, PointerEvent.orientation — працюють для стилусу на обох платформах.
Синхронізувати pressure/tilt на видалені клієнти — варто. Результат видний: кисть партнера відображається так само, як він рисував, з тією ж динамікою.
Оцінка
Спільне рисування з базовими інструментами (кисть, ластик, колір) на Flutter — 8–14 тижнів. З підтримкою стилусу, шарами, pixel-level eraser та масштабованим холстом — 20–28 тижнів.







