Если вы решили сделать собственное приложение для стриминга на Android, при разработке нужно учесть множество разных нюансов. Например, зрители могут свернуть вашу трансляцию в процессе просмотра, а через какое-то время вернуться обратно. Как должно при этом работать приложение? Должна ли трансляция приостановиться или идти фоном?
Какое именно поведение реализовать в подобной ситуации — решать вам. Но очень важно предусмотреть подобные моменты технически, чтобы при сворачивании всё работало так, как вы задумали.
Этот материал — продолжение моей предыдущей статьи про создание мобильного приложения для стриминга на Android. В ней я рассказывал о базовых моментах разработки. А сейчас поговорим о нюансах. Расскажу, как технически реализовать приостановку трансляции и фоновый стриминг на Android с помощью опенсорс-библиотеки rtmp-rtsp-stream-client-java.
Фоновый стриминг
Сначала разберём кейс, когда приложение переходит в фон и обратно на передний план. Если заглянуть чуть глубже в исходный код rtmp-rtsp-stream-client-java, станет понятно, что стриминг сам по себе проходит в отдельном потоке:
package com.pedro.rtmp.rtmp
class RtmpClient(private val connectCheckerRtmp: ConnectCheckerRtmp) {
//...
@JvmOverloads
fun connect(url: String?, isRetry: Boolean = false) {
//...
if (!isStreaming || isRetry) {
//...
isStreaming = true
thread = Executors.newSingleThreadExecutor()
thread?.execute post@{
try {
if (!establishConnection()) {
connectCheckerRtmp.onConnectionFailedRtmp("Handshake
failed")
return@post
}
val writer = this.writer ?: throw IOException("Invalid writer,
Connection failed")
commandsManager.sendChunkSize(writer)
commandsManager.sendConnect("", writer)
//read packets until you did success connection to server and
you are ready to send packets
while (!Thread.interrupted() && !publishPermitted) {
//Handle all command received and send response for it.
handleMessages()
}
//read packet because maybe server want send you something
while streaming
handleServerPackets()
} catch (e: Exception) {
Log.e(TAG, "connection error", e)
connectCheckerRtmp.onConnectionFailedRtmp("Error configure
stream, ${e.message}")
return@post
}
}
}
}
//...
}
Это очень упрощает задачу. Получается, нам не нужно пытаться самим вынести этот процесс в отдельный поток.
Но кроме этого нужно учитывать жизненный цикл компонента, в котором у нас инициализируется стриминг, чтобы быть уверенными, что с нашим объектом для стриминга и с самим вещанием ничего не произойдет. Поэтому я решил инициализировать стриминг во ViewModel. Он остается живым на протяжении всех жизненных процессов компонента, к которому привязан (Activity, Fragment).
Замечу, что это лишь один из способов, и можно использовать и другие: например, Foreground Service.
В жизненном цикле ViewModel ничего не изменится, даже если произойдет смена конфигурации, ориентации, переход в фон или что-нибудь ещё в этом роде. Но одна проблема всё-таки есть. Для стриминга нужно создать объект RtmpCamera2(). Он зависит от объекта OpenGlView, а это элемент UI, и значит, он уничтожится при переходе приложения в фон. И дальнейшее вещание станет невозможно.
К счастью, в библиотеке предусмотрена возможность заменять на лету View объекта RtmpCamera2. Мы можем заменить её любым объектом нашего приложения, в том числе Context, который живёт, пока сервис не уничтожен системой, или пользователь сам не закрыл его.
В итоге, индикатором перехода приложения в фон будем считать уничтожение объекта OpenGlView. А возврат на передний план, соответственно, создание этого View. Значит, нужно реализовать для этого соответствующий коллбэк:
private val surfaceHolderCallback = object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
viewModel.appInForeground(binding.openGlView)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int,
height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
viewModel.appInBackground()
}
}
binding.openGlView.holder.addCallback(surfaceHolderCallback)
Как вы уже могли догадаться, мы будем заменять объект OpenGlView на объект Context при переходе в фон и обратно при возврате на передний план. Для этого во ViewModel определим соответствующие методы.
class StartBroadcastViewModel(application: Application) :
AndroidViewModel(application) {
//...
fun appInForeground(openGlView: OpenGlView) {
rtmpCamera2?.let {
it.replaceView(openGlView)
it.startPreview(
StreamParameters.resolution.width,
StreamParameters.resolution.height
)
}
}
fun appInBackground() {
rtmpCamera2?.let {
it.stopPreview()
it.replaceView(getApplication() as Context)
}
}
//..override fun onCleared() {
super.onCleared()
rtmpCamera2?.let {
if (it.isStreaming) {
_streamState.value = StreamState.STOP
it.stopStream()
}
it.stopPreview()
}
}
}
Также нужно остановить трансляцию при уничтожении ViewModel.
Приостановка трансляции
К сожалению, в библиотеке rtmp-rtsp-stream-client-java не реализована функция приостановки стриминга с сохранением соединения с сервером. Приходится останавливать трансляцию и заново стартовать, а это приводит к лишним задержкам. Чтобы решить эту проблему, я решил имитировать приостановку трансляции отключением камеры и микрофона. Эти функции в библиотеке как раз были доступны.
В этом случае соединение с сервером не обрывается, и задержка при возобновлении трансляции не превышает 8 секунд (стандартная задержка в трансляциях). При этом битрейт при имитации снижается до 70-80 Кбит/с, а значит лишний интернет-трафик практически не расходуется.
//...
fun resumeStream() {
rtmpCamera2?.let {
it.enableAudio()
it.glInterface.unMuteVideo()
_streamState.value = StreamState.PLAY
}
}
fun pauseStream() {
rtmpCamera2?.let {
it.disableAudio()
it.glInterface.muteVideo()
_streamState.value = StreamState.PAUSE
}
}
//...
Как видите, реализовать и фоновый стриминг, и приостановку довольно просто. И rtmp-rtsp-stream-client-java даёт для этого все возможности.