Podstawy aplikacji

Ulepszanie aplikacji dzięki najnowszym funkcjom graficznym w Jetpack Compose i Androidzie

Głośniki

Informacje

Informacje o wystąpieniu

Chcesz, aby interfejs Twojej aplikacji na Androida nie tylko działał, ale też zachwycał?

Dołącz do nas, aby poznać najnowsze możliwości graficzne w Jetpack Compose. Pokażemy Ci, jak tworzyć nowe efekty wizualne, od zachwycających gradientów siatki po progresywne rozmycia, i jak korzystać z nowych, zaawansowanych funkcji graficznych na Androidzie. Pokażemy Ci też, jak poprawić wydajność funkcji graficznych bez skracania czasu pracy na baterii i zmniejszania liczby klatek na sekundę.

Podsumowanie

  • W Compose 1.12 wprowadziliśmy gradienty siatki, które umożliwiają tworzenie bogatych wielokolorowych przejść w dwuwymiarowej siatce wierzchołków. Na Androidzie 14 i nowszych możesz używać natywnego interfejsu drawMesh API do tworzenia niestandardowych efektów wizualnych opartych na geometrii.
  • Compose obsługuje szerokie gamy kolorów, co pozwala na płynniejsze mieszanie gradientów na obsługiwanym sprzęcie. Włącz tryb szerokiej gamy kolorów w aktywności lub oknie tylko podczas wyświetlania multimediów.
  • W Compose 1.13 wprowadziliśmy natywne interfejsy API PolygonShape i MorphPolygonShape do obsługi niestandardowych geometrii. MorphPolygonShape odczytuje postęp podczas fazy rozpoznawania konturu, aktualizując kształty klatka po klatce bez wywoływania rekompozycji.
  • Compose 1.13 dodaje progresywne rozmycie, a interfejs API na poziomie 37.2 wprowadza RenderNode.setBackdropRenderEffect do próbkowania treści w tle. Deweloperzy mogą łączyć efekty tła, aby tworzyć efekty matowego szkła lub powiększenia.
  • Zoptymalizuj wydajność grafiki, unikając niepotrzebnych działań w ramkach. Dekoduj tylko potrzebne piksele obrazu poza głównym wątkiem i umieść efekty animacji w elemencie graphicsLayer.

Zasoby

Gemini Notebook

Tematy

  • jetpack-compose
  • android-graphics
  • mesh-gradients
  • programy do cieniowania
  • zamazywanie
  • color-gamut
  • wydajność

Transkrypcja

Pobierz transkrypcję

Wprowadzenie do funkcji graficznych w Jetpack Compose

  • Cześć wszystkim. Witamy na sesji „Ulepszanie aplikacji dzięki najnowszym funkcjom graficznym w Compose i Androidzie”.

  • Nazywam się Rebecca Franks, a na scenie towarzyszy mi…

  • Nazywam się Aayush Chaudhary.

  • Cześć, nazywam się Rahul Ray.

  • Zauważamy nowy trend, w ramach którego przesuwacie granice tego, co jest możliwe na Androidzie i w Compose, wykorzystując AI do urzeczywistniania swoich niesamowitych pomysłów.

  • Zależy nam też na tym, aby aplikacje były wysokiej jakości, natywne i przyjemne w użyciu.

  • Wiemy, że aplikacje, które są dobrze odbierane i stale otrzymują świetne oceny od użytkowników, to te, które zazwyczaj korzystają z najnowszych zmian w interfejsie API na poziomie systemu, zachowują ciągłość między platformami dzięki udostępnionym elementom i rozmyciu, dostosowują się do różnych formatów, są wydajne, nie pozostawiają użytkowników w niepewności i wykorzystują wszystkie możliwości urządzenia.

  • W tym wystąpieniu pokażemy niektóre z najnowszych funkcji, które pomogą Ci wykorzystać możliwości Androida, takie jak zachwycanie użytkowników gradientami siatki, obsługa szerokiej gamy kolorów, efektowne przejścia z kształtami wielokątów, nowości w rozmyciu, a na koniec omówimy, jak możesz myśleć o wydajności, dodając do aplikacji nowe funkcje graficzne.

  • Podczas dzisiejszej prezentacji omówimy niektóre z nowych interfejsów API na różnych poziomach stosu.

  • Chociaż starszy zestaw narzędzi UI widoku jest obecnie w trybie konserwacji, nadal inwestujemy w podstawowe silniki renderowania sprzętowego grafiki na Androidzie, które obsługują Jetpack Compose.

  • Grafikę w Androidzie można podzielić na 3 warstwy. Pierwsza warstwa to interfejsy API Compose, które zawierają wszystkie nowe interfejsy API grafiki wysokiego poziomu, takie jak interfejsy API do malowania gradientów siatki, kształtów wielokątów i progresywnego rozmycia.

  • Mamy też warstwę pośrednią, w której DrawScope, węzły modyfikatora rysowania i AGSL w Compose umożliwiają tworzenie niestandardowych efektów.

  • Na koniec mamy platformę Androida, a Compose zawsze może uzyskać dostęp do natywnego Canvas i używać zupełnie nowych elementów renderowania systemu operacyjnego bezpośrednio w modyfikatorach Compose.

  • Pokażemy Ci kilka przykładów, jak to zrobić.

  • Zaczynajmy.

Gradienty siatki i niestandardowe renderowanie siatki

  • Najpierw przyjrzyjmy się gradientom siatki, czyli nowemu interfejsowi API wprowadzonemu w Compose 1.12.

  • Wcześniej mieliśmy gradienty liniowe, gradienty liniowe, gradienty przesunięcia i gradienty promieniowe, które świetnie nadają się do prostych przejść, ale jeśli chcesz uzyskać bogate wielokolorowe mieszanki, takie jak tła otoczenia lub efekty oświetlenia, potrzebujesz siatki 2D systemu, w której pozycje i kolory mogą się mieszać w przestrzeni.

  • Aby utworzyć taki gradient siatki, najpierw określ, ile wierszy i kolumn fragmentów chcesz mieć.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
    }
    }
  • Następnie w końcowej funkcji lambda wywołaj setVertex i przypisz znormalizowane przesunięcie z zakresu od 0F do 1F oraz kolor dla każdego przecięcia siatki.

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
       setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
    
    }
    }
  • Aby animować te wierzchołki, dodaj animowane przesunięcia dla 8 wierzchołków.

    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)
    )
  • Po zdefiniowaniu obiektu Painter wystarczy wywołać go z dowolnym elementem kompozycyjnym za pomocą modyfikatora Paint.

  • W aplikacji JetChat dodaliśmy funkcję polubienia wiadomości po dwukrotnym kliknięciu.

  • Aby to zrobić, skonfigurujemy siatkę 2 x 3 i rozmieścimy punkty kolorów w komponencie.

    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
    }
  • Zwróć uwagę, że ustawiliśmy tu wartość hasBicubicColor na „true”.

  • Domyślnie kolory są interpolowane liniowo między trójkątami, co może czasami pozostawiać widoczne plamy.

  • Włączenie tej opcji powoduje przełączenie cieniowania na płynną interpolację dwusześcienną, dzięki czemu kolory mogą płynnie się przenikać.

  • A co, jeśli chcesz utworzyć animowaną poświatę, taką jak ta za polem wprowadzania?

  • Aby uzyskać taki efekt za pomocą gradientu siatki, potrzebujesz 2 rzeczy.

  • Po pierwsze, płynny, naturalny ruch wewnętrznych wierzchołków i sposób na zmiękczenie zewnętrznych krawędzi, aby wokół karty nie było twardego prostokąta.

  • Zaczynamy od skonfigurowania rememberInfiniteTransition z 2 kątami fazowymi, które działają przez nieco inny czas, dzięki czemu ruch wydaje się naturalny, a nie mechaniczny.

    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",
    )
  • Tutaj animujemy wewnętrzne wierzchołki i przypinamy zewnętrzne pierścienie, zewnętrzny pierścień wierzchołków z krawędziami o wartości alfa 0.

  • Ponieważ zewnętrzna granica jest nieruchoma i przezroczysta, blask naturalnie się rozprasza.

  • Na koniec przekazujemy te animowane przesunięcia do 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])
           }
       }
    }
  • Widoczny przed chwilą gradient siatki jest rysowany za pomocą interfejsu API drawMesh na Androidzie 14 lub nowszym.

  • Przyjrzyjmy się bliżej temu interfejsowi API, tworząc efekt od podstaw.

  • Na ekranie powitalnym JetChat chcemy narysować ścieżkę, która wygląda jak wstążka.

  • Zwróć uwagę na 2 rzeczy: szerokość pociągnięcia nie jest stała, a kolor jest nakładany wzdłuż każdego pociągnięcia ścieżki.

  • Przeanalizujmy, czego potrzebujemy, aby uzyskać ten efekt.

  • W przypadku każdego piksela musimy zdecydować, czy go narysować i jakiego koloru użyć.

  • Aby to obliczyć, potrzebujemy 2 odległości.

  • Pierwsza wartość to odległość piksela od środka obrysu.

  • Druga odległość to odległość piksela od początku do końca ścieżki.

  • Dzięki temu wiemy, który kolor wybrać z gradientu, a dodanie odrobiny pierwszej wartości sprawia, że kolor zakrzywia się na całej szerokości.

  • Czy możemy narysować gradient wzdłuż ścieżki, używając tylko ścieżki i pędzla?

  • Tak, możemy.

  • Wymaga to spłaszczenia ścieżki do listy krótkich linii prostych, co pozwala też określić odległość wzdłuż ścieżki.

    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,
        )
    //...
    }
  • Następnie rysujemy każdą linię z własnym gradientem.

  • To działa, ale każda linia wymaga osobnego wywołania rysowania i obiektu Paint, a cała ścieżka nadal ma jedną szerokość.

  • Inną opcją jest cieniowanie środowiska wykonawczego, ale pobiera ono tylko pozycję piksela w granicach kontenera.

  • Nie mówi nam, co jest potrzebne do narysowania tej wstążki.

  • Nie możemy też łatwo obliczyć tego na podstawie pozycji pikseli.

  • Draw mesh gives us vertex shader for that.

  • Czym jest siatka?

  • Siatka składa się z trójkątów z danymi w każdym rogu, a każdy róg jest nazywany wierzchołkiem.

  • Każdy wierzchołek zawiera niewielki rekord danych.

  • Zwykle ten rekord zawiera pozycję, ale nie musi.

  • Program do cieniowania wierzchołków odczytuje ten rekord i oblicza na jego podstawie pozycję.

  • Oto ścieżka, którą pokonuje jeden trójkąt: moduł cieniowania wierzchołków jest uruchamiany dla każdego rogu, oblicza pozycję rogów i dowolną wartość, którą chcemy przekazać dla każdego piksela.

  • Następnie rasteryzator znajduje każdy piksel objęty tym trójkątem i interpoluje wartości z tych 3 rogów dla każdego piksela.

  • Na koniec dla tych pikseli uruchamiany jest program do cieniowania fragmentów, który wykorzystuje te interpolowane wartości i oblicza kolor pikseli.

  • Zobaczmy, jak możemy napisać kod modułu cieniującego wierzchołki i fragmenty na prostszym przykładzie – linii prostej z poruszającym się po niej impulsem.

    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;
    }
  • Program do cieniowania wierzchołków pobiera atrybut jako dane wejściowe i zwraca zmienne.

  • Atrybuty to dane dotyczące pojedynczego rogu.

  • Oto 2 liczby: postęp wzdłuż linii i strona linii.

  • Zmienne są następnie interpolowane i przekazywane do programu do cieniowania fragmentów.

  • Obliczamy odległość pikseli od początku, a w przypadku szerokości pociągnięcia używamy czasu, aby utworzyć puls na promieniu.

  • Następnie mnożymy ten promień przez bok narożnika, co daje nam odległość znaku od obrysu.

  • Gdy GPU interpoluje między tymi znakami, linie środkowe stają się zerem.

  • Na koniec obliczamy pozycję narożników i zapisujemy wszystkie te wartości w zmiennych obrysu, a następnie przekazujemy je do fragmentu kodu cieniowania.

  • W odróżnieniu od cieniowania środowiska wykonawczego cieniowanie fragmentu siatki generuje kolor za pomocą parametru i zwraca pozycję do próbkowania farby, jeśli jest ustawiona.

    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;
    }
  • Wartość bezwzględna współrzędnej poprzecznej daje nam odległość od linii środkowej w pikselach.

  • Porównujemy tę odległość z promieniem, aby uzyskać zasięg, a w przypadku koloru mieszamy go wzdłuż ścieżki linii i lekko zaginamy gradient w kierunku krawędzi.

  • Pomnóż to pokrycie przez pokrycie, a otrzymasz ostateczny kolor.

  • Po stronie Compose łączymy shadery i układ danych z obiektem MeshSpecification.

    val spec = remember {
        MeshSpecification.make(
            arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
            vertexStride = 8,
            arrayOf(Varying(TYPE_FLOAT3, "stroke")),
            LineShaders.VERTEX, LineShaders.FRAGMENT)
    }
  • Najpierw deklarujemy atrybut narożnika jako 2 wartości zmiennoprzecinkowe zaczynające się od bajtu 0.

  • Ponieważ 2 liczby zmiennoprzecinkowe zajmują 8 bajtów, każdy krok narożnika w buforze wynosi 8.

  • Następnie deklarujemy zmienne stroke jako 3 wartości zmiennoprzecinkowe i przekazujemy napisany właśnie moduł cieniujący wierzchołki i fragmenty.

  • Tworzymy bufor wierzchołków.

    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
    }
  • Na każdym etapie linii obliczamy postęp od 0 do 1 i zapisujemy 2 rogi w buforze.

  • Na koniec łączymy specyfikację i bufor w obiekt siatki.

    val mesh = Mesh(
        spec,
        mode = Mesh.TRIANGLE_STRIP,
        vertexBuffer = corners,
        vertexCount = (segments + 1) * 2,
        bounds = RectF(0f, 0f, size.width, size.height)
    )
  • Obszar roboczy Compose nie udostępnia jeszcze drawMesh, więc przechodzimy do nativeCanvas i rysujemy siatkę.

    Box(Modifier.fillMaxSize().drawWithCache {
       val mesh = Mesh(...)
       onDrawBehind {
           mesh.setFloatUniform("uTime", time.value)
           drawIntoCanvas {
              it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
           }
       }
    })
  • Ponieważ kontrolujesz geometrię i piksele, możesz narysować dowolny efekt wizualny oparty na geometrii, używając funkcji draw mesh.

  • Więcej informacji o tym interfejsie API znajdziesz w dokumentacji.

Obsługa szerokiej gamy kolorów

  • W Compose 1.12 wprowadziliśmy obsługę szerokiej gamy kolorów.

  • Co to tak naprawdę oznacza?

  • Czy wiesz, że ekrany mogą odtwarzać inną gamę kolorów niż tradycyjna przestrzeń barw sRGB?

  • Na przykład na tym obrazie biały trójkąt to zakres standardowych kolorów sRGB, które może wyświetlać większość ekranów.

  • Jednak nowsze ekrany obsługują szerszą gamę kolorów, jak pokazuje żółty trójkąt, z bardziej nasyconymi i żywymi barwami.

  • Jednak gdy reprezentujemy kolor, tak naprawdę reprezentujemy go za pomocą opisu każdego wierzchołka tego trójkąta.

  • Podczas porównywania gradientów należy odróżnić gamę wyświetlacza od interpolacji gradientów.

  • Na standardowym wyświetlaczu sRGB lub prawdopodobnie na tym projektorze ekran nie może fizycznie renderować kolorów spoza tej gamy sRGB.

  • Trudno mi to teraz zademonstrować na scenie.

  • Musisz mi więc uwierzyć na słowo, że kolory wyglądają lepiej na Androidzie z obsługą szerokiej gamy kolorów.

  • W tym przykładzie widzisz efekt interpolacji punktów gradientu w szerszej przestrzeni barw.

  • Mieszanie kolorów w szerokiej lub liniowej przestrzeni barw zmienia rzeczywistą matematyczną ścieżkę tego gradientu, zapobiegając powstawaniu szarości i ostrych pasów, które mogą się pojawić, gdy nie używa się szerokiej gamy kolorów.

  • W Compose 1.12 możesz korzystać z płynniejszego mieszania gradientów na dowolnym wyświetlaczu, a także z obsługi szerokiej gamy kolorów na obsługiwanym sprzęcie.

  • Aby zapewnić prawidłową obsługę na urządzeniu, upewnij się, że aktywność lub okno są ustawione tak, aby włączyć tryb szerokiej gamy kolorów.

  • Można to zrobić w pliku AndroidManifest.

    <!-- AndroidManifest.xml -->
    <activity
    android:name=".NavActivity"
    android:colorMode="wideColorGamut"
    android:exported="true">
    </activity>
  • Możesz też zrobić to automatycznie w funkcji kompozycyjnej.

    // Dynamic toggle in code
    DisposableEffect(window) {
       window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
       onDispose {
           window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
       }
    }
  • Jeśli nie ustawisz colorMode, okno pozostanie w standardowym 8-bitowym sRGB, a wszystkie kolory o szerokiej gamie renderowane przez GPU zostaną przycięte.

  • Dlatego ważne jest, aby unikać używania kolorów z szerokiej gamy, jeśli wyświetlacz nie może ich obsłużyć.

  • Aby oszczędzać baterię i zwiększyć wydajność, szeroką gamę kolorów należy włączać tylko w przypadku aktywności lub ścieżek kodu, które faktycznie wyświetlają multimedia, zdjęcia lub rozbudowaną grafikę.

  • Oznacza to, że możesz tworzyć kolory w różnych przestrzeniach barw w Compose i są one teraz respektowane.

    // 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
    )
  • Możesz przekazać typ colorSpace, w którym chcesz utworzyć kolor. Jeśli nie przekażesz żadnego colorSpace, zostanie użyty standardowy sRGB.

  • W przypadku bitmap możesz sprawdzić źródło bitmapy za pomocą metody getColorSpace, a popularne biblioteki do wczytywania obrazów automatycznie obsłużą prawidłowe przestrzenie barw bitmapy.

  • Aby zachować informacje o kolorach, użyj Bitmap.Config.HARDWARE, ponieważ konfiguracja taka jak 565 spowoduje utratę precyzji i widoczne dla użytkowników pasy.

  • Aby dowiedzieć się więcej o kolorach, możesz cofnąć się w czasie do 2017 roku i obejrzeć wystąpienie Romaina Guya, który opowiada o kolorach w Compose i na Androidzie.

Kształty wielokątów i morfing

  • W Compose 1.13 wprowadzamy natywną obsługę kształtu wielokąta.

  • Dzięki temu tworzenie niestandardowych kształtów i naturalnie wyglądających elementów interfejsu będzie znacznie łatwiejsze.

  • Wcześniej, aby narysować złożoną geometrię, trzeba było użyć RoundedPolygon do opisania geometrii, która wygeneruje ścieżkę, a następnie zmapować tę ścieżkę na interfejs API kształtu 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)
        )
    }
  • Najpierw definiujesz proporcje za pomocą RoundedPolygon, a my ustawiamy W tym fragmencie kodu ustawiamy punkty wewnętrzne na 84% od środka w porównaniu z punktami zewnętrznymi.

  • Możesz też zastosować zaokrąglenie rogów, ale opisuje ono tylko krzywe matematyczne.

  • Aby użyć tego wielokąta do przycinania lub tła w Compose, musisz napisać niestandardowy kod Shape, taki jak ten.

    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)
        }
    }
  • W tym fragmencie kodu createOutline widać, że musisz utworzyć ścieżkę, a następnie ręcznie przeskalować i przetłumaczyć elementy.

  • Dzięki nowemu interfejsowi API PolygonShape nie potrzebujesz już niestandardowego kodu Shape.

    private val AvatarShape = PolygonShape {
        val vertices = List(16) { i ->
            val radius = size.minDimension / 2f * if (i % 2 == 0) 1f else 0.86f
            val angle = i * PI.toFloat() / 8f
            size.center + Offset(cos(angle), sin(angle)) * radius
        }
        polygon(vertices, rounding = CornerRounding(radius = 8.dp))
    }
    
        // …
    Modifier.clip(AvatarShape)
  • Ponieważ jest to specjalny kreator kształtów Compose, od razu otrzymuje gęstość i rozmiar.

  • Oznacza to, że możesz dynamicznie obliczać wierzchołki i określać zaokrąglenie rogów bezpośrednio w dp lub nawet jako wartość procentową.

  • Po zdefiniowaniu możesz go przekazać do dowolnego interfejsu API, który przyjmuje kształt.

  • Wprowadzamy też interfejs API MorphPolygonShape, który umożliwia płynne przechodzenie między różnymi kształtami wielokątów.

    val recordShape = remember {
        MorphPolygonShape(
            start = PolygonShape.circle(),
            end = PolygonShape.star(
                numPoints = 9,
                innerRadiusRatio = 0.8f,
                outerRounding = CornerRounding(percent = 50),
            ),
        ) { shapeProgress.value }
    }
  • W tym przykładzie mamy przycisk nagrywania, który po interakcji zmienia się z okręgu w gwiazdę.

  • Wystarczy podać kształt początkowy, kształt końcowy i wartość postępu.

  • Interfejs API MorphPolygonShape odczytuje wartość postępu podczas fazy rozwiązywania konturu, czyli nie podczas rekom- nie podczas tworzenia kompozycji.

  • Oznacza to, że gdy kształt zmienia się między klatkami, po prostu przekształca się w nowy kontur bez wywoływania rekompozycji.

Efekty rozmycia i renderowania tła

  • Teraz porozmawiajmy o rozmyciu.

  • W ostatnich kilku wersjach staramy się ułatwić implementację rozmyć i zwiększyć ich ekspresyjność.

  • Gdy mówimy o rozmyciu, zwykle mamy na myśli ten modyfikator.

    Modifier
       .blur(
           radiusX = 10.dp,
           radiusY = 10.dp,
           edgeTreatment = BlurredEdgeTreatment(
               shape = RoundedCornerShape(8.dp),
           ),
       )
  • Compose ma ten modyfikator od Androida 12.

  • Sprawdza się świetnie, gdy chcesz uzyskać jednolite rozmycie, w którym ten sam promień rozmycia jest stosowany w całym elemencie kompozycyjnym.

  • Jednak nowoczesne projekty interfejsu często wymagają płynnego przejścia od ostrych do rozmytych obszarów, a nie ostrego odcięcia.

  • I tak dochodzimy do progresywnego rozmycia, które jest teraz oficjalnym interfejsem API, począwszy od Compose 1.13.

  • Zamiast jednego stałego promienia w całym elemencie kompozycyjnym możesz teraz zmieniać intensywność rozmycia w przestrzeni.

  • Oto przykład z ekranu profilu użytkownika w naszej aplikacji JetChat.

  • Nowy modyfikator rozmycia wymaga podania promienia rozmycia.

    Modifier.blur {
    blurRadiusSpec = BlurRadiusSpec.verticalGradient(
    listOf(
    BlurStop(0.5f, 0.dp),
    BlurStop(1.0f, 32.dp),
    )
    )
    edgeTreatment = BlurredEdgeTreatment.Unbounded
    }
  • W tym przypadku używamy gradientu pionowego z 2 punktami rozmycia, dzięki czemu górna połowa zdjęcia jest całkowicie ostra (0 dp), a dolna krawędź jest rozmyta (32 dp).

  • Zauważ, że ustawiliśmy też edgeTreatment jako nieograniczony, co w zasadzie oznacza, że rozmycie ma próbkować przezroczysty piksel również poza granicami elementu kompozycyjnego.

  • Oprócz pionowych zaników ta specyfikacja promienia rozmycia zapewnia kilka wbudowanych sposobów na uzyskanie progresywnego rozmycia.

  • Na przykład po lewej stronie widać gradient poziomy, w którym promień początkowy wynosi 0 dp, a promień końcowy jest określany przez wartość suwaka.

    BlurRadiusSpec.horizontalGradient(
    startRadius = 0.dp,
    endRadius = sliderValue.dp,
    )
  • Po prawej stronie mamy gradient promieniowy, w którego środku promień rozmycia wynosi 0 dp, a w miarę przesuwania się w kierunku fallOffRadius, który w tym przypadku wynosi 300 dp, rozmycie płynnie wzrasta do 24 dp.

    BlurRadiusSpec.radialGradient(
    center = center,
    fallOffRadius = 300.dp,
    startRadius = 0.dp,
    endRadius = 24.dp,
    )
  • Jeśli wszystkie te gradienty pionowe, promieniowe i poziome nie są wystarczające, zawsze możesz sterować rozmyciem progresywnym za pomocą niestandardowych cieniowania.

    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
       },
  • Tutaj piszemy mały fragment kodu cieniowania, który oblicza animowaną diagonalną falę sinusoidalną i generuje tę intensywność w kanale alfa.

  • Następnie specyfikacja cieniowania mapuje tę maskę od 0 do 1 bezpośrednio na maskę 24 dp maxRadius.

  • Zanim omówimy rozmycie tła, przyjrzyjmy się różnicom między tymi funkcjami.

  • Po lewej stronie mamy górny pasek aplikacji, na którym zastosowaliśmy rozmycie warstwy.

  • Oznacza to, że rozmywa sam element kompozycyjny.

  • Jak widać, tytuł i ikony działań są rozmyte, ale komponent czatu za nimi nie jest rozmyty.

  • Po prawej stronie mamy rozmycie tła.

  • W tym przypadku próbkowane i rozmywane są tylko wiadomości na czacie przewijane za górnym paskiem.

  • Jednak tekst i ikony są całkowicie ostre i czytelne.

  • Od Androida 37.2 możesz rozmywać tło za pomocą nowego interfejsu API o nazwie RenderNode.setBackdropRenderEffect.

  • Zanim jednak przejdziemy do tego, jak ulepszamy rozmycie, dowiedzmy się, jak działa Android, aby zapewnić wydajność rozmycia.

  • Android używa RenderNodes, czyli kontenerów list wyświetlania z akceleracją sprzętową, co oznacza, że mogą one kompresować i przechowywać wiele różnych poleceń rysowania za pomocą funkcji rozpoczęcia i zakończenia nagrywania.

  • RenderNode zawiera informacje takie jak przesunięcia 3D, obroty, kanał alfa, skale i granice pozycji.

  • Compose używa RenderNode.

  • Aby używać efektów renderowania tła w Compose, możemy utworzyć niestandardowy element Modifier.Node, który pozwoli nam ponownie używać tego modyfikatora.

    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()
       }
    }
  • Zachowujemy odwołanie do RenderNode, a RenderNode jest skonfigurowany z efektami, które chcemy uzyskać w innej funkcji.

  • Na przykład tutaj przekazujemy efekt rozmycia.

  • Następnie wywołujemy drawIntoCanvas, wywołując drawRenderNode na naszym natywnym obszarze roboczym.

  • Na koniec wywołujemy funkcję drawContent, aby narysować rzeczywistą, niewyostrzoną treść na górze.

  • Aby użyć utworzonego właśnie modyfikatora, wywołujemy Modifier.backdropBlur i przekazujemy specyfikację promienia rozmycia.

    JetchatAppBar(
        modifier = modifier
            .backdropBlur(
                tint = MaterialTheme.colorScheme
                    .surface.copy(alpha = 0.5f),
                shape = RectangleShape,
                spec = BlurRadiusSpec.uniform(5.dp)
            )
    )
  • Pamiętaj, że w tym przypadku nie musimy niczego umieszczać na warstwie bazowej.

  • RenderNode.setBackdropRenderEffect automatycznie pobiera próbki treści, które znajdują się pod powierzchnią paska aplikacji JetChat.

  • W efekcie otrzymujemy pięknie rozmyte tło.

  • Możemy też ustawić specyfikację promienia rozmycia progresywnego zamiast samego rozmycia progresywnego, aby uzyskać rozmycie pionowe treści.

    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),
                    ),
                )
            )
    )
  • W przypadku gradientu pionowego treść pod spodem jest rozmyta z różnymi promieniami w różnych częściach kontenera.

  • Pracujemy nad wbudowanym modyfikatorem rozmycia tła w Compose.

  • To tylko przykład technologii, która działa w tle na Androidzie z Compose.

  • Pokazany przykład działałby tylko na poziomie interfejsu API 37.2, ale pracujemy też nad backportem dla Compose, więc bądźcie czujni.

  • Innym ciekawym przykładem efektów tła RenderNode jest to, że możemy je łączyć, aby tworzyć naprawdę ładne interfejsy, które dają wiele różnych efektów.

  • Aby połączyć efekty, tworzymy te 2 efekty.

    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)
  • W tym przykładzie tworzymy efekt rozmycia i nasycenie na poziomie 30%.

  • Wywołujemy createChainEffect, najpierw stosując efekt rozmycia, a potem nasycenia.

  • Oto przykład w JetChat.

    val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
    val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
    
    val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
    renderNode?.setBackdropRenderEffect(finalEffect)
  • Przycisk „Przejdź do dołu”, który może wyglądać jak mała lupa, jeśli połączymy kilka różnych efektów, aby zaokrąglić krawędzie tego elementu kompozycyjnego.

  • Możesz nie tylko stosować efekty powiększenia, ale też łączyć ze sobą dowolne rodzaje cieniowania i nakładać je na tło.

  • Oto kilka ciekawych przykładów, które wdrożyliśmy, pokazujących, co można osiągnąć, łącząc te technologie.

  • Teraz można uzyskać tradycyjny efekt oszronionego szkła.

  • Możemy też dodać inne ciekawe efekty, np. kalejdoskop.

  • Jeśli chcemy przekształcić maskotkę Androida w element interfejsu, który zmienia zawartość pod spodem, możemy zastosować efekty renderowania tła, aby wyglądało to tak, jakbyśmy patrzyli przez tego małego droida.

  • Możemy to zrobić również za pomocą efektów renderowania tła.

  • Dzięki temu nowemu interfejsowi API nie tylko odblokowaliśmy rozmycie tła i efekt matowego szkła, ale teraz ogranicza Cię tylko wyobraźnia.

  • Kolejnym nowym interfejsem API, który wprowadziliśmy na platformie, jest SurfaceView.setBlurRegions.

  • Obecnie można rozmywać widoki tekstur i dowolne elementy kompozycyjne za pomocą efektów tła Modifier.blur i RenderNode, ale rozmywanie części widoku SurfaceView jest trudne i nie jest częścią drzewa RenderNode widoku aplikacji, ponieważ w rzeczywistości tworzy otwór w oknie.

  • Compose nie może więc próbkować bufora pomocniczego SurfaceView, co było niemożliwe do wprowadzenia interfejsu API 37.2.

  • Dlatego setBlurRegions SurfaceView umożliwia aplikacji bezpośrednie deklarowanie obszarów rozmycia.

  • Na przykład w tym odtwarzaczu wideo w JetChat dodaliśmy rozmycie SurfaceView, gdy elementy sterujące są wyświetlane i ukrywane.

  • Aby użyć tego nowego interfejsu API, możesz uzyskać dostęp do metody setBlurRegions w 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
  • Wymaga listy obszarów granic, promienia rozmycia, wartości alfa i listy promieni narożników.

  • Podczas przekazywania tych granic muszą one być względne do SurfaceView na ekranie.

  • Dlatego musisz śledzić współrzędne widoku SurfaceView i obszar, który chcesz rozmyć.

RuntimeColorFilter

  • Kolejną nowością w Androidzie 16 i nowszych jest RuntimeColorFilter.

  • Wcześniej w przypadku obrazów można było ustawić tylko statyczny filtr kolorów matrycy i efekty takie jak nasycenie.

  • Jednak w Androidzie 16 i nowszych możesz teraz przekazać cieniowanie, które pobiera kolor wejściowy, czyli piksel wejściowy dla danej treści, i tworzy nowy kolor.

  • Jeśli na przykład chcemy zastosować do zdjęcia filtr cyberpunkowy, możemy zdefiniować mały obiekt cieniujący, który będzie używany jako dane wejściowe w 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);
        }
    """
  • Ten cieniujący tworzy motyw cyberpunku, ale możesz uzyskać dowolny inny efekt.

  • Aby użyć cieniowania, stosujemy RuntimeColorFilter i konwertujemy go na filtr kolorów Compose.

    AsyncImage(
        model = imageUri,
        contentDescription = filter.displayName,
        colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                       .asComposeColorFilter(),
        contentScale = ContentScale.Crop,
        modifier = Modifier.fillMaxSize(),
    )
  • Jak widać w tym przykładzie, możemy teraz tworzyć bardziej dynamiczne efekty niż wcześniej, bez konieczności zagłębiania się w OpenGL, konfiguracje SurfaceView czy inne tego typu elementy.

  • Można to też zastosować do dowolnego elementu kompozycyjnego, nie tylko do obrazów.

Optymalizacja wydajności grafiki

  • Dziękujemy.

  • Udało nam się stworzyć naprawdę fajny efekt, ale upewnijmy się, że nasza aplikacja nadal działa szybko i płynnie.

  • Gdy przewijamy rozmowę, wszystkie efekty są odtwarzane jednocześnie.

  • Zacznijmy od zmierzenia tego przewijania.

    @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()
        }
    //...
  • Używamy makrotestu porównawczego, który uruchamia aplikację w nowym procesie i przewija rozmowę.

  • Następnie wysyłamy zapytanie do logu czasu, aby policzyć klatki, które nie zostały wyrenderowane na czas.

    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%'
  • To daje nam 6 opóźnionych klatek.

  • Otwórzmy jedną z późnych klatek.

  • Dlatego tuż przed tą klatką Compose pobiera wstępnie następną wiadomość, zanim zostanie ona przewinięta do widoku.

  • Pobieranie wstępne powinno zmieścić się w krótkiej przerwie między klatkami, ale przygotowanie tej wiadomości powoduje też dekodowanie obrazu bezpośrednio w głównym wątku, co trwa długo i nie przynosi żadnych korzyści.

  • Główny wątek utknął na dekodowaniu tego obrazu, więc nie można rozpocząć nowych klatek.

  • Gdy ta wiadomość pojawi się na ekranie, RenderThread musi przydzielić pamięć i przesłać wszystkie te piksele do GPU, więc ten sam obraz opóźnia drugą klatkę.

  • Teraz widzimy, na co poświęciliśmy czas, ale czy naprawdę musimy wykonać całą tę pracę, aby narysować tę klatkę?

  • Jak możemy to sprawdzić?

  • Ślad nam to powie.

  • Za każdym razem, gdy duża bitmapa jest rysowana na znacznie mniejszym obszarze ekranu, Android rejestruje znacznik bezpośrednio w śladzie.

  • Pokazuje 2 liczby w pikselach: pełny wymiar bitmapy i obszar, który faktycznie zajmuje na ekranie.

  • Podziel tę wartość przez gęstość ekranu, aby uzyskać wartość w DP.

  • Następnie wyszukujemy w kodzie tę wartość DP, aby znaleźć obraz.

  • Oto co znaleźliśmy.

  • Obraz jest wczytywany za pomocą painterResource, który nie wie, jak duży jest kontener, więc dekoduje cały obraz w pełnej rozdzielczości.

    Image(
        painter = painterResource(it),
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • Zastępujemy go więc AsyncImage z cewki.

    AsyncImage(
        model = it,
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • Najpierw mierzy kontener, więc dekoduje tylko potrzebny piksel i robi to w innym wątku.

  • Po ponownym uruchomieniu tego samego testu porównawczego dekodowanie obrazu w głównym wątku spadło do 0, a liczba opóźnionych klatek – do 2.

  • Nie będziemy tutaj debugować tych 2 klatek, ale wniosek jest prosty: staraj się nie obliczać pikseli, które nigdy nie dotrą do klatki.

  • Dekodowanie obrazu było więc na tyle duże, że powodowało opóźnienia klatek, ale nie każdy koszt jest widoczny jako pominięta klatka. Nadal istnieją mniejsze koszty, których możemy uniknąć podczas pisania kodu.

  • Przyjrzyjmy się 2 z nich.

  • Za każdym razem, gdy używamy RuntimeShader, powinniśmy utworzyć go raz, ponieważ chociaż jest on tylko kompilowany i buforowany, to i tak analizuje ciąg źródłowy za każdym razem, gdy go tworzymy.

  • Jeśli efekt animuje każdą klatkę, możemy go opakować w graphicsLayer, aby ponownie nagrywany był tylko animowany element, a nie cały interfejs wokół niego.

  • Przycisk Przejdź na dół pojawia się, gdy przewijasz.

  • Do uzyskania tego efektu używane są 2 RuntimeShader. Za każdym razem, gdy pojawia się przycisk, odtwarzamy oba te shadery z ich ciągu źródłowego.

    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 {
  • Analiza tych 2 programów do cieniowania i tworzenie efektu zajmuje setki mikrosekund w głównym wątku za każdym razem, gdy przycisk się pojawia.

  • Zamiast tego tworzymy obiekt cieniujący raz dla całego procesu lub możemy go zapamiętać, a następnie zaktualizować jego zmienną 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) }
  • Dzięki tej zmianie tworzenie tego efektu jest prawie 40 razy szybsze, a koszt pojawienia się przycisku jest 20 razy mniejszy.

  • W przypadku efektu świecenia gradientu siatki, który widzieliśmy wcześniej, efekt ten jest animowany w każdej klatce w 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)
    }
  • Odczytywanie stanu w fazie rysowania zachowuje kompozycję i układ, ale lista wyświetlania nie może zaktualizować pojedynczego polecenia rysowania na miejscu.

  • Compose przechodzi więc w górę drzewa, aby znaleźć najbliższy węzeł z warstwą graficzną, którym w tym przypadku jest czerwone obramowanie obejmujące cały ekran, i ponownie rejestruje całą tę warstwę w każdej klatce.

  • Po umieszczeniu graphicsLayer bezpośrednio na polu podróż zatrzymuje się na zielonym konturze, więc tylko pole świecenia jest ponownie nagrywane, a wycinek rysowania głównego wątku zmniejsza się o 2/10 milisekundy.

    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)
    }
  • Analizowanie błędów w logach czasu za pomocą Perfetto może być trudne, dlatego możesz skorzystać z umiejętności analizy logów czasu, aby zebrać pewne informacje.

  • Jeśli chcesz uzyskać dodatkową pomoc w pisaniu wydajnego kodu Compose, zainstaluj interfejs wiersza poleceń Androida i użyj umiejętności wydajności Compose.

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

Podsumowanie i zasoby

  • Jeśli chcesz zobaczyć przykłady, które dziś pokazaliśmy, zapoznaj się z aplikacją JetChat dostępną pod tym linkiem.

Kod

Fragmenty kodu

Fragment kodu w języku Kotlin w filmie „Mesh gradients and custom mesh rendering”
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}
Fragment kodu w języku Kotlin w 02:55 w filmie „Mesh gradients and custom mesh rendering”
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
   setVertex(0, 0, Offset(0.0f, 0.0f), indigo)

}
}
Fragment kodu w Kotlinie w 03:05 w filmie „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)
)
Fragment kodu w Kotlinie w 03:24 w filmie „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
}
Fragment kodu w języku Kotlin w 04:09 w filmie „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",
)
Fragment kodu w języku Kotlin w 04:33 w filmie „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])
       }
   }
}
Fragment kodu w języku Kotlin w 05:40 w filmie „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,
    )
//...
}
Fragment tekstu w 07:03 w filmie „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;
}
Fragment tekstu w 08:01 w filmie „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;
}
Fragment kodu w języku Kotlin w 08:37 w filmie „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)
}
Fragment kodu w języku Kotlin w 09:06 w filmie „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
}
Fragment kodu w języku Kotlin w 09:18 w filmie „Mesh gradients and custom mesh rendering”
val mesh = Mesh(
    spec,
    mode = Mesh.TRIANGLE_STRIP,
    vertexBuffer = corners,
    vertexCount = (segments + 1) * 2,
    bounds = RectF(0f, 0f, size.width, size.height)
)
Fragment kodu w języku Kotlin w 09:24 w filmie „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)
       }
   }
})
Fragment kodu XML w 11:22 w filmie „Wide color gamut support”
<!-- AndroidManifest.xml -->
<activity
android:name=".NavActivity"
android:colorMode="wideColorGamut"
android:exported="true">
</activity>
Fragment kodu w języku Kotlin w 11:24 w filmie „Obsługa szerokiej gamy kolorów”
// Dynamic toggle in code
DisposableEffect(window) {
   window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
   onDispose {
       window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
   }
}
Fragment kodu w języku Kotlin w 11:49 w filmie „Obsługa szerokiej gamy kolorów”
// 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
)
Fragment kodu w języku Kotlin w 12:45 w filmie „Kształty wielokątów i morfing”
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)
    )
}
Fragment kodu w języku Kotlin w 13:15 w filmie „Kształty wielokątów i morfing”
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)
    }
}
Fragment kodu w języku Kotlin w 13:30 w filmie „Kształty wielokątów i morfing”
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)
Fragment kodu w języku Kotlin w 13:53 w filmie „Kształty wielokątów i morfing”
val recordShape = remember {
    MorphPolygonShape(
        start = PolygonShape.circle(),
        end = PolygonShape.star(
            numPoints = 9,
            innerRadiusRatio = 0.8f,
            outerRounding = CornerRounding(percent = 50),
        ),
    ) { shapeProgress.value }
}
Fragment kodu w języku Kotlin w 14:37 w filmie „Blurring and backdrop render effects”
Modifier
   .blur(
       radiusX = 10.dp,
       radiusY = 10.dp,
       edgeTreatment = BlurredEdgeTreatment(
           shape = RoundedCornerShape(8.dp),
       ),
   )
Fragment kodu w języku Kotlin w 15:15 w filmie „Efekty renderowania rozmycia i tła”
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}
Fragment kodu w Kotlinie w 15:42 w filmie „Efekty renderowania rozmycia i tła”
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)
Fragment kodu w Kotlinie w 15:53 w filmie „Efekty renderowania rozmycia i tła”
BlurRadiusSpec.radialGradient(
center = center,
fallOffRadius = 300.dp,
startRadius = 0.dp,
endRadius = 24.dp,
)
Fragment kodu w języku Kotlin w 16:07 w filmie „Efekty renderowania rozmycia i tła”
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
   },
Fragment kodu w języku Kotlin w 17:40 w filmie „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()
   }
}
Fragment kodu w języku Kotlin w 18:09 w filmie „Blurring and backdrop render effects”
JetchatAppBar(
    modifier = modifier
        .backdropBlur(
            tint = MaterialTheme.colorScheme
                .surface.copy(alpha = 0.5f),
            shape = RectangleShape,
            spec = BlurRadiusSpec.uniform(5.dp)
        )
)
Fragment kodu w języku Kotlin w 18:30 w filmie „Efekty renderowania rozmycia i tła”
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),
                ),
            )
        )
)
Fragment kodu w Kotlinie w 19:10 w filmie „Efekty renderowania rozmycia i tła”
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)
Fragment kodu w Kotlinie w 19:23 w filmie „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)
Fragment kodu w języku Kotlin w 20:53 w filmie „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
Fragment kodu w języku Kotlin w 21:33 w filmie „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);
    }
"""
Fragment kodu w języku Kotlin w filmie „RuntimeColorFilter” (21:48)
AsyncImage(
    model = imageUri,
    contentDescription = filter.displayName,
    colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                   .asComposeColorFilter(),
    contentScale = ContentScale.Crop,
    modifier = Modifier.fillMaxSize(),
)
Fragment kodu w języku Kotlin w 22:21 w filmie „Optymalizacja wydajności grafiki”
@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()
    }
//...
Fragment SQL w 22:32 w filmie „Optymalizacja wydajności grafiki”
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%'
Fragment kodu w Kotlinie w 23:54 w filmie „Optymalizacja wydajności grafiki”
Image(
    painter = painterResource(it),
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
Fragment kodu w języku Kotlin w 24:02 w filmie „Optymalizacja wydajności grafiki”
AsyncImage(
    model = it,
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
Fragment kodu w języku Kotlin w 25:14 w filmie „Optymalizacja wydajności grafiki”
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 {
Fragment kodu w języku Kotlin w 25:32 w filmie „Optymalizacja wydajności grafiki”
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) }
Fragment kodu w języku Kotlin w 25:49 w filmie „Optymalizacja wydajności grafiki”
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)
}
Fragment kodu w języku Kotlin w 26:15 w filmie „Optymalizacja wydajności grafiki”
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)
}
Fragment kodu powłoki w 26:37 w filmie „Graphics performance optimization”
android skills add perfetto-trace-analysis
android skills add jetpack-compose-performance