Fazy Jetpack Compose

Podobnie jak większość innych zestawów narzędzi UI, Compose renderuje ramkę w kilku różnych fazach. Na przykład system widoku Androida ma 3 główne fazy: pomiar, układ i rysowanie. Compose działa bardzo podobnie, ale na początku ma ważną dodatkową fazę, która nazywa się kompozycją.

Dokumentacja Compose opisuje kompozycję w sekcjach Myślenie w Compose i Stan i Jetpack Compose.

Trzy fazy ramki

Compose ma 3 główne fazy:

  1. Kompozycja: jaki interfejs wyświetlić. Compose uruchamia funkcje typu „composable” i tworzy opis interfejsu.
  2. Układ: gdzie umieścić interfejs. Ta faza składa się z 2 etapów: pomiaru i umieszczania. Elementy układu mierzą i umieszczają siebie oraz wszystkie elementy podrzędne we współrzędnych 2D dla każdego węzła w drzewie układu.
  3. Rysowanie: jak jest renderowany. Elementy interfejsu są rysowane na obszarze roboczym, którym zwykle jest ekran urządzenia.
3 etapy, w których Compose przekształca dane w interfejs (w kolejności: dane, kompozycja, układ, rysowanie, interfejs).
Rysunek 1. 3 etapy, w których Compose przekształca dane w interfejs użytkownika.

Kolejność tych faz jest zwykle taka sama, co umożliwia przepływ danych w jednym kierunku – od kompozycji do układu i rysowania – w celu wygenerowania klatki (jest to tzw. jednokierunkowy przepływ danych). Wyjątkami są BoxWithConstraints, LazyColumn i LazyRow, w przypadku których kompozycja elementów podrzędnych zależy od fazy układu elementu nadrzędnego.

Teoretycznie każda z tych faz występuje w przypadku każdej klatki. Aby jednak zoptymalizować wydajność, Compose unika powtarzania pracy, która we wszystkich tych fazach dawałaby takie same wyniki na podstawie tych samych danych wejściowych. Compose pomija uruchamianie funkcji typu „composable”, jeśli może ponownie użyć poprzedniego wyniku, a interfejs Compose nie zmienia układu ani nie rysuje ponownie całego drzewa, jeśli nie jest to konieczne. Compose wykonuje tylko minimalną ilość pracy wymaganej do zaktualizowania interfejsu. Ta optymalizacja jest możliwa, ponieważ Compose śledzi odczyty stanu w różnych fazach.

Omówienie faz

W tej sekcji opisujemy bardziej szczegółowo, jak przebiegają 3 etapy tworzenia funkcji kompozycyjnych.

Kompozycja

W fazie kompozycji środowisko wykonawcze Compose wykonuje funkcje typu „composable” i generuje strukturę drzewa, która reprezentuje interfejs użytkownika. To drzewo interfejsu składa się z węzłów układu, które zawierają wszystkie informacje potrzebne do kolejnych faz, jak pokazano na tym filmie:

Rysunek 2. Drzewo reprezentujące interfejs użytkownika, które jest tworzone w fazie kompozycji.

Podsekcja kodu i drzewa UI wygląda tak:

Fragment kodu z 5 funkcjami kompozycyjnymi i wynikowym drzewem UI z węzłami podrzędnymi rozgałęziającymi się od węzłów nadrzędnych.
Rysunek 3. Podsekcja drzewa interfejsu z odpowiednim kodem.

W tych przykładach każda funkcja typu „composable” w kodzie jest mapowana na pojedynczy węzeł układu w drzewie interfejsu. W bardziej złożonych przykładach elementy kompozycyjne mogą zawierać logikę i przepływ sterowania oraz tworzyć różne drzewa w zależności od stanu.

Układ

W fazie układu Compose używa drzewa UI utworzonego w fazie kompozycji jako danych wejściowych. Kolekcja węzłów układu zawiera wszystkie informacje potrzebne do określenia rozmiaru i lokalizacji każdego węzła w przestrzeni 2D.

Rysunek 4. pomiar i umieszczanie każdego węzła układu w drzewie UI podczas fazy układu;

Podczas fazy układu drzewo jest przechodzone za pomocą tego 3-etapowego algorytmu:

  1. Mierzenie elementów podrzędnych: węzeł mierzy swoje węzły podrzędne, jeśli takie istnieją.
  2. Decydowanie o własnym rozmiarze: na podstawie tych pomiarów węzeł sam decyduje o swoim rozmiarze.
  3. Umieszczanie elementów podrzędnych: każdy węzeł podrzędny jest umieszczany względem pozycji węzła.

Na końcu tej fazy każdy węzeł układu ma:

  • przypisane wartości width i height,
  • współrzędne x, y, w których ma zostać narysowany;

Przypomnij sobie drzewo interfejsu z poprzedniej sekcji:

Fragment kodu z 5 funkcjami kompozycyjnymi i wynikowym drzewem interfejsu, w którym węzły podrzędne rozgałęziają się od węzłów nadrzędnych

W przypadku tego drzewa algorytm działa w ten sposób:

  1. Row mierzy swoje elementy podrzędne, Image i Column.
  2. Mierzona jest wartość Image. Nie ma żadnych elementów podrzędnych, więc określa swój rozmiar i przekazuje go do elementu Row.
  3. Następnie mierzony jest Column. Najpierw mierzy swoje elementy podrzędne (2 komponenty Text).
  4. Mierzony jest pierwszy Text. Nie ma żadnych elementów podrzędnych, więc określa swój rozmiar i zgłasza go do Column.
    1. Drugi element Text jest mierzony. Nie ma żadnych elementów podrzędnych, więc określa swój rozmiar i zgłasza go do elementu Column.
  5. Column określa swój rozmiar na podstawie wymiarów elementu podrzędnego. Używa maksymalnej szerokości elementu podrzędnego i sumy wysokości elementów podrzędnych.
  6. Column umieszcza elementy podrzędne względem siebie, umieszczając je pod sobą w pionie.
  7. Row używa wymiarów elementów podrzędnych, aby określić własny rozmiar. Używa maksymalnej wysokości elementu podrzędnego i sumy szerokości elementów podrzędnych. Następnie umieszcza elementy podrzędne.

Zwróć uwagę, że każdy węzeł został odwiedzony tylko raz. Środowisko wykonawcze Compose wymaga tylko jednego przejścia przez drzewo interfejsu, aby zmierzyć i umieścić wszystkie węzły, co poprawia wydajność. Gdy liczba węzłów w drzewie wzrasta, czas potrzebny na jego przejście rośnie liniowo. Jeśli natomiast każdy węzeł był odwiedzany wielokrotnie, czas przechodzenia rośnie wykładniczo.

Rysunek

W fazie rysowania drzewo jest ponownie przechodzone od góry do dołu, a każdy węzeł po kolei rysuje się na ekranie.

Rysunek 5. W fazie rysowania piksele są rysowane na ekranie.

W poprzednim przykładzie zawartość drzewa jest rysowana w ten sposób:

  1. Row rysuje dowolną zawartość, np. kolor tła.
  2. Image rysuje się sam.
  3. Column rysuje się sam.
  4. Pierwszy i drugi element Text rysują się odpowiednio.

Rysunek 6. Drzewo UI i jego narysowana reprezentacja.

Odczyty stanu

Gdy odczytujesz value elementu snapshot state podczas jednej z wymienionych wcześniej faz, Compose automatycznie śledzi, co robił podczas odczytywania value. To śledzenie umożliwia Compose ponowne wykonanie czytnika, gdy zmieni się value stanu, i jest podstawą obserwowalności stanu w Compose.

Stan tworzy się zwykle za pomocą mutableStateOf(), a następnie uzyskuje się do niego dostęp na jeden z 2 sposobów: przez bezpośredni dostęp do właściwości value lub za pomocą delegata właściwości Kotlin. Więcej informacji na ten temat znajdziesz w artykule Stan w funkcjach kompozycyjnych. Na potrzeby tego przewodnika „odczyt stanu” odnosi się do obu tych równoważnych metod dostępu.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

W delegacie właściwości funkcje „getter” i „setter” są używane do uzyskiwania dostępu do value stanu i jego aktualizowania. Te funkcje pobierające i ustawiające są wywoływane tylko wtedy, gdy odwołujesz się do właściwości jako wartości, a nie podczas jej tworzenia. Dlatego oba opisane wcześniej sposoby są równoważne.

Każdy blok kodu, który można ponownie wykonać po zmianie stanu odczytu, to zakres ponownego uruchomienia. Compose śledzi zmiany stanu value i ponownie uruchamia zakresy w różnych fazach.

Odczyty stanu fazowego

Jak wspomnieliśmy wcześniej, w Compose są 3 główne fazy, a Compose śledzi, jaki stan jest odczytywany w każdej z nich. Dzięki temu Compose może powiadamiać tylko określone fazy, które muszą wykonać pracę dla każdego elementu interfejsu, którego dotyczy zmiana.

W kolejnych sekcjach opisujemy poszczególne fazy i wyjaśniamy, co się dzieje, gdy wartość stanu jest odczytywana w danej fazie.

Faza 1. Kompozycja

Odczyty stanu w funkcji @Composable lub bloku lambda wpływają na kompozycję i potencjalnie na kolejne fazy. Gdy zmieni się value stanu, rekompozytor zaplanuje ponowne uruchomienie wszystkich funkcji typu „composable”, które odczytują value tego stanu. Pamiętaj, że środowisko wykonawcze może pominąć niektóre lub wszystkie funkcje typu „composable”, jeśli dane wejściowe nie uległy zmianie. Więcej informacji znajdziesz w sekcji Pominięcie, jeśli dane wejściowe nie uległy zmianie.

W zależności od wyniku kompozycji interfejs Compose uruchamia fazy układu i rysowania. Może pominąć te fazy, jeśli treść pozostanie taka sama, a rozmiar i układ nie ulegną zmianie.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

Etap 2. Układ

Etap układu składa się z 2 kroków: pomiaru i umieszczania. Krok pomiaru uruchamia m.in. funkcję lambda pomiaru przekazaną do funkcji kompozycyjnej Layout i metodę MeasureScope.measure interfejsu LayoutModifier. Krok umieszczania uruchamia blok umieszczania funkcji layout, blok lambda funkcji Modifier.offset { … } i podobne funkcje.

Odczyty stanu podczas każdego z tych kroków wpływają na układ i potencjalnie na fazę rysowania. Gdy zmieni się value stanu, interfejs Compose zaplanuje fazę układu. Jeśli rozmiar lub pozycja uległy zmianie, uruchamia też fazę rysowania.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

Faza 3. Losowanie

Odczyty stanu podczas wykonywania kodu rysowania wpływają na fazę rysowania. Typowe przykłady to Canvas(), Modifier.drawBehind i Modifier.drawWithContent. Gdy zmienia się value stanu, interfejs Compose UI uruchamia tylko fazę rysowania.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

Diagram pokazujący, że stan odczytany podczas fazy rysowania powoduje tylko ponowne uruchomienie fazy rysowania.

Optymalizacja odczytów stanu

Compose śledzi odczyty stanu lokalnie, więc możesz zminimalizować ilość pracy, odczytując każdy stan w odpowiedniej fazie.

Przyjrzyj się temu przykładowi. W tym przykładzie mamy element Image(), który używa modyfikatora przesunięcia, aby przesunąć ostateczną pozycję układu, co daje efekt paralaksy podczas przewijania.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

Ten kod działa, ale powoduje gorszą skuteczność. W obecnej postaci kod odczytuje value stanu firstVisibleItemScrollOffset i przekazuje go do funkcji Modifier.offset(offset: Dp). Gdy użytkownik przewija, value elementu firstVisibleItemScrollOffset się zmienia. Jak już wiesz, Compose śledzi wszystkie odczyty stanu, aby móc ponownie uruchomić (wywołać) kod odczytu, którym w tym przykładzie jest zawartość Box.

To jest przykład odczytu stanu w fazie kompozycji. Niekoniecznie jest to coś złego, a w rzeczywistości jest to podstawa rekompozycji, która umożliwia zmianom danych emitowanie nowego interfejsu.

Kluczowa kwestia: ten przykład jest nieoptymalny, ponieważ każde zdarzenie przewijania powoduje ponowną ocenę, pomiar, układ i ostatecznie narysowanie całej zawartości funkcji kompozycyjnej. Faza tworzenia jest uruchamiana przy każdym przewijaniu, mimo że wyświetlana treść się nie zmieniła, a jedynie jej pozycja. Możesz zoptymalizować odczyt stanu, aby ponownie uruchamiać tylko fazę układu.

Przesunięcie z lambdą

Dostępna jest inna wersja modyfikatora przesunięcia: Modifier.offset(offset: Density.() -> IntOffset).

Ta wersja przyjmuje parametr lambda, a wynikowe przesunięcie jest zwracane przez blok lambda. Zaktualizuj kod, aby go użyć:

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

Dlaczego jest to bardziej wydajne? Blok lambda, który przekazujesz do modyfikatora, jest wywoływany podczas fazy układu (konkretnie podczas etapu rozmieszczania w fazie układu), co oznacza, że stan firstVisibleItemScrollOffset nie jest już odczytywany podczas tworzenia. Compose śledzi odczytywanie stanu, więc jeśli zmieni się value elementu firstVisibleItemScrollOffset, Compose musi tylko ponownie uruchomić fazy układu i rysowania.

Oczywiście często odczytywanie stanów w fazie tworzenia kompozycji jest absolutnie konieczne. Mimo to w niektórych przypadkach możesz zminimalizować liczbę rekompozycji, filtrując zmiany stanu. Więcej informacji znajdziesz w artykule derivedStateOf: konwertowanie jednego lub wielu obiektów stanu na inny stan.

Pętla rekompozycji (cykliczna zależność fazy)

Wcześniej w tym przewodniku wspomnieliśmy, że fazy Compose są zawsze wywoływane w tej samej kolejności i nie można cofnąć się w tej samej klatce. Nie uniemożliwia to jednak aplikacjom wchodzenia w pętle kompozycji w różnych klatkach. Przeanalizuj ten przykład:

Box {
    var imageHeightPx by remember { mutableIntStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

W tym przykładzie zastosowano pionową kolumnę z obrazem u góry i tekstem poniżej. Używa Modifier.onSizeChanged(), aby uzyskać ostateczny rozmiar obrazu, a następnie Modifier.padding() na tekście, aby przesunąć go w dół. Nienaturalna konwersja z Px z powrotem na Dp już wskazuje, że kod ma jakiś problem.

Problem z tym przykładem polega na tym, że kod nie dociera do „ostatecznego” układu w ramach jednej klatki. Kod opiera się na wielu klatkach, co powoduje niepotrzebne działania i skakanie interfejsu na ekranie użytkownika.

Kompozycja pierwszej klatki

W fazie tworzenia pierwszej klatki imageHeightPx ma początkowo wartość 0. W rezultacie kod dostarcza tekst z Modifier.padding(top = 0). Kolejna faza układu wywołuje wywołanie zwrotne modyfikatora onSizeChanged, które aktualizuje imageHeightPx do rzeczywistej wysokości obrazu. Następnie Compose planuje rekompozycję dla następnej klatki. Jednak podczas bieżącej fazy rysowania tekst jest renderowany z dopełnieniem 0, ponieważ zaktualizowana wartość imageHeightPx nie jest jeszcze odzwierciedlona.

Kompozycja drugiej klatki

Compose inicjuje drugą klatkę, wywołaną przez zmianę wartości imageHeightPx. W fazie tworzenia tej klatki stan jest odczytywany w bloku treści Box. Tekst jest teraz wyświetlany z dopełnieniem, które dokładnie odpowiada wysokości obrazu. Podczas fazy układu imageHeightPx jest ustawiana ponownie, ale nie jest planowana dalsza rekompozycja, ponieważ wartość pozostaje spójna.

Diagram przedstawiający pętlę rekompozycji, w której zmiana rozmiaru w fazie układu wywołuje rekompozycję, co powoduje ponowne utworzenie układu.

Ten przykład może wydawać się nienaturalny, ale uważaj na ten ogólny wzorzec:

  • Modifier.onSizeChanged(), onGloballyPositioned() lub inne operacje na układzie
  • Zaktualizuj stan
  • Użyj tego stanu jako danych wejściowych modyfikatora układu (padding(), height() lub podobnego).
  • Potencjalnie powtórz

Rozwiązaniem w przypadku poprzedniego przykładu jest użycie odpowiednich elementów podstawowych układu. Poprzedni przykład można zaimplementować za pomocą Column(), ale możesz mieć bardziej złożony przykład, który wymaga czegoś niestandardowego, co będzie wymagało napisania niestandardowego układu. Więcej informacji znajdziesz w przewodniku po niestandardowych układach.

Ogólna zasada polega na tym, aby mieć jedno źródło wiarygodnych danych dla wielu elementów interfejsu, które powinny być mierzone i umieszczane względem siebie. Użycie odpowiedniego elementu podstawowego układu lub utworzenie niestandardowego układu oznacza, że minimalny wspólny element nadrzędny służy jako źródło informacji, które może koordynować relacje między wieloma elementami. Wprowadzenie stanu dynamicznego narusza tę zasadę.

Więcej informacji o pętlach rekompozycji i o tym, jak unikać zapisywania stanu w różnych fazach, znajdziesz w artykule o zapisach wstecznych w Compose.