Как создать нативный видео-чат на Android с использованием WebRTC

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

Создание WebRTC-соединения состоит из двух этапов:
- Установка логического соединения — устройства должны согласовать формат данных, кодеки и так далее.
- Установка физического соединения — устройства должны знать адреса друг друга
Для начала отметим, что на этапе установления соединения для обмена данными между устройствами используется сигнальный механизм. В качестве такого механизма может выступать любой канал передачи данных — например, сокеты.
Предположим, мы хотим установить видеосвязь между двумя устройствами. Для этого нужно создать логическое соединение между ними.
Логическое соединение
Логическое соединение устанавливается с использованием протокола описания сессии (SDP). Для этого один из пиров:
- Создаёт объект PeerConnection
- Формирует 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()
)
}
В свою очередь, другой пир:
- Также создает объект PeerConnection
- При помощи сигнального механизма получает SDP-offer, отправленный первым пиром, и сохраняет его у себя
- Формирует 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-пакетами через сигнальный механизм, как описано выше. По завершении этого процесса собеседник начнёт получать наш видеопоток. С этого момента соединение считается установленным.
