Основы работы с приложениями

Улучшите свои приложения с помощью новейших графических функций в Jetpack Compose и Android

Колонки

  • Ребекка Франкс (Rebecca Franks)

    Инженер по связям с разработчиками

  • Аюш Чаудхари (Aayush Chaudhary)

    Разработчик ПО

  • Рахул Рэй (Rahul Ray)

    Инженер по связям с разработчиками

О приложении

О выступлении

Хотите, чтобы интерфейс вашего приложения для Android был не просто удобным, а по-настоящему красивым?

Присоединяйтесь к нам, чтобы узнать о последних графических возможностях в Jetpack Compose. Мы покажем, как создавать новые визуальные эффекты, от восхитительных градиентов сетки до прогрессивного размытия, и использовать новые мощные графические функции в Android. Мы также расскажем, как улучшить графику, не жертвуя временем работы от батареи и частотой кадров.

Сводка

  • В Compose 1.12 представлены сетчатые градиенты для насыщенных многоцветных смесей по двумерной сетке вершин. В Android 14 и более поздних версиях вы можете использовать нативный API drawMesh для создания собственных визуальных эффектов на основе геометрии.
  • Compose поддерживает широкую цветовую гамму, чтобы обеспечить более плавное смешивание градиентов на поддерживаемом оборудовании. Включайте режим широкой цветовой гаммы для действия или окна только при показе мультимедийного контента.
  • В Compose 1.13 появились нативные API PolygonShape и MorphPolygonShape для пользовательских геометрических фигур. MorphPolygonShape считывает прогресс на этапе разрешения контура, обновляя фигуры кадр за кадром без запуска рекомпозиции.
  • В Compose 1.13 добавлено прогрессивное размытие, а в уровень API 37.2 – RenderNode.setBackdropRenderEffect для выборки фонового контента. Разработчики могут объединять эффекты фона, чтобы создавать эффекты матового стекла или увеличительного стекла.
  • Оптимизируйте производительность графики, избегая ненужной работы с кадрами. Декодируйте только необходимые пиксели изображения вне основного потока и поместите эффекты анимации в graphicsLayer.

Ресурсы

Gemini Notebook

Темы

  • jetpack-compose
  • android-graphics
  • сетчатые градиенты
  • шейдеры
  • размытие
  • color-gamut
  • производительность

Расшифровка

Обзор графических функций в Jetpack Compose

  • Приветствую всех на нашем семинаре "Улучшение приложений с помощью новейших графических функций в Compose и Android".

  • Меня зовут Ребекка Фрэнкс, и сегодня со мной на сцене…

  • Меня зовут Ааюш Чаудхари.

  • Привет, я Рахул Рэй.

  • Мы видим, что вы все активнее используете ИИ, чтобы расширять границы возможного в Android и Compose и воплощать в жизнь свои невероятные идеи.

  • Мы любим приложения, которые выглядят премиально, нативно и восхитительно.

  • Мы знаем, что приложения, которые нравятся пользователям и получают высокие оценки, обычно используют последние изменения API на уровне системы, имеют преемственность между поверхностями с общими элементами и размытием, прекрасно адаптируются к различным форм-факторам, работают быстро, не заставляют пользователей гадать и расширяют границы возможного на вашем устройстве.

  • Сегодня мы расскажем о некоторых новых функциях, которые помогут вам использовать все преимущества Android. Вы узнаете, как порадовать пользователей сетчатыми градиентами, включить поддержку широкой цветовой гаммы, добавить красивые переходы с многоугольниками и размытие. Наконец, мы обсудим, как учитывать производительность при добавлении новых графических функций в приложения.

  • Но сегодня мы поговорим о некоторых новых API на разных уровнях стека.

  • Хотя устаревший набор инструментов View UI сейчас находится в режиме обслуживания, мы продолжаем инвестировать в базовые механизмы аппаратного рендеринга графики Android, на которых работает Jetpack Compose.

  • Графику в Android можно разделить на три уровня. Первый уровень – это Compose API, где находятся все новые высокоуровневые API для графики пользовательского интерфейса, например API для рисования градиентных сеток, многоугольников и прогрессивного размытия.

  • И, наконец, промежуточный уровень, где DrawScope, узлы модификаторов рисования и AGSL позволяют создавать собственные эффекты.

  • И наконец, у нас есть платформа Android, и Compose всегда может получить доступ к нативному Canvas и использовать совершенно новые примитивы рендеринга ОС прямо в модификаторах Compose.

  • Сегодня мы покажем вам несколько примеров.

  • Давайте начнем!

Сетчатые градиенты и настраиваемый рендеринг сетки.

  • Сначала рассмотрим сетчатые градиенты – новый API, представленный в Compose 1.12.

  • Раньше у нас были линейные, конические и радиальные градиенты, которые отлично подходят для простых переходов. Но если вы хотите получить насыщенные многоцветные смеси, такие как фоны или эффекты заливающего света, вам нужна двумерная сетка, в которой позиции и цвета могут смешиваться в пространстве.

  • Чтобы создать такой сетчатый градиент, сначала укажите, сколько строк и столбцов фрагментов вам нужно.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
    }
    }
  • Затем в завершающей лямбда-функции вызовите setVertex и назначьте нормализованное смещение в диапазоне от 0F до 1F, а также цвет для каждого пересечения сетки.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
       setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
    
    }
    }
  • А затем, чтобы анимировать эти вершины, добавьте анимированные смещения для восьми вершин.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
       setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
         // ....
       setVertex(1, 1, Offset(0.2f, 0.4f) + Offset(animatedOffset, animatedOffset), coral)
       // ....
       setVertex(3, 0, Offset(0.0f, 1.0f), sunshine)
       // ....
    }
    }
    
    Box(
       modifier = modifier.padding(32.dp)
           .safeContentPadding()
           .aspectRatio(16 / 9f)
           .fillMaxWidth()
           .paint(gradientPainter)
    )
  • После того как вы определили объект Painter, просто вызовите его с любой composable-функцией через модификатор paint.

  • Теперь в приложении JetChat мы добавили функцию, позволяющую поставить лайк сообщению, дважды нажав на него.

  • Для этого мы создаем сетку 2 на 3 и размещаем контрольные точки цвета по всему компоненту.

    MeshGradientPainter(
       rows = 2,
       columns = 3,
       hasBicubicColor = true,
    ) {
       // Row 0 (top edge, y = 0.0f)
       setVertex(0, 0, Offset(0.0000f, 0.0000f), Color(0xFFFF6B6B))
       setVertex(0, 1, Offset(0.3300f, 0.0000f), Color(0xFFFFD6D6))
       setVertex(0, 2, Offset(0.6700f, 0.0000f), Color(0xFFE8C3FF))
       setVertex(0, 3, Offset(1.0000f, 0.0000f), Color(0xFFE396FF))
    
       // ... remaining vertices for Rows 0, 1, and 2
    }
  • Обратите внимание, что мы установили для hasBicubicColor значение true.

  • По умолчанию цвета интерполируются линейно по треугольникам, что иногда может оставлять видимые пятна.

  • Если включить этот параметр, шейдер переключится на плавную бикубическую интерполяцию, чтобы цвета могли плавно переходить друг в друга.

  • А что, если вы хотите создать анимированное свечение, как здесь, за полем ввода?

  • Чтобы создать такой эффект с помощью сетчатого градиента, вам понадобятся две вещи.

  • Во-первых, плавное органичное движение внутренних вершин и способ смягчить внешние края, чтобы вокруг карты не получился жесткий прямоугольник.

  • Для начала мы настраиваем rememberInfiniteTransition с двумя фазовыми углами, которые немного отличаются по длительности. Это позволяет сделать движение более естественным.

    val transition = rememberInfiniteTransition(label = "userInputGlowMesh")
    
    val phase1 by transition.animateFloat(
       initialValue = 0f,
       targetValue = (2 * PI).toFloat(),
       animationSpec = infiniteRepeatable(
           animation = tween(durationMillis = 2800, easing = LinearEasing),
           repeatMode = RepeatMode.Restart,
       ),
       label = "glowPhase1",
    )
    
    val phase2 by transition.animateFloat(
       initialValue = 0f,
       targetValue = (2 * PI).toFloat(),
       animationSpec = infiniteRepeatable(
           animation = tween(durationMillis = 4200, easing = LinearEasing),
           repeatMode = RepeatMode.Restart,
       ),
       label = "glowPhase2",
    )
  • Здесь мы анимируем внутренние вершины и закрепляем внешние кольца, внешнее кольцо вершин с ребрами с нулевой альфой.

  • Поскольку внешняя граница неподвижна и прозрачна, свечение естественным образом затухает.

  • И, наконец, мы передаем эти анимированные смещения в наш MeshGradientPainter.

    MeshGradientPainter(
       rows = 6,
       columns = 5,
       hasBicubicColor = true,
    ) {
       for (r in 0..6) {
           for (c in 0..5) {
                   val offset = if (r in 1..5 && c in 1..4) {
                   val ampX = when (r) {
                       1, 2 -> 0.048f
                       3 -> 0.042f
                       4 -> 0.028f
                       else -> 0.036f
                   }
                   val ampY = when (r) {
                       1, 2 -> 0.028f
                       3 -> 0.020f
                       4 -> 0.022f
                       else -> 0.016f
                   }
                   val d = orbit(c, r, ampX, ampY)
                   Offset(
                       x = (baseU + d.x).coerceIn(0.04f, 0.96f),
                       y = (baseV + d.y).coerceIn(0.05f, 0.95f),
                   )
               } else {
                   Offset(baseU, baseV)
               }
               setVertex(r, c, offset, gridColors[r][c])
           }
       }
    }
  • Сетчатый градиент, который вы только что видели, нарисован с помощью API drawMesh на Android 14 и более поздних версиях.

  • Давайте рассмотрим этот API подробнее и создадим эффект с нуля.

  • Для экрана приветствия JetChat мы хотим нарисовать контур, похожий на ленту.

  • Обратите внимание на две вещи: ширина обводки не постоянна, а цвет проходит вдоль каждого штриха контура.

  • Давайте разберем, что нам нужно для создания этого эффекта.

  • Для каждого пикселя нам нужно решить, рисовать его или нет и какой цвет использовать.

  • Чтобы это выяснить, нам нужно два расстояния.

  • Первое значение – это расстояние от пикселя до центра штриха.

  • Второе расстояние – это расстояние от начала до конца пути, которое пиксель проходит по этому пути.

  • Это указывает, какой цвет выбрать из градиента, а смешивание с небольшим количеством первого значения делает цвет кривой по всей ширине.

  • Можно ли нарисовать градиент вдоль контура, используя только контур и краску?

  • Так что да, мы можем.

  • Для этого нужно преобразовать путь в список коротких прямых линий, что также дает расстояние вдоль пути.

    val lines = remember {
            path.asAndroidPath().flatten(0.5f)
        }
    //... inside draw scope
    lines.forEach { line ->
    //...
        drawLine(
            brush = Brush.linearGradient(...),
            start = Offset(line.start.x,
    line.start.y),
            end = Offset(line.end.x, line.end.y),
            strokeWidth = 30f,
        )
    //...
    }
  • Затем мы рисуем каждую линию со своим градиентом.

  • Это работает, но для каждой линии требуется отдельный вызов отрисовки и объект рисования, а весь контур по-прежнему имеет одну ширину.

  • Другой вариант – шейдер времени выполнения, но он получает только положение пикселей в пределах контейнера.

  • Она не говорит нам, что нам нужно, чтобы нарисовать эту ленту.

  • И мы не можем легко рассчитать это по положению пикселей.

  • Для этого в DrawMesh есть вершинный шейдер.

  • Что такое сетка?

  • Сетка состоит из треугольников, в каждом углу которых есть данные. Каждый угол называется вершиной.

  • Каждая вершина содержит небольшую запись данных.

  • Обычно эта запись содержит позицию, но это не обязательно.

  • Вершинный шейдер считывает эту запись и вычисляет на ее основе позицию.

  • Вот путь, который проходит один треугольник: вершинный шейдер запускается для каждого угла, вычисляет положение углов и любое значение, которое мы хотим передать для каждого пикселя.

  • Затем растеризатор находит каждый пиксель, покрытый этим треугольником, и интерполирует значения из этих трех углов для каждого пикселя.

  • Наконец, для этих пикселей запускается фрагментный шейдер, который использует интерполированные значения и вычисляет цвет пикселей.

  • Давайте посмотрим, как можно написать вершинный и фрагментный шейдеры на более простом примере – прямой линии с движущимся по ней импульсом.

    LineShaders.VERTEX
    
    uniform float2 uStart;
    uniform float2 uEnd;
    uniform float uHalfWidth;
    uniform float uTime;
    
    Varyings main(const Attributes a) {
        Varyings v;
        float2 dir = uEnd - uStart;
        float2 normal = normalize(float2(-dir.y, dir.x));
        float along = a.corner.x * length(dir);
        float pulse = (a.corner.x - uTime) / 0.18;
        float radius = uHalfWidth * (1.0 + 0.6 * exp(-pulse * pulse));
        float across = a.corner.y * (radius + 1.0);
        v.position = uStart + dir * a.corner.x + normal * across;
        v.stroke = float3(along, across, radius);
        return v;
    }
  • Вершинный шейдер принимает атрибут в качестве входных данных и возвращает переменные.

  • Атрибуты – это данные для одного уголка.

  • Здесь всего два числа: его положение на линии и то, с какой стороны линии он находится.

  • Затем переменные интерполируются и передаются во фрагментный шейдер.

  • Мы рассчитываем расстояние в пикселях от начала, а для ширины штриха используем время, чтобы создать импульс на радиусе.

  • Затем мы умножаем этот радиус на сторону угла, что дает нам расстояние знака по штриху.

  • Поскольку графический процессор интерполирует между этими знаками, центральные линии становятся нулевыми.

  • Наконец, мы вычисляем положение углов, сохраняем все эти значения в переменных обводки и передаем их во фрагментный шейдер.

  • В отличие от шейдера времени выполнения, фрагментный шейдер сетки выводит цвет через параметр и возвращает позицию для выборки краски, если она задана.

    LineShaders.FRAGMENT
    
    uniform float uLength;
    layout(color) uniform float4 uStartColor;
    layout(color) uniform float4 uEndColor;
    
    float2 main(const Varyings v, out float4 color) {
        float distance = abs(v.stroke.y);
        float coverage = 1.0 - smoothstep(
        v.stroke.z - 0.5, v.stroke.z + 0.5, distance);
        float side = distance / v.stroke.z;
        float4 ink = mix(uStartColor, uEndColor,
            clamp(v.stroke.x / uLength + 0.4 * side * side, 0.0, 1.0));
        color = ink * coverage;
        return v.position;
    }
  • Абсолютное значение поперечной координаты дает нам расстояние в пикселях от центральной линии.

  • Мы сравниваем это расстояние с радиусом, чтобы получить покрытие, а для цвета смешиваем вдоль пути линии и слегка изгибаем градиент к краю.

  • Умножьте это покрытие на умножьте это на покрытие, это наш окончательный цвет.

  • В Compose мы связываем шейдеры и макет данных с помощью объекта MeshSpecification.

    val spec = remember {
        MeshSpecification.make(
            arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
            vertexStride = 8,
            arrayOf(Varying(TYPE_FLOAT3, "stroke")),
            LineShaders.VERTEX, LineShaders.FRAGMENT)
    }
  • Сначала мы объявляем атрибут угла как два числа с плавающей запятой, начиная с байта 0.

  • Поскольку два числа с плавающей запятой занимают 8 байт, каждый шаг угла в буфере равен 8.

  • Затем мы объявляем переменные stroke как три числа с плавающей запятой и передаем вершинный и фрагментный шейдеры, которые только что написали.

  • Мы создаем буфер вершин.

    fun lineCorners(segments: Int): ByteBuffer {
        val buffer = ByteBuffer.allocateDirect((segments + 1) * 2 * 8)
            .order(ByteOrder.nativeOrder())
        for (i in 0..segments) {
            val t = i / segments.toFloat()
            buffer.putFloat(t).putFloat(-1f) // one side
            buffer.putFloat(t).putFloat(1f)  // the other side
        }
        return buffer.rewind() as ByteBuffer
    }
  • На каждом шаге вдоль линии мы вычисляем прогресс от 0 до 1 и записываем два угла в буфер.

  • Наконец, мы объединяем спецификацию и буфер в объект сетки.

    val mesh = Mesh(
        spec,
        mode = Mesh.TRIANGLE_STRIP,
        vertexBuffer = corners,
        vertexCount = (segments + 1) * 2,
        bounds = RectF(0f, 0f, size.width, size.height)
    )
  • Холст Compose пока не поддерживает drawMesh, поэтому мы используем nativeCanvas и рисуем сетку.

    Box(Modifier.fillMaxSize().drawWithCache {
       val mesh = Mesh(...)
       onDrawBehind {
           mesh.setFloatUniform("uTime", time.value)
           drawIntoCanvas {
              it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
           }
       }
    })
  • Поскольку вы управляете геометрией и пикселями, вы можете нарисовать любой визуальный эффект, управляемый геометрией, с помощью сетки рисования.

  • Дополнительную информацию об этом API можно найти в документации.

Поддержка широкой цветовой гаммы

  • В Compose 1.12 мы добавили поддержку широкой цветовой гаммы.

  • Что это значит?

  • Знаете ли вы, что экраны могут воспроизводить более широкий диапазон цветов, чем традиционное цветовое пространство sRGB?

  • Например, на этом изображении белый треугольник – это диапазон стандартных цветов sRGB, которые может отображать большинство экранов.

  • Однако современные экраны поддерживают более широкий диапазон цветов, как показано желтым треугольником, некоторые из которых более насыщенные и яркие.

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

  • При сравнении градиентов важно различать цветовую гамму дисплея и интерполяцию градиентов.

  • На стандартном дисплее sRGB или, вероятно, на этом проекторе экран не может физически отображать цвета за пределами этой гаммы sRGB.

  • Поэтому мне сейчас очень сложно продемонстрировать вам это на сцене.

  • Поэтому вам придется поверить мне на слово, что на устройствах Android с поддержкой широкой цветовой гаммы цвета выглядят лучше.

  • В этом примере вы видите эффект интерполяции контрольных точек цвета в более широком цветовом пространстве.

  • Смешивание цветов в широком или линейном цветовом пространстве меняет математический путь градиента, предотвращая появление серого оттенка и резких полос, которые могут возникнуть без широкой цветовой гаммы.

  • В Compose 1.12 улучшено смешивание градиентов на любых экранах, а также добавлена поддержка широкой цветовой гаммы на совместимых устройствах.

  • Чтобы обеспечить правильную поддержку на устройстве, убедитесь, что в вашем действии или окне включен режим широкой цветовой гаммы.

  • Это можно сделать в файле AndroidManifest.

    <!-- AndroidManifest.xml -->
    <activity
    android:name=".NavActivity"
    android:colorMode="wideColorGamut"
    android:exported="true">
    </activity>
  • или программно в composable-функции.

    // Dynamic toggle in code
    DisposableEffect(window) {
       window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
       onDispose {
           window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
       }
    }
  • Если не задать colorMode, окно останется в стандартном 8-битном sRGB, а все цвета широкой цветовой гаммы, отображаемые графическим процессором, будут обрезаны.

  • Поэтому важно не использовать широкую цветовую гамму, если дисплей ее не поддерживает.

  • Чтобы экономить заряд батареи и повысить производительность, включайте широкую цветовую гамму только для действий или путей выполнения, которые действительно отображают медиаконтент, фотографии или насыщенную графику.

  • Это означает, что вы можете создавать цвета в разных цветовых пространствах в Compose, и теперь они соблюдаются.

    // 1. Display P3 Color (Wider gamut: greens & reds are more saturated) colorMode=wideColorGamut
    val VividP3Red = Color(
       red = 1.0f,
       green = 0.05f,
       blue = 0.05f,
       alpha = 1.0f,
       colorSpace = ColorSpaces.DisplayP3
    )
    // 2. Extended sRGB (Enables HDR values > 1.0f) use colorMode=hdr
    val HdrNeonCyan = Color(
       red = 0.0f,
       green = 1.8f,  // Peak brightness > 1.0 in extended gamut
       blue = 2.0f,
       alpha = 1.0f,
       colorSpace = ColorSpaces.ExtendedSrgb
    )
    // 3. BT.2020 HLG (Broadcast HDR standard, supported API 34+) - use colorMode=hdr
    val HdrSunsetOrange = Color(
       red = 1.0f,
       green = 0.45f,
       blue = 0.1f,
       alpha = 1.0f,
       colorSpace = ColorSpaces.Bt2020Hlg
    )
  • Вы можете передать тип colorSpace, в котором хотите создать цвет. Если вы не передадите colorSpace, будет использоваться стандартный sRGB.

  • Для битовых карт можно проверить источник с помощью getColorSpace. Популярные библиотеки загрузки изображений автоматически обрабатывают правильные цветовые пространства битовых карт.

  • Чтобы сохранить информацию о цвете, используйте Bitmap.Config.HARDWARE. При использовании конфигурации 565 точность будет потеряна, и пользователи увидят полосы.

  • Чтобы узнать больше о цветах, вы можете вернуться в 2017 год и посмотреть, как Ромен Гай рассказывает о цветах в Compose или о цветах в Android.

Многоугольники и морфинг

  • В Compose 1.13 мы представляем встроенную поддержку многоугольников.

  • Это значительно упростит создание элементов интерфейса произвольной формы и органичного вида.

  • Раньше, чтобы нарисовать сложную фигуру, нужно было использовать RoundedPolygon для описания геометрии, которая выведет путь, а затем сопоставить этот путь с API фигур Compose.

    fun cookiePolygon(): RoundedPolygon {
        val vertices = FloatArray(32)
        for (i in 0 until 16) {
            val radius = if (i % 2 == 0) 1f else 0.84f
    // …
        }
        return RoundedPolygon(
            vertices,
            rounding = CornerRounding(0.12f)
        )
    }
  • Например, сначала вы задаете пропорции с помощью RoundedPolygon, а мы устанавливаем В этом фрагменте мы устанавливаем наши внутренние точки на 84% от центра по сравнению с нашими внешними точками.

  • Здесь также можно применить скругление углов, но это описывает только математические кривые.

  • Чтобы использовать этот многоугольник для обрезки или фона в Compose, вам нужно написать пользовательскую оболочку Shape, например такую.

    class RoundedPolygonShape(
     polygon: RoundedPolygonShape
    ) : Shape {
        // …
        override fun createOutline(size: Size, layoutDirection: LayoutDirection,
        density: Density,
        ): Outline {
            path = polygon.toPath().asComposePath()
            // …
            matrix.scale(size.width / maxDimension, size.height / maxDimension)
            matrix.translate(-bounds.left, -bounds.top)
            // …
            return Outline.Generic(path)
        }
    }
  • Внутри этого фрагмента createOutline вы можете увидеть, что вам нужно создать путь, а затем вручную масштабировать и перевести его.

  • Теперь, с новым API PolygonShape, вам больше не нужна эта пользовательская оболочка Shape.

    private val AvatarShape = PolygonShape {
        val vertices = List(16) { i ->
            val radius = size.minDimension / 2f * if (i % 2 == 0) 1f else 0.86f
            val angle = i * PI.toFloat() / 8f
            size.center + Offset(cos(angle), sin(angle)) * radius
        }
        polygon(vertices, rounding = CornerRounding(radius = 8.dp))
    }
    
        // …
    Modifier.clip(AvatarShape)
  • Поскольку это специальный конструктор Compose Shape, он получает плотность и размер непосредственно из коробки.

  • Это означает, что вы можете динамически вычислять вершины и указывать скругление углов непосредственно в dp или даже в процентах.

  • После определения вы просто передаете его любому API, который принимает Shape.

  • Мы также представляем MorphPolygonShape API, который позволяет плавно переходить между различными формами многоугольников.

    val recordShape = remember {
        MorphPolygonShape(
            start = PolygonShape.circle(),
            end = PolygonShape.star(
                numPoints = 9,
                innerRadiusRatio = 0.8f,
                outerRounding = CornerRounding(percent = 50),
            ),
        ) { shapeProgress.value }
    }
  • В этом примере у нас есть кнопка записи, которая превращается из круга в звезду, когда вы взаимодействуете с ней.

  • Вы просто указываете начальную форму, конечную форму и значение прогресса.

  • API MorphPolygonShape считывает значение прогресса на этапе разрешения контура, то есть не во время рекомпозиции, а во время композиции.

  • Это означает, что по мере того, как ваша фигура трансформируется между кадрами, она просто превращается в новый контур, не вызывая рекомпозиции.

Эффекты размытия и рендеринга фона

  • Теперь поговорим о размытии.

  • В последних нескольких выпусках мы пытаемся сделать реализацию размытия намного проще и выразительнее.

  • Когда мы говорили о размытии, это был наиболее часто используемый модификатор.

    Modifier
       .blur(
           radiusX = 10.dp,
           radiusY = 10.dp,
           edgeTreatment = BlurredEdgeTreatment(
               shape = RoundedCornerShape(8.dp),
           ),
       )
  • В Compose этот модификатор доступен с Android 12.

  • Он отлично подходит, когда вам нужно равномерное размытие, при котором ко всей composable-функции применяется один и тот же радиус размытия.

  • Однако в современных интерфейсах часто требуется плавный переход от резких областей к размытым, а не резкая граница.

  • И наконец, Progressive Blur, который теперь является официальным API, начиная с Compose 1.13.

  • Вместо одного постоянного радиуса для всей composable-функции теперь можно изменять интенсивность размытия в пространстве.

  • Вот пример из нашего приложения JetChat с экрана профиля пользователя.

  • Новый модификатор размытия принимает спецификацию радиуса размытия.

    Modifier.blur {
    blurRadiusSpec = BlurRadiusSpec.verticalGradient(
    listOf(
    BlurStop(0.5f, 0.dp),
    BlurStop(1.0f, 32.dp),
    )
    )
    edgeTreatment = BlurredEdgeTreatment.Unbounded
    }
  • Здесь мы используем вертикальный градиент с двумя контрольными точками размытия, сохраняя верхнюю половину фотографии полностью резкой (0 dp) и увеличивая размытие до 32 dp у нижнего края.

  • Обратите внимание, что мы также задали для edgeTreatment значение unbounded. Это указывает на то, что размытие должно также захватывать прозрачные пиксели за пределами границ composable-функции.

  • Помимо вертикального затухания, эта спецификация радиуса размытия предоставляет вам несколько встроенных способов управления прогрессивным размытием.

  • Например, слева вы видите горизонтальный градиент, где начальный радиус равен 0 dp, а конечный радиус определяется значением ползунка.

    BlurRadiusSpec.horizontalGradient(
    startRadius = 0.dp,
    endRadius = sliderValue.dp,
    )
  • А справа у нас радиальный градиент, где в центре радиус размытия равен 0 dp, а по мере продвижения к fallOffRadius, который в данном случае равен 300 dp, размытие плавно увеличивается до 24 dp.

    BlurRadiusSpec.radialGradient(
    center = center,
    fallOffRadius = 300.dp,
    startRadius = 0.dp,
    endRadius = 24.dp,
    )
  • Если вертикальных, радиальных и горизонтальных градиентов недостаточно, вы можете использовать собственные шейдеры.

    val customShader = remember {
       RuntimeShader(
           """
           uniform float2 size;
           uniform float time;
    
           half4 main(float2 coords) {
              // Diagonal wave mask
              float normalizedX = coords.x / size.x;
              float normalizedY = coords.y / size.y;
              float wave = sin((normalizedX + normalizedY) * 6.0 + time) * 0.5 + 0.5;
              return half4(0.0, 0.0, 0.0, wave);
           }
           """.trimIndent()
       )
    }
    
    val customBlurRadius = remember(customShader) {
       BlurRadiusSpec.shader(maxRadius = 24.dp) { customShader }
    }
    
    Modifier.blur {
           val w = size.width.toPx()
           val h = size.height.toPx()
           customShader.setFloatUniform("size", w, h)
           customShader.setFloatUniform("time", animatedTime)
           blurRadiusSpec = customBlurRadius
       },
  • Ниже приведен небольшой фрагментный шейдер, который вычисляет анимированную диагональную синусоиду и выводит ее интенсивность в альфа-канал.

  • Затем спецификация шейдера сопоставляет эту маску от 0 до 1 непосредственно с маской 24 dp maxRadius.

  • Прежде чем мы поговорим о размытии фона, давайте посмотрим на разницу между ними.

  • Слева у нас есть верхняя панель приложения, к которой мы применили размытие слоя.

  • То есть он размывает саму компонуемую функцию.

  • Как видите, заголовок и значки действий размыты, а компонент чата за ними – нет.

  • Справа – размытие фона.

  • В этом случае размываются только сообщения чата, которые прокручиваются за верхней панелью.

  • Однако текст и значки полностью четкие и читаемые.

  • Начиная с Android 37.2, вы можете размывать фон с помощью нового API RenderNode.setBackdropRenderEffect.

  • Но прежде чем мы перейдем к улучшениям, давайте разберемся, как работает размытие в Android.

  • Android использует RenderNodes, которые представляют собой контейнеры списка отображения с аппаратным ускорением. Это означает, что они могут компактно хранить множество различных команд рисования между началом и концом записи.

  • В RenderNode содержится информация о 3D-преобразованиях, поворотах, альфа-канале, масштабах и границах положения.

  • А Compose на самом деле использует RenderNode.

  • Чтобы использовать эффекты рендеринга фона в Compose, мы можем создать собственный Modifier.Node, чтобы иметь возможность повторно использовать этот модификатор.

    fun Modifier.backdropBlur(spec: BlurRadiusSpec, shape: Shape, tint: Color): Modifier =
       this then BackdropBlurElement(spec, shape, tint)
    
    private class BackdropBlurNode(...) : Modifier.Node(), DrawModifierNode {
       private var renderNode: RenderNode? = null
    
       override fun ContentDrawScope.draw() {
           val node = getOrCreateNode()
           if (isDirty || sizeChanged) {
               node.setPosition(0, 0, widthPx, heightPx)
               node.setBackdropRenderEffect(spec.createRenderEffect(this))
           }
           drawIntoCanvas { it.nativeCanvas.drawRenderNode(node) }
           drawContent()
       }
    }
  • Мы сохраняем ссылку на RenderNode, а RenderNode настраивается с эффектами, которые мы хотим использовать в другой функции.

  • Например, здесь мы передаем эффект размытия.

  • Затем мы вызываем drawIntoCanvas, вызывая drawRenderNode на нашем нативном холсте.

  • И наконец, мы вызываем drawContent, чтобы нарисовать поверх размытого фона четкий контент.

  • Чтобы использовать созданный модификатор, вызовем Modifier.backdropBlur и передадим спецификацию радиуса размытия.

    JetchatAppBar(
        modifier = modifier
            .backdropBlur(
                tint = MaterialTheme.colorScheme
                    .surface.copy(alpha = 0.5f),
                shape = RectangleShape,
                spec = BlurRadiusSpec.uniform(5.dp)
            )
    )
  • Обратите внимание, что при таком подходе нам не нужно ничего размещать на базовом слое.

  • RenderNode.setBackdropRenderEffect автоматически выбирает контент, который находится под панелью приложения JetChat.

  • В результате получается вот такое красивое размытие фона.

  • Мы также можем задать прогрессивный радиус размытия вместо просто прогрессивного, чтобы получить вертикальное размытие контента.

    JetchatAppBar(
        modifier = modifier
            .backdropBlur(
                tint = MaterialTheme.colorScheme
                    .surface.copy(alpha = 0.5f),
                shape = RectangleShape,
                spec = BlurRadiusSpec.verticalGradient(
                    listOf(
                        BlurStop(0.5f, 32.dp),
                        BlurStop(1f, 0.dp),
                    ),
                )
            )
    )
  • Который с вертикальным градиентом показывает контент под размытым с разными радиусами в разных долях контейнера.

  • Мы работаем над встроенным модификатором размытия фона в Compose.

  • И это просто пример базовой технологии на Android с Compose.

  • Пример, который я вам показал, будет работать только на уровне API 37.2, но мы также работаем над бэкпортом для Compose, так что следите за обновлениями.

  • Ещё один интересный пример с фоновыми эффектами RenderNode заключается в том, что мы можем объединять их в цепочки, чтобы создавать действительно красивые пользовательские интерфейсы и выполнять множество различных эффектов поверх них.

  • Чтобы связать эффекты, мы создаем эти два эффекта.

    val blur = RenderEffect.createBlurEffect(24f, 24f, Shader.TileMode.CLAMP)
    
    // Boost saturation by 30% and apply blur together
    val matrix = ColorMatrix().apply { setSaturation(1.3f) }
    val vibrancy = RenderEffect.createColorFilterEffect(ColorMatrixColorFilter(matrix))
    val vibrantBlur = RenderEffect.createChainEffect(vibrancy, blur)
  • В этом примере мы создаем эффект размытия и насыщенность 30 %.

  • Мы вызываем createChainEffect, сначала применяя эффект размытия, а затем насыщенности.

  • Это пример в JetChat.

    val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
    val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
    
    val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
    renderNode?.setBackdropRenderEffect(finalEffect)
  • Кнопка перехода вниз. Мы можем сделать ее похожей на маленькую лупу, объединив несколько разных эффектов, чтобы закруглить края этой composable-функции.

  • Но вы можете не только применять эффекты увеличения, но и объединять любые шейдеры в цепочки и применять их к фону.

  • Вот несколько интересных примеров того, что можно сделать, если объединить эти функции.

  • Теперь можно использовать традиционный эффект матового стекла.

  • Но мы также можем сделать другие забавные эффекты, например калейдоскоп.

  • Или, если мы хотим превратить этого талисмана Android в своего рода трансформирующийся элемент пользовательского интерфейса, который преобразует контент под ним, мы также можем применить эти эффекты рендеринга фона, чтобы создать впечатление, будто мы смотрим сквозь этого маленького дроида.

  • И мы также можем сделать это с помощью эффектов рендеринга фона.

  • Так что теперь вы можете не только размывать фон и создавать эффект матового стекла, но и делать все, что подскажет воображение.

  • Ещё один новый API, который мы добавили на платформу, – SurfaceView.setBlurRegions.

  • Сейчас вы можете размывать текстуры и любые composable-функции с помощью эффектов Modifier.blur и RenderNode Backdrop, но размыть части SurfaceView сложно, и это не часть дерева RenderNode представления приложения, поскольку на самом деле оно пробивает дыру в окне.

  • Поэтому Compose не может взять образец из резервного буфера SurfaceView, что было невозможно до API 37.2.

  • Поэтому setBlurRegions SurfaceView позволяет приложению напрямую объявлять области размытия.

  • Например, в этом видеопроигрывателе в JetChat мы добавили размытие SurfaceView, когда элементы управления появляются и скрываются.

  • Чтобы использовать новый API, вы можете получить доступ к методу setBlurRegions в SurfaceView.

    val region = RoundedRectBlurRegion().apply {
       bounds = RectF(left, top, right, bottom)
       setCornerRadii(spec.cornerRadiusPx.coerceAtLeast(0f))
       alpha = spec.alpha.coerceIn(0f, 1f)
       blurRadius = spec.blurRadiusPx.coerceAtLeast(0f)
    }
    
    surfaceView.setBlurRegions(listOf(region))
    
    surfaceView.setBlurRegions(emptyList()) // Clear
  • Он принимает список областей границ, радиус размытия, альфа-канал и список радиусов углов.

  • При передаче этих границ они должны быть относительными по отношению к SurfaceView на экране.

  • Поэтому вам нужно отслеживать координаты SurfaceView и область, которую вы хотите размыть.

RuntimeColorFilter

  • Ещё одна новая функция в Android 16 и более поздних версиях – RuntimeColorFilter.

  • Раньше для изображений можно было задать только статический матричный цветовой фильтр и применить эффекты, например насыщенность.

  • Однако в Android 16 и более поздних версиях вы можете передать шейдер, который принимает входной цвет (входной пиксель для определенного контента) и создает новый цвет.

  • Например, если мы хотим применить к фотографии фильтр в стиле киберпанк, мы можем определить небольшой шейдер, который используется в качестве входных данных для RuntimeColorFilter.

    @Language("AGSL")
    private const val CYBERPUNK_SHADER = """
        vec4 main(half4 in_color) {
            float lum = dot(in_color.rgb, vec3(0.299, 0.587, 0.114));
            vec3 shadow = vec3(0.06, 0.04, 0.25);
            vec3 mid = vec3(0.92, 0.12, 0.58);
            vec3 high = vec3(0.18, 0.94, 0.98);
            vec3 col = lum < 0.5 ? mix(shadow, mid, lum * 2.0) : mix(mid, high, (lum - 0.5) * 2.0);
            return vec4(mix(in_color.rgb, col, 0.7), in_color.a);
        }
    """
  • Этот шейдер создает тему киберпанка, но вы можете сделать любой другой эффект.

  • Чтобы использовать шейдер, мы применяем RuntimeColorFilter и преобразуем его в цветовой фильтр Compose.

    AsyncImage(
        model = imageUri,
        contentDescription = filter.displayName,
        colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                       .asComposeColorFilter(),
        contentScale = ContentScale.Crop,
        modifier = Modifier.fillMaxSize(),
    )
  • Как видно из этого примера, теперь мы можем создавать более динамичные эффекты, чем раньше, без необходимости углубляться в OpenGL, настройки SurfaceView или что-то в этом роде.

  • И это также можно применить к любой composable-функции, а не только к изображениям.

Оптимизация производительности графики

  • Спасибо!

  • Мы создали отличный эффект, но нужно убедиться, что приложение работает быстро и плавно.

  • Когда мы прокручиваем чат, все эффекты работают одновременно.

  • Давайте начнем с измерения этой прокрутки.

    @Test
    fun scrollFling() = measureScroll(swipeSteps = 5)
    
    private fun measureScroll(swipeSteps: Int) = rule.measureRepeated(
        packageName = TARGET_PACKAGE,
        metrics = defaultComposeScrollingMetrics(),
        compilationMode = CompilationMode.Full(),
        iterations = 5,
        setupBlock = {
            killProcess()
        }
    //...
  • Мы используем макротест, который запускает приложение в новом процессе и прокручивает чат.

  • Затем мы запрашиваем трассировку, чтобы подсчитать кадры, которые не уложились в срок.

    select count(*) as late_frames
    from actual_frame_timeline_slice
    where upid = (select upid from process where name = 'com.example.compose.jetchat.perf')
      and jank_type like '%App Deadline Missed%'
  • Получается шесть кадров с задержкой.

  • Давайте откроем один из поздних кадров.

  • Поэтому прямо перед этим кадром Compose предварительно выбирает следующее сообщение, прежде чем оно прокрутится вверх в представлении.

  • Предварительная выборка должна укладываться в короткий промежуток между кадрами, но подготовка этого сообщения также декодирует его изображение прямо в основной поток, который долго работает без уступки.

  • Поскольку основной поток занят декодированием этого изображения, новые кадры не могут быть запущены.

  • Затем, когда это сообщение фактически прокручивается в представлении, RenderThread должен выделить память и загрузить все эти пиксели в графический процессор, поэтому то же самое изображение задерживает второй кадр.

  • Теперь мы видим, на что ушло время, но нужно ли нам вообще выполнять всю эту работу, чтобы нарисовать этот кадр?

  • И как мы можем это определить?

  • Это можно узнать из трассировки.

  • Когда большая битовая карта отрисовывается в гораздо меньшей области на экране, Android регистрирует маркер прямо в трассировке.

  • Вы увидите два числа в пикселях: полный размер растрового изображения и область, которую оно занимает на экране.

  • Разделите это значение на плотность экрана, и вы получите значение в DP.

  • Затем мы ищем в коде это значение DP, чтобы найти изображение.

  • Вот что мы обнаружили.

  • Изображение загружается с помощью painterResource, который не знает, насколько велик контейнер, поэтому он декодирует все изображение в полном разрешении.

    Image(
        painter = painterResource(it),
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • Поэтому мы заменяем его на AsyncImage из катушки.

    AsyncImage(
        model = it,
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • Сначала измеряется контейнер, поэтому декодируются только нужные пиксели, и эта работа выполняется в отдельном потоке.

  • При повторном запуске того же теста декодирование изображений в основном потоке упало до 0, а задержка кадра упала до 2.

  • Мы не будем отлаживать эти два кадра, но вывод прост: старайтесь не вычислять пиксели, которые не попадут в кадр.

  • Итак, декодирование изображений было достаточно большим, чтобы задерживать кадры, но не все затраты отображаются как пропущенные кадры, и все же есть меньшие затраты, которых мы можем избежать при написании кода.

  • Давайте рассмотрим два из них.

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

  • А когда эффект анимирует каждый кадр, мы можем обернуть его в graphicsLayer, чтобы перезаписывался только анимированный элемент, а не перерисовывался весь интерфейс вокруг него.

  • Кнопка "Перейти вниз" появляется при прокрутке.

  • Для этого эффекта используются два шейдера RuntimeShader, и каждый раз, когда появляется кнопка, мы воссоздаем оба шейдера из их исходной строки.

    val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply {
        setFloatUniform("size", size.width, size.height)
        setFloatUniform("zoom", zoom)
    // …
    val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content")
    // …
    val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply {
  • Синтаксический анализ этих двух шейдеров и создание эффекта занимают сотни микросекунд в основном потоке каждый раз, когда появляется кнопка.

  • Вместо этого мы создаем шейдер один раз для всего процесса или можем запомнить его, а затем обновить его униформу.

    val lensShader = MagnifierShaders.lens.apply {
        setFloatUniform("size", size.width, size.height)
        setFloatUniform("zoom", zoom)
    // …
    private object MagnifierShaders {
        val lens: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(MAGNIFIER_LENS_SHADER) }
        val glassFinish: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(GLASS_FINISH_SHADER) }
  • Благодаря этому изменению создание эффекта происходит почти в 40 раз быстрее, а стоимость при появлении кнопки снижается в 20 раз.

  • В примере с сетчатым градиентом свечение анимируется в каждом кадре внутри drawBehind.

    Box(
        modifier = Modifier
            .matchParentSize()
            .drawBehind {
                // Behind the card, scaled past its bounds.
                translate(top = -58.dp.toPx()) {
                    scale(scaleX = 1.38f, scaleY = 2.85f) {
                        with(glowMeshPainter) {
    draw(size, alpha = glowAlpha)
    }
  • Таким образом, чтение состояния на этапе рисования сохраняет композицию и макет, но список отображения не может обновить одну команду рисования на месте.

  • Поэтому Compose поднимается по дереву, чтобы найти ближайший узел со слоем графики. В данном случае это красная рамка, которая представляет собой весь экран. Compose перезаписывает весь этот слой в каждом кадре.

  • Если поместить graphicsLayer прямо в поле, то перемещение остановится на зеленом контуре, поэтому будет перезаписано только поле свечения, а фрагмент отрисовки основного потока уменьшится на две десятых миллисекунды.

    Box(
        modifier = Modifier
            .matchParentSize()
                      .graphicsLayer()
            .drawBehind {
                // Behind the card, scaled past its bounds.
                translate(top = -58.dp.toPx()) {
                    scale(scaleX = 1.38f, scaleY = 2.85f) {
                        with(glowMeshPainter) {
    draw(size, alpha = glowAlpha)
    }
  • Анализировать трассировку в Perfetto может быть сложно, поэтому вы можете использовать навык анализа трассировки, чтобы получить полезную информацию.

  • Если вам нужна помощь в написании эффективного кода Compose, установите Android CLI и используйте навык Compose performance.

    android skills add perfetto-trace-analysis
    android skills add jetpack-compose-performance

Резюме и ресурсы

  • Если вы хотите посмотреть примеры, которые мы сегодня продемонстрировали, взгляните на приложение JetChat, доступное по этой ссылке.

Код

Фрагменты кода

Фрагмент кода Kotlin на 02:47 в видео "Сетчатые градиенты и пользовательский рендеринг сетки"
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}
Фрагмент кода на Kotlin в видео "Сетчатые градиенты и отрисовка пользовательских сеток"
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
   setVertex(0, 0, Offset(0.0f, 0.0f), indigo)

}
}
Фрагмент кода на Kotlin в видео "Сетчатые градиенты и пользовательский рендеринг сетки"
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
   setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
     // ....
   setVertex(1, 1, Offset(0.2f, 0.4f) + Offset(animatedOffset, animatedOffset), coral)
   // ....
   setVertex(3, 0, Offset(0.0f, 1.0f), sunshine)
   // ....
}
}

Box(
   modifier = modifier.padding(32.dp)
       .safeContentPadding()
       .aspectRatio(16 / 9f)
       .fillMaxWidth()
       .paint(gradientPainter)
)
Фрагмент кода Kotlin на отметке 03:24 в видео "Сетчатые градиенты и пользовательский рендеринг сетки"
MeshGradientPainter(
   rows = 2,
   columns = 3,
   hasBicubicColor = true,
) {
   // Row 0 (top edge, y = 0.0f)
   setVertex(0, 0, Offset(0.0000f, 0.0000f), Color(0xFFFF6B6B))
   setVertex(0, 1, Offset(0.3300f, 0.0000f), Color(0xFFFFD6D6))
   setVertex(0, 2, Offset(0.6700f, 0.0000f), Color(0xFFE8C3FF))
   setVertex(0, 3, Offset(1.0000f, 0.0000f), Color(0xFFE396FF))

   // ... remaining vertices for Rows 0, 1, and 2
}
Фрагмент кода на Kotlin в видео "Сетчатые градиенты и рендеринг пользовательских сеток"
val transition = rememberInfiniteTransition(label = "userInputGlowMesh")

val phase1 by transition.animateFloat(
   initialValue = 0f,
   targetValue = (2 * PI).toFloat(),
   animationSpec = infiniteRepeatable(
       animation = tween(durationMillis = 2800, easing = LinearEasing),
       repeatMode = RepeatMode.Restart,
   ),
   label = "glowPhase1",
)

val phase2 by transition.animateFloat(
   initialValue = 0f,
   targetValue = (2 * PI).toFloat(),
   animationSpec = infiniteRepeatable(
       animation = tween(durationMillis = 4200, easing = LinearEasing),
       repeatMode = RepeatMode.Restart,
   ),
   label = "glowPhase2",
)
Фрагмент кода на Kotlin в видео "Сетчатые градиенты и отрисовка пользовательских сеток"
MeshGradientPainter(
   rows = 6,
   columns = 5,
   hasBicubicColor = true,
) {
   for (r in 0..6) {
       for (c in 0..5) {
               val offset = if (r in 1..5 && c in 1..4) {
               val ampX = when (r) {
                   1, 2 -> 0.048f
                   3 -> 0.042f
                   4 -> 0.028f
                   else -> 0.036f
               }
               val ampY = when (r) {
                   1, 2 -> 0.028f
                   3 -> 0.020f
                   4 -> 0.022f
                   else -> 0.016f
               }
               val d = orbit(c, r, ampX, ampY)
               Offset(
                   x = (baseU + d.x).coerceIn(0.04f, 0.96f),
                   y = (baseV + d.y).coerceIn(0.05f, 0.95f),
               )
           } else {
               Offset(baseU, baseV)
           }
           setVertex(r, c, offset, gridColors[r][c])
       }
   }
}
Фрагмент кода на Kotlin в видео "Сетчатые градиенты и отрисовка пользовательских сеток"
val lines = remember {
        path.asAndroidPath().flatten(0.5f)
    }
//... inside draw scope
lines.forEach { line ->
//...
    drawLine(
        brush = Brush.linearGradient(...),
        start = Offset(line.start.x,
line.start.y),
        end = Offset(line.end.x, line.end.y),
        strokeWidth = 30f,
    )
//...
}
Фрагмент текста на 07:03 в видео "Сетчатые градиенты и пользовательский рендеринг сетки"
LineShaders.VERTEX

uniform float2 uStart;
uniform float2 uEnd;
uniform float uHalfWidth;
uniform float uTime;

Varyings main(const Attributes a) {
    Varyings v;
    float2 dir = uEnd - uStart;
    float2 normal = normalize(float2(-dir.y, dir.x));
    float along = a.corner.x * length(dir);
    float pulse = (a.corner.x - uTime) / 0.18;
    float radius = uHalfWidth * (1.0 + 0.6 * exp(-pulse * pulse));
    float across = a.corner.y * (radius + 1.0);
    v.position = uStart + dir * a.corner.x + normal * across;
    v.stroke = float3(along, across, radius);
    return v;
}
Фрагмент текста на отметке 08:01 в видео "Mesh gradients and custom mesh rendering"
LineShaders.FRAGMENT

uniform float uLength;
layout(color) uniform float4 uStartColor;
layout(color) uniform float4 uEndColor;

float2 main(const Varyings v, out float4 color) {
    float distance = abs(v.stroke.y);
    float coverage = 1.0 - smoothstep(
    v.stroke.z - 0.5, v.stroke.z + 0.5, distance);
    float side = distance / v.stroke.z;
    float4 ink = mix(uStartColor, uEndColor,
        clamp(v.stroke.x / uLength + 0.4 * side * side, 0.0, 1.0));
    color = ink * coverage;
    return v.position;
}
Фрагмент кода на Kotlin в видео "Сетчатые градиенты и отрисовка пользовательских сеток"
val spec = remember {
    MeshSpecification.make(
        arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
        vertexStride = 8,
        arrayOf(Varying(TYPE_FLOAT3, "stroke")),
        LineShaders.VERTEX, LineShaders.FRAGMENT)
}
Фрагмент кода на Kotlin в видео "Сетчатые градиенты и отрисовка пользовательских сеток"
fun lineCorners(segments: Int): ByteBuffer {
    val buffer = ByteBuffer.allocateDirect((segments + 1) * 2 * 8)
        .order(ByteOrder.nativeOrder())
    for (i in 0..segments) {
        val t = i / segments.toFloat()
        buffer.putFloat(t).putFloat(-1f) // one side
        buffer.putFloat(t).putFloat(1f)  // the other side
    }
    return buffer.rewind() as ByteBuffer
}
Фрагмент кода на Kotlin в видео "Сетчатые градиенты и рендеринг пользовательских сеток"
val mesh = Mesh(
    spec,
    mode = Mesh.TRIANGLE_STRIP,
    vertexBuffer = corners,
    vertexCount = (segments + 1) * 2,
    bounds = RectF(0f, 0f, size.width, size.height)
)
Фрагмент кода на Kotlin в видео "Сетчатые градиенты и пользовательский рендеринг сетки"
Box(Modifier.fillMaxSize().drawWithCache {
   val mesh = Mesh(...)
   onDrawBehind {
       mesh.setFloatUniform("uTime", time.value)
       drawIntoCanvas {
          it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
       }
   }
})
Фрагмент XML-кода на 11:22 в видео "Поддержка широкой цветовой гаммы"
<!-- AndroidManifest.xml -->
<activity
android:name=".NavActivity"
android:colorMode="wideColorGamut"
android:exported="true">
</activity>
Фрагмент кода на Kotlin в видео "Поддержка широкой цветовой гаммы"
// Dynamic toggle in code
DisposableEffect(window) {
   window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
   onDispose {
       window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
   }
}
Фрагмент кода на Kotlin в видео "Поддержка широкой цветовой гаммы"
// 1. Display P3 Color (Wider gamut: greens & reds are more saturated) colorMode=wideColorGamut
val VividP3Red = Color(
   red = 1.0f,
   green = 0.05f,
   blue = 0.05f,
   alpha = 1.0f,
   colorSpace = ColorSpaces.DisplayP3
)
// 2. Extended sRGB (Enables HDR values > 1.0f) use colorMode=hdr
val HdrNeonCyan = Color(
   red = 0.0f,
   green = 1.8f,  // Peak brightness > 1.0 in extended gamut
   blue = 2.0f,
   alpha = 1.0f,
   colorSpace = ColorSpaces.ExtendedSrgb
)
// 3. BT.2020 HLG (Broadcast HDR standard, supported API 34+) - use colorMode=hdr
val HdrSunsetOrange = Color(
   red = 1.0f,
   green = 0.45f,
   blue = 0.1f,
   alpha = 1.0f,
   colorSpace = ColorSpaces.Bt2020Hlg
)
Фрагмент кода на Kotlin в разделе "Многоугольники и морфинг"
fun cookiePolygon(): RoundedPolygon {
    val vertices = FloatArray(32)
    for (i in 0 until 16) {
        val radius = if (i % 2 == 0) 1f else 0.84f
// …
    }
    return RoundedPolygon(
        vertices,
        rounding = CornerRounding(0.12f)
    )
}
Фрагмент кода на Kotlin в разделе "Многоугольники и морфинг"
class RoundedPolygonShape(
 polygon: RoundedPolygonShape
) : Shape {
    // …
    override fun createOutline(size: Size, layoutDirection: LayoutDirection,
    density: Density,
    ): Outline {
        path = polygon.toPath().asComposePath()
        // …
        matrix.scale(size.width / maxDimension, size.height / maxDimension)
        matrix.translate(-bounds.left, -bounds.top)
        // …
        return Outline.Generic(path)
    }
}
Фрагмент кода на Kotlin в видео "Многоугольники и морфинг"
private val AvatarShape = PolygonShape {
    val vertices = List(16) { i ->
        val radius = size.minDimension / 2f * if (i % 2 == 0) 1f else 0.86f
        val angle = i * PI.toFloat() / 8f
        size.center + Offset(cos(angle), sin(angle)) * radius
    }
    polygon(vertices, rounding = CornerRounding(radius = 8.dp))
}

    // …
Modifier.clip(AvatarShape)
Фрагмент кода на Kotlin в видео "Многоугольники и морфинг"
val recordShape = remember {
    MorphPolygonShape(
        start = PolygonShape.circle(),
        end = PolygonShape.star(
            numPoints = 9,
            innerRadiusRatio = 0.8f,
            outerRounding = CornerRounding(percent = 50),
        ),
    ) { shapeProgress.value }
}
Фрагмент кода на Kotlin в видео "Эффекты размытия и фонового рендеринга"
Modifier
   .blur(
       radiusX = 10.dp,
       radiusY = 10.dp,
       edgeTreatment = BlurredEdgeTreatment(
           shape = RoundedCornerShape(8.dp),
       ),
   )
Фрагмент кода на Kotlin в видео "Эффекты размытия и фонового рендеринга"
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}
Фрагмент кода на Kotlin в разделе "Размытие и эффекты рендеринга фона"
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)
Фрагмент кода на Kotlin в видео "Эффекты размытия и фонового рендеринга"
BlurRadiusSpec.radialGradient(
center = center,
fallOffRadius = 300.dp,
startRadius = 0.dp,
endRadius = 24.dp,
)
Фрагмент кода на Kotlin в видео "Размытие и эффекты рендеринга фона"
val customShader = remember {
   RuntimeShader(
       """
       uniform float2 size;
       uniform float time;

       half4 main(float2 coords) {
          // Diagonal wave mask
          float normalizedX = coords.x / size.x;
          float normalizedY = coords.y / size.y;
          float wave = sin((normalizedX + normalizedY) * 6.0 + time) * 0.5 + 0.5;
          return half4(0.0, 0.0, 0.0, wave);
       }
       """.trimIndent()
   )
}

val customBlurRadius = remember(customShader) {
   BlurRadiusSpec.shader(maxRadius = 24.dp) { customShader }
}

Modifier.blur {
       val w = size.width.toPx()
       val h = size.height.toPx()
       customShader.setFloatUniform("size", w, h)
       customShader.setFloatUniform("time", animatedTime)
       blurRadiusSpec = customBlurRadius
   },
Фрагмент кода на Kotlin в разделе "Эффекты размытия и рендеринга фона"
fun Modifier.backdropBlur(spec: BlurRadiusSpec, shape: Shape, tint: Color): Modifier =
   this then BackdropBlurElement(spec, shape, tint)

private class BackdropBlurNode(...) : Modifier.Node(), DrawModifierNode {
   private var renderNode: RenderNode? = null

   override fun ContentDrawScope.draw() {
       val node = getOrCreateNode()
       if (isDirty || sizeChanged) {
           node.setPosition(0, 0, widthPx, heightPx)
           node.setBackdropRenderEffect(spec.createRenderEffect(this))
       }
       drawIntoCanvas { it.nativeCanvas.drawRenderNode(node) }
       drawContent()
   }
}
Фрагмент кода на Kotlin в видео "Эффекты размытия и фонового рендеринга"
JetchatAppBar(
    modifier = modifier
        .backdropBlur(
            tint = MaterialTheme.colorScheme
                .surface.copy(alpha = 0.5f),
            shape = RectangleShape,
            spec = BlurRadiusSpec.uniform(5.dp)
        )
)
Фрагмент кода на Kotlin в видео "Эффекты размытия и фонового рендеринга"
JetchatAppBar(
    modifier = modifier
        .backdropBlur(
            tint = MaterialTheme.colorScheme
                .surface.copy(alpha = 0.5f),
            shape = RectangleShape,
            spec = BlurRadiusSpec.verticalGradient(
                listOf(
                    BlurStop(0.5f, 32.dp),
                    BlurStop(1f, 0.dp),
                ),
            )
        )
)
Фрагмент кода на Kotlin в видео "Эффекты размытия и фонового рендеринга"
val blur = RenderEffect.createBlurEffect(24f, 24f, Shader.TileMode.CLAMP)

// Boost saturation by 30% and apply blur together
val matrix = ColorMatrix().apply { setSaturation(1.3f) }
val vibrancy = RenderEffect.createColorFilterEffect(ColorMatrixColorFilter(matrix))
val vibrantBlur = RenderEffect.createChainEffect(vibrancy, blur)
Фрагмент кода на Kotlin в видео "Эффекты размытия и фонового рендеринга"
val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")

val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)
Фрагмент кода на Kotlin в видео "Эффекты размытия и фонового рендеринга"
val region = RoundedRectBlurRegion().apply {
   bounds = RectF(left, top, right, bottom)
   setCornerRadii(spec.cornerRadiusPx.coerceAtLeast(0f))
   alpha = spec.alpha.coerceIn(0f, 1f)
   blurRadius = spec.blurRadiusPx.coerceAtLeast(0f)
}

surfaceView.setBlurRegions(listOf(region))

surfaceView.setBlurRegions(emptyList()) // Clear
Фрагмент кода на Kotlin в видео RuntimeColorFilter (21:33)
@Language("AGSL")
private const val CYBERPUNK_SHADER = """
    vec4 main(half4 in_color) {
        float lum = dot(in_color.rgb, vec3(0.299, 0.587, 0.114));
        vec3 shadow = vec3(0.06, 0.04, 0.25);
        vec3 mid = vec3(0.92, 0.12, 0.58);
        vec3 high = vec3(0.18, 0.94, 0.98);
        vec3 col = lum < 0.5 ? mix(shadow, mid, lum * 2.0) : mix(mid, high, (lum - 0.5) * 2.0);
        return vec4(mix(in_color.rgb, col, 0.7), in_color.a);
    }
"""
Фрагмент кода Kotlin на 21:48 в видео RuntimeColorFilter
AsyncImage(
    model = imageUri,
    contentDescription = filter.displayName,
    colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                   .asComposeColorFilter(),
    contentScale = ContentScale.Crop,
    modifier = Modifier.fillMaxSize(),
)
Фрагмент кода на Kotlin в видео "Оптимизация производительности графики"
@Test
fun scrollFling() = measureScroll(swipeSteps = 5)

private fun measureScroll(swipeSteps: Int) = rule.measureRepeated(
    packageName = TARGET_PACKAGE,
    metrics = defaultComposeScrollingMetrics(),
    compilationMode = CompilationMode.Full(),
    iterations = 5,
    setupBlock = {
        killProcess()
    }
//...
Фрагмент SQL-кода на 22:32 в видео "Оптимизация производительности графики"
select count(*) as late_frames
from actual_frame_timeline_slice
where upid = (select upid from process where name = 'com.example.compose.jetchat.perf')
  and jank_type like '%App Deadline Missed%'
Фрагмент кода на Kotlin в видео "Оптимизация производительности графики"
Image(
    painter = painterResource(it),
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
Фрагмент кода на Kotlin в видео "Оптимизация производительности графики"
AsyncImage(
    model = it,
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
Фрагмент кода на Kotlin в видео "Оптимизация производительности графики"
val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply {
    setFloatUniform("size", size.width, size.height)
    setFloatUniform("zoom", zoom)
// …
val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content")
// …
val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply {
Фрагмент кода на Kotlin в видео "Оптимизация производительности графики"
val lensShader = MagnifierShaders.lens.apply {
    setFloatUniform("size", size.width, size.height)
    setFloatUniform("zoom", zoom)
// …
private object MagnifierShaders {
    val lens: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(MAGNIFIER_LENS_SHADER) }
    val glassFinish: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(GLASS_FINISH_SHADER) }
Фрагмент кода Kotlin на 25:49 в видео "Оптимизация производительности графики"
Box(
    modifier = Modifier
        .matchParentSize()
        .drawBehind {
            // Behind the card, scaled past its bounds.
            translate(top = -58.dp.toPx()) {
                scale(scaleX = 1.38f, scaleY = 2.85f) {
                    with(glowMeshPainter) {
draw(size, alpha = glowAlpha)
}
Фрагмент кода на Kotlin в разделе "Оптимизация производительности графики" (26:15)
Box(
    modifier = Modifier
        .matchParentSize()
                  .graphicsLayer()
        .drawBehind {
            // Behind the card, scaled past its bounds.
            translate(top = -58.dp.toPx()) {
                scale(scaleX = 1.38f, scaleY = 2.85f) {
                    with(glowMeshPainter) {
draw(size, alpha = glowAlpha)
}
Фрагмент кода Shell на 26:37 в видео "Оптимизация производительности графики"
android skills add perfetto-trace-analysis
android skills add jetpack-compose-performance