Статья рассчитана на читателя продвинутого уровня, уже знакомого с 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):
Второй замер (GPU):
Как видим, чудес не бывает. Ключевые особенности каждого способа заметно отражаются на производительности.
Загрузка 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-устройстве.