Всем привет! Меня зовут Антон Урывский, я 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
и получать его в удобном месте.
На этом я заканчиваю свой рассказ и надеюсь, что мой опыт поможет вам с лёгкостью решить нестандартную задачу.
grishkaa
Не знаю, что там как в этом compose, но битмапов лучше избегать по максимуму, потому что это программный рендеринг. Я экспериментировал немного с RenderEffect и RuntimeShader, в итоге нашёл способ отрисовывать подложку в RenderNode полностью аппаратно. Просто берём и делаем вот так:
То есть, отрисовываем размываемую вьюшку второй раз. И инвалидируем из
onDescendantInvalidated
в ней же.Скриншот того, что получилось