Статья рассчитана на читателя продвинутого уровня, уже знакомого с Jetpack Compose и Android-разработкой в целом.

Привет! Меня зовут Владимир, и я мобильный разработчик в компании Финам. В своей практике мы активно используем Android Jetpack Compose, который зарекомендовал себя с лучшей стороны.

В статье я хочу показать простой способ решения известной в Android-разработке проблемы – проигрывания видео-файла с полноценной прозрачностью. В Compose для этого пока нет готовых компонентов,  поэтому разработчику приходится придумывать разные хитрости.

Какая может быть польза от этого решения? Ответ очевиден – любая сложная анимация в приложении с минимальным размером. Например, мультик на картинке для привлечения внимания занимает всего 370 КБ памяти при размере кадра 480х270.

Откуда вообще взялась эта проблема? Дело в том, что не все кодеки в Android поддерживают альфа-канал в кадре (потенциальные кандидаты – H.265, VP8, VP9). Производителей много, но никто не гарантирует, что файл проиграется штатными средствами как положено. Чаще всего поддержки прозрачности просто нет совсем! А в мобильной разработке, особенно на Android, очень важно получить стабильный и предсказуемый продукт на максимальном охвате клиентских устройств.

В Интернете уже есть несколько статей на эту тему, и даже есть готовый работающий код. Я нашел два основных информационных источника, заслуживающих внимания: раз и два. Оба описывают почти один и тот же способ. Но первый – как это сделать в xml-разметке, второй – адаптирует первый способ на Compose.

В основе всех способов (в том числе того, который предлагается в этой статье) лежит общий принцип восстановления прозрачности видеокадра по маске. Это означает, что видео-файл, уже включенный в ресурсы приложения, должен быть подготовлен специальным образом. Для этого сначала основной видеопоток разделяется на два параллельных – на цветовой (RGB) и альфа-маску. А затем оба потока в подготовленном файле «склеиваются» в один, где каждый занимает половину кадра.

Примерно так выглядит подготовленный видео-файл при проигрывании обычным плеером.
Примерно так выглядит подготовленный видео-файл при проигрывании обычным плеером.

Подготовить любой видео-файл для упаковки в ресурсы приложения можно с помощью всем известной утилиты ffmpeg:

ffmpeg -i input_file.mov -vf "split [a], pad=iw*2:ih [b], [a] alphaextract, [b] overlay=w" -c:v libx264 -s 960x270 output_file.mp4

Как уже описано в упомянутых выше источниках, далее для отрисовки анимированного изображения в общую верстку экрана добавляется полотно для рисования с контекстом OpenGL (GLSurfaceView или TextureView). А также экземпляр видеоплеера, которому передается ссылка на ресурс подготовленного видео-файла для проигрывания. При этом в процесс рендеринга изображения видео-потока встроен специальный пиксельный шейдер, склеивающий две половинки кадра в одну – в формате RGBA (цвет с прозрачностью). Таким образом, картинка обретает прозрачность на этапе манипуляций с изображением в контексте OpenGL, с чем он хорошо справляется на большинстве Android-устройств.

Второй упомянутый способ для Compose по сути делает тоже самое, что и первый. Но вместо стандартного MediaPlayer предлагается использовать ExoPlayer, обернутый TextureView в Compose-совместимый компонент AndroidView (от Compose в этом случае – только interop-обертка для View).

Меньше посредников, больше контроля

Я предлагаю сделать с заранее подготовленным видео-файлом примерно то же самое, но упростить процесс до двух минимально необходимых звеньев: видео-кодека и непосредственно самого Compose в чистом виде без оберток.

Для начала напишем свой удобный компонент для извлечения сырых данных из видео-файла для последующего декодирования. Внешний интерфейс нашего компонента будет таким:

interface VideoDataSource {
    fun getMediaFormat(): MediaFormat
    fun getNextSampleData(): ByteBuffer
}

Для реализации компонента воспользуемся стандартным классом Android для извлечения данных из медиа-контейнеров — MediaExtractor. Один экземпляр класса имплементации будет отвечать за чтение одного файла. Для этого добавим простую фабрику:

object VideoDataSourceFactory {

    fun getVideoDataSource(context: Context, uri: Uri): VideoDataSource {
        return VideoDataSourceImpl(context = context, uri = uri)
    }
}

Методы нашего компонента:

  • getMediaFormat(): получить структуру MediaFormat с описанием характеристик открытого файла – она нам понадобится для настройки кодека;

  • getNextSampleData(): прочитать очередную порцию сырых данных видео-потока (для последующей передачи кодеку).

Код класса нашего компонента:

Hidden text
internal class VideoDataSourceImpl(context: Context, uri: Uri) : VideoDataSource {

    private val mediaExtractor = MediaExtractor().apply {
        setDataSource(context, uri, null)
        setVideoTrack()
    }

    private var mediaFormat: MediaFormat? = null

    private var initialSampleTime: Long = 0L

    private val dataBuffer = ByteBuffer
        .allocate(SAMPLE_DATA_BUFFER_SIZE)
        .apply { limit(0) }

    override fun getMediaFormat(): MediaFormat {
        return mediaFormat!!
    }

    override fun getNextSampleData(): ByteBuffer {
        if (!dataBuffer.hasRemaining()) {
            mediaExtractor.readSampleData(dataBuffer, 0)
            if (!mediaExtractor.advance()) {
                mediaExtractor.seekTo(initialSampleTime, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
            }
        }
        return dataBuffer
    }

    private fun MediaExtractor.setVideoTrack() {
        val availableMimeTypes =
        	(0 until trackCount).mapNotNull { getTrackFormat(it).getString(MediaFormat.KEY_MIME) }

        val videoTrackIndex = availableMimeTypes
            .indexOfFirst { it.startsWith("video/") }
            .takeIf { it >= 0 }

        this.selectTrack(requireNotNull(videoTrackIndex))

        mediaFormat = this.getTrackFormat(videoTrackIndex)
        initialSampleTime = this.sampleTime
    }
}

private const val SAMPLE_DATA_BUFFER_SIZE = 100_000

Компонент в целях демонстрации бесконечно «зацикливает» чтение данных простым условием:

if (!mediaExtractor.advance()) {
            mediaExtractor.seekTo(initialSampleTime, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
}

Далее нам необходим компонент для декодирования сырых данных видео-потока, интерфейс которого будет иметь всего один метод:

interface VideoFramesDecoder {
    fun getOutputFramesFlow(inputSampleDataCallback: () -> ByteBuffer): Flow<Bitmap>
}

Единственный метод компонента будет возвращать Flow с декодированными изображениями (кадрами) в виде класса Bitmap, готовыми для отрисовки. Для реализации компонента воспользуемся стандартным классом Android для декодирования видео-потока — MediaCodec.

Создавать экземпляр класса компонента будем так же через фабрику:

object VideoFramesDecoderFactory {

    fun getVideoFramesDecoder(mediaFormat: MediaFormat): VideoFramesDecoder {
        return VideoFramesDecoderImpl(mediaFormat = mediaFormat)
    }
}

Код класса нашего компонента:

Hidden text
internal class VideoFramesDecoderImpl(private val mediaFormat: MediaFormat) : VideoFramesDecoder {

    private val mimeType = mediaFormat.getString(MediaFormat.KEY_MIME)!!
    private val frameRate = mediaFormat.getInteger(MediaFormat.KEY_FRAME_RATE)

    private val nowMs: Long
        get() = System.currentTimeMillis()

    private val random = Random(nowMs)

    override fun getOutputFramesFlow(inputSampleDataCallback: () -> ByteBuffer): Flow<Bitmap> {
        return channelFlow {
            val threadName = "${this.javaClass.name}_HandlerThread_${random.nextLong()}"
            val handlerThread = HandlerThread(threadName).apply { start() }
            val handler = Handler(handlerThread.looper)

            val decoder = MediaCodec.createDecoderByType(mimeType)

            val frameIntervalMs = (1_000f / frameRate).toLong()
            var nextFrameTimestamp = nowMs

            val callback = object : MediaCodec.Callback() {

                override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
                    runCatching {
                        val sampleDataBuffer = inputSampleDataCallback()
                        val bytesCopied = sampleDataBuffer.remaining()
                        codec.getInputBuffer(index)?.put(sampleDataBuffer)
                        codec.queueInputBuffer(index, 0, bytesCopied, 0, 0)
                    }
                }

                override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
                    runCatching {
                        codec.getOutputImage(index)?.let { frame ->
                            val bitmap = frame.toBitmap()
                            val diff = (nextFrameTimestamp - nowMs).coerceAtLeast(0L)
                            runBlocking { delay(diff) }
                            trySend(bitmap)
                            nextFrameTimestamp = nowMs + frameIntervalMs
                        }
                        codec.releaseOutputBuffer(index, false)
                    }
                }

                override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) = Unit

                override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) = Unit
            }

        	decoder.apply {
            	    setCallback(callback, handler)
            	    configure(mediaFormat, null, null, 0)
            	    start()
        	}

            awaitClose {
         	     decoder.apply {
                	stop()
                	release()
            	     }
            }
        }.conflate()
    }
}

В методе getOutputFramesFlow() класс создает и возвращает ChannelFlow, удобный для работы с callback-вызовами, в нашем случае с MediaCodec.Callback().

Через обратные вызовы onInputBufferAvailable() и onOutputBufferAvailable() кодек сообщает о готовности входного и выходного буфера соответственно.

Если готов очередной входной буфер, то отдаем ему порцию прочитанных сырых данных, возвращаемых функцией inputSampleDataCallback. А по готовности выходного буфера – читаем массив байтов изображения и отдаем по подписке всем потребителям данных нашего Flow.

Перед отправкой изображения подписчикам производим задержку, равную межкадровому интервалу (в миллисекундах это 1000/FrameRate). Задержка сделана по-простому, через не-suspend блокировку потока (runBlocking). Для тестовой среды этого вполне достаточно: один отдельно выделенный поток в период ожидания не будет потреблять ресурс CPU и оказывать влияние на результат измерений.

Затем сводим все компоненты вместе в один несложный Compose-виджет:

@Composable
fun VideoAnimationWidget(
    @RawRes resourceId: Int,
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current
    var lastFrame by remember { mutableStateOf<Bitmap?>(null) }

    LaunchedEffect(resourceId) {
        withContext(Dispatchers.IO) {
            val videoDataSource = VideoDataSourceFactory.getVideoDataSource(
                context = context,
                uri = context.getUri(resourceId = resourceId)
            )
            val videoFramesDecoder = VideoFramesDecoderFactory.getVideoFramesDecoder(
                mediaFormat = videoDataSource.getMediaFormat()
            )

            videoFramesDecoder
                .getOutputFramesFlow(inputSampleDataCallback = { videoDataSource.getNextSampleData() })
                .collectLatest { lastFrame = it }
        }
    }

    Canvas(modifier = modifier) {
        lastFrame?.let { frame ->
            drawImage(
                image = frame.asImageBitmap(),
                topLeft = Offset(
                    x = (size.width - frame.width) / 2,
                    y = (size.height - frame.height) / 2
                ),
                blendMode = BlendMode.SrcOver
            )
        }
    }
}

В LaunchedEffect создаем источник данных и подписываемся на Flow, отдающий текущий кадр для отрисовки. Высвобождение ресурсов и закрытие файла происходит автоматически внутри компонента-декодера (по отписке от Flow), поэтому внутри виджета ничего для этого специально не делаем. В Canvas просто рисуем последний текущий кадр.

Всё! Минимальный набор в Compose для видео с прозрачностью готов.

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

Стандартный кодек Android в callback-функции возвращает изображение в формате YUV_420_888 (класс Image). И для отрисовки на Canvas его еще надо как-то преобразовать в понятные всем RGBA-пиксели. А заодно восстановить прозрачность каждого пикселя (мы же подготовили наш файл заранее, разделив цветовую и альфа составляющие на две половинки кадра).

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

И эти вычисления, к слову, будут выполняться на CPU, а не графическом процессоре устройства. Да, это цена, которую надо заплатить за гибкость… Но об этом далее.

Код извлечения конечного изображения в формате RGBA сразу с умножением на альфа-маску:

Hidden text
    private fun Image.getBitmapWithAlpha(buffers: Buffers): ByteArray {
        val yBuffer = this.planes[0].buffer
        yBuffer.get(buffers.yBytes, 0, yBuffer.remaining())

        val uBuffer = this.planes[1].buffer
        uBuffer.get(buffers.uBytes, 0, uBuffer.remaining())

        val vBuffer = this.planes[2].buffer
        vBuffer.get(buffers.vBytes, 0, vBuffer.remaining())

        val yRowStride = this.planes[0].rowStride
        val yPixelStride = this.planes[0].pixelStride

        val uvRowStride = this.planes[1].rowStride
        val uvPixelStride = this.planes[1].pixelStride

        val halfWidth = this.width / 2

        for (y in 0 until this.height) {
            for (x in 0 until halfWidth) {

                val yIndex = y * yRowStride + x * yPixelStride
                val yValue = (buffers.yBytes[yIndex].toInt() and 0xff) - 16

                val uvIndex = (y / 2) * uvRowStride + (x / 2) * uvPixelStride
                val uValue = (buffers.uBytes[uvIndex].toInt() and 0xff) - 128
                val vValue = (buffers.vBytes[uvIndex].toInt() and 0xff) - 128

                val r = 1.164f * yValue + 1.596f * vValue
                val g = 1.164f * yValue - 0.392f * uValue - 0.813f * vValue
                val b = 1.164f * yValue + 2.017f * uValue

                val yAlphaIndex = yIndex + halfWidth * yPixelStride
                val yAlphaValue = (buffers.yBytes[yAlphaIndex].toInt() and 0xff) - 16

                val uvAlphaIndex = uvIndex + this.width * uvPixelStride
                val vAlphaValue = (buffers.vBytes[uvAlphaIndex].toInt() and 0xff) - 128

                val alpha = 1.164f * yAlphaValue + 1.596f * vAlphaValue

                val pixelIndex = x * 4 + y * 4 * halfWidth

                buffers.bitmapBytes[pixelIndex + 0] = (r * alpha / 255f).toInt().coerceIn(0, 255).toByte()
                buffers.bitmapBytes[pixelIndex + 1] = (g * alpha / 255f).toInt().coerceIn(0, 255).toByte()
                buffers.bitmapBytes[pixelIndex + 2] = (b * alpha / 255f).toInt().coerceIn(0, 255).toByte()
                buffers.bitmapBytes[pixelIndex + 3] = alpha.toInt().coerceIn(0, 255).toByte()
            }
        }

        return buffers.bitmapBytes
    }

Производительность

Теперь оценим применимость этого способа, сравнив его производительность с рендерингом в OpenGL.

Для замеров скорости работы и потребления ресурсов я не стал дополнять код супер-модными бенчмарками, прогревая сборщик мусора и кэши всех видов. Вместо этого я выбрал самый простой подход – на одном и том же эмуляторе был запущен рендеринг одного видео-файла двумя разными способами. А стандартными инструментами профилирования записаны результаты загрузки центрального процессора (CPU) и графической подсистемы (GPU) в виде красивых графиков.

Параметры эмулятора (Android API 34):

Параметры ПК (ноутбук), на котором проводились эксперименты:

Intel Core i5-12500H, RAM 40 ГБ, GeForce RTX 3050 4 ГБ

Первый замер (CPU):

CPU загрузка: рисование в Compose
CPU загрузка: рисование в Compose
CPU загрузка: OpenGL с шейдером
CPU загрузка: OpenGL с шейдером

Второй замер (GPU):

GPU рендеринг: рисование в Compose
GPU рендеринг: рисование в Compose
GPU рендеринг: OpenGL с шейдером
GPU рендеринг: OpenGL с шейдером

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

Загрузка CPU выше у чистого Compose-рисования, так как основные вычисления происходят в функции преобразования каждого кадра (из формата YUV_420_888 в формат RGBA). В рендеринге OpenGL это делает плеер (кодек), тесно связанный с контекстом OpenGL, и GPU-шейдеры. Это снимает всю вычислительную нагрузку с CPU.

На GPU-диаграмме видим ту же картину: время на подготовку кадра в OpenGL уходит заметно больше (красная область). Compose почти не тратит ресурс GPU (только на свой внутренний механизм рисования). Отличие в оранжевых областях (сплошное поле против редких баров) я списываю на особенности работы обоих подсистем. Эта область для Compose выглядит точно так же, даже если запустить простейшую векторную анимацию.

Вместо выводов

Цель статьи – показать Jetpack Compose с еще одной хорошей стороны, но ни в коем случае не мотивировать использовать его абсолютно везде. Каждому инструменту – свой случай.

Рендеринг с помощью OpenGL (GLSurfaceView, TextureView), по моему мнению, предназначен для видео-анимации с поверхностью отображения в единственном числе (идеально для видеоплеера и игрового приложения). С увеличением числа полотен рендеринга нагрузка на GPU (да и CPU тоже) кратно возрастает. У меня даже получилось «уронить» эмулятор высокой нагрузкой (уже на 20 одновременно запущенных анимациях OpenGL). При этом аварийное завершение процессов произошло не в приложении, а именно в самом виртуальном устройстве.

Способ же, предложенный в статье, может быть уместен в случаях, когда шаблонная анимация нужна во множественном числе в один момент времени. Например, когда нужны живые метки на карте. В этом случае будет достаточно только одного компонента с кодеком, отдающим через Flow кадры отрисовки всем подписчикам. При этом нагрузка на ресурсы устройства не будет возрастать с ростом числа виджетов на экране.

Исходный код для самостоятельного тестирования тут, там же есть готовая release-сборка для быстрого запуска на Android-устройстве.

Комментарии (2)


  1. alexpurs1980
    12.07.2024 08:27
    +1

    Отлично. Полезно. Спасибо!


  1. faritowich
    12.07.2024 08:27

    Сложно, но интересно)


  1. faritowich
    12.07.2024 08:27

    Сложно, но интересно)