Разработка голосовых звонков (VoIP) в мобильном приложении
Реализация VoIP-звонков в мобильном приложении — не одна задача, а несколько связанных технических слоёв: сигнальный протокол, медиатранспорт, управление аудиосессией и интеграция с системными API звонков. Пропустить или упростить любой из этих слоёв — получить звонок, который работает в демо, но ломается в продакшне: эхо, пропадание звука при входящем уведомлении, невозможность принять звонок с заблокированного экрана.
Архитектура VoIP-звонка
Голосовой звонок состоит из двух независимых потоков:
Сигнализация — управляющий канал: кто звонит, принять/отклонить, завершить. Передаётся через WebSocket, SIP или XMPP. Сигнальные сообщения малы (JSON несколько сотен байт), но требуют надёжной доставки.
Медиапоток — аудио между участниками. Передаётся через WebRTC (RTP поверх UDP) или проприетарные протоколы (Twilio, Vonage). UDP допускает потерю пакетов ради низкой задержки — это нормально для голоса. Задержка важнее, чем потеря 1–2% пакетов.
Самостоятельная реализация медиаслоя — WebRTC. Managed-решения — Twilio Voice, Vonage Voice, Agora. Разница в сложности, гибкости и стоимости.
iOS: CallKit — без него не обойтись
На iOS VoIP без CallKit технически возможен, но:
- Приложение не получает аудиосессию при входящем звонке на заблокированном экране
- Система не показывает системный экран входящего звонка (пользователи привыкли к нему)
- После iOS 13 приложения без CallKit не получают PushKit уведомления для VoIP
CallKit — системный фреймворк для интеграции VOIP-звонков в интерфейс телефона. Показывает системный экран входящего звонка, управляет аудиосессией, поддерживает Bluetooth/AirPods, отображает звонки в истории вызовов.
import CallKit
class CallManager: NSObject {
let provider: CXProvider
let callController = CXCallController()
init() {
let config = CXProviderConfiguration()
config.supportsVideo = false
config.maximumCallsPerCallGroup = 1
config.supportedHandleTypes = [.phoneNumber, .emailAddress]
provider = CXProvider(configuration: config)
super.init()
provider.setDelegate(self, queue: nil)
}
func reportIncomingCall(uuid: UUID, callerName: String) {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: callerName)
update.hasVideo = false
provider.reportNewIncomingCall(with: uuid, update: update) { error in
// начинаем отвечать на звонок после разрешения системы
}
}
}
PushKit для входящих звонков в фоне. В отличие от обычных APNs push, PushKit будит приложение мгновенно и с высоким приоритетом. Но с iOS 13 Apple требует немедленно вызвать reportNewIncomingCall при получении VoIP push — иначе приложение завершается с crash. Нельзя делать сетевой запрос перед вызовом CallKit.
Android: ConnectionService и Telecom API
Android аналог CallKit — ConnectionService из пакета android.telecom. Позволяет приложению стать «телефонным аккаунтом» в системе, показывать звонки на экране блокировки, управлять аудиомаршрутизацией.
Для входящих звонков в фоне — FCM push с priority: high. На Android 14+ фоновые сервисы ограничены, но ForegroundService типа phoneCall (добавлен в Android 14) решает именно эту задачу — у него нет ограничений на запуск при входящем звонке.
Управление аудиосессией через AudioManager:
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
audioManager.isSpeakerphoneOn = false
// при завершении:
audioManager.mode = AudioManager.MODE_NORMAL
Bluetooth-гарнитуры — отдельная головная боль. BluetoothHeadset profile, SCO соединение для голоса (не A2DP!), BroadcastReceiver на ACTION_SCO_AUDIO_STATE_UPDATED. Без явного управления SCO звук идёт через динамик даже при подключённой Bluetooth-гарнитуре.
WebRTC для медиаслоя
Если используем WebRTC самостоятельно (а не через Twilio/Vonage), нужен TURN-сервер для пробивания NAT. Без него звонки работают только в одной сети — классический баг на демо, который ломается у клиента за офисным NAT.
coturn — open source TURN сервер, разворачивается на VPS за несколько часов. Конфигурация ICE:
// Android WebRTC SDK
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.example.com:3478").createIceServer(),
PeerConnection.IceServer.builder("turn:turn.example.com:3478")
.setUsername("user")
.setPassword("password")
.createIceServer()
)
Кодеки: Opus для аудио (адаптивный битрейт, хорошо работает при потерях пакетов). WebRTC SDK включает его по умолчанию.
Типичные ошибки в продакшне
Эхо. Появляется если AudioManager.MODE_IN_COMMUNICATION не установлен — система не включает echo cancellation. WebRTC SDK включает программный AEC, но аппаратный (через режим) надёжнее.
Звонок прерывается при входящем SMS. AVAudioSession на iOS теряет фокус. Нужно подписаться на AVAudioSessionInterruptionNotification и переактивировать сессию после прерывания.
Задержка > 300 мс. Обычно причина — TURN relay вместо прямого P2P соединения. Проверяем ICE candidate type в статистике WebRTC: relay вместо host или srflx.
Что входит в работу
Проектируем архитектуру под требования (managed SDK vs WebRTC), реализуем CallKit/ConnectionService интеграцию, настраиваем сигнальный протокол, медиатранспорт и TURN-сервер. Тестируем на реальных устройствах с разными сетевыми условиями, Bluetooth-гарнитурами, прерываниями.
Срок: 2–5 недель в зависимости от выбранного стека и требований к качеству звука.







