Как я сделал групповые звонки в React Native мессенджере: WebRTC, CallKit и грабли production'а
- среда, 13 мая 2026 г. в 00:00:17
Уровень: senior мобильная разработка и WebRTC Стек: React Native, Expo SDK 54, @livekit/react-native-webrtc, expo-notifications, CallKit, FCM Что внутри: production WebRTC с trickle ICE, VoIP push notifications, CallKit интеграция, обработка фоновых состояний
Это третья статья из серии про инженерные решения в ONEMIX — моём мессенджере на React Native. В первой я разбирал трёхуровневый кэш сообщений, во второй — реализацию Double Ratchet E2E. Сегодня — про звонки.
Звонки в мессенджере — это та функция, которая работает либо отлично, либо никак. Пользователь привык что WhatsApp/Telegram звонят мгновенно, показывают входящие на заблокированном экране, переживают переключения Wi-Fi/LTE, и работают из фона. Если твоя реализация делает хоть что-то из этого хуже — пользователь это сразу заметит и переключится на "нормальный" мессенджер.
Я потратил несколько месяцев на то чтобы довести звонки в ONEMIX до production-уровня. В процессе пришлось изучить WebRTC изнутри, разобраться с iOS CallKit и VoIP push notifications, и собрать десяток граблей которые в туториалах не упоминают. В этой статье — как это устроено, какие решения оказались критичными, и что бы я сделал по-другому.
Сразу оговорка. Я не использую готовые SDK типа Agora, Twilio, 100ms. У них отличное качество и поддержка, но они не дают полного контроля над процессом — а для мессенджера контроль критичен. Когда звонок не проходит, пользователь винит приложение, а не "SDK от третьей стороны". Плюс готовые SDK стоят денег, которые на раннем этапе продукта лучше направить в другие места.
Если загуглить "WebRTC React Native", первые десять туториалов показывают примерно одно: createPeerConnection, createOffer, setLocalDescription, собрать все ICE-кандидаты, потом отправить offer с кандидатами собеседнику. Это называется "gather-then-send" и для учебного примера работает нормально.
В production это даёт задержку 8-12 секунд от момента "позвонить" до первого аудио. Причина: современные мобильные устройства собирают ICE-кандидаты медленно — особенно в мобильных сетях, особенно если STUN/TURN серверы далеко. ICE gathering может занимать 3-5 секунд на стороне caller, потом ещё 3-5 секунд на стороне receiver. Только после этого начинается actual ICE checking и установление соединения.
Telegram и WhatsApp используют trickle ICE: offer/answer отправляются сразу после setLocalDescription, а ICE-кандидаты трикллируются по одному по мере появления. Первое соединение устанавливается как только появится первая подходящая пара кандидатов, обычно за 500-1500 мс.
Разница огромная. В gather-then-send звонок "думает" 8 секунд. В trickle ICE — полсекунды, максимум секунду.
// ── Trickle ICE: кандидаты уходят сразу как появляются ────────────── this.pc.onicecandidate = ({ candidate }: any) => { if (candidate) { console.log('[WebRTC] Sending ICE candidate:', candidate.type, candidate.protocol); this.sendSignal({ type: 'ice-candidate', candidate }); } else { console.log('[WebRTC] ICE gathering complete'); } }; // Offer отправляется сразу после setLocalDescription const offer = await this.pc.createOffer(offerOpts); await this.pc.setLocalDescription(offer); this.sendSignal({ type: 'offer', sdp: offer.sdp }); // Не ждём gather!
При получении offer получатель делает то же самое:
await this.pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp })); const answer = await this.pc.createAnswer(); await this.pc.setLocalDescription(answer); this.sendSignal({ type: 'answer', sdp: answer.sdp }); // Сразу!
Кандидаты которые пришли до setRemoteDescription сохраняются в pendingCandidates и применяются пачкой после:
private async handleRemoteCandidate(candidate: any): Promise<void> { if (this.pc.remoteDescription) { await this.pc.addIceCandidate(new RTCIceCandidate(candidate)); } else { // Пришёл раньше answer/offer — сохраняем this.pendingCandidates.push(candidate); } }
Результат: время от "позвонить" до первого аудио сократилось с 8-12 секунд до 800-1500 мс. Это разница между "медленный звонок" и "нормальный звонок".
WebRTC нужны STUN/TURN серверы. STUN — для определения своего внешнего IP. TURN — для relay когда direct connection невозможен (strict firewall, symmetric NAT). Настройка серверов выглядит примерно так:
const iceServers = [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'turn:my-server.com:3478', username: 'temp-user-12345', credential: 'temp-password-67890' } ];
TURN credentials обычно временные (живут 12-24 часа) и выдаются твоим backend'ом по запросу. Наивный подход: перед каждым звонком запросить свежие credentials с backend'а. Это плохо, потому что добавляет HTTP round-trip в критический путь. На плохой сети это ещё +1-2 секунды к задержке звонка.
Я кэширую credentials в памяти на 20 минут:
let _cachedIceServers: any[] | null = null; let _cacheExpiry: number = 0; const CACHE_TTL_MS = 20 * 60 * 1000; export async function preWarmTurnCredentials(): Promise<void> { const turnData = await api.getTurnCredentials(); if (turnData?.ice_servers?.length) { _cachedIceServers = turnData.ice_servers; _cacheExpiry = Date.now() + CACHE_TTL_MS; } } function getIceServers(): any[] { if (_cachedIceServers && Date.now() < _cacheExpiry) { return _cachedIceServers; } return DEFAULT_ICE_SERVERS; // fallback }
preWarmTurnCredentials() вызывается при запуске приложения и каждые 15 минут в background. К моменту первого звонка credentials уже в памяти, HTTP-запроса нет.
Fallback на DEFAULT_ICE_SERVERS критичен. Если backend недоступен (техработы, проблемы с сетью), звонки всё равно должны работать через публичные STUN и мой статичный TURN. Качество может быть хуже, но звонки проходят.
Мобильные устройства переключаются между Wi-Fi, LTE, 5G постоянно. WebRTC connection при этом может оборваться — старые ICE кандидаты указывают на предыдущий IP, а новые ещё не появились. Стандартного решения "переподключиться автоматически" у WebRTC нет.
Есть механизм ICE restart: генерируется новый offer с флагом iceRestart: true, и весь процесс ICE checking начинается заново.
this.pc.oniceconnectionstatechange = () => { const state = this.pc?.iceConnectionState; if (state === 'failed') { this.scheduleIceRestart(); } else if (state === 'disconnected') { // Ждём 4s — может само восстановиться this.iceRestartTimer = setTimeout(() => { if (this.pc?.iceConnectionState === 'disconnected' || this.pc?.iceConnectionState === 'failed') { this.scheduleIceRestart(); } }, 4000); } };
ICE restart с экспоненциальным backoff и лимитом попыток:
private scheduleIceRestart(): void { this.iceFailCount++; if (this.iceFailCount > 3) { this.callbacks?.onConnectionStateChange('failed'); return; } const delay = Math.min(1000 * this.iceFailCount, 3000); // 1s, 2s, 3s this.iceRestartTimer = setTimeout(async () => { this.pc.restartIce(); if (this.role === 'caller') { await this.createOffer(true); // iceRestart: true } }, delay); }
Это спасает разговор когда пользователь переходит из дома (Wi-Fi) в машину (LTE). Без ICE restart звонок бы оборвался окончательно.
На iOS звонки должны выглядеть как system calls. Когда приходит звонок, пользователь видит полноэкранный incoming call UI или Dynamic Island (на новых iPhone). Без этого входящие звонки выглядят как обычные push-нотификации и теряются в общем шуме.
CallKit + VoIP push — сложная тема с множеством edge case. Показываю основную логику.
// При запуске приложения VoipPushNotification.addEventListener('register', (token: string) => { _voipToken = token; api.updateVoipToken(token); // Отправляем на backend }); VoipPushNotification.registerVoipToken();
Backend получает VoIP token и использует его для отправки VoIP push через APNs. VoIP push отличается от обычного push тем, что может разбудить приложение из состояния "убитого системой" и запустить background task.
Когда приходит VoIP push, iOS запускает background task. В этом таске нужно немедленно вызвать CallKit, иначе iOS убьёт приложение за "ложную" VoIP нотификацию:
TaskManager.defineTask(INCOMING_CALL_TASK, async ({ data, error }) => { const callData = data?.notification?.request?.content?.data; if (!callData?.call_id) return; const CK = require('react-native-callkeep'); try { CK.displayIncomingCall( callData.call_id, callData.caller_name, callData.caller_name, 'generic', callData.type === 'incoming_video_call' ); } catch (e) { console.error('displayIncomingCall failed:', e); } });
Этот task должен быть зарегистрирован на уровне модуля, не в React компоненте. iOS должна знать про task до получения первого push.
Результат: когда кто-то звонит пользователю, на экране появляется стандартный iOS incoming call UI, даже если приложение закрыто. Пользователь может ответить или отклонить как обычный телефонный звонок.
Peer-to-peer звонки (один на один) технически проще: два RTCPeerConnection, один SDP exchange, один набор ICE кандидатов. Групповые звонки — другая история.
Наивный подход — mesh: каждый участник поддерживает отдельное WebRTC соединение с каждым другим участником. На 3 человека это 6 соединений (A↔B, A↔C, B↔C). На 5 человек — 20 соединений. На 10 человек — 90 соединений. Это быстро становится неуправляемым, особенно на мобильных устройствах.
Альтернатива — SFU (Selective Forwarding Unit): один центральный сервер, к которому каждый участник подключается отдельно. Участник отправляет свой аудио/видео поток на SFU, а SFU форвардит потоки всех остальных участников обратно. На 10 человек это 10 соединений клиент↔сервер вместо 90 mesh-соединений.
Я использую LiveKit как SFU. LiveKit — open-source медиасервер с WebRTC API, который можно селф-хостить. Клиентская часть подключается к LiveKit room, отправляет свой поток и получает потоки остальных участников.
// Инициация группового звонка const initiateCall = async (groupId: string, callType: 'audio' | 'video') => { const result = await api.initiateGroupCall(groupId, callType); const callDetails = await api.getGroupCall(result.id); setActiveCall(callDetails); return callDetails; }; // Подключение к LiveKit room const joinLiveKitRoom = async (roomToken: string) => { const room = new Room(); await room.connect(livekitUrl, roomToken); room.localParticipant.enableCameraAndMicrophone(); };
LiveKit выдаёт JWT token для подключения к конкретной room. Backend генерирует токен с нужными правами (publish audio/video, subscribe to others) и временем жизни. Клиент подключается к LiveKit напрямую — backend после выдачи токена в медиа-потоке не участвует.
На iOS вызов getUserMedia без предварительно полученных permissions крашит приложение с "The operation couldn't be completed". На Android permissions запрашиваются системой автоматически в момент getUserMedia. Это означает platform-specific код:
// iOS: запрашиваем permissions ПЕРЕД getUserMedia if (Platform.OS === 'ios') { const { Audio } = require('expo-av'); const micResult = await Audio.requestPermissionsAsync(); if (!micResult.granted) { callbacks.onError('Нет доступа к микрофону'); return null; } if (callType === 'video') { const { Camera } = require('expo-camera'); const camResult = await Camera.requestCameraPermissionsAsync(); if (!camResult.granted) { callbacks.onError('Нет доступа к камере'); return null; } } }
При завершении звонка важен порядок операций. Если сначала закрыть RTCPeerConnection, а потом обнулить callbacks, то pc.close() сгенерирует событие onconnectionstatechange('closed'), которое попытается вызвать callback и записать состояние "closed" в UI. Следующий звонок начнётся с отображением "Соединение закрыто".
cleanup(): void { // Обнуляем callbacks ДО закрытия PC const savedWsUnsub = this.wsUnsub; this.callbacks = null; this.wsUnsub = null; try { savedWsUnsub?.(); this.localStream?.getTracks().forEach(t => t.stop()); this.pc?.close(); } catch {} }
React Native по умолчанию не экспортирует WebRTC глобальные объекты (RTCPeerConnection, mediaDevices). Их нужно регистрировать через @livekit/react-native-webrtc.registerGlobals(). В Expo Go этой библиотеки нет, поэтому нужна защита от краша:
export function ensureLiveKitGlobals() { try { const webrtc = require('@livekit/react-native-webrtc'); if (webrtc?.registerGlobals) { webrtc.registerGlobals(); } } catch (e) { console.log('[WebRTC] @livekit/react-native-webrtc not available:', e); } }
Вызов перед любой WebRTC операцией. В production build библиотека есть, в Expo Go приложение не крашится.
Если пользователь быстро завершит один звонок и начнёт другой, может произойти race: cleanup первого звонка обнулит WebRTC globals в момент когда второй звонок их инициализирует. Результат: второй звонок крашится с "RTCPeerConnection is not defined".
Решение: singleton WebRTCService с мьютексом на cleanup/initialize:
class WebRTCService { private isCleaningUp = false; private isInitializing = false; async initialize(/* ... */) { if (this.isCleaningUp) { await new Promise(resolve => setTimeout(resolve, 100)); } this.isInitializing = true; // ... actual initialization this.isInitializing = false; } cleanup() { if (this.isInitializing) return; this.isCleaningUp = true; // ... actual cleanup this.isCleaningUp = false; } }
На многих Android устройствах aggressive battery optimization убивает фоновые процессы через несколько минут. Если пользователь принял звонок и свернул приложение (например, чтобы открыть календарь), через 2-5 минут система убьёт WebRTC connection и звонок оборвётся.
Частичное решение — foreground service во время звонка:
// Android: запускаем foreground service при начале звонка if (Platform.OS === 'android') { await startForegroundService({ taskName: 'VoiceCall', taskTitle: 'Идёт звонок', taskDesc: 'ONEMIX звонок активен', taskIcon: { name: 'ic_launcher', type: 'mipmap' } }); }
Пользователь увидит постоянную нотификацию "Идёт звонок" — это показывает что приложение активно и не должно быть убито. При завершении звонка service останавливается.
Если бы начинал сейчас с текущими знаниями, вот что изменил бы:
Начал бы с готового SDK на MVP-стадии. Agora/Twilio дают working solution за неделю вместо месяцев. Когда пользовательская база вырастет и появятся специфичные требования (кастомная UI, особая логика переключения сетей, интеграция с E2E) — тогда переписать на собственное решение. Но на старте готовый SDK выигрывает по time-to-market.
Больше внимания Android-специфике. Я сфокусировался на iOS (CallKit, VoIP push) и Android получил меньше внимания. В результате на iOS звонки выглядят как native phone calls, а на Android — как push-нотификации с кнопками. Android тоже поддерживает ConnectionService API для интеграции с system dialer, но я это не реализовал.
Медиа-качество как первоприоритетный фактор. Я оптимизировал скорость подключения и надёжность, но не уделил достаточно времени аудио/видео quality. Результат: звонки подключаются быстро, но на плохой сети качество может деградировать сильнее чем у WhatsApp. Adaptive bitrate, аудио-preprocessing, эхо-подавление — это отдельная область экспертизы.
Тестирование на широком спектре устройств. WebRTC ведёт себя по-разному на разных чипсетах, версиях Android, типах сети. У меня было 3-4 тестовых устройства, этого оказалось мало. Некоторые баги (например, эхо на Samsung Galaxy с определёнными версиями прошивки) обнаружились уже после релиза.
Звонки в мессенджере — это 90% инженерии и 10% фич. Большая часть работы не в том чтобы "добавить кнопку звонка", а в том чтобы звонки работали предсказуемо в максимальном количестве условий: плохая сеть, переключения Wi-Fi/LTE, фоновое состояние, battery optimization, разные устройства.
Ключевые решения которые оказались критичными: trickle ICE вместо gather-then-send (разница в 8 секунд), кэширование TURN credentials (убирает HTTP round-trip), автоматический ICE restart (спасает звонки при переключении сети), правильная интеграция с CallKit на iOS (system-like UX).
Если делаете звонки в своём приложении — не недооценивайте сложность. Это не "добавим WebRTC за выходные". Но результат того стоит: когда пользователи говорят что звонки в вашем приложении "работают как в WhatsApp" — это один из лучших комплиментов качества инженерии.
Это третья статья из серии про ONEMIX. В предыдущих: трёхуровневый кэш сообщений и Double Ratchet E2E. Следующая будет про iOS App Store deployment с EAS Build — что реально происходит за кадром. В Telegram-канале пишу про разработку чаще и короче — заходите (ссылка).
Если есть вопросы по WebRTC, CallKit или конкретным кускам кода — пишите в комменты. Особенно интересно услышать от тех кто делал что-то похожее и нашёл другие решения.