Всем привет! Меня зовут Антон Урывский, я Android‑разработчик в Яндекс Вертикалях. Мы создаём знакомые всем сервисы: Яндекс Путешествия, Недвижимость и Аренда, Авто.ру.
Сегодня я поделюсь опытом создания эффекта морозного стекла, который блюрит не содержимое View, а всё, что находится под ним. На iOS он достигается достаточно легко, а вот на Android всё не так просто, и я уверен, что многие разработчики сталкивались со сложностями при работе с ним.
Моё большое путешествие началось со специфического ТЗ: заблюрить всё под View. «Приятным бонусом» стали поддержка на API 28–35. Для тех, кто не в курсе, BlurRenderEffect из коробки доступен только с API 31 с реализацией средствами Compose. А для нас очень важно, чтобы приложение выглядело одинаково на всех смартфонах — даже на старых.
Вот и все вводные. Можем поплакать и начать.
Ждём контент
Несмотря на важность этого пункта, ничего интересного здесь я вам не расскажу. В Яндекс Путешествиях за меня это делает SubcomposeAsyncImage
из библиотеки coil, которая по onSuccess
сообщает, что изображение получено и нарисовано.
Копируем бэкграунд под вьюхой
В отличие от AndroidView, в Compose нет аналога View.toBitmap()
, а PixelCopy уже морально устарел и требует специфического контекста для корректной работы. Танцев с бубном нам не надо, поэтому мы используем инструменты самого Compose.
Если кратко, то для этого надо с помощью Modifier.drawWithCache
дождаться onDrawWithContent
и отрисовать контент в Canvas
.
Modifier .drawWithCache { val width = size.width.toInt() val height = size.height.toInt() onDrawWithContent { // Начинаем запись контента в picture val pictureCanvas = Canvas( picture.beginRecording( width, height ) ) // «Срисовываем» контент draw(this, layoutDirection, pictureCanvas, size) { this@onDrawWithContent.drawContent() } // Заканчиваем «запись» picture.endRecording() // Перерисовываем canvas в наш picture drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) } } }
Во время запуска мы столкнёмся с проблемой сохранения стейта. Если мы сначала сделаем скрин экрана, а потом при рекомпозиции обновим контент, то в результате задник окажется «просроченным», а это совсем не то, что нам нужно.
Разобраться с проблемой мне помогла статья, в которой разработчик предусмотрел изменение стейта и предоставил удобный интерфейс для работы с захватом контента.
Теперь, когда у нас в руках есть необходимая Bitmap, выходим на финишную прямую.
Блюрим
Основной проблемой будет блюр на версиях с API ниже 31. У меня было два варианта решения:
-
RenderScript (устарел для API 31 и выше).
Я выбрал второй пункт за скорость. На изображении 2220 × 1080 пикселей RS отрабатывает за 3–10 мс против 150–350 мс на FastBlur. Но есть недостаток: у RenderScript
потолок по радиусу размытия — 25 пикселей, а в алгоритме таких ограничений нет.
Работать со скриптом достаточно просто: создаём RenderScript
и эффект (в случае с блюром — ScriptIntrinsicBlur
), выделяем память под исходную и результирующую Bitmap
, задаём радиус размытия и даём команду к действию.
private fun legacyBlur(context: Context, inputBitmap: Bitmap, radius: Int): Bitmap { val outputBitmap = Bitmap.createBitmap(inputBitmap) // Подготавливаем RenderScript val script = RenderScript.create(context) val theIntrinsic = ScriptIntrinsicBlur.create(script, Element.U8_4(script)) // Выделяем память для исходной и результирующей bitmap val tmpIn = Allocation.createFromBitmap(script, inputBitmap) val tmpOut = Allocation.createFromBitmap(script, outputBitmap) // Делаем преобразование theIntrinsic.setRadius(radius.toFloat()) theIntrinsic.setInput(tmpIn) theIntrinsic.forEach(tmpOut) tmpOut.copyTo(outputBitmap) // Profit! return outputBitmap }
На Android 12 и выше используем новый API RenderEffect. Поскольку он работает на RenderNodes
, добавляем его к View
или Modifier
в одну строчку. Но для работы с Bitmap
надо обязательно сделать вираж через Canvas
. Поэтому необходимо создать мнимую ноду, применить к ней эффект блюра, прогнать через неё нашу Bitmap
и извлечь из неё результат.
@RequiresApi(Build.VERSION_CODES.S) private fun newBlur(bitmap: Bitmap, radius: Int): Bitmap? { // Готовим ридер val imageReader = ImageReader.newInstance( bitmap.width, bitmap.height, PixelFormat.RGBA_8888, 1, HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT, ) // Готовим RenderNode и HardwareRenderer val renderNode = RenderNode("BlurEffect") val hardwareRenderer = HardwareRenderer() // Передаём в renderer наш ридер hardwareRenderer.setSurface(imageReader.surface) hardwareRenderer.setContentRoot(renderNode) renderNode.setPosition(0, 0, imageReader.width, imageReader.height) // Наш блюр RenderEffect val blurRenderEffect = RenderEffect.createBlurEffect( radius.toFloat(), radius.toFloat(), Shader.TileMode.MIRROR, ) renderNode.setRenderEffect(blurRenderEffect) // Рисуем наш Bitmap в ноду val renderCanvas = renderNode.beginRecording() renderCanvas.drawBitmap( bitmap, 0f, 0f, null, ) renderNode.endRecording() // Блюрим! hardwareRenderer.createRenderRequest() .setWaitForPresent(true) .syncAndDraw() // Достаём нашу размытую картинку val image = imageReader.acquireNextImage() val hardwareBuffer = image.hardwareBuffer val outputBitmap = hardwareBuffer?.let { Bitmap.wrapHardwareBuffer(it, null) } // Чистим всё, где наследили hardwareBuffer?.close() image?.close() imageReader.close() renderNode.discardDisplayList() hardwareRenderer.destroy() // Profit! return outputBitmap }
Выводим
Я решил не заморачиваться с Modifier
и сделал простую Compose‑функцию. Простор для улучшений кода ещё есть, поэтому обойдёмся основной идеей:
-
ждём, пока вьюха «измерится» и займёт свое место;
-
читаем
Rect
нашей вьюхи черезonGloballyPositioned
; -
рисуем
Canvas
по этомуRect
; -
с помощью
Path
«вырезаем» кусок размытойBitmap
и отрисовываем его.
Canvas( modifier = Modifier .matchParentSize() .width(sizeX.dp) .height(sizeY.dp) ) { val path = drawPath?.invoke(this) ?: Path().apply { addRoundRect( RoundRect( Rect( offset = Offset.Zero, size = Size( width = sizeX.toFloat(), height = sizeY.toFloat()), ) ) ) } clipPath(path) { if (blurredBg != null) { drawImage( image = blurredBg.asImageBitmap(), topLeft = Offset(-offsetX, -offsetY), colorFilter = ColorFilter.tint(color = color, BlendMode.SrcAtop), ) } else { drawPath( path = path, color = color, style = Fill ) } } }
После всех манипуляций получаем заветный результат!
При желании можно быстро добавить работу с кастомными Path
и не ограничивать форму контейнеров.
Записки выжившего
-
Поскольку вам придётся дождаться отрисовки контента, который надо заблюрить, помните про небольшой визуальный лаг. Нивелировать его легко, особенно если вы загружаете картинку по URI/URL. Эти два процесса можно закрыть общим плейсхолдером.
-
Библиотеки Capturable и HardwareBuffer под капотом пишут всё в
Immutable Hardware Bitmap
, а постобработка на CPU вызовет ошибку. Поэтому перед манипуляциями необходимо перевести всё в форматSoftware
и разрешить редактирование с помощьюBitmap.copy(config, isMutable)
. -
Не забывайте «проносить» заблюренную Bitmap через композиции и конфигурации, чтобы не повторять операции каждый раз. Доступный
rememberSaveable
не подойдёт, поэтому хранить файл придётся где‑то ещё. У меня этоViewModel
. -
Чтобы заблюрить требуемый кусок, учитывайте иерархию сomposable‑функций. Если вложенность нулевая, то для определения области нашего контента хватит обычного
onGloballyPositioned { positionInParent() }.
В более глубоких случаях рекомендую посмотреть в сторонуpositionInRoot
/positionInWindow
или передавать в наш контейнерRect
и получать его в удобном месте.
На этом я заканчиваю свой рассказ и надеюсь, что мой опыт поможет вам с лёгкостью решить нестандартную задачу.
ссылка на оригинал статьи https://habr.com/ru/articles/823992/
Добавить комментарий