Всем привет! Меня зовут Антон Урывский, я 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. У меня было два варианта решения:

  1. Алгоритм быстрого размытия.

  2. 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 и не ограничивать форму контейнеров.

Записки выжившего

  1. Поскольку вам придётся дождаться отрисовки контента, который надо заблюрить, помните про небольшой визуальный лаг. Нивелировать его легко, особенно если вы загружаете картинку по URI/URL. Эти два процесса можно закрыть общим плейсхолдером.

  2. Библиотеки Capturable и HardwareBuffer под капотом пишут всё в Immutable Hardware Bitmap, а постобработка на CPU вызовет ошибку. Поэтому перед манипуляциями необходимо перевести всё в формат Software и разрешить редактирование с помощью Bitmap.copy(config, isMutable).

  3. Не забывайте «проносить» заблюренную Bitmap через композиции и конфигурации, чтобы не повторять операции каждый раз. Доступный rememberSaveable не подойдёт, поэтому хранить файл придётся где‑то ещё. У меня это ViewModel.

  4. Чтобы заблюрить требуемый кусок, учитывайте иерархию сomposable‑функций. Если вложенность нулевая, то для определения области нашего контента хватит обычного onGloballyPositioned { positionInParent() }. В более глубоких случаях рекомендую посмотреть в сторону positionInRoot / positionInWindow или передавать в наш контейнер Rect и получать его в удобном месте.

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

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


  1. grishkaa
    25.06.2024 09:02
    +1

    Не знаю, что там как в этом compose, но битмапов лучше избегать по максимуму, потому что это программный рендеринг. Я экспериментировал немного с RenderEffect и RuntimeShader, в итоге нашёл способ отрисовывать подложку в RenderNode полностью аппаратно. Просто берём и делаем вот так:

    RecordingCanvas nc=bgNode.beginRecording(getWidth(), getHeight());
    viewBehind.draw(nc);
    bgNode.endRecording();
    

    То есть, отрисовываем размываемую вьюшку второй раз. И инвалидируем из onDescendantInvalidated в ней же.

    Скриншот того, что получилось