Apps foundation

Mejora tus apps con las funciones de gráficos más recientes de Jetpack Compose y Android

Bocinas

Acerca de

Acerca de la charla

¿Todo listo para llevar la IU de Android de "funciona" a "es una obra de arte"?

Acompáñanos en un recorrido por las funciones de gráficos más recientes de Jetpack Compose. Te mostraremos cómo crear nuevos efectos visuales, desde agradables gradientes de malla hasta desenfoques progresivos, y cómo usar las nuevas y potentes funciones de gráficos de Android. También te mostraremos cómo mejorar el rendimiento de tus funciones de gráficos sin sacrificar la duración de batería ni las velocidades de fotogramas.

Resumen

  • Compose 1.12 presenta gradientes de malla para combinaciones multicolores enriquecidas en una cuadrícula 2D de vértices. En Android 14 y versiones posteriores, puedes usar la API de drawMesh nativa para crear efectos visuales personalizados basados en la geometría.
  • Compose admite amplias gamas de colores para proporcionar una combinación de gradientes más fluida en el hardware compatible. Habilita el modo de amplia gama de colores en tu actividad o ventana solo cuando muestres contenido multimedia enriquecido.
  • Compose 1.13 presenta las APIs nativas de PolygonShape y MorphPolygonShape para geometrías personalizadas. MorphPolygonShape lee el progreso durante la fase de resolución del contorno y actualiza las formas fotograma por fotograma sin activar la recomposición.
  • Compose 1.13 agrega el desenfoque progresivo, mientras que el nivel de API 37.2 presenta RenderNode.setBackdropRenderEffect para muestrear el contenido en segundo plano. Los desarrolladores pueden encadenar efectos de fondo para crear efectos de cristal esmerilado o de lupa.
  • Optimiza el rendimiento de los gráficos evitando el trabajo innecesario en los fotogramas. Decodifica solo los píxeles de imagen necesarios fuera del subproceso principal y une los efectos de animación en un graphicsLayer.

Recursos

Gemini Notebook

Temas

  • jetpack-compose
  • android-graphics
  • mesh-gradients
  • sombreadores
  • difuminado
  • color-gamut
  • rendimiento

Transcripción

Descargar transcripción

Introducción a las funciones de gráficos en Jetpack Compose

  • Hola a todos. Les damos la bienvenida a nuestra sesión, Mejora tus apps con las funciones de gráficos más recientes de Compose y Android.

  • Mi nombre es Rebecca Franks y hoy me acompaña en el escenario…

  • Soy Aayush Chaudhary.

  • Hola, soy Rahul Ray.

  • Estamos viendo una tendencia emergente en la forma en que todos ustedes están desafiando los límites de lo que es posible en Android y Compose, usando la IA para dar vida a sus increíbles ideas.

  • Nos encanta ver apps que se sienten premium, nativas y agradables.

  • Sabemos que las apps que se sienten bien y reciben calificaciones excelentes de los usuarios de forma constante son las que, por lo general, aprovechan los cambios más recientes de la API a nivel del sistema, tienen continuidad entre las plataformas con elementos compartidos y desenfoque, se adaptan de forma agradable a todos los factores de forma, tienen un buen rendimiento, no dejan a los usuarios con dudas y superan los límites de lo que es posible con tu dispositivo.

  • En esta charla, te mostraremos algunas de las funciones más recientes que te ayudan a aprovechar Android, desde deleitar a los usuarios con gradientes de malla, habilitar la compatibilidad con una amplia gama de colores, transiciones agradables con formas poligonales, novedades en el desenfoque y, por último, cómo puedes pensar en el rendimiento cuando agregas nuevas funciones de gráficos a tus apps.

  • Sin embargo, durante la charla de hoy, abordaremos algunas de las APIs nuevas en diferentes niveles de la pila.

  • Si bien el kit de herramientas de IU de vistas heredado se encuentra en modo de mantenimiento, seguimos invirtiendo en los motores de renderización de hardware de gráficos de Android subyacentes que potencian Jetpack Compose.

  • Puedes pensar en los gráficos de Android en tres capas diferentes: las APIs de Compose en la primera capa, y aquí es donde se encuentran todas las APIs de gráficos de IU nuevas y de alto nivel, como los pintores de gradientes de malla, las formas de polígonos y las APIs de desenfoque progresivo.

  • Luego, tenemos la capa intermedia, en la que DrawScope de Compose, los nodos modificadores de dibujo y AGSL te permiten crear efectos personalizados.

  • Por último, tenemos la plataforma de Android, y Compose siempre puede acceder a un Canvas nativo y usar primitivas de renderización del SO nuevas directamente dentro de tus modificadores de Compose.

  • Hoy te mostraremos algunos ejemplos de cómo hacerlo.

  • ¡Aquí vamos!

Degradados de malla y renderización de mallas personalizadas

  • Primero, veamos los gradientes de malla, una nueva API que se introdujo en Compose 1.12.

  • Anteriormente, teníamos gradientes lineales, de barrido y radiales, que son excelentes para transiciones simples, pero si quieres mezclas multicolores ricas, como fondos ambientales o efectos de iluminación, necesitas una cuadrícula 2D de sistema donde las posiciones y los colores se puedan mezclar en el espacio.

  • Ahora, para crear un degradado de malla como este, primero especifica cuántas filas y columnas de parches quieres.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
    }
    }
  • Luego, dentro de la expresión lambda final, llama a setVertex y asigna un desplazamiento normalizado que vaya de 0F a 1F, junto con un color para cada intersección de la cuadrícula.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
       setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
    
    }
    }
  • Y, luego, para animar esos vértices, agrega compensaciones animadas para ocho vértices.

    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)
    )
  • Una vez que se defina el pintor, simplemente llámalo con cualquier elemento componible a través de un modificador de pintura.

  • Ahora, en la app de JetChat, agregamos una función para indicar que te gusta un mensaje cuando lo presionas dos veces.

  • Para crear esto, configuramos una malla de 2 por 3 y colocamos nuestros puntos de color en todo el componente.

    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
    }
  • Observa que establecimos hasBicubicColor como verdadero aquí.

  • De forma predeterminada, los colores se interpolan de forma lineal en los triángulos, lo que a veces puede dejar parches visibles.

  • Si activas esta opción, el sombreador cambiará a una interpolación bicúbica suave para que los colores se mezclen sin problemas.

  • Ahora, ¿qué pasa si quieres crear un resplandor ambiental animado como este detrás del cuadro de entrada?

  • Para lograr un efecto como este con un gradiente de malla, necesitas dos cosas.

  • Primero, un movimiento orgánico suave para los vértices interiores y una forma de suavizar los bordes exteriores para que no se obtenga un rectángulo rígido alrededor de la tarjeta.

  • Por lo tanto, comenzamos configurando un rememberInfiniteTransition con dos ángulos de fase que se ejecutan con duraciones ligeramente diferentes para que el movimiento se sienta orgánico en lugar de mecánico.

    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",
    )
  • Aquí, animamos los vértices interiores y fijamos los anillos exteriores, el anillo exterior de los vértices con bordes con alfa 0.

  • Como el límite exterior es estático y transparente, el resplandor se difumina de forma natural.

  • Por último, pasamos esos desplazamientos animados a nuestro 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])
           }
       }
    }
  • El gradiente de malla que acabas de ver se dibuja con la API de drawMesh en Android 14 y versiones posteriores.

  • Analicemos esta API con más detalle creando un efecto desde cero.

  • Para la pantalla de bienvenida de JetChat, queremos dibujar una ruta que parezca una cinta.

  • Observa dos cosas aquí: el ancho del trazo no es constante y el color se extiende a lo largo de cada trazo de la ruta.

  • Analicemos esto y comprendamos qué necesitamos realmente para crear este efecto.

  • Para cada píxel, debemos decidir si lo dibujamos o no, y qué color usar.

  • Para averiguarlo, necesitamos dos distancias.

  • El primer valor es la distancia de un píxel desde el centro del trazo.

  • La segunda distancia es la distancia que recorre un píxel a lo largo de la ruta, de principio a fin.

  • Esto nos indica qué color elegir del gradiente, y mezclar un poco del primer valor hace que el color se curve a lo largo del ancho.

  • Entonces, ¿podemos dibujar un gradiente a lo largo de una ruta usando solo Path y Paint?

  • Así que, sí, podemos.

  • Para ello, se debe aplanar una ruta de acceso en una lista de líneas rectas cortas, lo que también proporciona la distancia a lo largo de la ruta de acceso.

    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,
        )
    //...
    }
  • Luego, dibujamos cada línea con su propio gradiente.

  • Funciona, pero cada línea requiere una llamada de dibujo y un objeto Paint independientes, y toda la ruta de acceso sigue teniendo un solo ancho.

  • La otra opción es un sombreador de tiempo de ejecución, pero solo obtiene la posición de los píxeles dentro del límite del contenedor.

  • No nos dice lo que necesitamos para dibujar esta cinta.

  • Y tampoco podemos calcularlo fácilmente a partir de la posición de los píxeles.

  • Draw mesh nos proporciona un sombreador de vértices para eso.

  • Entonces, ¿qué es una malla?

  • Una malla se compone de triángulos con datos en cada esquina, y cada esquina se llama vértice.

  • Cada vértice contiene un pequeño registro de datos.

  • Por lo general, ese registro contiene una posición, pero no es obligatorio.

  • El sombreador de vértices lee ese registro y calcula la posición a partir de él.

  • Aquí, esta es la ruta que toma un triángulo: El sombreador de vértices se ejecuta para cada esquina, calcula la posición de las esquinas y cualquier valor que queramos pasar para cada píxel.

  • Luego, el rasterizador encuentra cada píxel cubierto por ese triángulo y, para cada píxel, interpola los valores de esas tres esquinas.

  • Por último, el sombreador de fragmentos se ejecuta para esos píxeles, usa esos valores interpolados y calcula el color de los píxeles.

  • Veamos cómo podemos escribir un sombreador de vértices y fragmentos con un ejemplo más simple: una línea recta con un pulso que se mueve a lo largo de ella.

    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;
    }
  • El sombreador de vértices toma un atributo como entrada y devuelve una variable.

  • Los atributos son los datos de una sola esquina.

  • Aquí solo hay dos números: su progreso a lo largo de la línea y de qué lado de la línea se encuentra.

  • Luego, las variables se interpolan y se pasan al sombreador de fragmentos.

  • Calculamos la distancia en píxeles desde el inicio y, para el ancho del trazo, usamos el tiempo para crear un pulso en el radio.

  • Luego, multiplicamos ese radio por el lado de la esquina, lo que nos da la distancia del signo a lo largo del trazo.

  • A medida que la GPU interpola entre esos signos, las líneas centrales se vuelven cero.

  • Por último, calculamos la posición de las esquinas, almacenamos todo este valor en variables de trazo y las pasamos al sombreador de fragmentos.

  • A diferencia del sombreador de tiempo de ejecución, un sombreador de fragmentos de malla genera un color a través de un parámetro y devuelve una posición para muestrear la pintura si se establece una.

    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;
    }
  • Si tomamos el valor absoluto de nuestra coordenada transversal, obtenemos la distancia píxel por píxel desde la línea central.

  • Comparamos esa distancia con el radio para obtener la cobertura y, para el color, mezclamos a lo largo de la ruta de la línea y doblamos el gradiente ligeramente hacia el borde.

  • Multiplica esa cobertura por la cobertura, ese es nuestro color final.

  • En Compose, vinculamos nuestros sombreadores y el diseño de datos con el objeto MeshSpecification.

    val spec = remember {
        MeshSpecification.make(
            arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
            vertexStride = 8,
            arrayOf(Varying(TYPE_FLOAT3, "stroke")),
            LineShaders.VERTEX, LineShaders.FRAGMENT)
    }
  • Primero, declaramos nuestro atributo de esquina como dos números de punto flotante que comienzan en el byte 0.

  • Dado que dos valores de punto flotante ocupan 8 bytes, cada paso de esquina en el búfer es de 8.

  • Luego, declaramos nuestras variables de trazo como tres números de punto flotante y pasamos el sombreador de vértices y fragmentos que acabamos de escribir.

  • Creamos el búfer de vértices.

    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
    }
  • En cada paso de la línea, completamos el progreso, calculamos el progreso de 0 a 1 y escribimos dos esquinas en el búfer.

  • Por último, combinamos la especificación y el búfer en un objeto de malla.

    val mesh = Mesh(
        spec,
        mode = Mesh.TRIANGLE_STRIP,
        vertexBuffer = corners,
        vertexCount = (segments + 1) * 2,
        bounds = RectF(0f, 0f, size.width, size.height)
    )
  • El lienzo de Compose aún no expone drawMesh, por lo que bajamos a nativeCanvas y dibujamos la malla.

    Box(Modifier.fillMaxSize().drawWithCache {
       val mesh = Mesh(...)
       onDrawBehind {
           mesh.setFloatUniform("uTime", time.value)
           drawIntoCanvas {
              it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
           }
       }
    })
  • Como controlas la geometría y el píxel, puedes dibujar cualquier efecto visual basado en la geometría con la malla de dibujo.

  • Obtén más información sobre esta API en la documentación.

Compatibilidad con amplia gama de colores

  • En Compose 1.12, presentamos la compatibilidad con gamas de colores amplias.

  • ¿Qué significa esto?

  • ¿Sabías que las pantallas pueden reproducir un rango de colores diferente al del espacio de color sRGB tradicional?

  • Por ejemplo, si miras esta imagen, verás que el triángulo blanco es el rango de colores sRGB estándar que pueden mostrar la mayoría de las pantallas.

  • Sin embargo, con las pantallas más nuevas, algunas de ellas admiten una gama más amplia de colores, como lo que se muestra en el triángulo amarillo, algunos más saturados y vibrantes.

  • Sin embargo, cuando representamos un color, en realidad lo hacemos con la descripción de cada vértice de ese triángulo.

  • Cuando observas esta comparación de gradientes, es importante distinguir entre la gama de colores de la pantalla y la interpolación de gradientes.

  • En una pantalla sRGB estándar o, probablemente, en este proyector, la pantalla no puede renderizar físicamente los colores fuera de esta gama sRGB.

  • En realidad, es muy difícil hacer una demostración en el escenario en este momento.

  • Por lo tanto, tendrás que confiar en mí cuando te digo que los colores se ven mejor en Android con la compatibilidad con una amplia gama de colores.

  • En este ejemplo, lo que realmente ves aquí es este efecto de interpolar paradas de color en un espacio de color más amplio.

  • La mezcla de colores en un espacio de color amplio o lineal cambia la ruta matemática real de este gradiente, lo que evita que se produzca este tono grisáceo medio y las bandas marcadas que pueden aparecer cuando no se usa una amplia gama de colores.

  • Con Compose 1.12, obtienes el beneficio de una combinación de gradientes más fluida en cualquier pantalla, así como una amplia gama de colores real cuando ejecutas la app en un hardware compatible.

  • Para habilitar la compatibilidad correcta en el dispositivo, debes asegurarte de que tu actividad o ventana estén configuradas para habilitar el modo de amplia gama de colores.

  • Esto se puede hacer en tu AndroidManifest.

    <!-- AndroidManifest.xml -->
    <activity
    android:name=".NavActivity"
    android:colorMode="wideColorGamut"
    android:exported="true">
    </activity>
  • O de manera programática en tu elemento de componibilidad.

    // Dynamic toggle in code
    DisposableEffect(window) {
       window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
       onDispose {
           window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
       }
    }
  • Sin configurar colorMode, la ventana permanece en sRGB estándar de 8 bits, y se recortarán los colores de amplia gama de colores que renderice la GPU.

  • Por lo tanto, es importante evitar el uso de colores de amplia gama si la pantalla no puede admitirlos.

  • Solo debes habilitar las amplias gamas de colores en las actividades o instrucciones de código que realmente muestren contenido multimedia, fotos o gráficos enriquecidos para ahorrar batería y mejorar el rendimiento.

  • Esto significa que puedes crear colores en diferentes espacios de color en Compose, y ahora se respetan.

    // 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
    )
  • Puedes pasar el tipo de colorSpace en el que deseas que se cree el color y, si no pasas ningún colorSpace, se usará el sRGB estándar.

  • En el caso específico de los mapas de bits, puedes verificar cuál es la fuente del mapa de bits con getColorSpace, y las bibliotecas de carga de imágenes populares controlarán automáticamente los espacios de color correctos de los mapas de bits.

  • Debes usar Bitmap.Config.HARDWARE para conservar la información de color, ya que una configuración como 565 perderá precisión y tendrá bandas visibles para los usuarios.

  • Para obtener más información sobre los colores, puedes volver al 2017 y mirar a Romain Guy hablar sobre el color en Compose o en Android.

Formas de polígonos y transformación

  • Con Compose 1.13, presentamos la compatibilidad nativa con la forma de polígono.

  • Esto facilitará mucho la creación de formas personalizadas y elementos de la IU de aspecto orgánico.

  • Anteriormente, si querías dibujar una geometría compleja como esta, tenías que usar RoundedPolygon para describir una geometría que generara una ruta de acceso y, luego, asignar esa ruta de acceso a una API de forma de 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)
        )
    }
  • Por ejemplo, primero, defines las proporciones con un RoundedPolygon y, luego, establecemos En este fragmento, establecemos que nuestros puntos internos estén al 84% del centro en comparación con nuestros puntos externos.

  • También puedes aplicar el redondeo de esquinas aquí, pero esto solo describe las curvas matemáticas.

  • Para usar este polígono para recortar o para fondos en Compose, debes escribir un wrapper de Shape personalizado como este.

    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)
        }
    }
  • Y, dentro de este fragmento de createOutline, puedes ver que tienes que crear la ruta, escalarla y traducirla de forma manual.

  • Ahora, con la nueva API de PolygonShape, ya no necesitas ese wrapper de Shape personalizado.

    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)
  • Como este es el compilador de Shape dedicado de Compose, recibe la densidad y el tamaño directamente de forma predeterminada.

  • Esto significa que puedes calcular tus vértices de forma dinámica y especificar el redondeo de las esquinas directamente en dp o incluso como porcentaje.

  • Una vez que se define, solo debes pasarlo a cualquier API que tome un Shape.

  • También presentamos la API de MorphPolygonShape, que te permite realizar transiciones sin problemas entre diferentes formas de polígonos.

    val recordShape = remember {
        MorphPolygonShape(
            start = PolygonShape.circle(),
            end = PolygonShape.star(
                numPoints = 9,
                innerRadiusRatio = 0.8f,
                outerRounding = CornerRounding(percent = 50),
            ),
        ) { shapeProgress.value }
    }
  • En este ejemplo, tenemos un botón de grabación que se transforma de un círculo a una estrella cuando interactúas con él.

  • Solo debes proporcionar la forma inicial, la forma final y el valor de progreso.

  • La API de MorphPolygonShape lee el valor de progreso durante la fase de resolución del contorno, es decir, no durante la recomposición ni la composición.

  • Esto significa que, a medida que la forma se transforma entre fotogramas, simplemente se resuelve en un contorno nuevo sin activar una recomposición.

Efectos de renderización de desenfoque y fondo

  • Ahora, hablemos sobre el desenfoque.

  • En las últimas versiones, intentamos que la implementación de los desenfoques sea mucho más fácil y expresiva.

  • Cuando hablamos de desenfoque, este es el modificador de uso frecuente que vemos.

    Modifier
       .blur(
           radiusX = 10.dp,
           radiusY = 10.dp,
           edgeTreatment = BlurredEdgeTreatment(
               shape = RoundedCornerShape(8.dp),
           ),
       )
  • Compose tiene este modificador desde Android 12.

  • Funciona muy bien cuando quieres un desenfoque uniforme, en el que se aplica exactamente el mismo radio de desenfoque en todo un elemento componible.

  • Sin embargo, los diseños de IU modernos suelen requerir una transición fluida de las regiones nítidas a las desenfocadas, en lugar de un corte abrupto.

  • Y eso nos lleva a Progressive Blur, que ahora es una API oficial a partir de Compose 1.13.

  • En lugar de un solo radio constante en todo el elemento componible, ahora puedes variar la intensidad del desenfoque en todo el espacio.

  • Este es el ejemplo de nuestra app de JetChat de la pantalla de perfil de usuario.

  • El nuevo modificador de desenfoque toma una especificación de radio de desenfoque.

    Modifier.blur {
    blurRadiusSpec = BlurRadiusSpec.verticalGradient(
    listOf(
    BlurStop(0.5f, 0.dp),
    BlurStop(1.0f, 32.dp),
    )
    )
    edgeTreatment = BlurredEdgeTreatment.Unbounded
    }
  • Aquí usamos un gradiente vertical con dos puntos de desenfoque, lo que mantiene la mitad superior de la foto completamente nítida en 0 dp y aumenta hasta un desenfoque de 32 dp en el borde inferior.

  • Observa que también configuramos edgeTreatment como ilimitado, lo que básicamente le indica al desenfoque que también tome muestras de píxeles transparentes fuera de los límites del elemento que admite composición.

  • Además de los desvanecimientos verticales, esta especificación de radio de desenfoque te brinda varias formas integradas de generar un desenfoque progresivo.

  • Por ejemplo, a la izquierda, puedes ver un gradiente horizontal, en el que el radio inicial es de 0 dp y el radio final se controla con un valor de control deslizante.

    BlurRadiusSpec.horizontalGradient(
    startRadius = 0.dp,
    endRadius = sliderValue.dp,
    )
  • Y, a la derecha, tenemos un gradiente radial, donde, en el centro, el radio de desenfoque es de 0 dp y, a medida que te mueves hacia el fallOffRadius, que es de 300 dp en este caso, el desenfoque aumenta de forma gradual hasta 24 dp.

    BlurRadiusSpec.radialGradient(
    center = center,
    fallOffRadius = 300.dp,
    startRadius = 0.dp,
    endRadius = 24.dp,
    )
  • Y si todos estos gradientes verticales, radiales y horizontales no son suficientes, siempre puedes controlar el desenfoque progresivo con sombreadores personalizados.

    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
       },
  • Aquí escribimos un pequeño sombreador de fragmentos que calcula una onda sinusoidal diagonal animada y genera esa intensidad en un canal alfa.

  • Luego, la especificación del sombreador asigna esa máscara de 0 a 1 directamente a una máscara de 24 dp maxRadius.

  • Antes de hablar del desenfoque del fondo, veamos la diferencia entre ambos.

  • Ahora, a la izquierda, tenemos una barra superior de la app en la que aplicamos el desenfoque de capa.

  • Es decir, desenfoca el elemento componible en sí.

  • Como puedes ver, el título y los íconos de acción están desenfocados, pero el componente de chat que está detrás no lo está en absoluto.

  • A la derecha, tenemos el desenfoque del fondo.

  • Aquí, solo se muestrean y desenfocan los mensajes de chat que se desplazan detrás de la barra superior.

  • Sin embargo, el texto y los íconos son completamente nítidos y legibles.

  • A partir de Android 37.2, puedes desenfocar el fondo con una nueva API llamada RenderNode.setBackdropRenderEffect.

  • Pero antes de profundizar en cómo está mejorando el desenfoque, comprendamos cómo funciona Android de forma interna para garantizar que el desenfoque sea eficiente.

  • Android usa RenderNodes de forma interna, que son contenedores de listas de visualización con aceleración de hardware, lo que significa que pueden compactar y almacenar un montón de comandos de dibujo diferentes a través de begin recording y end recording.

  • El RenderNode contiene información como las traducciones, las rotaciones, el alfa, las escalas y los límites de posición en 3D.

  • De hecho, Compose usa RenderNode de forma interna.

  • Por lo tanto, para usar los efectos de renderización de fondo en Compose, podemos crear un Modifier.Node personalizado para poder reutilizar este modificador.

    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()
       }
    }
  • Mantenemos una referencia a un RenderNode, y este RenderNode se configura con los efectos que queremos en otra función.

  • Por ejemplo, pasamos un efecto de desenfoque aquí.

  • Luego, llamamos a drawIntoCanvas, que llama a drawRenderNode en nuestro lienzo nativo.

  • Por último, también llamamos a drawContent para dibujar el contenido real sin desenfoque en la parte superior.

  • Para usar este modificador que acabamos de crear, llamamos a Modifier.backdropBlur y pasamos una especificación de radio de desenfoque.

    JetchatAppBar(
        modifier = modifier
            .backdropBlur(
                tint = MaterialTheme.colorScheme
                    .surface.copy(alpha = 0.5f),
                shape = RectangleShape,
                spec = BlurRadiusSpec.uniform(5.dp)
            )
    )
  • Ten en cuenta que, con este enfoque, no necesitamos colocar nada en la capa subyacente.

  • RenderNode.setBackdropRenderEffect toma muestras automáticamente del contenido que se encuentra debajo de la superficie de la barra de la app de JetChat.

  • Y el resultado es este encantador desenfoque de fondo.

  • También podríamos establecer una especificación de radio de desenfoque progresivo en lugar de solo uno progresivo y obtener un desenfoque vertical en el contenido.

    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),
                    ),
                )
            )
    )
  • Con un gradiente vertical, muestra el contenido debajo desenfocado con diferentes radios en diferentes fracciones del contenedor.

  • Estamos trabajando en un modificador de desenfoque de fondo integrado en Compose.

  • Este es solo un ejemplo de la tecnología subyacente en Android con Compose.

  • Este ejemplo que también te mostré solo funcionaría en el nivel de API 37.2, pero también estamos trabajando en una retrocompatibilidad para Compose, así que mantente al tanto.

  • Otro ejemplo divertido con los efectos de fondo de RenderNode es que también podemos encadenarlos para formar IU muy atractivas y que realicen múltiples efectos diferentes sobre ellas.

  • Para encadenar efectos, creamos estos dos efectos.

    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)
  • En este ejemplo, creamos un efecto de desenfoque y una saturación del 30%.

  • Llamamos a createChainEffect con el efecto de desenfoque que se aplica primero y la saturación después.

  • Este es un ejemplo en JetChat.

    val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
    val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
    
    val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
    renderNode?.setBackdropRenderEffect(finalEffect)
  • El botón para saltar al final, que podemos hacer que parezca una pequeña lupa combinando algunos efectos diferentes para que los bordes de este elemento componible se redondeen como la lupa.

  • Sin embargo, no solo puedes aplicar efectos de aumento, sino que también puedes aplicar cualquier tipo de sombreador encadenado para aplicarlo a un fondo.

  • Estos son algunos ejemplos divertidos que implementamos de lo que es posible cuando se combinan.

  • Ahora es posible lograr el efecto tradicional de tipo vidrio esmerilado.

  • Pero también podríamos hacer otros efectos divertidos, como un caleidoscopio.

  • O si quisiéramos convertir esta mascota de Android en una especie de elemento de la IU que se transforma y que transforma el contenido que está debajo, también podríamos aplicar estos efectos de renderización de fondo para que parezca que estamos mirando a través de este pequeño androide.

  • Y también podemos hacerlo con los efectos de renderización del fondo.

  • Por lo tanto, no solo se desbloquearon el desenfoque de fondo y el vidrio esmerilado con esta nueva API, sino que ahora el único límite es tu imaginación.

  • Otra API nueva que incorporamos a la plataforma es SurfaceView.setBlurRegions.

  • Actualmente, puedes desenfocar vistas de texturas y cualquier elemento componible con los efectos de fondo Modifier.blur y RenderNode, pero desenfocar partes de un SurfaceView es complicado y no forma parte del árbol RenderNode de la vista de la app, ya que, en realidad, hace un agujero en la ventana.

  • Por lo tanto, Compose no puede muestrear el búfer de respaldo de SurfaceView, lo que antes era imposible hasta la API 37.2.

  • Por lo tanto, el setBlurRegions de SurfaceView permite que la app declare regiones de desenfoque directamente.

  • Por ejemplo, en este reproductor de video de JetChat, agregamos un desenfoque de SurfaceView cuando se muestran y ocultan los controles.

  • Para usar esta nueva API, puedes acceder a setBlurRegions en un método de 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
  • Esto toma la lista de áreas de límites, el radio de desenfoque, el valor alfa y una lista de radios de esquina.

  • Cuando se pasan estos límites, deben ser relativos a la SurfaceView en la pantalla.

  • Por lo tanto, te recomendamos que hagas un seguimiento de las coordenadas de SurfaceView y del área que quieres desenfocar.

RuntimeColorFilter

  • Otra novedad de Android 16 y versiones posteriores es RuntimeColorFilter.

  • Anteriormente, con las imágenes, solo podías establecer un filtro de color de matriz estático y aplicar efectos como la saturación.

  • Sin embargo, con Android 16 y versiones posteriores, ahora puedes pasar un sombreador que tome un color de entrada, el píxel de entrada para ese contenido en particular, y produzca un color nuevo.

  • Por ejemplo, si quisiéramos aplicar un filtro cyberpunk a una foto, podríamos definir un sombreador pequeño que se use como entrada en el 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);
        }
    """
  • Este sombreador produce un tema cyberpunk, pero puedes aplicar cualquier otro efecto que desees.

  • Por lo tanto, para usar el sombreador, aplicamos RuntimeColorFilter y lo convertimos en un filtro de color de Compose.

    AsyncImage(
        model = imageUri,
        contentDescription = filter.displayName,
        colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                       .asComposeColorFilter(),
        contentScale = ContentScale.Crop,
        modifier = Modifier.fillMaxSize(),
    )
  • Como puedes ver en este ejemplo, ahora podemos crear efectos más dinámicos que antes sin necesidad de profundizar en OpenGL, configuraciones de SurfaceView ni nada por el estilo.

  • Y esto también se puede aplicar a cualquier elemento componible, no solo a las imágenes.

Optimización del rendimiento de los gráficos

  • Gracias.

  • Creamos un efecto muy agradable, pero asegurémonos de que nuestra app siga siendo rápida y fluida.

  • Cuando nos desplazamos por la conversación, todos nuestros efectos se ejecutan juntos.

  • Comencemos por medir este desplazamiento.

    @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()
        }
    //...
  • Usamos una macrocomparativa que inicia una app en un proceso nuevo y ejecuta el desplazamiento por la conversación.

  • Luego, consultamos el registro para contar los fotogramas que no cumplen con los plazos.

    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%'
  • Eso nos da seis fotogramas retrasados.

  • Abramos uno de los fotogramas tardíos.

  • Por lo tanto, justo antes de este fotograma, Compose realiza la carga previa del siguiente mensaje antes de que se desplace hacia arriba en la vista.

  • Se supone que la carga previa debe caber en el breve espacio entre fotogramas, pero la preparación de este mensaje también decodifica su imagen directamente en el subproceso principal, que se ejecuta durante mucho tiempo sin producir resultados.

  • Debido a que el subproceso principal está bloqueado decodificando esta imagen, no se pueden iniciar fotogramas nuevos.

  • Luego, cuando ese mensaje se desplaza a la vista, RenderThread tiene que asignar memoria y subir todos esos píxeles a la GPU, por lo que la misma imagen retrasa un segundo fotograma.

  • Ahora, vemos en qué se nos fue el tiempo, pero ¿necesitamos todo este trabajo para dibujar este fotograma?

  • ¿Y cómo podemos saberlo?

  • El seguimiento nos lo dice.

  • Cada vez que se dibuja un mapa de bits grande en un área mucho más pequeña de la pantalla, Android registra un marcador directamente en el registro.

  • Muestra dos números en píxeles: la dimensión completa del mapa de bits y el área que realmente cubre en la pantalla.

  • Divide ese valor por la densidad de la pantalla y obtendrás el valor en dp.

  • Luego, buscamos en nuestro código con ese valor de dp para encontrar esa imagen.

  • Esto es lo que encontramos.

  • La imagen se carga con painterResource, que no conoce el tamaño del contenedor, por lo que decodifica toda la imagen en resolución completa.

    Image(
        painter = painterResource(it),
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • Por lo tanto, lo reemplazamos por AsyncImage de Coil.

    AsyncImage(
        model = it,
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • Primero mide el contenedor, por lo que solo decodifica el píxel que necesita, y realiza ese trabajo en un subproceso diferente.

  • Cuando se vuelve a ejecutar la misma comparativa, la decodificación de imágenes en el subproceso principal se reduce a 0 y los fotogramas retrasados se reducen a 2.

  • No depuraremos esos dos fotogramas aquí, pero la conclusión es simple: intenta no calcular píxeles que nunca llegarán al fotograma.

  • Por lo tanto, la decodificación de imágenes fue lo suficientemente grande como para retrasar los fotogramas, pero no todos los costos se muestran como fotogramas perdidos, y todavía hay costos más pequeños que podemos evitar mientras escribimos el código.

  • Veamos dos de ellas.

  • Cada vez que usemos un RuntimeShader, deberíamos crearlo una sola vez, ya que, si bien solo se compila y se almacena en caché, analiza su cadena de origen cada vez que creamos uno.

  • Y cada vez que un efecto anime cada fotograma, podemos unirlo en un graphicsLayer, de modo que solo se vuelva a grabar el elemento que se anima, en lugar de volver a dibujar la IU que lo rodea.

  • En el caso del botón Ir al final, aparece cada vez que nos desplazamos.

  • Esto usa dos RuntimeShader para mostrar ese efecto y, cada vez que aparece el botón, volvemos a crear ambos sombreadores a partir de su cadena de origen.

    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 {
  • Analizar esos dos sombreadores y compilar el efecto toma cientos de microsegundos en el subproceso principal cada vez que aparece el botón.

  • En su lugar, creamos el sombreador una vez para todo el proceso, o podemos recordarlo y, luego, actualizar su uniforme.

    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) }
  • Con ese cambio, la compilación de ese efecto es casi 40 veces más rápida, y el costo cuando aparece el botón disminuye 20 veces.

  • En el resplandor del gradiente de malla que vimos antes, el resplandor se anima en cada fotograma dentro de 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)
    }
  • Por lo tanto, leer el estado en la fase de dibujo mantiene la composición y el diseño, pero la lista de visualización no puede actualizar un solo comando de dibujo en su lugar.

  • Por lo tanto, Compose recorre el árbol para encontrar el nodo más cercano con la capa de gráficos, que aquí es el contorno rojo, que es toda la pantalla, y vuelve a grabar toda esa capa en cada fotograma.

  • Si colocas un graphicsLayer justo en el cuadro, el recorrido se detiene en el contorno verde, por lo que solo se vuelve a registrar el cuadro de brillo y el segmento de dibujo del subproceso principal se reduce en dos décimas de milisegundo.

    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)
    }
  • Con Perfetto, puede ser difícil analizar qué sale mal en el seguimiento, por lo que puedes usar la habilidad de análisis de seguimiento para obtener algunas estadísticas.

  • Y si quieres más ayuda para escribir código de Compose de alto rendimiento, asegúrate de instalar la CLI de Android y usar la habilidad de rendimiento de Compose para comenzar.

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

Resumen y recursos

  • Si quieres ver los ejemplos que mostramos hoy, consulta la app de JetChat, disponible en este vínculo.

Código

Fragmentos de código

Fragmento de Kotlin en el minuto 2:47 de "Gradientes de malla y renderización de mallas personalizadas"
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}
Fragmento de Kotlin en el minuto 02:55 de "Mesh gradients and custom mesh rendering"
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
   setVertex(0, 0, Offset(0.0f, 0.0f), indigo)

}
}
Fragmento de Kotlin en el minuto 3:05 de "Gradientes de malla y renderización de mallas personalizadas"
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)
)
Fragmento de Kotlin en el minuto 03:24 de "Degradados de malla y renderización de mallas personalizadas"
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
}
Fragmento de Kotlin en el minuto 04:09 de "Mallas de gradientes y renderización de mallas personalizadas"
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",
)
Fragmento de Kotlin en el minuto 04:33 de "Degradados de malla y renderización de malla personalizada"
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])
       }
   }
}
Fragmento de Kotlin en el minuto 05:40 de "Degradados de malla y renderización de mallas personalizadas"
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,
    )
//...
}
Fragmento de texto en el minuto 07:03 en "Gradientes de malla y renderización de malla personalizada"
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;
}
Fragmento de texto en el minuto 08:01 de "Degradados de malla y renderización de mallas personalizadas"
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;
}
Fragmento de Kotlin en el minuto 08:37 de "Degradados de malla y renderización de malla personalizada"
val spec = remember {
    MeshSpecification.make(
        arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
        vertexStride = 8,
        arrayOf(Varying(TYPE_FLOAT3, "stroke")),
        LineShaders.VERTEX, LineShaders.FRAGMENT)
}
Fragmento de Kotlin en el minuto 09:06 de "Degradados de malla y renderización de mallas personalizadas"
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
}
Fragmento de Kotlin en el minuto 9:18 de "Degradados de malla y renderización de malla personalizada"
val mesh = Mesh(
    spec,
    mode = Mesh.TRIANGLE_STRIP,
    vertexBuffer = corners,
    vertexCount = (segments + 1) * 2,
    bounds = RectF(0f, 0f, size.width, size.height)
)
Fragmento de Kotlin en el minuto 09:24 de "Degradados de malla y renderización de mallas personalizadas"
Box(Modifier.fillMaxSize().drawWithCache {
   val mesh = Mesh(...)
   onDrawBehind {
       mesh.setFloatUniform("uTime", time.value)
       drawIntoCanvas {
          it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
       }
   }
})
Fragmento XML en el minuto 11:22 de "Compatibilidad con una amplia gama de colores"
<!-- AndroidManifest.xml -->
<activity
android:name=".NavActivity"
android:colorMode="wideColorGamut"
android:exported="true">
</activity>
Fragmento de Kotlin en el minuto 11:24 de "Compatibilidad con una amplia gama de colores"
// Dynamic toggle in code
DisposableEffect(window) {
   window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
   onDispose {
       window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
   }
}
Fragmento de Kotlin en el minuto 11:49 de "Compatibilidad con una amplia gama de colores"
// 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
)
Fragmento de Kotlin en el minuto 12:45 de "Formas de polígonos y transformación"
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)
    )
}
Fragmento de Kotlin en el minuto 13:15 de "Formas de polígonos y transformación"
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)
    }
}
Fragmento de Kotlin en el minuto 13:30 de "Formas de polígonos y transformación"
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)
Fragmento de Kotlin en el minuto 13:53 de "Formas de polígonos y transformación"
val recordShape = remember {
    MorphPolygonShape(
        start = PolygonShape.circle(),
        end = PolygonShape.star(
            numPoints = 9,
            innerRadiusRatio = 0.8f,
            outerRounding = CornerRounding(percent = 50),
        ),
    ) { shapeProgress.value }
}
Fragmento de Kotlin en el minuto 14:37 de "Efectos de renderización de desenfoque y fondo"
Modifier
   .blur(
       radiusX = 10.dp,
       radiusY = 10.dp,
       edgeTreatment = BlurredEdgeTreatment(
           shape = RoundedCornerShape(8.dp),
       ),
   )
Fragmento de Kotlin en el minuto 15:15 de "Efectos de renderización de desenfoque y fondo"
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}
Fragmento de Kotlin en el minuto 15:42 de "Efectos de renderización de desenfoque y fondo"
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)
Fragmento de Kotlin en el minuto 15:53 de "Efectos de renderización de desenfoque y fondo"
BlurRadiusSpec.radialGradient(
center = center,
fallOffRadius = 300.dp,
startRadius = 0.dp,
endRadius = 24.dp,
)
Fragmento de Kotlin en el minuto 16:07 de "Efectos de renderización de desenfoque y fondo"
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
   },
Fragmento de Kotlin en el minuto 17:40 de "Efectos de renderización de desenfoque y fondo"
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()
   }
}
Fragmento de Kotlin en el minuto 18:09 de "Efectos de renderización de desenfoque y fondo"
JetchatAppBar(
    modifier = modifier
        .backdropBlur(
            tint = MaterialTheme.colorScheme
                .surface.copy(alpha = 0.5f),
            shape = RectangleShape,
            spec = BlurRadiusSpec.uniform(5.dp)
        )
)
Fragmento de Kotlin en el minuto 18:30 de "Efectos de renderización de desenfoque y fondo"
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),
                ),
            )
        )
)
Fragmento de Kotlin en el minuto 19:10 de "Efectos de renderización de desenfoque y fondo"
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)
Fragmento de Kotlin en el minuto 19:23 de "Efectos de renderización de desenfoque y fondo"
val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")

val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)
Fragmento de Kotlin en el minuto 20:53 de "Efectos de renderización de desenfoque y fondo"
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
Fragmento de Kotlin en el minuto 21:33 en "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);
    }
"""
Fragmento de Kotlin en el minuto 21:48 de "RuntimeColorFilter"
AsyncImage(
    model = imageUri,
    contentDescription = filter.displayName,
    colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                   .asComposeColorFilter(),
    contentScale = ContentScale.Crop,
    modifier = Modifier.fillMaxSize(),
)
Fragmento de Kotlin en el minuto 22:21 de "Optimización del rendimiento de los gráficos"
@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()
    }
//...
Fragmento de SQL en el minuto 22:32 de "Optimización del rendimiento de gráficos"
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%'
Fragmento de Kotlin en el minuto 23:54 de "Optimización del rendimiento de gráficos"
Image(
    painter = painterResource(it),
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
Fragmento de Kotlin en el minuto 24:02 de "Optimización del rendimiento de los gráficos"
AsyncImage(
    model = it,
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
Fragmento de Kotlin en el minuto 25:14 de "Optimización del rendimiento de los gráficos"
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 {
Fragmento de Kotlin en el minuto 25:32 de "Optimización del rendimiento de los gráficos"
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) }
Fragmento de Kotlin en el minuto 25:49 de "Optimización del rendimiento de los gráficos"
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)
}
Fragmento de Kotlin en el minuto 26:15 de "Optimización del rendimiento de los gráficos"
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)
}
Fragmento de shell en el minuto 26:37 de "Optimización del rendimiento de los gráficos"
android skills add perfetto-trace-analysis
android skills add jetpack-compose-performance