Fazy Jetpack Compose

Podobnie jak większość innych zestawów narzędzi interfejsu, 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. Funkcja Compose jest bardzo podobna, ale na początku ma ważną dodatkową fazę o nazwie kompozycja.

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

Trzy fazy ramki

Tworzenie obejmuje 3 główne etapy:

  1. Kompozycja: który interfejs użytkownika ma być wyświetlany. Compose uruchamia funkcje typu „composable” i tworzy opis interfejsu.
  2. Układ: gdzie umieścić interfejs. Ten etap składa się z 2 krokó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: sposób renderowania. Elementy interfejsu są rysowane na obiekcie Canvas, zwykle na ekranie 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, na 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, aby utworzyć klatkę (znaną też jako jednokierunkowy przepływ danych). BoxWithConstraints, LazyColumn i LazyRow to ważne wyjątki, w których skład elementów podrzędnych zależy od fazy układu elementu nadrzędnego.

Każda z tych faz występuje w przypadku każdej klatki, ale aby zoptymalizować wydajność, Compose unika powtarzania działań, które w każdej z tych faz dałyby te same wyniki na podstawie tych samych danych wejściowych. Funkcja Compose pomija uruchomienie funkcji typu „composable”, jeśli może ponownie wykorzystać poprzedni wynik, a interfejs Compose UI nie zmienia układu ani nie rysuje ponownie całego drzewa, jeśli nie musi tego robić. Compose wykonuje tylko minimalną ilość pracy potrzebną 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 fazy Compose w przypadku funkcji kompozycyjnych.

Kompozycja

W fazie kompozycji środowisko wykonawcze Compose wykonuje funkcje kompozycyjne i generuje strukturę drzewa reprezentującą interfejs. Drzewo interfejsu składa się z węzłów układu, które zawierają wszystkie informacje potrzebne w kolejnych fazach, jak pokazano na tym filmie:

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

Fragment kodu i drzewa interfejsu 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 interfejsu z odpowiednim kodem.

W tych przykładach każda funkcja typu „composable” w kodzie jest powiązana z jednym węzłem układu w drzewie interfejsu. W bardziej złożonych przykładach funkcje 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 interfejsu wygenerowanego w fazie kompozycji jako danych wejściowych. Kolekcja 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 podczas fazy 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 własny rozmiar.
  3. Umieszczanie elementów podrzędnych: każdy węzeł podrzędny jest umieszczany względem własnej pozycji węzła.

Po zakończeniu tego etapu każdy węzeł układu ma:

  • przypisana szerokośćwysokość;
  • Współrzędne x, y miejsca, w którym ma być narysowany

Przypomnij sobie drzewo interfejsu 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 w ten sposób:

  1. Węzeł Row mierzy swoje węzły podrzędne ImageColumn.
  2. Mierzona jest wartość Image. Nie ma żadnych elementów podrzędnych, więc określa własny rozmiar i przekazuje go z powrotem do Row.
  3. Następnie mierzony jest Column. Najpierw mierzy swoje elementy podrzędne (2 komponenty Text).
  4. Mierzona jest pierwsza wartość Text. Nie ma elementów podrzędnych, więc określa własny rozmiar i przekazuje go do Column.
    1. Mierzona jest druga wartość Text. Nie ma żadnych elementów podrzędnych, więc określa własny rozmiar i przesyła go do Column.
  5. Column na podstawie pomiarów dziecka określa swój rozmiar. Wykorzystuje maksymalną szerokość elementu podrzędnego i sumę wysokości elementów podrzędnych.
  6. Element Column umieszcza elementy podrzędne względem siebie, jeden pod drugim w pionie.
  7. Row na podstawie pomiarów dziecka określa swój rozmiar. Wykorzystuje maksymalną wysokość elementu podrzędnego i sumę 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 zwiększa wydajność. Gdy liczba węzłów w drzewie rośnie, czas potrzebny na jego przejście zwiększa się liniowo. Jeśli jednak każdy węzeł był odwiedzany wielokrotnie, czas przejścia rośnie wykładniczo.

Rysunek

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

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

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

  1. Element Row rysuje wszystkie elementy, które może zawierać, np. kolor tła.
  2. Image rysuje się samo.
  3. Column rysuje się samo.
  4. Pierwszy i drugi znak Text rysują się odpowiednio.

Rysunek 6. Drzewo UI i jego narysowana reprezentacja.

Odczyty stanu

Gdy podczas jednej z wymienionych wcześniej faz odczytasz valuesnapshot state, Compose automatycznie śledzi, co robił w momencie odczytania value. To śledzenie umożliwia ponowne wykonanie funkcji odczytu, gdy zmieni się stan value, i jest podstawą obserwacji stanu w Compose.

Stan tworzy się zwykle za pomocą mutableStateOf(), a dostęp do niego uzyskuje się na 2 sposoby: bezpośrednio przez właściwość value lub za pomocą delegata właściwości Kotlin. Więcej informacji o nich znajdziesz w artykule Stan w komponentach 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 przypadku delegata właściwości funkcje „getter” i „setter” służą 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 wtedy, gdy jest ona tworzona. Dlatego opisane wcześniej 2 sposoby są równoważne.

Każdy blok kodu, który można ponownie wykonać, gdy zmieni się stan 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 te fazy, które muszą wykonać pracę dla każdego elementu interfejsu.

W sekcjach poniżej opisujemy poszczególne fazy i wyjaśniamy, co się dzieje, gdy w ich trakcie 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, kompozytor ponownie uruchamia wszystkie funkcje kompozycyjne, które odczytują value. Pamiętaj, że środowisko wykonawcze może pominąć niektóre lub wszystkie funkcje kompozycyjne, jeśli dane wejściowe nie uległy zmianie. Więcej informacji znajdziesz w sekcji Pomijanie, jeśli dane wejściowe nie uległy zmianie.

W zależności od wyniku kompozycji interfejs Compose przeprowadza fazy układu i rysowania. Może pominąć te fazy, jeśli treść pozostanie bez zmian, 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

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

Odczytywanie stanu na każdym z tych etapów wpływa na układ i potencjalnie na fazę rysowania. Gdy stan value się zmieni, interfejs Compose UI zaplanuje fazę układu. Jeśli rozmiar lub pozycja uległy zmianie, uruchamia też fazę rysowania.

stan odczytany w kroku dotyczącym miejsca docelowego może wpływać na inne zakresy ponownego uruchomienia należące do kroku pomiaru.

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 podczas rysowania mają wpływ na fazę rysowania. Typowe przykłady to Canvas(), Modifier.drawBehindModifier.drawWithContent. Gdy stan value się zmieni, interfejs Compose wykona 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 odczyt stanu podczas fazy rysowania powoduje tylko ponowne uruchomienie tej fazy.

Optymalizacja odczytów stanu

Kompozycja śledzi odczyty lokalnego stanu, więc możesz zminimalizować ilość pracy wykonywanej przez odczytywanie każdego stanu w odpowiedniej fazie.

Przyjrzyj się temu przykładowi. W tym przykładzie występuje element Image(), który używa modyfikatora przesunięcia, aby przesunąć swoją końcową pozycję 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 gorszą skuteczność. Zgodnie z zapisem kod odczytuje value stanu firstVisibleItemScrollOffset i przekazuje go do funkcji Modifier.offset(offset: Dp). Podczas przewijania przez użytkownika firstVisibleItemScrollOffsetvalue będzie się zmieniać. Jak już wiesz, Compose śledzi wszystkie odczyty stanu, aby móc ponownie uruchomić (ponownie wywołać) kod odczytu, którym w tym przykładzie jest zawartość funkcji Box.

To przykład odczytywania stanu w fazie kompozycji. Niekoniecznie jest to coś złego, a w zasadzie jest to podstawa rekompozycji, która umożliwia emitowanie nowego interfejsu w wyniku zmian danych.

Kluczowa kwestia: ten przykład jest nieoptymalny, ponieważ każde zdarzenie przewijania powoduje ponowną ocenę, pomiar, rozmieszczenie i w końcu narysowanie całej treści kompozycyjnej. Faza tworzenia jest wywoływana przy każdym przewijaniu, nawet jeśli wyświetlana treść nie uległa zmianie, tylko jej pozycja. Możesz zoptymalizować odczyt stanu, aby ponownie wywołać tylko fazę układu.

Przesunięcie za pomocą funkcji lambda

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. Aby użyć kodu, zaktualizuj go:

Box {
    val listState = rememberLazyListState()

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

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

Dlaczego ta metoda jest bardziej wydajna? Blok lambda przekazywany do modyfikatora jest wywoływany podczas fazy układu (a konkretnie podczas kroku umieszczania w tej fazie), co oznacza, że stan firstVisibleItemScrollOffset nie jest już odczytywany podczas kompozycji. Ponieważ Compose śledzi, kiedy stan jest odczytywany, ta zmiana oznacza, że jeśli value firstVisibleItemScrollOffset ulegnie zmianie, Compose musi tylko ponownie uruchomić fazy układu i rysowania.

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

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

W tym przewodniku wspominaliśmy wcześniej, że fazy funkcji Compose są zawsze wywoływane w tej samej kolejności i nie można się cofnąć w ramach tej samej klatki. 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 kolumnę pionową, w której obraz znajduje się u góry, a tekst poniżej. Używa parametru Modifier.onSizeChanged(), aby uzyskać ostateczny rozmiar obrazu, a następnie parametru Modifier.padding() na tekście, aby przesunąć go 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 dociera do „końcowego” układu w ramach jednej klatki. Kod opiera się na wielu klatkach, co powoduje niepotrzebną pracę i sprawia, że interfejs użytkownika przeskakuje na ekranie.

Kompozycja pierwszej klatki

Podczas fazy kompozycji pierwszej klatki wartość imageHeightPx jest początkowo równa 0. W efekcie kod zapewnia tekst z Modifier.padding(top = 0). W kolejnej fazie układu wywoływane jest wywołanie zwrotne modyfikatora onSizeChanged, które aktualizuje wartość imageHeightPx do rzeczywistej wysokości obrazu. Komponuje, a następnie planuje rekompozycję na potrzeby następnej klatki. Jednak w bieżącej fazie rysowania tekst jest renderowany z wypełnieniem 0, ponieważ zaktualizowana wartość imageHeightPx nie jest jeszcze uwzględniona.

Kompozycja drugiej klatki

Funkcja Compose inicjuje drugą klatkę, która jest wywoływana przez zmianę wartości imageHeightPx. W fazie kompozycji tej ramki stan jest odczytywany w bloku treści Box. Tekst jest teraz uzupełniony o odstępy, które dokładnie odpowiadają wysokości obrazu. Podczas fazy układu wartość imageHeightPx jest ponownie ustawiana, 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ę, która następnie powoduje ponowne ułożenie.

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

  • Modifier.onSizeChanged(), onGloballyPositioned() lub inny układ operacji
  • Aktualizowanie stanu
  • Użyj tego stanu jako danych wejściowych modyfikatora układu (padding(), height() lub podobnego).
  • Potencjalnie powtarzające się

Rozwiązaniem w przypadku powyższego przykładu jest użycie odpowiednich elementów układu. Powyższy przykład można zaimplementować za pomocą elementu 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 Układy niestandardowe.

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 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 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 Zapisywanie wsteczne w Compose.