Apps-Grundlage

Apps mit den neuesten Grafikfunktionen in Jetpack Compose und Android optimieren

Lautsprecher

Info

Über den Vortrag

Sind Sie bereit, Ihre Android-Benutzeroberfläche von „funktioniert“ zu „ein Kunstwerk“ zu machen?

In diesem Video stellen wir euch die neuesten Grafikfunktionen in Jetpack Compose vor. Wir zeigen euch, wie ihr neue visuelle Effekte erstellt, von ansprechenden Mesh-Farbverläufen bis hin zu progressiven Unschärfen, und wie ihr die leistungsstarken neuen Grafikfunktionen in Android nutzt. Außerdem zeigen wir dir, wie du die Leistung deiner Grafikfunktionen verbessern kannst, ohne die Akkulaufzeit oder die Frameraten zu beeinträchtigen.

Zusammenfassung

  • In Compose 1.12 werden Mesh-Farbverläufe für vielfältige Farbmischungen über ein 2D-Raster von Eckpunkten eingeführt. Unter Android 14 und höher können Sie die native drawMesh API verwenden, um benutzerdefinierte, geometriebasierte visuelle Effekte zu erstellen.
  • Compose unterstützt große Farbräume, um auf unterstützter Hardware weichere Farbverläufe zu ermöglichen. Aktiviere den Modus für einen großen Farbraum in deiner Aktivität oder deinem Fenster nur, wenn Rich Media angezeigt werden.
  • In Compose 1.13 werden native PolygonShape- und MorphPolygonShape-APIs für benutzerdefinierte Geometrien eingeführt. MorphPolygonShape liest den Fortschritt während der Phase der Umrissauflösung und aktualisiert die Formen Frame für Frame, ohne eine Neuzusammensetzung auszulösen.
  • In Compose 1.13 wird die progressive Unschärfe hinzugefügt, während API-Level 37.2 RenderNode.setBackdropRenderEffect für das Sampling von Hintergrundinhalten einführt. Entwickler können Hintergrundeffekte verketten, um Milchglas- oder Vergrößerungseffekte zu erzielen.
  • Optimieren Sie die Grafikleistung, indem Sie unnötige Arbeit über Frames hinweg vermeiden. Decodieren Sie nur die erforderlichen Bildpixel außerhalb des Hauptthreads und verpacken Sie Animationseffekte in ein graphicsLayer.

Ressourcen

Gemini Notebook

Themen

  • jetpack-compose
  • android-graphics
  • mesh-gradients
  • Shader
  • Unkenntlichmachen
  • color-gamut
  • Leistung

Transkript

Einführung in Grafikfunktionen in Jetpack Compose

  • Hallo zusammen und willkommen zu unserer Session „Apps mit den neuesten Grafikfunktionen in Compose und Android optimieren“.

  • Mein Name ist Rebecca Franks und heute mit mir auf der Bühne ist…

  • Ich bin Aayush Chaudhary.

  • Hallo, ich bin Rahul Ray.

  • Wir beobachten einen neuen Trend: Ihr alle erweitert die Grenzen des Machbaren auf Android und Compose und nutzt KI, um eure unglaublichen Ideen zum Leben zu erwecken.

  • Wir freuen uns immer über Apps, die sich hochwertig, nativ und ansprechend anfühlen.

  • Apps, die sich gut anfühlen und von Nutzern durchgängig gute Bewertungen erhalten, nutzen in der Regel die neuesten API-Änderungen auf Systemebene, bieten Kontinuität zwischen Oberflächen mit gemeinsamen Elementen und Unschärfe, passen sich gut an verschiedene Formfaktoren an, sind leistungsstark, lassen Nutzer nicht im Unklaren und erweitern die Möglichkeiten des Geräts.

  • In diesem Vortrag stellen wir Ihnen einige der neuesten Funktionen vor, mit denen Sie die Vorteile von Android nutzen können. Dazu gehören Mesh-Farbverläufe, die Unterstützung eines breiten Farbraums, ansprechende Übergänge mit Polygonformen, Neuerungen beim Weichzeichnen und schließlich die Berücksichtigung der Leistung beim Hinzufügen neuer Grafikfunktionen zu Ihren Apps.

  • Im Laufe des heutigen Vortrags werden wir einige der neuen APIs auf verschiedenen Ebenen des Stacks behandeln.

  • Das alte View-UI-Toolkit befindet sich jetzt im Wartungsmodus. Wir investieren aber weiterhin in die zugrunde liegenden Android-Grafik-Hardware-Rendering-Engines, die Jetpack Compose unterstützen.

  • Grafiken in Android lassen sich in drei verschiedene Ebenen unterteilen: Die Compose APIs auf der ersten Ebene, auf der sich alle neuen High-Level-UI-Grafik-APIs befinden, wie Mesh-Gradient-Painter, Polygonformen und Progressive-Blur-APIs.

  • Und dann gibt es noch die mittlere Ebene, auf der Sie mit DrawScope, Draw-Modifikatorknoten und AGSL von Compose benutzerdefinierte Effekte erstellen können.

  • Und schließlich haben wir die Android-Plattform. Compose kann immer auf einen nativen Canvas zugreifen und brandneue Rendering-Primitive des Betriebssystems direkt in Ihren Compose-Modifikatoren verwenden.

  • Wir zeigen Ihnen heute einige Beispiele dafür.

  • Also legen wir los!

Mesh-Farbverläufe und benutzerdefiniertes Mesh-Rendering

  • Sehen wir uns zuerst die Mesh-Farbverläufe an, eine neue API, die in Compose 1.12 eingeführt wurde.

  • Bisher gab es lineare, Sweep- und radiale Farbverläufe, die sich gut für einfache Übergänge eignen. Wenn Sie jedoch komplexe mehrfarbige Mischungen wie Umgebungs- oder Flutlichteffekte erstellen möchten, benötigen Sie ein 2D-Gittersystem, in dem Positionen und Farben im Raum gemischt werden können.

  • Um einen solchen Netzverlauf zu erstellen, geben Sie zuerst an, wie viele Zeilen und Spalten von Patches Sie möchten.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
    }
    }
  • Rufen Sie dann im abschließenden Lambda setVertex auf und weisen Sie einen normalisierten Offset von 0F bis 1F sowie eine Farbe für jeden Gitterkreuzungspunkt zu.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
       setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
    
    }
    }
  • Um diese Eckpunkte zu animieren, fügen Sie animierte Offsets für acht Eckpunkte hinzu.

    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)
    )
  • Nachdem Sie den Painter definiert haben, rufen Sie ihn einfach mit einem beliebigen Composable über einen Paint-Modifikator auf.

  • In der JetChat-App haben wir eine Funktion hinzugefügt, mit der Sie eine Nachricht mit „Gefällt mir“ markieren können, wenn Sie doppelt darauf tippen.

  • Dazu richten wir ein 2 × 3-Raster ein und platzieren die Farbstopps über die Komponente hinweg.

    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
    }
  • Beachten Sie, dass wir hier hasBicubicColor auf „true“ setzen.

  • Standardmäßig werden Farben linear über Dreiecke interpoliert, was manchmal zu sichtbaren Flecken führen kann.

  • Wenn du diese Option aktivierst, wird der Shader auf eine weiche bikubische Interpolation umgestellt, sodass die Farben nahtlos ineinander übergehen.

  • Was ist, wenn Sie ein animiertes Umgebungsleuchten wie dieses hinter dem Eingabefeld erstellen möchten?

  • Um einen solchen Effekt mit einem Mesh-Farbverlauf zu erzielen, benötigen Sie zwei Dinge.

  • Zuerst eine flüssige, organische Bewegung für die inneren Scheitelpunkte und eine Möglichkeit, die äußeren Kanten weicher zu machen, damit kein harter rechteckiger Rahmen um die Karte entsteht.

  • Wir beginnen mit der Einrichtung eines rememberInfiniteTransition mit zwei Phasenwinkeln, die leicht unterschiedliche Dauern haben, damit sich die Bewegung organisch und nicht mechanisch anfühlt.

    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",
    )
  • Hier animieren wir die inneren Eckpunkte und fixieren die äußeren Ringe, den äußeren Ring der Eckpunkte mit Kanten mit 0 Alpha.

  • Da die äußere Grenze stationär und transparent ist, wird das Leuchten auf natürliche Weise weicher.

  • Und schließlich übergeben wir diese animierten Offsets an unsere 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])
           }
       }
    }
  • Der Mesh-Farbverlauf, den Sie gerade gesehen haben, wird unter Android 14 und höher mit der drawMesh API gezeichnet.

  • Sehen wir uns diese API genauer an, indem wir einen Effekt von Grund auf neu erstellen.

  • Für den JetChat-Begrüßungsbildschirm möchten wir einen Pfad zeichnen, der wie ein Band aussieht.

  • Beachten Sie hier zwei Dinge: Die Strichbreite ist nicht konstant und die Farbe verläuft entlang jedes Strichs des Pfads.

  • Sehen wir uns im Detail an, was wir für diesen Effekt benötigen.

  • Für jedes Pixel müssen wir entscheiden, ob es gezeichnet werden soll oder nicht und welche Farbe verwendet werden soll.

  • Dazu benötigen wir zwei Entfernungen.

  • Der erste Wert gibt an, wie weit ein Pixel von der Mitte des Strichs entfernt ist.

  • Die zweite Entfernung ist die Position eines Pixels auf dem Pfad, vom Anfang bis zum Ende.

  • So wird festgelegt, welche Farbe aus dem Farbverlauf ausgewählt werden soll. Durch das Hinzufügen eines kleinen Teils des ersten Werts wird die Farbe über die Breite hinweg gekrümmt.

  • Können wir also einen Farbverlauf entlang eines Pfads nur mit „Path“ und „Paint“ zeichnen?

  • Ja, das ist möglich.

  • Dazu muss ein Pfad in eine Liste kurzer gerader Linien abgeflacht werden, die auch die Entfernung entlang des Pfads angeben.

    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,
        )
    //...
    }
  • Dann zeichnen wir jede Linie mit einem eigenen Farbverlauf.

  • Das funktioniert, aber für jede Linie ist ein separater Zeichenbefehl und ein Paint-Objekt erforderlich und der gesamte Pfad hat immer noch eine einzige Breite.

  • Die andere Option ist ein Laufzeit-Shader, der jedoch nur die Position des Pixels innerhalb der Containergrenze erhält.

  • Sie sagt uns nicht, was wir brauchen, um dieses Band zu zeichnen.

  • Und wir können sie auch nicht einfach anhand der Pixelposition berechnen.

  • Draw Mesh bietet uns dafür einen Vertex-Shader.

  • Was ist also ein Mesh?

  • Ein Mesh besteht aus Dreiecken mit Daten an jeder Ecke. Jede Ecke wird als Vertex bezeichnet.

  • Jeder Knoten enthält einen kleinen Datensatz.

  • Normalerweise enthält dieser Datensatz eine Position, das ist aber nicht zwingend erforderlich.

  • Der Vertex-Shader liest diesen Datensatz und berechnet daraus die Position.

  • Hier ist der Pfad, den ein Dreieck nimmt: Der Vertex-Shader wird für jede Ecke ausgeführt, berechnet die Position der Ecken und alle Werte, die wir für jedes Pixel weitergeben möchten.

  • Dann findet der Rasterizer jedes Pixel, das von diesem Dreieck abgedeckt wird, und interpoliert für jedes Pixel Werte aus diesen drei Ecken.

  • Schließlich wird der Fragment-Shader für diese Pixel ausgeführt, wobei die interpolierten Werte verwendet und die Farbe der Pixel berechnet werden.

  • Sehen wir uns an, wie wir Vertex- und Fragment-Shader mit einem einfacheren Beispiel schreiben können: einer geraden Linie, auf der sich ein Impuls bewegt.

    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;
    }
  • Der Vertex-Shader verwendet ein Attribut als Eingabe und gibt eine Varying-Variable zurück.

  • Attribute sind die Daten für eine einzelne Ecke.

  • Hier sind nur zwei Zahlen: der Fortschritt entlang der Linie und die Seite der Linie, auf der es sich befindet.

  • Die Varyings werden dann interpoliert und an den Fragment-Shader übergeben.

  • Wir berechnen den Pixelabstand vom Start und verwenden für die Strichbreite die Zeit, um einen Puls auf dem Radius zu erzeugen.

  • Dann multiplizieren wir diesen Radius mit der Eckseite, was uns den Abstand des Schildes über den Strich hinweg gibt.

  • Da die GPU zwischen diesen Vorzeichen interpoliert, werden die Mittellinien zu null.

  • Schließlich berechnen wir die Position der Ecken, speichern alle diese Werte in einer Strich-Variablen und übergeben sie an den Fragment-Shader.

  • Im Gegensatz zum Laufzeit-Shader gibt ein Mesh-Fragment-Shader eine Farbe über einen Parameter aus und gibt eine Position für die Stichprobenerhebung der Farbe zurück, falls eine festgelegt ist.

    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;
    }
  • Wenn wir den Absolutwert der Querkoordinate nehmen, erhalten wir Pixel für Pixel den Abstand von der Mittellinie.

  • Wir vergleichen diese Entfernung mit dem Radius, um die Abdeckung zu erhalten. Für die Farbe mischen wir entlang des Pfads der Linie und biegen den Farbverlauf leicht zum Rand hin.

  • Multiplizieren Sie diese Abdeckung mit der Abdeckung, um die endgültige Farbe zu erhalten.

  • In Compose verknüpfen wir unsere Shader und das Datenlayout mit dem MeshSpecification-Objekt.

    val spec = remember {
        MeshSpecification.make(
            arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
            vertexStride = 8,
            arrayOf(Varying(TYPE_FLOAT3, "stroke")),
            LineShaders.VERTEX, LineShaders.FRAGMENT)
    }
  • Zuerst deklarieren wir das Attribut „corner“ als zwei Gleitkommazahlen, die bei Byte 0 beginnen.

  • Da zwei Gleitkommazahlen 8 Byte belegen, beträgt der Schritt für jede Ecke im Zwischenspeicher 8.

  • Als Nächstes deklarieren wir die Stroke-Variablen als drei Gleitkommazahlen und übergeben den Vertex- und Fragment-Shader, den wir gerade geschrieben haben.

  • Wir erstellen den Vertex-Zwischenspeicher.

    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
    }
  • Bei jedem Schritt entlang der Linie wird der Fortschritt von 0 bis 1 berechnet und zwei Ecken werden in den Zwischenspeicher geschrieben.

  • Schließlich kombinieren wir die Spezifikation und den Puffer zu einem Mesh-Objekt.

    val mesh = Mesh(
        spec,
        mode = Mesh.TRIANGLE_STRIP,
        vertexBuffer = corners,
        vertexCount = (segments + 1) * 2,
        bounds = RectF(0f, 0f, size.width, size.height)
    )
  • Der Compose-Canvas stellt drawMesh noch nicht bereit. Daher greifen wir auf nativeCanvas zurück und zeichnen das Mesh.

    Box(Modifier.fillMaxSize().drawWithCache {
       val mesh = Mesh(...)
       onDrawBehind {
           mesh.setFloatUniform("uTime", time.value)
           drawIntoCanvas {
              it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
           }
       }
    })
  • Da Sie die Geometrie und die Pixel steuern, können Sie mit „draw mesh“ jeden geometriebasierten visuellen Effekt zeichnen.

  • Weitere Informationen zu dieser API finden Sie in der Dokumentation.

Unterstützung für großen Farbraum

  • In Compose 1.12 haben wir die Unterstützung für Wide Color Gamuts eingeführt.

  • Was bedeutet das?

  • Wusstest du, dass Displays einen anderen Farbbereich als den herkömmlichen sRGB-Farbraum wiedergeben können?

  • Wenn du dir dieses Bild ansiehst, siehst du beispielsweise, dass das weiße Dreieck den Bereich der Standard-sRGB-Farben darstellt, die die meisten Bildschirme anzeigen können.

  • Neuere Bildschirme unterstützen jedoch oft eine größere Farbpalette, wie sie durch das gelbe Dreieck dargestellt wird, mit gesättigteren und lebendigeren Farben.

  • Wenn wir eine Farbe darstellen, tun wir das mit der Beschreibung der einzelnen Eckpunkte dieses Dreiecks.

  • Bei diesem Farbverlaufsvergleich ist es wichtig, zwischen dem Farbraum des Displays und der Interpolation von Farbverläufen zu unterscheiden.

  • Auf einem Standard-sRGB-Display oder diesem Projektor können die Farben außerhalb des sRGB-Farbraums wahrscheinlich nicht physisch dargestellt werden.

  • Das ist hier auf der Bühne schwer zu demonstrieren.

  • Sie müssen mir also einfach glauben, dass Farben auf Android-Geräten mit Unterstützung für einen großen Farbraum besser aussehen.

  • In diesem Beispiel sehen Sie den Effekt der Interpolation von Farbstopps in einem größeren Farbraum.

  • Durch das Mischen von Farben in einem breiten oder linearen Farbraum wird der tatsächliche mathematische Pfad dieses Farbverlaufs geändert. Dadurch wird das Vergrauen in der Mitte und das harte Banding verhindert, das auftreten kann, wenn Sie ohne breiten Farbraum arbeiten.

  • Mit Compose 1.12 profitieren Sie von einer flüssigeren Farbverlaufsmischung auf jedem Display sowie von einer echten Unterstützung für einen großen Farbraum, wenn Sie auf unterstützter Hardware arbeiten.

  • Damit die Unterstützung auf dem Gerät korrekt funktioniert, muss für die Aktivität oder das Fenster der Modus für einen großen Farbraum aktiviert sein.

  • Dies kann in der AndroidManifest-Datei erfolgen.

    <!-- AndroidManifest.xml -->
    <activity
    android:name=".NavActivity"
    android:colorMode="wideColorGamut"
    android:exported="true">
    </activity>
  • Oder programmatisch in Ihrer zusammensetzbaren Funktion.

    // Dynamic toggle in code
    DisposableEffect(window) {
       window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
       onDispose {
           window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
       }
    }
  • Wenn colorMode nicht festgelegt ist, bleibt das Fenster im standardmäßigen 8-Bit-sRGB-Format und alle von der GPU gerenderten Farben mit großem Farbraum werden abgeschnitten.

  • Es ist also wichtig, keine Farben mit großem Farbraum zu verwenden, wenn das Display diese nicht unterstützt.

  • Du solltest breite Farbräume nur für Aktivitäten oder Codepfade aktivieren, in denen tatsächlich Medien, Fotos oder komplexe Grafiken angezeigt werden, um Akku und Leistung zu sparen.

  • Das bedeutet, dass Sie in Compose Farben in verschiedenen Farbräumen erstellen können, die jetzt berücksichtigt werden.

    // 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
    )
  • Sie können den Typ von colorSpace übergeben, in dem die Farbe erstellt werden soll. Wenn Sie keinen colorSpace übergeben, wird der Standard-sRGB-Farbraum verwendet.

  • Speziell für Bitmaps können Sie mit „getColorSpace“ die Quelle der Bitmap prüfen. Gängige Bibliotheken zum Laden von Bildern verarbeiten die richtigen Bitmap-Farbräume automatisch für Sie.

  • Sie sollten Bitmap.Config.HARDWARE verwenden, um Farbinformationen beizubehalten, da bei einer Konfiguration wie 565 die Genauigkeit verloren geht und für Nutzer sichtbare Streifen entstehen.

  • Wenn Sie mehr über Farben erfahren möchten, können Sie sich den Vortrag von Romain Guy aus dem Jahr 2017 ansehen, in dem er über Farben in Compose und Farben in Android spricht.

Polygonformen und Morphen

  • Mit Compose 1.13 führen wir die native Unterstützung für Polygonformen ein.

  • Das wird das Erstellen von benutzerdefinierten Formen und organisch aussehenden UI-Elementen erheblich vereinfachen.

  • Bisher mussten Sie, wenn Sie eine komplexe Geometrie wie diese zeichnen wollten, RoundedPolygon verwenden, um eine Geometrie zu beschreiben, die einen Pfad ausgibt, und diesen Pfad dann einer Compose Shape API zuordnen.

    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)
        )
    }
  • Zuerst definieren Sie die Proportionen mit einem RoundedPolygon. In diesem Snippet legen wir fest, dass die inneren Punkte im Vergleich zu den äußeren Punkten 84 % vom Mittelpunkt entfernt sind.

  • Sie können hier auch die Eckenrundung anwenden, aber das beschreibt nur die mathematischen Kurven.

  • Wenn Sie dieses Polygon für Ausschnitte oder Hintergründe in Compose verwenden möchten, müssen Sie einen benutzerdefinierten Shape-Wrapper wie diesen schreiben.

    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)
        }
    }
  • In diesem createOutline-Snippet sehen Sie, dass Sie den Pfad abrufen und die Elemente manuell skalieren und übersetzen müssen.

  • Mit der neuen PolygonShape API ist dieser benutzerdefinierte Shape-Wrapper nicht mehr erforderlich.

    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)
  • Da es sich um den dedizierten Compose Shape Builder handelt, werden Dichte und Größe direkt übernommen.

  • Das bedeutet, dass Sie Ihre Eckpunkte dynamisch berechnen und die Eckenrundung direkt in dp oder sogar als Prozentsatz angeben können.

  • Nachdem Sie sie definiert haben, können Sie sie an jede API übergeben, die eine Form akzeptiert.

  • Außerdem führen wir die MorphPolygonShape API ein, mit der Sie nahtlos zwischen verschiedenen Polygonformen wechseln können.

    val recordShape = remember {
        MorphPolygonShape(
            start = PolygonShape.circle(),
            end = PolygonShape.star(
                numPoints = 9,
                innerRadiusRatio = 0.8f,
                outerRounding = CornerRounding(percent = 50),
            ),
        ) { shapeProgress.value }
    }
  • In diesem Beispiel haben wir einen Aufnahme-Button, der sich von einem Kreis in einen Stern verwandelt, wenn du mit ihm interagierst.

  • Sie geben einfach die Startform, die Endform und den Fortschrittswert an.

  • Die MorphPolygonShape API liest den Fortschrittswert während der Phase der Gliederungsauflösung, also nicht während der Neuzusammensetzung, nicht während der Zusammensetzung.

  • Das bedeutet, dass sich die Form von Frame zu Frame einfach in eine neue Kontur verwandelt, ohne dass jemals eine Neuzusammensetzung ausgelöst wird.

Weichzeichner- und Hintergrund-Rendering-Effekte

  • Als Nächstes sprechen wir über das Unkenntlichmachen.

  • In den letzten Versionen haben wir versucht, die Implementierung von Unschärfeeffekten einfacher und ausdrucksstärker zu gestalten.

  • Wenn wir über Unkenntlichmachung gesprochen haben, war dies der am häufigsten verwendete Modifikator.

    Modifier
       .blur(
           radiusX = 10.dp,
           radiusY = 10.dp,
           edgeTreatment = BlurredEdgeTreatment(
               shape = RoundedCornerShape(8.dp),
           ),
       )
  • In Compose gibt es diesen Modifikator seit Android 12.

  • Diese Option eignet sich gut, wenn Sie eine gleichmäßige Unschärfe wünschen, bei der der genaue Unschärferadius auf die gesamte zusammensetzbare Funktion angewendet wird.

  • Moderne UI-Designs erfordern jedoch oft einen fließenden Übergang von scharfen zu unscharfen Bereichen anstelle eines harten Übergangs.

  • Und damit kommen wir zur progressiven Unschärfe, die ab Compose 1.13 eine offizielle API ist.

  • Anstatt eines einzelnen konstanten Radius für das gesamte Composable können Sie jetzt die Intensität der Weichzeichnung im gesamten Bereich variieren.

  • Hier ist das Beispiel aus unserer JetChat-App vom Nutzerprofilbildschirm.

  • Der neue Unschärfe-Modifikator verwendet eine Angabe für den Unschärferadius.

    Modifier.blur {
    blurRadiusSpec = BlurRadiusSpec.verticalGradient(
    listOf(
    BlurStop(0.5f, 0.dp),
    BlurStop(1.0f, 32.dp),
    )
    )
    edgeTreatment = BlurredEdgeTreatment.Unbounded
    }
  • Hier verwenden wir einen vertikalen Farbverlauf mit zwei Unschärfestopps, wobei die obere Hälfte des Fotos bei 0 dp vollständig scharf bleibt und die Unschärfe am unteren Rand auf 32 dp ansteigt.

  • Beachten Sie, dass wir auch edgeTreatment als unbegrenzt festgelegt haben. Dadurch wird „blur“ angewiesen, auch transparente Pixel außerhalb der Grenzen des Composables zu erfassen.

  • Neben vertikalen Ausblendungen bietet diese Spezifikation für den Unschärferadius mehrere integrierte Möglichkeiten, eine progressive Unschärfe zu erzielen.

  • Links sehen Sie beispielsweise einen horizontalen Farbverlauf, bei dem der Startradius 0 dp beträgt und der Endradius durch einen Schiebereglerwert bestimmt wird.

    BlurRadiusSpec.horizontalGradient(
    startRadius = 0.dp,
    endRadius = sliderValue.dp,
    )
  • Rechts sehen Sie einen radialen Farbverlauf. In der Mitte beträgt der Unschärferadius 0 dp. Wenn Sie sich in Richtung fallOffRadius bewegen, was in diesem Fall 300 dp entspricht, steigt die Unschärfe gleichmäßig auf bis zu 24 dp an.

    BlurRadiusSpec.radialGradient(
    center = center,
    fallOffRadius = 300.dp,
    startRadius = 0.dp,
    endRadius = 24.dp,
    )
  • Und wenn Ihnen all diese vertikalen, radialen und horizontalen Verläufe nicht ausreichen, können Sie Ihre progressive Unschärfe auch mit benutzerdefinierten Shadern steuern.

    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
       },
  • Hier schreiben wir einen kleinen Fragment-Shader, der eine animierte diagonale Sinuswelle berechnet und diese Intensität in einen Alphakanal ausgibt.

  • Anschließend wird die Maske von 0 bis 1 direkt einer 24-dp-Maske maxRadius zugeordnet.

  • Bevor wir über die Hintergrundunschärfe sprechen, sehen wir uns einen Unterschied zwischen den beiden an.

  • Links sehen Sie eine obere App-Leiste, auf die wir eine Ebenenunschärfe angewendet haben.

  • Das heißt, das Composable selbst wird weichgezeichnet.

  • Wie Sie sehen, sind der Titel und die Aktionssymbole unscharf, die Chatkomponente dahinter jedoch nicht.

  • Rechts ist der Hintergrund weichgezeichnet.

  • Hier werden nur die Chatnachrichten, die hinter der oberen Leiste scrollen, gesampelt und unkenntlich gemacht.

  • Text und Symbole sind jedoch vollständig scharf und lesbar.

  • Ab Android 37.2 können Sie den Hintergrund mit einer neuen API namens RenderNode.setBackdropRenderEffect weichzeichnen.

  • Bevor wir uns aber damit beschäftigen, wie die Unschärfe verbessert wird, sollten wir uns ansehen, wie Android im Hintergrund funktioniert, um sicherzustellen, dass die Unschärfe leistungsstark ist.

  • Android verwendet im Hintergrund RenderNodes. Das sind hardwarebeschleunigte DisplayList-Container, die über „beginRecording“ und „endRecording“ eine Reihe verschiedener Zeichenbefehle komprimieren und speichern können.

  • Die RenderNode enthält Informationen wie 3D-Übersetzungen, Rotationen, Alpha, Skalierungen und Positionsgrenzen.

  • Compose verwendet im Hintergrund tatsächlich RenderNode.

  • Wenn Sie die Hintergrund-Rendering-Effekte in Compose verwenden möchten, können Sie einen benutzerdefinierten Modifier.Node erstellen, um diesen Modifikator wiederzuverwenden.

    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()
       }
    }
  • Wir behalten eine Referenz auf ein RenderNode bei, das RenderNode mit den gewünschten Effekten in einer anderen Funktion konfiguriert ist.

  • Beispielsweise übergeben wir hier einen Unschärfeeffekt.

  • Als Nächstes rufen wir drawIntoCanvas auf und rufen drawRenderNode auf unserem nativen Canvas auf.

  • Schließlich rufen wir drawContent auf, um den eigentlichen, nicht unscharfen Inhalt darüber zu zeichnen.

  • Um diesen gerade erstellten Modifikator zu verwenden, rufen wir Modifier.backdropBlur auf und übergeben eine Spezifikation für den Unschärferadius.

    JetchatAppBar(
        modifier = modifier
            .backdropBlur(
                tint = MaterialTheme.colorScheme
                    .surface.copy(alpha = 0.5f),
                shape = RectangleShape,
                spec = BlurRadiusSpec.uniform(5.dp)
            )
    )
  • Bei diesem Ansatz müssen wir nichts auf der zugrunde liegenden Ebene platzieren.

  • RenderNode.setBackdropRenderEffect nimmt automatisch Stichproben des Inhalts, der sich unter der Oberfläche der JetChat-App-Leiste befindet.

  • Das Ergebnis ist diese wunderbare Hintergrundunschärfe.

  • Wir könnten auch eine progressive Unschärferadius-Spezifikation festlegen, anstatt nur eine progressive, und auch eine vertikale Unschärfe für den Inhalt festlegen.

    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),
                    ),
                )
            )
    )
  • Bei einem vertikalen Farbverlauf wird der darunter liegende Inhalt mit unterschiedlichen Radien an verschiedenen Stellen des Containers unscharf dargestellt.

  • Wir arbeiten an einem integrierten Modifikator für die Hintergrundunschärfe in Compose.

  • Dies ist nur ein Beispiel für die zugrunde liegende Technologie auf Android mit Compose.

  • Das Beispiel, das ich Ihnen gezeigt habe, funktioniert nur auf API-Ebene 37.2. Wir arbeiten aber auch an einem Backport für Compose. Bleiben Sie also dran.

  • Ein weiteres interessantes Beispiel für RenderNode-Hintergrundeffekte ist, dass wir sie miteinander verketten können, um wirklich ansprechende Benutzeroberflächen zu erstellen, die mehrere verschiedene Effekte gleichzeitig ausführen.

  • Um Effekte zu verketten, erstellen wir diese beiden Effekte.

    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)
  • In diesem Beispiel erstellen wir einen Unschärfeeffekt und eine Sättigung von 30 %.

  • Wir rufen createChainEffect auf, wobei zuerst der Unschärfeeffekt und dann die Sättigung angewendet wird.

  • Dies ist ein Beispiel in JetChat.

    val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
    val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
    
    val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
    renderNode?.setBackdropRenderEffect(finalEffect)
  • Die Schaltfläche „Nach unten springen“: Wir können sie wie eine kleine Lupe aussehen lassen, indem wir verschiedene Effekte kombinieren, um die Ränder dieser komponierbaren Funktion wie eine Lupe abzurunden.

  • Sie können aber nicht nur Vergrößerungseffekte anwenden, sondern auch jede Art von Shader miteinander verknüpfen, um sie auf einen Hintergrund anzuwenden.

  • Hier sind einige Beispiele, die wir implementiert haben, um zu zeigen, was möglich ist, wenn man sie kombiniert.

  • Der traditionelle Milchglaseffekt ist jetzt möglich.

  • Wir können aber auch andere lustige Effekte wie ein Kaleidoskop erstellen.

  • Oder wenn wir dieses Android-Maskottchen in ein sich veränderndes UI-Element verwandeln möchten, das den darunter liegenden Inhalt transformiert, können wir diese Hintergrund-Rendereffekte anwenden, damit es so aussieht, als würden wir durch diesen kleinen Droiden blicken.

  • Das können wir auch mit den Render-Effekten für den Hintergrund tun.

  • Mit dieser neuen API können wir also nicht nur den Hintergrund weichzeichnen und Milchglas-Effekte erzeugen, sondern auch unserer Fantasie freien Lauf lassen.

  • Eine weitere neue API, die wir in die Plattform eingeführt haben, ist SurfaceView.setBlurRegions.

  • Derzeit können Sie Texturansichten und beliebige Composables mit Modifier.blur- und RenderNode-Backdrop-Effekten weichzeichnen. Das Weichzeichnen von Teilen einer SurfaceView ist jedoch schwierig und nicht Teil des RenderNode-Baums der App-Ansicht, da stattdessen ein Loch durch das Fenster gestanzt wird.

  • Compose kann also den zugrunde liegenden Puffer der SurfaceView nicht abfragen, was bis API 37.2 unmöglich war.

  • Mit setBlurRegions von SurfaceView kann die App Unschärfebereiche direkt deklarieren.

  • In diesem Videoplayer in JetChat haben wir beispielsweise einen SurfaceView-Weichzeichner hinzugefügt, wenn die Steuerelemente ein- und ausgeblendet werden.

  • Wenn du diese neue API verwenden möchtest, kannst du auf die setBlurRegions-Methode in einer SurfaceView zugreifen.

    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
  • Dazu werden die Liste der Begrenzungsbereiche, der Unschärferadius, der Alphawert und eine Liste der Eckenradien verwendet.

  • Diese Grenzen müssen relativ zur SurfaceView auf dem Bildschirm sein.

  • Sie müssen also die Koordinaten der SurfaceView und den Bereich, den Sie weichzeichnen möchten, im Auge behalten.

RuntimeColorFilter

  • Eine weitere Neuerung ab Android 16 ist RuntimeColorFilter.

  • Bisher konnten Sie bei Bildern nur einen statischen Matrixfarbfilter festlegen und Effekte wie Sättigung anwenden.

  • Ab Android 16 können Sie jedoch einen Shader übergeben, der eine Eingabefarbe (das Eingabepixel für diesen bestimmten Inhalt) annimmt und eine neue Farbe erzeugt.

  • Wenn wir beispielsweise einen Cyberpunk-Filter auf ein Foto anwenden möchten, können wir einen kleinen Shader definieren, der als Eingabe für den RuntimeColorFilter verwendet wird.

    @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);
        }
    """
  • Dieser Shader erzeugt ein Cyberpunk-Design, aber Sie können auch jeden anderen gewünschten Effekt erzielen.

  • Um den Shader selbst zu verwenden, wenden wir den RuntimeColorFilter an und konvertieren ihn in einen Compose-Farbfilter.

    AsyncImage(
        model = imageUri,
        contentDescription = filter.displayName,
        colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                       .asComposeColorFilter(),
        contentScale = ContentScale.Crop,
        modifier = Modifier.fillMaxSize(),
    )
  • Wie Sie in diesem Beispiel sehen, können wir jetzt dynamischere Effekte als zuvor erstellen, ohne uns mit OpenGL, SurfaceView-Konfigurationen oder Ähnlichem befassen zu müssen.

  • Das kann auch auf alle anderen Composables angewendet werden, nicht nur auf Bilder.

Optimierung der Grafikleistung

  • Vielen Dank.

  • Wir haben also einen wirklich schönen Effekt erstellt. Jetzt müssen wir aber dafür sorgen, dass unsere App immer noch schnell und flüssig läuft.

  • Wenn wir durch die Unterhaltung scrollen, werden alle unsere Effekte zusammen ausgeführt.

  • Beginnen wir mit der Messung dieses Scrollvorgangs.

    @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()
        }
    //...
  • Wir verwenden einen Makro-Benchmark, der eine App in einem neuen Prozess startet und durch die Unterhaltung scrollt.

  • Anschließend fragen wir den Trace ab, um die Frames zu zählen, die ihre Fristen verpassen.

    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%'
  • Das ergibt sechs späte Frames.

  • Öffnen wir einen der späten Frames.

  • Kurz vor diesem Frame ruft Compose also die nächste Nachricht ab, bevor sie in die Ansicht gescrollt wird.

  • Das Prefetching soll in die kurze Lücke zwischen den Frames passen. Bei der Vorbereitung dieser Nachricht wird jedoch auch das Bild direkt im Hauptthread decodiert, was lange dauert, ohne dass die Kontrolle abgegeben wird.

  • Da der Hauptthread mit dem Decodieren dieses Bildes beschäftigt ist, können keine neuen Frames gestartet werden.

  • Wenn die Nachricht dann tatsächlich in die Ansicht gescrollt wird, muss RenderThread Speicher zuweisen und alle diese Pixel auf die GPU hochladen. Das gleiche Bild verzögert also einen zweiten Frame.

  • Jetzt sehen wir, wo unsere Zeit geblieben ist. Aber brauchen wir überhaupt all diese Arbeit, um diesen Frame zu zeichnen?

  • Und wie können wir das feststellen?

  • Der Trace gibt Aufschluss darüber.

  • Wenn eine große Bitmap in einen viel kleineren Bereich auf dem Bildschirm gezeichnet wird, protokolliert Android eine Markierung direkt im Trace.

  • Es werden zwei Zahlen in Pixeln angezeigt: die vollständige Bitmap-Größe und der Bereich, den sie tatsächlich auf dem Bildschirm abdeckt.

  • Teilen Sie diesen Wert durch die Bildschirmdichte, um den Wert in DP zu erhalten.

  • Anschließend suchen wir in unserem Code nach diesem DP-Wert, um das Bild zu finden.

  • Das ist das Ergebnis.

  • Das Bild wird mit painterResource geladen. Da die Größe des Containers nicht bekannt ist, wird das gesamte Bild in voller Auflösung decodiert.

    Image(
        painter = painterResource(it),
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • Wir ersetzen sie also durch AsyncImage von Coil.

    AsyncImage(
        model = it,
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • Zuerst wird der Container gemessen, damit nur die benötigten Pixel decodiert werden. Diese Arbeit wird in einem anderen Thread ausgeführt.

  • Wenn wir denselben Benchmark noch einmal ausführen, sinkt die Bilddecodierung im Hauptthread auf 0 und die Anzahl der verspäteten Frames auf 2.

  • Wir werden diese beiden Frames hier nicht debuggen, aber die Schlussfolgerung ist einfach: Versuchen Sie, keine Pixel zu berechnen, die nie den Frame erreichen.

  • Die Bilddecodierung war also groß genug, um Frames zu verzögern, aber nicht alle Kosten werden als ausgelassene Frames angezeigt. Es gibt immer noch kleinere Kosten, die wir beim Schreiben des Codes vermeiden können.

  • Sehen wir uns zwei davon an.

  • Wenn wir einen RuntimeShader verwenden, sollten wir ihn nur einmal erstellen. Er wird zwar nur kompiliert und im Cache gespeichert, aber der Quellstring wird jedes Mal geparst, wenn wir einen erstellen.

  • Und wenn ein Effekt jeden Frame animiert, können wir ihn in ein graphicsLayer-Element einbinden, damit nur das animierte Element neu aufgezeichnet wird, anstatt die Benutzeroberfläche drumherum neu zu zeichnen.

  • Die Schaltfläche „Zum Ende springen“ wird angezeigt, wenn wir scrollen.

  • Hier werden zwei RuntimeShader verwendet, um diesen Effekt zu erzielen. Jedes Mal, wenn die Schaltfläche angezeigt wird, werden beide Shader aus ihrem Quellstring neu erstellt.

    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 {
  • Das Parsen dieser beiden Shader und das Erstellen des Effekts dauert jedes Mal, wenn die Schaltfläche angezeigt wird, Hunderte von Mikrosekunden im Hauptthread.

  • Stattdessen erstellen wir den Shader einmal für den gesamten Prozess oder speichern ihn und aktualisieren dann seine Uniform.

    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) }
  • Mit dieser Änderung ist die Erstellung dieses Effekts fast 40-mal schneller und die Kosten, wenn die Schaltfläche angezeigt wird, sinken um das 20-Fache.

  • Beim Mesh-Farbverlauf, den wir uns vorhin angesehen haben, wird das Leuchten in jedem Frame innerhalb von drawBehind animiert.

    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)
    }
  • Wenn der Status in der Zeichenphase gelesen wird, bleiben Komposition und Layout erhalten, aber die Anzeigeliste kann keinen einzelnen Zeichenbefehl an Ort und Stelle aktualisieren.

  • Compose geht also den Baum hinauf, um den nächsten Knoten mit einer Grafikebene zu finden. Das ist hier der rote Rahmen, der den gesamten Bildschirm umfasst. Diese gesamte Ebene wird dann in jedem Frame neu aufgezeichnet.

  • Wenn Sie ein graphicsLayer direkt auf dem Feld platzieren, wird die Bewegung am grünen Umriss gestoppt. Es wird also nur das Leuchtfeld neu aufgezeichnet und der Slice für das Zeichnen im Hauptthread wird um zwei Zehntel Millisekunden verkürzt.

    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)
    }
  • Mit Perfetto kann es schwierig sein, zu analysieren, was in einem Trace schiefgelaufen ist. Sie können daher die Trace-Analyse-Funktion verwenden, um Informationen zu erhalten.

  • Wenn Sie weitere Unterstützung beim Schreiben von leistungsstarkem Compose-Code benötigen, installieren Sie die Android-Befehlszeile und verwenden Sie die Compose-Leistungsfunktion, um loszulegen.

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

Zusammenfassung und Ressourcen

  • Wenn Sie sich die Beispiele ansehen möchten, die wir heute gezeigt haben, werfen Sie einen Blick auf die JetChat-App, die Sie über diesen Link finden.

Code

Code-Snippets

Kotlin-Snippet bei 02:47 in „Mesh-Farbverläufe und benutzerdefiniertes Mesh-Rendering“
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}
Kotlin-Snippet bei 02:55 in „Mesh gradients and custom mesh rendering“
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
   setVertex(0, 0, Offset(0.0f, 0.0f), indigo)

}
}
Kotlin-Snippet bei 03:05 in „Mesh gradients and custom mesh rendering“
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-Snippet bei 03:24 in „Mesh gradients and custom mesh rendering“
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-Snippet bei 04:09 in „Mesh gradients and custom mesh rendering“
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-Snippet bei 04:33 in „Mesh gradients and custom mesh rendering“
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-Snippet bei 05:40 in „Mesh gradients and custom mesh rendering“
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,
    )
//...
}
Textausschnitt bei 07:03 in „Mesh gradients and custom mesh rendering“
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;
}
Textausschnitt bei 08:01 in „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-Snippet bei 08:37 in „Mesh gradients and custom mesh rendering“
val spec = remember {
    MeshSpecification.make(
        arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
        vertexStride = 8,
        arrayOf(Varying(TYPE_FLOAT3, "stroke")),
        LineShaders.VERTEX, LineShaders.FRAGMENT)
}
Kotlin-Snippet bei 09:06 in „Mesh gradients and custom mesh rendering“
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-Snippet bei 09:18 in „Mesh-Farbverläufe und benutzerdefiniertes Mesh-Rendering“
val mesh = Mesh(
    spec,
    mode = Mesh.TRIANGLE_STRIP,
    vertexBuffer = corners,
    vertexCount = (segments + 1) * 2,
    bounds = RectF(0f, 0f, size.width, size.height)
)
Kotlin-Snippet bei 09:24 in „Mesh gradients and custom mesh rendering“
Box(Modifier.fillMaxSize().drawWithCache {
   val mesh = Mesh(...)
   onDrawBehind {
       mesh.setFloatUniform("uTime", time.value)
       drawIntoCanvas {
          it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
       }
   }
})
XML-Snippet bei 11:22 in „Unterstützung für breiten Farbraum“
<!-- AndroidManifest.xml -->
<activity
android:name=".NavActivity"
android:colorMode="wideColorGamut"
android:exported="true">
</activity>
Kotlin-Snippet bei 11:24 in „Unterstützung für breiten Farbraum“
// Dynamic toggle in code
DisposableEffect(window) {
   window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
   onDispose {
       window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
   }
}
Kotlin-Snippet bei 11:49 in „Unterstützung für breiten Farbraum“
// 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-Snippet bei 12:45 in „Polygonformen und Morphing“
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-Snippet bei 13:15 in „Polygon shapes and morphing“
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-Snippet bei 13:30 in „Polygon shapes and morphing“
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-Snippet bei 13:53 in „Polygonformen und Morphing“
val recordShape = remember {
    MorphPolygonShape(
        start = PolygonShape.circle(),
        end = PolygonShape.star(
            numPoints = 9,
            innerRadiusRatio = 0.8f,
            outerRounding = CornerRounding(percent = 50),
        ),
    ) { shapeProgress.value }
}
Kotlin-Snippet bei 14:37 in „Blurring and backdrop render effects“
Modifier
   .blur(
       radiusX = 10.dp,
       radiusY = 10.dp,
       edgeTreatment = BlurredEdgeTreatment(
           shape = RoundedCornerShape(8.dp),
       ),
   )
Kotlin-Snippet bei 15:15 in „Blurring and backdrop render effects“
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}
Kotlin-Snippet bei 15:42 in „Blurring and backdrop render effects“
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)
Kotlin-Snippet bei 15:53 in „Blurring and backdrop render effects“
BlurRadiusSpec.radialGradient(
center = center,
fallOffRadius = 300.dp,
startRadius = 0.dp,
endRadius = 24.dp,
)
Kotlin-Snippet bei 16:07 in „Blurring and backdrop render effects“
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-Snippet bei 17:40 in „Blurring and backdrop render effects“
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-Snippet bei 18:09 in „Blurring and backdrop render effects“
JetchatAppBar(
    modifier = modifier
        .backdropBlur(
            tint = MaterialTheme.colorScheme
                .surface.copy(alpha = 0.5f),
            shape = RectangleShape,
            spec = BlurRadiusSpec.uniform(5.dp)
        )
)
Kotlin-Snippet bei 18:30 in „Blurring and backdrop render effects“
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-Snippet bei 19:10 in „Blurring and backdrop render effects“
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-Snippet bei 19:23 in „Blurring and backdrop render effects“
val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")

val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)
Kotlin-Snippet bei 20:53 in „Blurring and backdrop render effects“
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-Snippet bei 21:33 in „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);
    }
"""
Kotlin-Snippet bei 21:48 in „RuntimeColorFilter“
AsyncImage(
    model = imageUri,
    contentDescription = filter.displayName,
    colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                   .asComposeColorFilter(),
    contentScale = ContentScale.Crop,
    modifier = Modifier.fillMaxSize(),
)
Kotlin-Snippet bei 22:21 in „Grafikleistung optimieren“
@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-Snippet bei 22:32 in „Graphics performance optimization“
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-Snippet bei 23:54 in „Grafikleistung optimieren“
Image(
    painter = painterResource(it),
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
Kotlin-Snippet bei 24:02 in „Graphics performance optimization“
AsyncImage(
    model = it,
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
Kotlin-Snippet bei 25:14 in „Graphics performance optimization“
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-Snippet bei 25:32 in „Grafikleistungsoptimierung“
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-Snippet bei 25:49 in „Graphics performance optimization“
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-Snippet bei 26:15 in „Grafikleistungsoptimierung“
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-Snippet bei 26:37 in „Graphics performance optimization“
android skills add perfetto-trace-analysis
android skills add jetpack-compose-performance