Реализация Live Cursors в мобильном приложении
Разработчики часто недооценивают сложность live cursors: кажется, что достаточно отправлять координаты по WebSocket и отрисовывать их. Но на практике возникает множество нюансов: задержка сети, разные разрешения экранов, масштабирование, большое количество пользователей. Ошибка в любом из этих пунктов делает курсоры неюзабельными. Мы внедряем live cursors с нуля или улучшаем существующую реализацию. В этой статье разберём ключевые технические решения на основе нашего опыта: выбор протокола (Y.js Awareness или кастомный WebSocket), нормализацию координат, интерполяцию анимации и масштабирование при 50+ участниках. Вы узнаете, как избежать типичных ошибок и добиться плавности на мобильных устройствах.
Выбор протокола: Y.js Awareness или кастомный WebSocket
Y.js Awareness Protocol — оптимальное решение, если в приложении уже используется Y.js для синхронизации контента. Awareness хранит ephemeral-состояния: они не персистят, не входят в историю изменений, автоматически удаляются при отключении пользователя.
// Обновляем позицию своего курсора
provider.awareness.setLocalStateField('cursor', {
x: normalizedX, // в координатах документа, не экрана
y: normalizedY,
timestamp: Date.now()
});
// Подписываемся на изменения чужих курсоров
provider.awareness.on('change', ({ updated }) => {
updated.forEach(clientId => {
if (clientId === provider.awareness.clientID) return;
const state = provider.awareness.getStates().get(clientId);
if (state?.cursor) {
updateRemoteCursor(clientId, state.cursor);
}
});
});
Если Y.js не используется — кастомный WebSocket-канал с throttle 30ms (≈33fps). Более частые обновления не дают заметного улучшения UX, но увеличивают трафик. Серверная сторона может выполнять throttling до 50ms на клиента для экономии ресурсов.
| Характеристика | Y.js Awareness | Кастомный WebSocket |
|---|---|---|
| Сложность | Низкая (встроен) | Средняя (свой протокол) |
| Персистентность | Нет | Опционально |
| Масштабирование | До 100+ | Ограничено реализацией |
| Совместимость | Только с Y.js | Любой бэкенд |
Как нормализация координат решает проблему дёрганья?
Критический момент: координаты курсора нужно передавать в системе координат документа, не экрана. У разных пользователей разные zoom-уровни, размеры экрана, положение scroll. Если передавать screen coordinates — курсоры будут прыгать по экрану вместо плавного следования за реальной позицией. Например, при zoom 200% у одного пользователя и 50% у другого, одни и те же экранные координаты соответствуют разным точкам документа.
Формула преобразования: cursorX = (screenX + scrollX) / scale. На приёме обратно: displayX = documentX * scale - scrollX. При смене zoom у получателя позиция курсора автоматически пересчитывается, что исключает рассинхрон.
Как обеспечить плавность при задержке сети?
Raw-позиции от сервера — ступенчатые, особенно при задержке 100–200ms. Нужна интерполяция.
В React Native с react-native-reanimated:
const remoteCursorX = useSharedValue(0);
const remoteCursorY = useSharedValue(0);
// При получении новой позиции от сервера
const updateCursor = (x, y) => {
remoteCursorX.value = withSpring(x, { damping: 20, stiffness: 300 });
remoteCursorY.value = withSpring(y, { damping: 20, stiffness: 300 });
};
const animStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: remoteCursorX.value },
{ translateY: remoteCursorY.value },
]
}));
withSpring добавляет пружинную интерполяцию — курсор «догоняет» реальную позицию плавно. Альтернатива: withTiming с duration: 80 — проще, менее «живой».
На Flutter: AnimationController + Tween<Offset> с CurvedAnimation(curve: Curves.easeOut).
На нативном iOS: UIViewPropertyAnimator с .interruptible option — позволяет прерывать и перезапускать анимацию при новых позициях без артефактов.
| Параметр | withSpring (RN) | withTiming (RN) | AnimationController (Flutter) |
|---|---|---|---|
| Ощущение | Пружинная, живая | Плавная, линейная | Кастомная через кривые |
| Настройка | damping, stiffness | duration, easing | Curve, duration |
| Производительность | Высокая (napi) | Высокая | Средняя (зависит от кривой) |
Почему Canvas-рендеринг лучше 50 отдельных View?
При 50+ пользователях рендеринг каждого курсора как отдельного View создаёт 50+ вьюшек, каждая со своим потоком анимации. Это грузит GPU и увеличивает потребление памяти. Canvas-based рендеринг рисует все курсоры за один проход в одном контексте. На Flutter используем CustomPainter, на React Native — react-native-skia или Canvas из expo-gl. Тесты показывают снижение нагрузки на CPU до 40% при 100 курсорах по сравнению с отдельными View.
Масштабирование: 50+ пользователей
При большом количестве пользователей несколько проблем:
Трафик. N пользователей × 33fps × ~50 байт = при 50 пользователях ~82 KB/s только на cursor updates. Решение: server-side throttling (сервер не пересылает updates чаще чем раз в 50ms на клиента) + отключение курсоров для пользователей за пределами viewport.
Рендеринг. 50 анимированных вьюшек одновременно на мобиле — нагрузка. Используем Canvas-based рендеринг вместо отдельных View для каждого курсора. Рисуем все курсоры в одном CustomPainter / SKCanvas / Canvas за один проход.
Идентификация. При 50 пользователях имя под курсором нечитаемо. Показываем имя только при hover/tap на курсор, остальное время — только цветовая точка с аватаром.
Отображение имени и аватара
Имя пользователя рядом с курсором — классический UX. Реализация: floating label, которая следует за курсором с небольшим offset. Проблема: при движении к краю экрана label выходит за bounds. Нужен clamp — если курсор ближе чем X px к правому краю, label отображается слева от курсора.
Аватар вместо стандартного указателя — часто лучше, чем цветная стрелка. Круглое изображение 24px диаметром, кэшированное в памяти.
Типичные ошибки при интеграции Live Cursors
- Отсутствие throttle: отправка позиций на каждый пиксель — избыточно. 30 мс — оптимум.
- Игнорирование координат документа: курсоры "разбегаются" при разных zoom.
- Отрисовка курсоров как отдельные View при большом количестве: тормозит UI.
- Необработка отсоединения пользователя: "мёртвые" курсоры остаются на экране.
Пошаговый план интеграции
- Настройка WebSocket-канала (Y.js Awareness или кастомный).
- Реализация нормализации координат в системе документа.
- Интерполяция анимации с настройками под конкретную платформу.
- Оптимизация рендеринга (Canvas-подход при >20 курсорах).
- Тестирование под нагрузкой (до 200 одновременных курсоров).
Что входит в нашу работу
- Аудит текущей архитектуры и выбор протокола (Y.js Awareness / кастомный WebSocket).
- Реализация на вашем стеке: iOS (Swift + Combine), Android (Kotlin + Compose), Flutter, React Native.
- Интерполяция анимации с настройками под конкретные платформы.
- Масштабирование до 100+ пользователей с Canvas-рендерингом.
- Тестирование под нагрузкой (до 200 одновременных курсоров).
- Документация и передача кода, обучение команды.
Наш опыт: 5 лет разработки коллаборативных мобильных приложений, более 20 проектов с real-time синхронизацией. Гарантируем корректную работу на всех целевых устройствах и версиях ОС.
Сроки и стоимость
Ориентировочные сроки: от 1 недели на прототип до 3 недель на полноценную интеграцию с нагрузочным тестированием. Стоимость рассчитывается индивидуально — зависит от сложности стека и необходимой масштабируемости. Свяжитесь с нами для аудита вашего проекта. Закажите внедрение Live Cursors под ключ — получите консультацию инженера в течение дня.







