WebRTC на Android: как разработать нативный видео чат? — обложка

Об особенностях технологии разработки видео-чатов и конференций WebRTC на Android.

Поможет разработчикам, которые только начинают изучать тему. Будет полезен руководителям и владельцам бизнеса, планирующим создать Android-приложение с видеосвязью. В коде разобраться не получится, но вы узнаете о возможных подходах к разработке, их плюсах и минусах, а также поймёте, какие задачи стоят перед программистом.

Вкратце о WebRTC

WebRTC — технология для создания видео-чатов и конференций. Она позволяет устанавливать прямое соединение между мобильными устройствами и браузерами для передачи аудио- и видеопотоков. Подробнее об устройстве технологии и принципах её работы можно прочитать в нашей статье про WebRTC простым языком.

2 способа реализации видеосвязи с WebRTC на Android

  1. Самый простой и быстрый способ — воспользоваться одним из коммерческих решений, например, Twilio или LiveSwitch. Эти сервисы предлагают готовые SDK для разных платформ и реализуют нужную функциональность «из коробки», но у них есть недостатки. Они платные, а возможности ограничены: вы сможете использовать только те функции, которые уже предусмотрены, а не любые, какие захотите реализовать.
  2. Другой вариант — использовать одну из готовых библиотек. Этот подход потребует больше кода, но сэкономит деньги и даст больше гибкости при реализации функциональности. В этой статье мы рассмотрим именно его, а в качестве библиотеки возьмём https://webrtc.github.io/webrtc-org/native-code/android/

Создание соединения

Как создать WebRTC-соединение

Создание WebRTC-соединения состоит из двух этапов:

  1. Установка логического соединения — устройства должны согласовать формат данных, кодеки и так далее.
  2. Установка физического соединения — устройства должны знать адреса друг друга

Для начала отметим, что на этапе установления соединения для обмена данными между устройствами используется сигнальный механизм. В качестве такого механизма может выступать любой канал передачи данных — например, сокеты.

Предположим, мы хотим установить видеосвязь между двумя устройствами. Для этого нужно создать логическое соединение между ними.

Логическое соединение

Логическое соединение устанавливается с использованием протокола описания сессии (SDP). Для этого один из пиров:

  1. Создаёт объект PeerConnection
  2. Формирует SDP-offer — объект с данными о предстоящей сессии — и отправляет его собеседнику через сигнальный механизм.
 
 val peerConnectionFactory: PeerConnectionFactory 
lateinit var peerConnection: PeerConnection 
fun createPeerConnection(iceServers: List) { 
  val rtcConfig = PeerConnection.RTCConfiguration(iceServers) 
  peerConnection = peerConnectionFactory.createPeerConnection( 
      rtcConfig, 
      object : PeerConnection.Observer { 
          ... 
      } 
  )!! 
} 
fun sendSdpOffer() { 
  peerConnection.createOffer( 
      object : SdpObserver { 
          override fun onCreateSuccess(sdpOffer: SessionDescription) { 
              peerConnection.setLocalDescription(sdpObserver, sdpOffer) 
              signaling.sendSdpOffer(sdpOffer) 
          } 
          ... 

      }, MediaConstraints() 
  ) 
}
 

В свою очередь, другой пир:

  1. Также создает объект PeerConnection
  2. При помощи сигнального механизма получает SDP-offer, отправленный первым пиром, и сохраняет его у себя
  3. Формирует SDP-ответ и отправляет его обратно через тот же сигнальный механизм
 
 fun onSdpOfferReceive(sdpOffer: SessionDescription) {// Saving the received SDP-offer 
  peerConnection.setRemoteDescription(sdpObserver, sdpOffer) 
  sendSdpAnswer() 
} 
// Формирование и отправка SDP-answer 
fun sendSdpAnswer() { 
  peerConnection.createAnswer( 
      object : SdpObserver { 
          override fun onCreateSuccess(sdpOffer: SessionDescription) { 
              peerConnection.setLocalDescription(sdpObserver, sdpOffer) 
              signaling.sendSdpAnswer(sdpOffer) 
          } 
           … 
      }, MediaConstraints() 
  ) 
} 
 

Первый пир, получив SDP-ответ, сохраняет его у себя

 
fun onSdpAnswerReceive(sdpAnswer: SessionDescription) { 
  peerConnection.setRemoteDescription(sdpObserver, sdpAnswer) 
  sendSdpAnswer() 
}
 

После успешного обмена объектами SessionDescription логическое соединение считается установленным.

Физическое соединение

Теперь нужно установить физическое соединение между устройствами — это чаще всего сложная задача. Обычно устройства в интернете не имеют публичных IP-адресов, потому что находятся за роутерами и межсетевыми экранами. Чтобы решить эту проблему, в WebRTC используется технология ICE (Interactive Connectivity Establishment).

Важной частью ICE являются серверы STUN и TURN. Их задача — помочь установить соединение между устройствами, у которых нет публичных IP-адресов.

Stun-сервер

Устройство запрашивает у STUN-сервера свой публичный IP-адрес и получает его в ответ. Затем с помощью сигнального механизма передаёт этот адрес собеседнику. Когда собеседник проделает то же самое, оба устройства узнают, где друг друга искать в сети, и смогут начать обмен данными.

Turn-сервер

В некоторых случаях роутер может использовать режим «Symmetric NAT». Он не позволяет устройствам напрямую устанавливать соединение. Тогда на помощь приходит TURN-сервер: он выступает посредником, и весь трафик проходит через него. Подробнее об этом можно почитать в документации по WebRTC от Mozilla.

Как мы увидели, STUN и TURN сервера играют важную роль в установке физического соединения между устройствами. Именно для этой цели мы, при создании объекта PeerConnection, передаем список с доступными ICE-серверами.

Для установки физического соединения один пир генерирует ICE-кандидаты — объекты, содержащие информацию о том, как устройство можно найти в сети, и отправляет их через сигнальный механизм собеседнику

 
lateinit var peerConnection: PeerConnection  
fun createPeerConnection(iceServers: List) { 
  val rtcConfig = PeerConnection.RTCConfiguration(iceServers) 
  peerConnection = peerConnectionFactory.createPeerConnection( 
      rtcConfig, 
      object : PeerConnection.Observer { 
          override fun onIceCandidate(iceCandidate: IceCandidate) { 
              signaling.sendIceCandidate(iceCandidate) 
          }           … 
      } 
  )!! 
}
 

Далее второй пир, получив через сигнальный механизм ICE-кандидаты первого пира, сохраняет их. При этом он сам генерирует свои ICE-кандидаты и отправляет их обратно

 
fun onIceCandidateReceive(iceCandidate: IceCandidate) { 
  peerConnection.addIceCandidate(iceCandidate) 
}
 

Теперь, когда узлы обменялись адресами, можно приступать к передаче и приёму данных.

Получение данных

Библиотека, установив логическое и физическое соединение с собеседником, вызовет колбэк onAddTrack и передаст в него объект MediaStream, содержащий VideoTrack и AudioTrack собеседника

 
fun createPeerConnection(iceServers: List) { 
   val rtcConfig = PeerConnection.RTCConfiguration(iceServers) 
   peerConnection = peerConnectionFactory.createPeerConnection( 
       rtcConfig, 
       object : PeerConnection.Observer { 
           override fun onIceCandidate(iceCandidate: IceCandidate) { … } 
           override fun onAddTrack( 
               rtpReceiver: RtpReceiver?, 
               mediaStreams: Array 
           ) { 
               onTrackAdded(mediaStreams) 
           } 
           …  
       } 
   )!! 
}
 

Дальше нужно получить VideoTrack из MediaStream и отобразить его на экране.

 
private fun onTrackAdded(mediaStreams: Array) { 
   val videoTrack: VideoTrack? = mediaStreams.mapNotNull {                                                    
       it.videoTracks.firstOrNull()  
   }.firstOrNull() 
   displayVideoTrack(videoTrack) 
   …  
}
 

Для отображения VideoTrack нужно передать объект, реализующий интерфейс VideoSink. Для этого библиотека предоставляет класс SurfaceViewRenderer.

 
 fun displayVideoTrack(videoTrack: VideoTrack?) { 
   videoTrack?.addSink(binding.surfaceViewRenderer) 
} 
 

Для получения звука собеседника нам не нужно делать ничего дополнительного — библиотека все сделает за нас. Но все же, если мы хотим более тонкой настройки звука, можно получить объект AudioTrack и через него менять настройки аудио

 
 var audioTrack: AudioTrack? = null 
private fun onTrackAdded(mediaStreams: Array) { 
   …  
   audioTrack = mediaStreams.mapNotNull {  
       it.audioTracks.firstOrNull()  
   }.firstOrNull() 
} 
 

Например, следующим образом мы можем замьючить собеседника

 
 fun muteAudioTrack() { 
   audioTrack.setEnabled(false) 
} 
 

Отправка данных

Отправка видео и аудио со своего устройства также начинается с создания объекта PeerConnection и отправки IceCandidate-ов. Но в отличие от создания SDPOffer при получении видеопотока собеседника, в данном случае мы должны сначала создать объект MediaStream, который включает в себя AudioTrack и VideoTrack.

Для отправки своих аудио и видеопотоков, нужно создать объект PeerConnection, после чего при помощи сигнального механизма обменяться IceCandidate и SDP пакетами. Но вместо того, чтобы получить от библиотеки медиапоток собеседника, мы должны получить медиапоток нашего устройства и передать его в библиотеку, чтобы она передала его нашему собеседнику

 
 fun createLocalConnection() {  
   localPeerConnection = peerConnectionFactory.createPeerConnection( 
       rtcConfig, 
       object : PeerConnection.Observer { 
            ... 
       } 
   )!! 
   val localMediaStream = getLocalMediaStream() 
   localPeerConnection.addStream(localMediaStream) 
   localPeerConnection.createOffer( 
       object : SdpObserver { 
            ... 
       }, MediaConstraints() 
   ) 
}
 

Теперь нам нужно создать объект MediaStream и передать в него объекты AudioTrack и VideoTrack

 
 val context: Context 
private fun getLocalMediaStream(): MediaStream? { 
   val stream = peerConnectionFactory.createLocalMediaStream("user") 
   val audioTrack = getLocalAudioTrack() 
   stream.addTrack(audioTrack) 
   val videoTrack = getLocalVideoTrack(context) 
   stream.addTrack(videoTrack) 
   return stream 
} 
 

Получить AudioTrack можно следующим образом

 
 private fun getLocalAudioTrack(): AudioTrack { 
   val audioConstraints = MediaConstraints() 
   val audioSource = peerConnectionFactory.createAudioSource(audioConstraints) 
   return peerConnectionFactory.createAudioTrack("user_audio", audioSource) 
} 
 

Получение VideoTrack происходит чуть сложнее. Сначала получаем список всех камер устройства:

 
lateinit var capturer: CameraVideoCapturer 
private fun getLocalVideoTrack(context: Context): VideoTrack { 
   val cameraEnumerator = Camera2Enumerator(context) 
   val camera = cameraEnumerator.deviceNames.firstOrNull { 
       cameraEnumerator.isFrontFacing(it) 
   } ?: cameraEnumerator.deviceNames.first() 
   ... 
}
 

Далее нужно создать объект CameraVideoCapturer, который и будет захватывать изображение

 
private fun getLocalVideoTrack(context: Context): VideoTrack { 
   ... 

   capturer = cameraEnumerator.createCapturer(camera, null) 
   val surfaceTextureHelper = SurfaceTextureHelper.create( 
       "CaptureThread", 
       EglBase.create().eglBaseContext 
   ) 
   val videoSource = 
       peerConnectionFactory.createVideoSource(capturer.isScreencast ?: false) 
   capturer.initialize(surfaceTextureHelper, context, videoSource.capturerObserver) 
   
   ... 
} 
 

Теперь, после получения CameraVideoCapturer начинаем захватывать изображение и добавляем его в MediaStream

 
 private fun getLocalMediaStream(): MediaStream? { 
  ... 
  val videoTrack = getLocalVideoTrack(context) 
  stream.addTrack(videoTrack) 
  return stream 
} 
private fun getLocalVideoTrack(context: Context): VideoTrack { 
    ... 
  capturer.startCapture(1024, 720, 30) 
  return peerConnectionFactory.createVideoTrack("user0_video", videoSource) 
} 
 

После создания MediaStream и добавления его в PeerConnection библиотека формирует SDP-offer, и начинается обмен SDP-пакетами через сигнальный механизм, как описано выше. По завершении этого процесса собеседник начнёт получать наш видеопоток. С этого момента соединение считается установленным.

  • Разработка