Реализация совместного рисования в реальном времени в мобильном приложении
Совместное рисование — это ещё более требовательная задача, чем 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 батч за 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 недель.







