Fazy Jetpack Compose

Podobnie jak większość innych zestawów narzędzi UI, Compose renderuje klatkę w kilku różnych fazach. Na przykład system widoków Androida ma 3 główne fazy: pomiar, układ i rysowanie. Compose jest bardzo podobny, ale na początku ma ważną dodatkową fazę o nazwie kompozycja.

Więcej informacji o kompozycji znajdziesz w dokumentacji Compose w artykułach Myślenie w Compose oraz Stan i Jetpack Compose.

3 fazy klatki

Compose ma 3 główne fazy:

  1. Kompozycja: co ma być wyświetlane. 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 umieszczenia. 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 renderować. Elementy interfejsu rysują się na Canvas, zwykle na ekranie urządzenia.
Trzy fazy, w których Compose przekształca dane w interfejs (w kolejności: dane, kompozycja, układ, rysowanie, interfejs).
Rysunek 1. 3 fazy, w których Compose przekształca dane w interfejs.

Kolejność tych faz jest zwykle taka sama, co umożliwia przepływ danych w jednym kierunku – od kompozycji przez układ do rysowania – w celu utworzenia klatki (znanej też jako jednokierunkowy przepływ danych). BoxWithConstraints, LazyColumn, i LazyRow to godne uwagi wyjątki, w 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 każdej klatce. Aby jednak zoptymalizować wydajność, Compose unika powtarzania pracy, która w tych wszystkich fazach dawałaby te same wyniki przy tych samych danych wejściowych. Compose pomija uruchamianie funkcji typu „composable”, jeśli może ponownie wykorzystać poprzedni wynik, 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 wymaganą do zaktualizowania interfejsu. Ta optymalizacja jest możliwa, ponieważ Compose śledzi odczyty stanu w różnych fazach.

Omówienie faz

W tej sekcji bardziej szczegółowo opisujemy, jak są wykonywane 3 fazy Compose w przypadku funkcji typu „composable”.

Kompozycja

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

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

Podsekcja kodu i drzewa UI wygląda tak:

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

W tych przykładach każda funkcja typu „composable” w kodzie odpowiada jednemu węzłowi układu w drzewie UI. W bardziej złożonych przykładach funkcje typu „composable” mogą zawierać logikę i przepływ sterowania oraz tworzyć inne drzewo w zależności od stanu.

Układ

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

Rysunek 4. Pomiar i umieszczenie każdego węzła układu w drzewie UI w fazie układu.

W fazie układu drzewo jest przeszukiwane 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. Określanie własnego rozmiaru: na podstawie tych pomiarów węzeł określa swój rozmiar.
  3. Umieszczanie elementów podrzędnych: każdy węzeł podrzędny jest umieszczany względem własnej pozycji węzła.

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

  • przypisaną szerokość i wysokość ;
  • współrzędne x, y , w których ma być narysowany.

Przypomnij sobie drzewo UI z poprzedniej sekcji:

Fragment kodu z 5 funkcjami kompozycyjnymi i wynikowym drzewem interfejsu z węzłami podrzędnymi odgałęziającymi się od węzłów nadrzędnych.

W przypadku tego drzewa algorytm działa tak:

  1. Row mierzy swoje elementy podrzędne, czyli Image i Column.
  2. Mierzony jest element Image. Nie ma on elementów podrzędnych, więc określa swój rozmiar i zgłasza go do elementu Row.
  3. Następnie mierzony jest element Column. Najpierw mierzy on swoje elementy podrzędne (2 funkcje typu „composable” Text).
  4. Mierzona jest pierwsza funkcja Text. Nie ma ona elementów podrzędnych, więc określa swój rozmiar i zgłasza go do elementu Column.
    1. Mierzona jest druga funkcja Text. Nie ma ona elementów podrzędnych, więc określa swój rozmiar i zgłasza go do elementu Column.
  5. Element Column używa pomiarów elementów podrzędnych do określenia swojego rozmiaru. Używa maksymalnej szerokości elementu podrzędnego i sumy wysokości elementów podrzędnych.
  6. Element Column umieszcza swoje elementy podrzędne względem siebie, umieszczając je pionowo jeden pod drugim.
  7. Element Row używa pomiarów elementów podrzędnych do określenia swojego rozmiaru. Używa maksymalnej wysokości elementu podrzędnego i sumy szerokości elementów podrzędnych. Następnie umieszcza swoje elementy podrzędne.

Pamiętaj, że każdy węzeł został odwiedzony tylko raz. Środowisko wykonawcze Compose wymaga tylko jednego przejścia przez drzewo UI, aby zmierzyć i umieścić wszystkie węzły, co zwiększa wydajność. Gdy liczba węzłów w drzewie rośnie, czas potrzebny na jego przeszukanie rośnie liniowo. Natomiast jeśli każdy węzeł byłby odwiedzany wielokrotnie, czas przeszukiwania rósłby wykładniczo.

Rysowanie

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

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

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

  1. Element Row rysuje wszystkie treści, które może zawierać, np. kolor tła.
  2. Element Image rysuje się.
  3. Element Column rysuje się.
  4. Pierwsza i druga funkcja Text rysują się.

Rysunek 6. Drzewo UI i jego narysowana reprezentacja.

Odczyty stanu

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

Stan zwykle tworzysz za pomocą funkcji mutableStateOf(), a następnie uzyskujesz do niego dostęp na 2 sposoby: bezpośrednio przez 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 jednej z 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 do uzyskiwania dostępu do value stanu i jego aktualizowania używane są funkcje „getter” i „setter” . Te funkcje getter i setter są wywoływane tylko wtedy, gdy odwołujesz się do właściwości jako wartości, a nie wtedy, gdy jest ona tworzona. Dlatego 2 opisane wcześniej sposoby są równoważne.

Każdy blok kodu, który można ponownie wykonać, gdy zmieni się stan odczytu, jest zakresem ponownego uruchomienia. Compose śledzi zmiany value stanu i zakresy ponownego uruchomienia w różnych fazach.

Odczyty stanu w fazach

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 te fazy, które muszą wykonać pracę w przypadku każdego elementu interfejsu.

W sekcjach poniżej opisujemy każdą fazę i to, co się dzieje, gdy w jej obrębie odczytywana jest wartość stanu.

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, funkcja recomposer 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 się nie zmieniły. Więcej informacji znajdziesz w sekcji Pomijanie, jeśli dane wejściowe się nie zmieniły.

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 się nie zmienią.

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)
)

Faza 2. Układ

Faza układu składa się z 2 etapów: pomiaru i umieszczenia. Etap pomiaru uruchamia m.in. lambdę pomiaru przekazaną do funkcji typu „composable” Layout oraz metodę MeasureScope.measure interfejsu LayoutModifier. Etap umieszczenia uruchamia blok umieszczenia funkcji layout, blok lambda funkcji Modifier.offset { … } i podobne funkcje.

Odczyty stanu na każdym z tych etapów wpływają na układ i potencjalnie na fazę rysowania. Gdy zmieni się value stanu, interfejs Compose zaplanuje fazę układu. Jeśli zmieni się rozmiar lub pozycja, uruchomi 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. Rysowanie

Odczyty stanu w kodzie rysowania wpływają na fazę rysowania. Typowe przykłady to Canvas(), Modifier.drawBehind i Modifier.drawWithContent. Gdy zmieni się value stanu, interfejs Compose uruchomi 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 odczytanie stanu podczas fazy rysowania powoduje tylko ponowne uruchomienie tej fazy.

Optymalizowanie odczytów stanu

Ponieważ Compose wykonuje lokalne śledzenie odczytu stanu, możesz zminimalizować ilość pracy wykonywanej przez odczytywanie każdego stanu w odpowiedniej fazie.

Rozważ ten przykład. Ten przykład zawiera element Image(), który używa modyfikatora przesunięcia do przesunięcia jego ostatecznej pozycji układu, co powoduje efekt paralaksy podczas przewijania przez użytkownika.

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 suboptymalną wydajność. W takiej postaci kod odczytuje value stanu firstVisibleItemScrollOffset i przekazuje go do funkcji Modifier.offset(offset: Dp). Gdy użytkownik przewija, value elementu firstVisibleItemScrollOffset będzie się zmieniać. Jak już wiesz, Compose śledzi wszystkie odczyty stanu, aby móc ponownie uruchomić (ponownie wywołać) kod odczytu, który w tym przykładzie jest zawartością elementu Box.

To przykład odczytywania stanu w fazie kompozycji. Niekoniecznie jest to złe, a wręcz przeciwnie – jest to podstawa ponownej kompozycji, która umożliwia wyświetlanie nowego interfejsu po zmianie danych.

Ważne: ten przykład jest suboptymalny, ponieważ każde zdarzenie przewijania powoduje ponowną ocenę, pomiar, układ i wreszcie narysowanie całej zawartości funkcji typu „composable”. Faza Compose jest wywoływana przy każdym przewijaniu, mimo że wyświetlana treść się nie zmieniła, tylko jej pozycja. Możesz zoptymalizować odczyt stanu, aby ponownie wywoływać 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, w którym wynikowe przesunięcie jest zwracane przez blok lambda. Zaktualizuj kod, aby go używać:

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 w fazie układu (a konkretnie na etapie umieszczenia w fazie układu), co oznacza, że stan firstVisibleItemScrollOffset nie jest już odczytywany podczas kompozycji. Ponieważ Compose śledzi, kiedy stan jest odczytywany, ta zmiana oznacza, że jeśli zmieni się value elementu firstVisibleItemScrollOffset, Compose musi tylko ponownie uruchomić fazy układu i rysowania.

Oczywiście często jest absolutnie konieczne odczytywanie stanów w fazie kompozycji. Mimo to w niektórych przypadkach możesz zminimalizować liczbę ponownych kompozycji, filtrując zmiany stanu. Więcej informacji na ten temat znajdziesz w artykule zobacz derivedStateOf: przekształcanie jednego lub kilku obiektów stanu w inny stan.

Pętla ponownej kompozycji (cykliczna zależność faz)

W tym przewodniku wspomnieliśmy wcześniej, że fazy Compose są zawsze wywoływane w tej samej kolejności i że w tej samej klatce nie można się cofnąć. Nie uniemożliwia to jednak aplikacjom wchodzenia w pętle kompozycji w różnych klatkach. Rozważ 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() }
        )
    )
}

Ten przykład implementuje kolumnę pionową z obrazem u góry i tekstem pod nim. Używa funkcji Modifier.onSizeChanged() do uzyskania rozwiązanego rozmiaru obrazu, a następnie używa funkcji Modifier.padding() na tekście, aby go przesunąć w dół. Nienaturalna konwersja z Px z powrotem na Dp wskazuje już, że kod ma jakiś problem.

Problem z tym przykładem polega na tym, że kod nie osiąga „ostatecznego” układu w jednej klatce. Kod opiera się na wystąpieniu wielu klatek, co powoduje niepotrzebną pracę i sprawia, że interfejs przeskakuje na ekranie.

Kompozycja pierwszej klatki

W fazie kompozycji pierwszej klatki imageHeightPx ma początkowo wartość 0. W związku z tym kod udostępnia 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. Compose planuje następnie ponowną kompozycję na następną klatkę. Jednak w bieżącej fazie rysowania tekst jest renderowany z dopełnieniem 0, ponieważ zaktualizowana wartość imageHeightPx nie jest jeszcze uwzględniona.

Kompozycja drugiej klatki

Compose inicjuje drugą klatkę, wywołaną przez zmianę wartości imageHeightPx. W fazie kompozycji tej klatki stan jest odczytywany w bloku treści Box. Tekst ma teraz dopełnienie, które dokładnie odpowiada wysokości obrazu. W fazie układu imageHeightPx jest ponownie ustawiany, ale nie jest planowana żadna dalsza ponowna kompozycja, ponieważ wartość pozostaje spójna.

Diagram przedstawiający pętlę ponownego komponowania, w której zmiana rozmiaru w fazie układu wywołuje ponowne komponowanie, co z kolei powoduje ponowne ułożenie elementów.

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

  • Modifier.onSizeChanged(), onGloballyPositioned() lub inne operacje układu.
  • Aktualizowanie stanu.
  • Używanie tego stanu jako danych wejściowych do modyfikatora układu (padding(), height() lub podobnego).
  • Potencjalne powtórzenie.

Rozwiązaniem w przypadku poprzedniego przykładu jest użycie odpowiednich elementów pierwotnych układu. Poprzedni przykład można zaimplementować za pomocą funkcji 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 Niestandardowe układy.

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 pierwotnego układu lub utworzenie niestandardowego układu oznacza, że minimalny wspólny element nadrzędny służy jako źródło wiarygodnych danych, które mogą koordynować relacje między wieloma elementami. Wprowadzenie stanu dynamicznego narusza tę zasadę.