Fazy Jetpack Compose

Podobnie jak większość innych narzędzi interfejsu, funkcja Compose renderuje klatkę na kilku różnych fazach. W systemie Android View wyróżniamy 3 główne etapy: pomiar, układ i rysowanie. Tworzenie jest bardzo podobne, ale na początku znajduje się dodatkowy etap nazywany kompozycją.

Opis funkcji kompozycja jest opisany w naszych dokumentach dotyczących tworzenia wiadomości, m.in. w sekcjach Thinking in Compose oraz State i Jetpack Compose.

Trzy fazy ramki

Proces tworzenia wiadomości dzieli się na 3 główne etapy:

  1. Kompozycja: Co ma być widoczne w interfejsie. Tworzenie uruchamia funkcje kompozycyjne i tworzy opis interfejsu użytkownika.
  2. Układ: Gdzie umieścić interfejs. Ten etap składa się z 2 etapów: pomiaru i miejsca docelowego. Elementy układu mierzą i umieszczają siebie oraz wszystkie elementy podrzędne we współrzędnych 2D w każdym węźle drzewa układu.
  3. Rysunek: sposób renderowania. Elementy interfejsu wyświetlają się w Canvas, zwykle na ekranie urządzenia.
Obraz przedstawiający 3 fazy, w których funkcja tworzenia wiadomości przekształca dane w interfejs (kolejność, dane, kompozycja, układ, rysunek, interfejs).
Rysunek 1. 3 fazy, na których funkcja Utwórz przekształca dane w interfejs.

Kolejność tych faz jest zasadniczo taka sama, co umożliwia przepływ danych w jednym kierunku od kompozycji, układu do rysowania, co powoduje utworzenie ramki (nazywanej też jednokierunkowym przepływem danych). BoxWithConstraints, LazyColumn i LazyRow są ważnymi wyjątkami, w których kompozycja elementów podrzędnych zależy od fazy układu elementu nadrzędnego.

Możesz spokojnie założyć, że te 3 fazy mają miejsce wirtualnie w przypadku każdej klatki, ale ze względu na wydajność funkcja Compose unika powtarzania tych samych czynności, które powodowałyby obliczanie tych samych wyników na wszystkich tych samych danych wejściowych. Tworzenie pomija wykonywanie funkcji kompozycyjnej, jeśli może ona ponownie użyć poprzedniego wyniku, a interfejs tej funkcji nie ponownie tworzy ani nie rysuje całego drzewa, jeśli nie jest to konieczne. Tworzenie wymaga tylko minimalnej pracy wymaganej do aktualizacji interfejsu użytkownika. Ta optymalizacja jest możliwa, ponieważ funkcja tworzenia śledzi odczyty stanu na różnych etapach.

Informacje o fazach

W tej sekcji szczegółowo opisujemy, jak przebiegają 3 etapy tworzenia w przypadku elementów kompozycyjnych.

Kompozycja

Na etapie tworzenia środowisko wykonawcze wykonuje funkcje kompozycyjne i generuje strukturę drzewa reprezentującą interfejs użytkownika. To drzewo interfejsu składa się z węzłów układu, które zawierają wszystkie informacje potrzebne w kolejnych fazach, jak widać na tym filmie:

Rysunek 2. Drzewo reprezentujące interfejs użytkownika utworzony na etapie kompozycji.

Sekcja kodu i drzewa interfejsu wygląda tak:

Fragment kodu z 5 komponentami i powstałym drzewem interfejsu użytkownika z węzłami podrzędnymi rozgałęzionymi 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 mapowana na pojedynczy węzeł układu w drzewie interfejsu. W bardziej złożonych przykładach funkcje kompozycyjne mogą obejmować przepływ logiczny i kontrolny oraz tworzyć inne drzewo o różnych stanach.

Układ

Na etapie układu element Compose wykorzystuje jako dane wejściowe drzewo interfejsu utworzone na etapie kompozycji. Zbiór 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 rozmieszczenie każdego węzła układu w drzewie interfejsu na etapie układu.

Na etapie układu drzewo jest przemierzane za pomocą tego algorytmu:

  1. Pomiar elementów podrzędnych: węzeł mierzy swoje elementy podrzędne, jeśli istnieją.
  2. Ustal własny rozmiar: 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łasnego węzła.

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

  • przypisane wartości width i height,
  • współrzędna x, y, gdzie należy ją narysować;

Wywołaj drzewo interfejsu z poprzedniej sekcji:

Fragment kodu z 5 komponentami i powstałym drzewem interfejsu użytkownika z węzłami podrzędnymi rozgałęzionymi od węzłów nadrzędnych

W przypadku tego drzewa algorytm działa w następujący sposób:

  1. Row mierzy swoje elementy podrzędne – Image i Column.
  2. Image jest mierzony. Nie ma żadnych elementów podrzędnych, więc określa swój rozmiar i zgłasza rozmiar do Row.
  3. Następnie jest mierzony Column. Najpierw mierzy własne elementy podrzędne (2 elementy kompozycyjne Text).
  4. Jest mierzony pierwszy Text. Nie ma żadnych elementów podrzędnych, więc określa swój rozmiar i zgłasza swój rozmiar do Column.
    1. Drugi Text jest mierzony. Nie ma żadnych dzieci, więc decyduje o swoim rozmiarze i przesyła zgłoszenie do: Column.
  5. Element Column korzysta z pomiarów podrzędnych, aby określić swój rozmiar. Wykorzystuje maksymalną szerokość dziecka i sumę wysokości jego elementów podrzędnych.
  6. Element Column umieszcza swoje elementy podrzędne względem siebie, co oznacza, że znajdują się one nad innymi w pionie.
  7. Element Row korzysta z pomiarów podrzędnych, aby określić swój rozmiar. Uwzględnia maksymalną wysokość dziecka i sumę szerokości jego elementów podrzędnych. Następnie umieszcza swoje dzieci.

Pamiętaj, ż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 wzrasta, czas poświęcony na przemierzanie tego drzewa rośnie liniowo. Jeśli natomiast każdy węzeł był odwiedzany kilka razy, czas przemierzania rośnie wykładniczo.

Rysunek

Na etapie rysowania drzewo jest ponownie przesuwane z góry na dół, 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 następujący sposób:

  1. Row rysuje dowolne treści, takie jak kolor tła.
  2. Image się rysuje.
  3. Column się rysuje.
  4. Pierwsza i druga Text rysują się odpowiednio.

Rysunek 6. Drzewo interfejsu użytkownika i jego narysowana reprezentacja.

Odczyty stanu

Jeśli podczas jednego z wymienionych wyżej etapów przeczytasz wartość stanu zrzutu, funkcja Utwórz automatycznie śledzi jej działania w momencie odczytania danej wartości. To śledzenie umożliwia polecenie Compose ponowne wykonywanie kodu w przypadku zmiany wartości stanu. Jest to podstawa dostrzegalności stanu w interfejsie tworzenia wiadomości.

Stan jest zwykle tworzony za pomocą mutableStateOf(). Dostęp do niego można uzyskać na jeden z 2 sposobów: przez bezpośredni dostęp do usługi value lub za pomocą delegata usługi Kotlin. Więcej informacji na ten temat znajdziesz w sekcji Stan w obiektach kompozycyjnych. Na potrzeby tego przewodnika „odczyt stanu” odnosi się do jednej z 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)
)

Do uzyskiwania dostępu do dokumentu value stanowego i aktualizowania go są używane funkcje „getter” i „setter”. Funkcje getter i setter są wywoływane tylko wtedy, gdy odwołasz się do właściwości jako wartości, a nie w momencie jej utworzenia, dlatego te 2 sposoby są równoważne.

Każdy blok kodu, który można wykonać ponownie po zmianie stanu odczytu, jest zakresem ponownego uruchamiania. Funkcja tworzenia umożliwia śledzenie zmian wartości stanu i zakresów ponownego uruchamiania na różnych etapach.

Odczyty stanu pośredniego

Jak wspomnieliśmy wcześniej, w tworzeniu wiadomości dzieli się 3 główne fazy, a w tej sekcji można śledzić stan odczytywany w każdej z nich. Dzięki temu funkcja tworzenia wiadomości powiadamia tylko te etapy, które muszą działać w przypadku każdego z tych elementów interfejsu.

Przeanalizujmy każdy etap i opiszmy, co się dzieje, gdy odczytuje się w nim wartość stanu.

Faza 1. Kompozycja

Odczyty stanu w funkcji @Composable lub bloku lambda wpływają na kompozycję i potencjalnie na kolejne fazy. Gdy wartość stanu ulegnie zmianie, narzędzie planuje ponowne uruchamianie wszystkich funkcji kompozycyjnych, które odczytują tę wartość stanu. Pamiętaj, że jeśli dane wejściowe się nie zmienią, środowisko wykonawcze może pominąć niektóre lub wszystkie funkcje kompozycyjne. Więcej informacji znajdziesz w sekcji Pomijanie danych, jeśli dane wejściowe się nie zmieniły.

W zależności od efektu kompozycji interfejs tworzenia uruchamia etapy układu i rysowania. Może pominąć te fazy, jeśli treść pozostaje taka sama, a rozmiar i układ się nie zmieniają.

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: pomiaru i miejsca docelowego. Ten krok pomiarowy uruchamia wskazówkę lambda przekazaną do funkcji kompozycyjnej Layout, metodę MeasureScope.measure w interfejsie LayoutModifier itd. Etap miejsca docelowego uruchamia blok miejsc docelowych funkcji layout, blok lambda obiektu Modifier.offset { … } itd.

Odczyty stanu podczas każdego z tych kroków mają wpływ na układ i ewentualnie etap rysowania. Gdy wartość stanu ulegnie zmianie, UI tworzenia planuje etap układu. Wykonuje również fazę rysowania, jeśli zmieni się jej rozmiar lub położenie.

Ściślej rzecz ujmując, etap pomiaru i miejsce docelowe mają osobne zakresy ponownego uruchomienia, co oznacza, że odczyt stanu w kroku dotyczącym miejsca docelowego nie spowoduje ponownego wywołania wcześniejszego kroku pomiaru. Te 2 etapy są jednak często ze sobą powiązane, więc stan odczytywany na etapie umieszczania może wpływać na inne zakresy ponownego uruchamiania należące do etapu 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)
    }
)

Etap 3. Rysowanie

Odczyty stanu podczas rysowania mają wpływ na fazę rysowania. Typowe przykłady to Canvas(), Modifier.drawBehind i Modifier.drawWithContent. Gdy wartość stanu ulegnie zmianie, UI tworzenia uruchamia tylko etap 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)
}

Optymalizuję odczyty stanu

Ponieważ funkcja Compose przeprowadza lokalne śledzenie odczytu stanu, możemy zminimalizować ilość pracy wykonywanej przez czytanie każdego stanu w odpowiedniej fazie.

Przeanalizujmy przykład. Tutaj mamy element Image(), który korzysta z modyfikatora przesunięcia, aby przesunąć ostateczne położenie układu, uzyskując 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 nie zapewnia optymalnej wydajności. W takiej postaci kod odczytuje wartość stanu firstVisibleItemScrollOffset i przekazuje ją do funkcji Modifier.offset(offset: Dp). Gdy użytkownik przewinie, wartość firstVisibleItemScrollOffset będzie się zmieniać. Jak wiemy, funkcja Compose śledzi odczyty stanu, aby ponownie uruchomić (ponownie wywołać) kod odczytu, który w naszym przykładzie jest zawartością elementu Box.

Oto przykład stanu odczytywanego na etapie kompozycji. Nie musi to być wcale zła sytuacja. W rzeczywistości jest to podstawa zmiany kompozycji, która umożliwia generowanie nowych elementów interfejsu przez zmiany danych.

W tym przykładzie jest to jednak nieoptymalne, ponieważ każde zdarzenie przewijania spowoduje ponowne sprawdzenie całej treści kompozycyjnej, a następnie jej pomiar, dodanie i wyciąganie. Etap tworzenia rozpoczynamy przy każdym przewijaniu, mimo że to, co pokazujemy, nie uległo zmianie, tylko gdzie jest widoczne. Możemy zoptymalizować odczyt stanu tak, aby ponownie wywoływać fazę układu.

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

Ta wersja przyjmuje parametr lambda, którego wynikowe przesunięcie jest zwracane przez blok lambda. Zaktualizujmy nasz kod, aby z niego korzystać:

Box {
    val listState = rememberLazyListState()

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

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

Dlaczego ten model jest bardziej wydajny? Blok lambda dostarczany modyfikatorowi jest wywoływany w fazie układu (zwłaszcza na etapie umieszczania na etapie układu), co oznacza, że stan firstVisibleItemScrollOffset nie jest już odczytywany na etapie kompozycji. Funkcja Utwórz śledzi tylko stan odczytywany, więc jeśli zmieni się wartość firstVisibleItemScrollOffset, tworzenie musi tylko ponownie uruchomić układ i fazy rysowania.

W tym przykładzie do optymalizacji wynikowego kodu wykorzystywane są różne modyfikatory przesunięcia, ale ogólna zasada jest taka: spróbuj zlokalizować odczyty stanu do najniższej możliwej fazy, co pozwoli tworzyć kompozycję w minimalnym stopniu.

Oczywiście często absolutnie konieczne jest odczytanie stanów w fazie kompozycji. Mimo to w niektórych przypadkach możemy zminimalizować liczbę zmian kompozycji, filtrując zmiany stanu. Więcej informacji znajdziesz w sekcji derivedStateOf: konwertowania jednego lub wielu obiektów stanu na inny stan.

Pętla rekompozycji (zależność fazowa)

Wcześniej wspomnieliśmy, że fazy tworzenia są zawsze wywoływane w tej samej kolejności i nie można przejść wstecz w tej samej ramce. Nie oznacza to jednak, że aplikacje mogą tworzyć 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 przypadku zaimplementowaliśmy (błędnie) pionową kolumnę, w której obraz jest u góry, a poniżej tekst. Używamy Modifier.onSizeChanged(), aby poznać ustalony rozmiar obrazu, a następnie przesunięcie go w dół za pomocą funkcji Modifier.padding(). Nienaturalna konwersja z Px na Dp już wskazuje, że w kodzie występuje jakiś problem.

Problem w tym przykładzie polega na tym, że nie dochodzimy do „ostatecznego” układu w pojedynczej klatce. Kod bazuje na wielu klatkach, co wykonuje niepotrzebną pracę, przez co interfejs użytkownika przeskakuje na ekranie.

Przyjrzyjmy się poszczególnym ramkom, by sprawdzić, co się dzieje:

Na etapie kompozycji pierwszej klatki imageHeightPx ma wartość 0, a tekst jest dostarczany za pomocą funkcji Modifier.padding(top = 0). Następnie następuje faza układu i jest wywoływane wywołanie zwrotne modyfikatora onSizeChanged. Pole imageHeightPx zostanie zaktualizowane do rzeczywistej wysokości obrazu. Tworzenie harmonogramu zmiany kompozycji do następnej klatki. Na etapie rysowania tekst jest renderowany z dopełnieniem wynoszącym 0, ponieważ zmiana wartości nie została jeszcze odzwierciedlona.

Tworzenie rozpoczyna wtedy drugą klatkę zaplanowaną przez zmianę wartości imageHeightPx. Stan jest odczytywany w bloku treści Box i jest wywoływany w fazie kompozycji. Tym razem tekst jest uzupełniany o dopełnienie pasujące do wysokości obrazu. Na etapie układu kod ponownie ustawia wartość imageHeightPx, ale nie jest planowana rekompozycja, ponieważ wartość pozostaje taka sama.

Ostatecznie otrzymujemy wymagane dopełnienie w tekście, ale nie jest optymalne wydawanie dodatkowej klatki, by przekazać wartość dopełnienia z powrotem do innej fazy. Spowodowałoby to utworzenie ramki z nakładającą się treścią.

Ten przykład może się wydawać przesadzony, ale należy zachować ostrożność w odniesieniu do tego ogólnego wzorca:

  • Modifier.onSizeChanged(), onGloballyPositioned() lub inne operacje układu
  • Zaktualizuj stan
  • Wykorzystaj ten stan jako dane wejściowe dla modyfikatora układu (padding(), height() lub podobne)
  • Może się powtarzać

Rozwiązaniem problemu w powyższym przykładzie jest użycie odpowiednich podstawowych elementów układu. Powyższy przykład można wdrożyć za pomocą prostego elementu Column(), ale możesz mieć bardziej złożony przykład, który wymaga czegoś niestandardowego, co wymaga napisania niestandardowego układu. Więcej informacji znajdziesz w przewodniku po układach niestandardowych.

Ogólnie obowiązuje tu jedno źródło wiarygodnych danych dla wielu elementów interfejsu, które należy mierzyć i rozmieszczać względem siebie. Użycie odpowiedniego podstawowego podstawowego układu lub utworzenie układu niestandardowego oznacza, że minimalny wspólny element nadrzędny służy jako źródło danych, które może koordynować relacje między wieloma elementami. Wprowadzenie dynamicznego stanu łamuje tę zasadę.