Как мы в Яндекс Путешествиях на Compose стёкла морозили

от автора

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/823992/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *