Всем привет! Меня зовут Владимир, и я мобильный разработчик в компании «Финам».
Jetpack Compose, про хитрости которого пойдет речь в этой статье, уже уверенно вошел в индустрию мобильной разработки, но получение некоторых визуальных эффектов до сих пор не так очевидно, как хотелось бы.
Например – как нарисовать полупрозрачный заголовок с эффектом размытия над готовым экраном? Звучит достаточно просто, но на деле Compose не предоставляет для этого готовых инструментов. Приходится что-то изобретать. В данной статье приведен как раз один из способов «наложения» эффекта рендеринга на готовый контент (да-да, заголовок статьи именно про это – эффекты на готовой поверхности).
Дисклеймер: статья рассчитана на читателя продвинутого уровня, уже знакомого с Jetpack Compose и Android-разработкой в целом.

Когда в реальной задаче появилась подобная проблема, в сети был найден уже готовый рецепт (далее по тексту – первоисточник).
Автор вопроса в первоисточнике успешно решает данную проблему и даже приводит вполне себе рабочий код. Но код достаточно специфичный и привязан к верстке. Мы сделаем его более универсальным, а заодно попробуем понять, как это работает по шагам. А далее расширим пример, где вместо готового эффекта «замороженного стекла» сделаем свой эффект линзы при помощи шейдеров.
Сразу стоить проговорить про поддерживаемые версии Android. Всё, о чем здесь идет речь, актуально только для версии операционной системы 12 и выше (API 31). Но, статистика за 2024 год из разных источников показывает, что количество активных устройств с системой ниже 12 уже меньше половины от общего числа. На мой взгляд, это является вполне достаточным поводом повнимательней присмотреться к написанному.
Схематичное представление предложенного способа
Для создания сложных визуальных эффектов в Compose, таких как размытие поверх готового контента, предлагается использовать комбинацию компонентов GraphicsLayer и RenderNode. Этот подход позволяет захватить часть уже нарисованной поверхности, обработать её с помощью эффектов, и отрисовать поверх основного содержимого. При этом, если обработанную область накладывать строго по геометрическим размерам захватываемой части экрана, то создается эффект обработки «по месту».
Краткое описание возможностей GraphicsLayer из документации

Главная замечательная особенность GraphicsLayer в том, что мы можем захватывать любую часть содержимого экрана в отдельный графический слой для последующего использования. При этом надо отметить, что «запись» происходит без тяжелых операций графического вывода, создается и запоминается только так называемый DisplayList.
Из документации

Для применения эффектов к захваченной части экрана используется RenderNode – специальный класс, у которого тоже есть механизм «записи» нарисованных объектов. То есть содержимое отдельного графического слоя можно «перенаправить» в отдельный RenderNode, у которого предварительно может быть задан эффект обработки содержимого во время записи (класс RenderEffect). На самом последнем шаге содержимое RenderNode просто выводится на нужный канвас, как правило, в одном из графических модификаторов (например, Modifier.drawWithContent()).

Необходимые для такого способа условия:
контент, в котором захватывается часть поверхности, и обработанная область с эффектом должны иметь одного родителя в одной координатной системе (или, в противном случае, нужно обеспечить правильную синхронизацию координатных систем);
контент, в котором захватывается часть поверхности, должен иметь непрозрачный фон (или, в противном случае, нужно обязательно задать правильный подходящий фоновый цвет для области с эффектом).
Все взаимодействия компонентов при этом должны оставаться в пределах фазы графического вывода Compose, что обеспечит максимальную производительность.
«Talk is cheap. Show me the code»
Для начала создадим общий стейт, который будет содержать все нужные данные (графический слой и геометрические размеры области рендеринга с эффектом):
@Stable
data class GraphicsLayerRecordingState(
val graphicsLayer: GraphicsLayer,
val region: MutableState<Region>,
) {
sealed class Region {
data class Rectangle(val rect: Rect) : Region()
data class Circle(val center: Offset, val size: Size) : Region()
}
fun setRectRegion(rect: Rect) {
this.region.value = Region.Rectangle(rect = rect)
}
fun setCircleRegion(center: Offset, size: Size) {
this.region.value = Region.Circle(center = center, size = size)
}
}
И вспомогательные remember-функции к нему:
@Composable
fun rememberGraphicsLayerRecordingState(
regionState: MutableState<GraphicsLayerRecordingState.Region> = rememberGraphicsLayerRecordingRegion(),
): GraphicsLayerRecordingState {
val graphicsLayer = rememberGraphicsLayer()
return remember { GraphicsLayerRecordingState(graphicsLayer = graphicsLayer, region = regionState) }
}
@Composable
fun rememberGraphicsLayerRecordingRegion(): MutableState<GraphicsLayerRecordingState.Region> {
return remember { mutableStateOf(GraphicsLayerRecordingState.Region.Rectangle(rect = Rect.Zero)) }
}
Функции промежуточного сохранения слоя для каждой геометрической формы, которые мы задали в стейте, и одна публичная основная функция для использования в готовых экранах:
fun Modifier.recordLayer(
state: GraphicsLayerRecordingState,
): Modifier {
return this.drawWithContent {
drawContent()
when (val region = state.region.value) {
is GraphicsLayerRecordingState.Region.Rectangle -> {
recordRectangleLayer(graphicsLayer = state.graphicsLayer, rect = region.rect)
}
is GraphicsLayerRecordingState.Region.Circle -> {
recordCircleLayer(graphicsLayer = state.graphicsLayer, center = region.center, size = region.size)
}
}
}
}
private fun ContentDrawScope.recordRectangleLayer(
graphicsLayer: GraphicsLayer,
rect: Rect,
) {
graphicsLayer.record(size = IntSize(rect.width.toInt(), rect.height.toInt())) {
translate(left = -rect.left, top = -rect.top) {
clipRect(
left = rect.left,
top = rect.top,
right = rect.right,
bottom = rect.bottom,
clipOp = ClipOp.Intersect,
) {
this@recordRectangleLayer.drawContent()
}
}
}
}
private fun ContentDrawScope.recordCircleLayer(
graphicsLayer: GraphicsLayer,
center: Offset,
size: Size,
) {
val rect = Rect(offset = center, size = size)
val path = Path().apply { addOval(rect) }
graphicsLayer.record(size = size.toIntSize()) {
translate(left = -rect.left, top = -rect.top) {
clipPath(path = path, clipOp = ClipOp.Intersect) {
this@recordCircleLayer.drawContent()
}
}
}
}
Функции непосредственно рендеринга данных, записанных ранее в графический слой стейта, используя любой эффект в виде передаваемого аргумента:
@RequiresApi(Build.VERSION_CODES.S)
fun Modifier.renderLayer(
state: GraphicsLayerRecordingState,
renderEffect: RenderEffect,
): Modifier {
return this.renderLayer(graphicsLayer = state.graphicsLayer, renderEffect = renderEffect)
}
@RequiresApi(Build.VERSION_CODES.S)
private fun Modifier.renderLayer(
graphicsLayer: GraphicsLayer,
renderEffect: RenderEffect,
): Modifier {
val renderNode = RenderNode("RenderNode")
val canvasHolder = CanvasHolder()
return this.drawBehind {
renderNode.setRenderEffect(renderEffect)
renderNode.setPosition(0, 0, size.width.toInt(), size.height.toInt())
drawIntoCanvas { canvas ->
val recordingCanvas = renderNode.beginRecording()
canvasHolder.drawInto(recordingCanvas) {
drawContext.canvas = this@drawInto
drawLayer(graphicsLayer)
drawContext.canvas = canvas
}
renderNode.endRecording()
canvas.nativeCanvas.drawRenderNode(renderNode)
}
}
}
В приватной функции Modifier.renderLayer() код почти полностью взят из первоисточника, но с небольшими изменениями:
вместо Modifier.drawWithContent() используется более простая функция Modifier.drawBehind(), так как нам достаточно сразу рисовать область с эффектом позади объекта;
после подмены канваса и рисования графического слоя через DrawScope, исходный канвас возвращается на место;
убраны лишние присваивания.
Думаю, стоит отдельно акцентировать внимание на том, как в целом работает эта функция, так как здесь используется довольно хитрый трюк с подменой канваса.
Дело в том, что для применения эффекта к области отображения используется RenderNode через механизм класса RecordingCanvas, который в свою очередь является наследником класса Canvas из пакета android.graphics. А для вывода содержимого графического слоя на канвас используется функция drawLayer(), предоставляемая DrawScope, который привязан к классу Canvas из пакета androidx.compose.ui.graphics. Для связки разных типов канвасов используется вспомогательный класс CanvasHolder с единственным методом drawInto(). Перед самым вызовом функции drawLayer() происходит подмена канваса в DrawScope через контекст drawContext (прямое присваивание в поле canvas), а затем после отрисовки – возврат назад старого канваса. Таким образом, DrawScope модифайера drawBehind() используется дважды: сначала пишем в RenderNode графический слой, а затем рендерим в канвас финальное изображение.
Код функции drawLayer():
fun DrawScope.drawLayer(graphicsLayer: GraphicsLayer) {
drawIntoCanvas { canvas -> graphicsLayer.draw(canvas, drawContext.graphicsLayer) }
}
Да, возможно, это немного странное решение, но механизм его понятен, и оно работает. Поиск способа сразу записать графический слой в канвас не дал результатов (не используя сложных оберток). Функция drawLayer() внутри использует метод draw() класса GraphicsLayer, но он имеет модификатор доступа – internal, поэтому напрямую использовать его нельзя.
Набор базовых функций готов, напишем теперь несколько демонстрационных примеров.
Полупрозрачный header экрана с эффектом размытия:
@Composable
fun DemoHeader(
modifier: Modifier = Modifier,
) {
val state = rememberGraphicsLayerRecordingState()
Box(modifier = modifier) {
Content(modifier = Modifier.recordLayer(state = state))
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.align(Alignment.TopCenter)
.renderLayerWithBlurEffect(state = state)
.background(color = Color.Gray.copy(alpha = 0.2f)),
)
}
}
@Composable
private fun Modifier.renderLayerWithBlurEffect(state: GraphicsLayerRecordingState): Modifier {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return this
val renderEffect = remember { RenderEffect.createBlurEffect(20f, 20f, Shader.TileMode.CLAMP) }
return this
.onGloballyPositioned { layoutCoordinates -> state.setRectRegion(rect = layoutCoordinates.boundsInParent()) }
.renderLayer(state = state, renderEffect = renderEffect)
}
В качестве эффекта рендеринга используем готовый компонент, создаваемый вспомогательным методом createBlurEffect() класса RenderEffect. Чтобы сделать границы области header более четкими для наглядности, сверху добавлено немного серого цвета.
Функция Content() здесь не приводится, так как может быть любой (см. полный исходный код демо-проекта).
Результат:

Полупрозрачный круг с эффектом размытия (можно переместить касанием):
@Composable
fun DemoCircle(
effect: Effect,
modifier: Modifier = Modifier,
) {
val state = rememberGraphicsLayerRecordingState()
var pointerOffset = remember { mutableStateOf(Offset.Zero) }
Box(
modifier = modifier
.pointerInput("dragging") { detectDragGestures { _, dragAmount -> pointerOffset.value += dragAmount } }
.onSizeChanged { pointerOffset.value = Offset(it.width / 3f, it.height / 3f) },
) {
Content(modifier = Modifier.recordLayer(state = state), userScrollEnabled = false)
Box(
modifier = Modifier
.size(200.dp)
.offset { pointerOffset.value.toIntOffset() }
.renderLayerWithBlurEffect(state = state, centerOffset = pointerOffset)
.background(color = Color.White.copy(alpha = 0.2f), shape = CircleShape),
)
}
}
@Composable
private fun Modifier.renderLayerWithBlurEffect(
state: GraphicsLayerRecordingState,
centerOffset: MutableState<Offset>,
): Modifier {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return this
val renderEffect = remember { RenderEffect.createBlurEffect(20f, 20f, Shader.TileMode.CLAMP) }
val ownSize = remember { mutableStateOf(IntSize.Zero) }
LaunchedEffect(Unit) {
snapshotFlow { centerOffset.value to ownSize.value }
.onEach { (center, intSize) -> state.setCircleRegion(center = center, size = intSize.toSize()) }
.launchIn(this)
}
return this
.onSizeChanged { size -> ownSize.value = size }
.renderLayer(state = state, renderEffect = renderEffect)
}
Результат:

Полупрозрачный круг с эффектом линзы (с помощью шейдера):
@Composable
fun DemoCircle(
effect: Effect,
modifier: Modifier = Modifier,
) {
val state = rememberGraphicsLayerRecordingState()
var pointerOffset = remember { mutableStateOf(Offset.Zero) }
Box(
modifier = modifier
.pointerInput("dragging") { detectDragGestures { _, dragAmount -> pointerOffset.value += dragAmount } }
.onSizeChanged { pointerOffset.value = Offset(it.width / 3f, it.height / 3f) },
) {
Content(modifier = Modifier.recordLayer(state = state), userScrollEnabled = false)
Box(
modifier = Modifier
.size(200.dp)
.offset { pointerOffset.value.toIntOffset() }
.renderLayerWithMagnifierEffect(state = state, centerOffset = pointerOffset)
.background(color = Color.White.copy(alpha = 0.2f), shape = CircleShape),
)
}
}
@Composable
private fun Modifier.renderLayerWithMagnifierEffect(
state: GraphicsLayerRecordingState,
centerOffset: MutableState<Offset>,
): Modifier {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return this
val shaderInput = "image"
val shader = remember { RuntimeShader(MAGNIFIER_SHADER) }
val renderEffect = remember { mutableStateOf(RenderEffect.createRuntimeShaderEffect(shader, shaderInput)) }
val ownSize = remember { mutableStateOf(IntSize.Zero) }
LaunchedEffect(Unit) {
snapshotFlow { centerOffset.value to ownSize.value }
.onEach { (center, intSize) -> state.setCircleRegion(center = center, size = intSize.toSize()) }
.launchIn(this)
}
LaunchedEffect(Unit) {
snapshotFlow { ownSize.value }
.onEach { intSize ->
val size = intSize.toSize()
shader.setFloatUniform("size", size.width, size.height)
renderEffect.value = RenderEffect.createRuntimeShaderEffect(shader, shaderInput)
}
.launchIn(this)
}
return this
.onSizeChanged { size -> ownSize.value = size }
.renderLayer(state = state, renderEffect = renderEffect.value)
}
@Language("AGSL")
private val MAGNIFIER_SHADER = """
uniform shader image;
uniform float2 size;
half4 main(float2 fragCoord) {
float zoomPower = 1.4;
float radius = 0.5;
float2 centerUV = float2(0.5, 0.5);
float2 uv = fragCoord / size;
float dist = distance(uv, centerUV);
if (dist < radius) {
float2 zoomedUV = centerUV + (uv - centerUV) / zoomPower;
return image.eval(zoomedUV * size);
}
return image.eval(fragCoord);
}
""".trimIndent()
Здесь в качестве эффекта используется шейдер, который преобразуем в RenderEffect с помощью метода RenderEffect.createRuntimeShaderEffect().
Результат:

Контроль бенчмарком
Для объективности оценки производительности данного способа сделаем замер кадровых интервалов через FrameTimingMetric() из стандартного набора пакета androidx.benchmark.macro. Все исследования проводились на реальном устройстве (телефон Realme 11 RMX3636, Android 14).
Для демо с header напишем простой тест для измерения (скролл списка вниз, а затем обратно):
Код
@RunWith(AndroidJUnit4::class)
class BenchmarkHeader {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun composeRender() = benchmarkRule.measureRepeated(
packageName = "com.app.compose",
metrics = listOf(FrameTimingMetric()),
compilationMode = CompilationMode.DEFAULT,
iterations = 3,
startupMode = StartupMode.WARM,
setupBlock = {
pressHome()
startActivityAndWait()
},
measureBlock = {
val selector = By.res("DemoHeader")
device.wait(Until.hasObject(selector), 10_000)
val content = device.findObject(selector)
repeat(3) {
content.fling(Direction.DOWN, 3000)
device.waitForIdle()
content.fling(Direction.UP, 3000)
device.waitForIdle()
}
},
)
}
Результат без эффекта (лишний код полностью убран, header с простым полупрозрачным фоном без размытия):

Результат с эффектом размытия:

Для демо с кругом также напишем тест для измерения (хаотичное движение круга в разные стороны):
Код
@RunWith(AndroidJUnit4::class)
class BenchmarkCircle {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun composeRender() = benchmarkRule.measureRepeated(
packageName = "com.app.compose",
metrics = listOf(FrameTimingMetric()),
compilationMode = CompilationMode.DEFAULT,
iterations = 3,
startupMode = StartupMode.WARM,
setupBlock = {
pressHome()
startActivityAndWait()
},
measureBlock = {
val selector = By.res("DemoCircle")
device.wait(Until.hasObject(selector), 10_000)
val content = device.findObject(selector)
val directions = List(20) { Direction.entries }.flatten().shuffled()
directions.forEach { direction ->
val percent = (5..15).random() / 100f
content.swipe(direction, percent, 500)
}
device.waitForIdle()
},
)
}
Результат без эффекта (лишний код полностью убран):

Результат с эффектом размытия:

Результат с эффектом линзы:

Таким образом, сравнивая результаты замеров (с учетом случайных отклонений), видим, что производительность при использовании эффектов не страдает.
В заключение хочется отметить, что итоговый код, который можно добавить в рабочий проект, рекомендуется обернуть в compat-функции с проверками:
на версию API 31 и выше;
на доступность аппаратного ускорения (необходимое условие для работы RenderNode и GraphicsLayer, так как вся обработка происходит на GPU).
Репозиторий с исходным кодом демо-проекта тут.
Готовый релизный apk-файл демо-проекта тут.
Если эта статья показалась вам полезной, то на нашем сайте вы можете узнать о том, какие еще технологии и инструменты мы применяем в финтех-разработке.