WebRTC в iOS: как добавить видеозвонки в приложение

Если вы когда-нибудь задумывались о создании приложения для онлайн-конференций или хотели добавить в своё приложение видеозвонки, то, скорее всего, слышали о WebRTC. Но материалов по этой технологии не так уж много, и почти все они рассчитаны на разработчиков. Сегодня мы не будем углубляться в технические детали, а просто попробуем понять, что такое WebRTC и почему видеосвязь — это вовсе не магия. Надеюсь, эта статья поможет вам разобраться, что же это за технология.
Кратко о WebRTC
WebRTC (Web Real Time Communications) — это протокол, который обеспечивает передачу аудио и видео в режиме реального времени. Он работает как по протоколу UDP, так и по TCP, и может переключаться между ними. Главное преимущество WebRTC — возможность устанавливать прямое соединение между пользователями (p2p, или «пиринговое» соединение): медиапоток передаётся напрямую, без участия сервера. Однако для использования p2p-соединений важно учитывать особенности как самого протокола WebRTC, так и особенности таких соединений.
STUN и TURN
Сети обычно проектируются с использованием частных IP-адресов. Эти адреса используются внутри организации, чтобы устройства могли общаться локально — они не маршрутизируются в интернете. Чтобы устройство с приватным IP-адресом могло обращаться к ресурсам за пределами локальной сети, его адрес нужно преобразовать в публичный. Такой перевод выполняет NAT (Network Address Translation). NAT не является основной темой статьи, поэтому подробнее о нём можно почитать здесь. Нам важно знать лишь, что в роутере есть таблица NAT, и чтобы к нашему клиенту приходили пакеты, в этой таблице должна быть специальная запись. Чтобы создать такую запись, клиент должен отправить что-то удалённому клиенту. Проблема в том, что ни один из них не знает своего внешнего IP-адреса. Для решения этой проблемы используются STUN и TURN-серверы. Сразу оговоримся: двух клиентов можно соединить и без STUN и TURN, но это возможно только в том случае, если они находятся в одной сети.
Начнем со STUN-сервера. STUN — это сервер, подключённый напрямую к интернету. Он получает пакет, в котором указан внешний адрес клиента, отправившего этот пакет, и отправляет его обратно. Клиент узнаёт свой внешний адрес и порт, который нужен, чтобы роутер понимал, какой именно клиент отправил пакет — ведь несколько устройств из внутренней сети могут одновременно обращаться к внешним ресурсам. Так в таблице NAT создаётся нужная запись.
TURN – это улучшенный STUN-сервер: он может работать как обычный STUN, но его существование оправдано определёнными задачами. Существует несколько типов NAT, и некоторые из них запоминают не только внешний IP-адрес, но и порт STUN-сервера, и блокируют пакеты, приходящие не от него. В результате NAT не пропускает пакеты от удалённого клиента, и прямое соединение не устанавливается. В таких случаях как раз нужен TURN. Также, например, в сетях 3G невозможно организовать прямое p2p-соединение, и тогда TURN-сервер выступает в роли ретранслятора, хотя клиенты продолжают считать, что общаются напрямую.
Сигнальный сервер
Отлично, мы поняли, зачем нужны STUN и TURN серверы, но это не единственная особенность WebRTC. WebRTC не умеет передавать данные о соединении — значит, с его помощью нельзя напрямую соединить клиентов. Нам нужно как-то организовать обмен информацией о соединении. Что именно передаётся и зачем — разберём ниже. Для этого используется сигнальный сервер. Подойдёт любой способ передачи данных, лишь бы стороны могли обмениваться информацией. В нашей компании, например, обычно используют вебсокеты.
Видеозвонок 1 на 1
Мы поговорили о STUN и TURN-серверах, о том, зачем нужен сигнальный сервер. Но до сих пор непонятно, как создать работающий звонок. Теперь разберёмся, какие шаги нужно выполнить, чтобы организовать видеозвонок.
Для начала скажем, что с помощью WebRTC ваш iPhone может подключиться к любому устройству — не обязательно, чтобы оба участника общались с iPhone. Соединение возможно и с устройством на Android, и с десктопным компьютером.
Имеем двух клиентов: инициатор звонка и ожидающий звонок.
Чтобы позвонить оппоненту, инициатор должен:
- Получить свой локальный медиапоток. Медиапоток — это поток видео- и аудиоданных. Каждый поток может включать несколько медиатреков, а каждый медиатрек — несколько медиаканалов.
Медиапотоков может быть несколько — например, с фронтальной камеры и с рабочего стола. Медиапоток синхронизирует свои медиатреки, но разные медиапотоки между собой не синхронизированы. То есть звук и видео с камеры будут синхронизированы, но не с видео с экрана. Медиаканалы внутри одного медиатрека тоже синхронизированы. Получение локального медиапотока в коде выглядит примерно так:
func startLocalStream() {
let stream = streamsContainer.stream(forIdentifier: PublishStreamModel.publish)
stream.startCameraCapturer(processDeviceRotations: false, prefferedFrameSize: CGSize(width: 640,height: 480), prefferedFrameRate: 15)
}
2. Сформировать offer, то есть предложить начать звонок
if self.positioningType == .caller {
self.prepareAndSendOffer()
}
3. Передать свой SDP через сигнальный сервер. Что такое SDP? У устройства есть множество параметров, необходимых для установления соединения — например, поддерживаемые кодеки. Все эти параметры собираются в объект SDP, или дескриптор сессии, который передаётся собеседнику через сигнальный сервер. Важно: локальный SDP хранится в текстовом виде и его можно отредактировать перед отправкой — например, чтобы вручную выбрать кодек. Однако такой подход используется редко и не всегда работает.
func stream(_ stream: StreamController?,
shouldSendSessionDescriptionsessionDescriptionModel: StreamSessionDescriptionModel,
identifier: String,
completion: ((Bool)-> ())?) {
shouldSendSessionDescription?(sessionDescriptionModel, identifier)
}
4. Передать свои Ice Candidate через сигнальный сервер. Что такое Ice Candidate? SDP помогает установить логическое соединение, но физически клиенты пока не могут найти друг друга. Объекты Ice Candidate содержат информацию о местоположении клиента в сети — с их помощью клиенты находят друг друга и начинают обмениваться медиапотоком. Важно: локальный SDP генерируется один раз, а объектов Ice Candidate может быть несколько. Это нужно потому, что клиент может быть доступен по внутреннему IP-адресу, через TURN-серверы или по внешнему адресу маршрутизатора — и таких адресов может быть несколько. Поэтому для определения местоположения клиента в сети требуется несколько Ice Candidate.
func stream(_ stream: StreamController?,
shouldSendCandidate candidateModel: StreamCandidateModel,
identifier: String,
completion: ((Bool) -> ())?) {
shouldSendCandidate?(candidateModel, identifier)
}
5. Принять удалённый медиапоток (поток оппонента) и отобразить его. На iOS для рендеринга видеопотока можно использовать OpenGL или Metal.
func stream(_ stream: StreamController?, shouldShowLocalVideoView videoView: View?, identifier id: String) {
guard let video = videoView else { return }
self.localVideo = video
shouldShowRemoteStream?(video, id)
}
Оппонент в это же время должен выполнить те же шаги, за исключением второго: он формирует answer, а не offer — то есть принимает звонок.
if self.positioningType == .callee && self.peerConnection?.localDescription == nil {
self.prepareAndSendAnswer()
}
На самом деле answer и offer это одно и тоже, отличие состоит лишь в том, что когда ожидающий звонка формирует answer, то есть генерирует свой локальный SDP, он опирается на SDP объект инициатора звонка. Таким образом клиенты будут знать о параметрах устройства друг друга и, к примеру, смогут более корректно подобрать кодек.
Если кратко подытожить, то можно сказать так: клиенты сначала обмениваются SDP (устанавливают логическое соединение), а затем Ice Candidate (устанавливают физическое соединение). Таким образом клиенты успешно соединяются, видят друг друга, слышат друг друга и могут общаться.
Но это ещё не всё, что нужно учесть при работе с WebRTC в iOS. Если оставить всё как есть, пользователи смогут общаться, но только если приложение открыто — чтобы узнать о звонке и ответить, его нужно держать запущенным. Однако эту проблему легко решить: в iOS есть так называемый VoIP-пуш. Что это такое? Это специальный тип пуш-уведомления, созданный именно для работы с голосовыми вызовами.
Регистрируется он так:
// Ссылка фреймворк PushKit
import PushKit
// Активируем VoIP регистрацию при запуске
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: NSDictionary?) -> Bool {
self.voipRegistration()
return true
}
// Созздаем VoIP уведомления
func voipRegistration() {
let mainQueue = dispatch_get_main_queue()
// Создаем пуш
let voipRegistry: PKPushRegistry = PKPushRegistry(mainQueue)
// Делигируем на себя
voipRegistry.delegate = self
// Задаем типа пуша на VoIP
voipRegistry.desiredPushTypes = [PKPushTypeVoIP]
}
С помощью этого пуш-уведомления можно показать экран входящего вызова, где пользователь сможет принять или отклонить звонок. Для этого используется следующая функция:
func reportNewIncomingCall(with UUID: UUID,
update: CXCallUpdate,
completion: @escaping (Error?) -> Void)
И совершенно неважно, чем в этот момент занят пользователь — играет в игру или телефон заблокирован. VoIP-пуш имеет самый высокий приоритет, поэтому уведомления приходят всегда, и пользователи легко могут звонить друг другу. При интеграции звонков обязательно нужно подключать VoIP-пуш-уведомления, если вы хотите, чтобы ими реально пользовались. Без них пользоваться звонками крайне неудобно: чтобы вызов прошёл, оба пользователя должны сидеть в приложении и ждать звонка. Это выглядит странно, и в таком случае люди скорее выберут другое приложение.
Заключение
Мы поговорили о некоторых особенностях WebRTC, узнали, что нужно учесть для успешного соединения двух клиентов, разберёмся в последовательности шагов, которые должны выполнить клиенты, чтобы звонок состоялся, а также — что ещё потребуется помимо интеграции WebRTC, чтобы пользователи на iOS могли звонить друг другу. Надеюсь, после прочтения этой статьи WebRTC уже не кажется вам таким пугающим и загадочным, и теперь вы примерно понимаете, что нужно сделать, чтобы внедрить его в свой продукт.
