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 tworzenia jest bardzo podobna, ale na początku ma dodatkową fazę o nazwie kompozycja.

Dokumentacja Compose opisuje kompozycję w artykułach Myślenie w kategoriach ComposeStan i Jetpack Compose.

Trzy fazy ramki

Proces tworzenia składa się z 3 głównych etapów:

  1. Kompozycja: który interfejs użytkownika ma się wyświetlać. 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, na 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. Kompozycja pomija uruchamianie funkcji kompozycyjnej, 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 etapów

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 typu „composable” 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 utworzone w fazie kompozycji.

Fragment kodu i drzewa interfejsu wygląda tak:

Fragment kodu z 5 komponentami i wynikowe drzewo 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 kompozycyjna 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 interfejsu podczas fazy układu.

W fazie układu drzewo jest przeszukiwane za pomocą tego 3-etapowego algorytmu:

  1. Mierzenie dzieci: węzeł mierzy swoje dzieci, jeśli istnieją.
  2. Określanie własnego rozmiaru: na podstawie tych pomiarów węzeł określa własny rozmiar.
  3. Umieść elementy podrzędne: każdy węzeł podrzędny jest umieszczany względem własnej pozycji węzła.

Na koniec 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 komponentami i wynikowe drzewo 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. Row mierzy swoje elementy podrzędne, Image i Column.
  2. Mierzona jest wartość Image. Nie ma elementów podrzędnych, więc określa własny rozmiar i przesyła go do Row.
  3. Następnie mierzona jest Column. Najpierw mierzy własne elementy podrzędne (2 komponenty Text).
  4. Mierzona jest pierwsza Text. Nie ma elementów podrzędnych, więc określa własny rozmiar i przesyła go z powrotem do Column.
    1. Mierzona jest druga wartość Text. Nie ma żadnych elementów podrzędnych, więc określa własny rozmiar i przekazuje 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.

Rysowanie

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

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

Na podstawie poprzedniego przykładu zawartość drzewa jest rysowana w ten sposób:

  1. Element Row rysuje wszelkie treści, 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 interfejsu i jego reprezentacja graficzna.

Odczyty stanu

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

Stan tworzy się zwykle za pomocą mutableStateOf(), a następnie uzyskuje się do niego dostęp 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 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 przypadku delegata właściwości do uzyskiwania dostępu do value i aktualizowania go używane są funkcje „getter” i „setter”. 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, który 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 stan value się zmieni, kompozytor ponownie uruchomi wszystkie funkcje kompozycyjne, które odczytują ten stan 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 UI przeprowadza fazy układu i rysowania. Może pominąć te etapy, 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 podczas każdego z tych krokó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 położenie uległy zmianie, uruchamia też fazę rysowania.

odczytanie stanu w kroku dotyczącym umieszczenia może mieć wpływ 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. 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.

Rozważmy ten przykład. W tym przykładzie występuje Image(), które używa modyfikatora przesunięcia, aby przesunąć swoją ostateczną 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 suboptymalną wydajność. 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óry w tym przykładzie jest treścią funkcji Box.

To przykład odczytywania stanu w fazie kompozycji. Niekoniecznie jest to coś złego, a w zasadzie jest to podstawa ponownego komponowania, 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, rozmieszczenie i wreszcie 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, a zmieniła się 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 firstVisibleItemScrollOffset value 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 artykule derivedStateOf: przekształcanie jednego lub kilku obiektów stanu w inny stan.

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

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

Box {
    var imageHeightPx by remember { mutableStateOf(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 pod nim. Używa parametru Modifier.onSizeChanged(), aby uzyskać 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 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. Funkcja Compose, a następnie planuje ponowne komponowanie 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 odzwierciedlona.

Kompozycja drugiej klatki

Funkcja Compose inicjuje drugą klatkę, co jest wywoływane przez zmianę wartości imageHeightPx. W fazie kompozycji tej ramki stan jest odczytywany w Boxbloku treści. 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ę ponownego komponowania, w której zmiana rozmiaru w fazie układu wywołuje ponowne komponowanie, co z kolei 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 inne operacje układu.
  • 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 niestandardowego rozwiązania, co będzie wymagać 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 informacji, które może koordynować relacje między wieloma elementami. Wprowadzenie stanu dynamicznego narusza tę zasadę.