Реализация коллаборативного рисования в мобильном приложении
Встречается ситуация: два пользователя рисуют на одном холсте, и спустя 200 мс штрихи накладываются не в том порядке. Причина — отсутствие честного упорядочивания. Мы решаем это с помощью CRDT (Conflict-free Replicated Data Type). Наш стек — Flutter 3.x, Kotlin Multiplatform, Swift 5.9. Синхронизация построена на бинарном протоколе поверх WebSocket, что даёт задержку менее 100 мс при 60 FPS.
Бинарный протокол вместо JSON даёт выигрыш в 3 раза по объёму данных. Типичная ошибка — отправлять каждое событие отдельно. Мы используем батчинг: группируем точки за 50–80 мс и отправляем одним сообщением. Это снижает нагрузку на канал и сервер, экономя ресурсы.
Как работает CRDT для рисования?
Конфликты при одновременном рисовании в одной области — частая проблема. Мы применяем CRDT с вектором часов. Каждое действие (начало, точки, завершение штриха) получает метку времени. При получении конфликтующих правок порядок восстанавливается по вектору: отправитель не ждёт подтверждения, а просто применяет локально и фоново синхронизирует. Это гарантирует отсутствие потери данных и честный порядок.
Как обеспечивается локальный отклик при удалённой синхронизации?
Пользователь должен видеть свой штрих сразу, без ожидания сети. Для этого мы рендерим локальный штрих немедленно, а синхронизацию запускаем параллельно.
// 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); } } Синхронизация идёт независимо — локальный рендер не блокируется.
Почему важен батчинг точек?
На мобильном устройстве 60 кадров в секунду генерируют до 60 событий pointerMove. Если отправлять каждое сразу, будет избыточный трафик и задержка на сериализацию. Мы собираем точки в батчи по 10–15 штук и отправляем каждые 50–80 мс. Это баланс между задержкой и трафиком.
Формат бинарного сообщения (экономия 3–5x по сравнению с JSON):
[strokeId: 16 bytes][pointCount: uint8][x1:f32][y1:f32][p1:f16][x2:f32]... Float16 для давления (0.0–1.0 с точностью 0.001) достаточно. Координаты — float32 для субпиксельной точности. Итого ~10 байт на точку против ~30 в JSON.
На WebSocket передаём бинарные кадры (ArrayBuffer в JS, Uint8List в Dart, ByteBuffer в Kotlin).
Алгоритм сглаживания на стороне получателя
Удалённые точки приходят батчами с задержкой и дискретно. Простая отрисовка линий между точками даёт ступенчатые линии. Нужно сглаживание. Мы рекомендуем два метода:
- Catmull-Rom Spline — проходит через все контрольные точки, низкая сложность, среднее качество.
- Perfect Freehand — симулирует форму кисти с учётом давления и скорости, генерирует SVG-path. Высокое качество, средняя сложность.
Для удалённого штриха применяем Perfect Freehand ко всему массиву точек при каждом обновлении. Path перерисовывается полностью — это дороже по CPU, но визуально правильно (сглаживание учитывает весь контекст).
| Метод сглаживания | Сложность | Качество |
|---|---|---|
| Catmull-Rom Spline | Низкая | Среднее |
| Perfect Freehand | Средняя | Высокое |
Слои и порядок объектов
Базовая модель: каждый штрих имеет z-index (timestamp создания). При конкурентном рисовании в одной области — кто нарисовал последним, тот сверху.
Слои (layers) — опциональная фича. Каждый слой — отдельный Y.Array объектов. Пользователь выбирает активный слой. Видимость/блокировка слоя — поле в Y.Map слоя.
При рендеринге: Canvas рисует слои снизу вверх. Каждый слой — отдельный offscreen canvas (iOS: UIGraphicsImageRenderer, Android: Bitmap с Canvas). Слои кешируются и перерисовываются только при изменении.
Ластик: специальный инструмент
Ластик не рисует белым — он удаляет пиксели. Два варианта:
- Object-level eraser — удаляет весь штрих при пересечении. Прост в реализации, хорошо синхронизируется (delete(strokeId) — атомарная операция).
- Pixel-level eraser — разрезает штрих на части. Требует геометрических вычислений (clip polygon by path). В коллаборативном контексте сложнее — нужно разрезать штрих и создать новые объекты, что проблема для CRDT.
Мы рекомендуем object-level eraser для начала.
Apple Pencil и Android Stylus
Apple Pencil через UITouch.type == .pencil передаёт давление (force 0.0–1.0), угол наклона (altitudeAngle, azimuthAngle) и предсказанные точки (predictedTouches). Flutter унифицирует: PointerEvent.pressure, PointerEvent.tilt, PointerEvent.orientation. Синхронизация pressure/tilt на удалённые клиенты даёт реалистичное отображение кисти партнёра.
Как интегрировать коллаборативное рисование: пошаговая инструкция
- Анализ требований — определите число одновременно рисующих, требуемые инструменты, уровень сглаживания.
- Выбор стека — Flutter, Kotlin Multiplatform или нативный Swift/Kotlin. Мы рекомендуем Flutter для кроссплатформенности.
- Интеграция SDK — подключите нашу библиотеку, следуйте документации. Примеры кода для iOS, Android и Flutter включены.
- Настройка серверной части — разверните WebSocket-сервер с поддержкой CRDT. Мы предоставляем Docker-образ.
- Тестирование — проверьте синхронизацию при различных сетевых условиях, используйте TestFlight/Firebase Distribution.
- Деплой — публикация в App Store / Google Play.
Сроки и что входит
Коллаборативное рисование с базовыми инструментами (кисть, ластик, цвет) на Flutter — 8–14 недель. С поддержкой стилуса, слоями, pixel-level eraser и масштабируемым холстом — 20–28 недель.
В разработку входит:
- Документация API и архитектуры
- Доступ к репозиторию с примерами
- Обучение команды заказчика
- Поддержка в течение месяца после релиза
Для точной оценки вашего сценария свяжитесь с нами — мы рассчитаем сроки и бюджет под конкретные требования. Получите консультацию наших инженеров с 10-летним опытом.
Типичные ошибки при реализации:
- Отправка каждого события отдельным сообщением вместо батчинга
- Использование JSON для точек — высокий overhead
- Игнорирование предсказания касаний для стилуса
- Отсутствие CRDT для конфликтов
- Рендеринг всех слоёв каждый фрейм без кеширования
| Транспорт | Размер на точку | Задержка сериализации |
|---|---|---|
| JSON | ~30 байт | ~0.5 мс |
| Бинарный | ~10 байт | ~0.1 мс |
Свяжитесь с нами, чтобы обсудить вашу задачу. Получите оценку проекта уже сегодня.







