Статья рассчитана на читателя продвинутого уровня, уже знакомого с 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 960×270 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-устройстве.
ссылка на оригинал статьи https://habr.com/ru/articles/828322/
Добавить комментарий