Podobnie jak większość innych pakietów narzędzi do tworzenia interfejsu użytkownika, Compose renderuje ramkę w kilku fazach. System Android View składa się z 3 głównych faz: pomiaru, układu i rysowania. Jest on bardzo podobny, ale na początku ma dodatkowy ważny etap o nazwie kompozycja.
Temat kompozycji jest opisany w dokumentacji Compose, w tym w artykułach Myślenie w Compose i Stan i Jetpack Compose.
3 fazy kadru
Proces tworzenia składa się z 3 głównych faz:
- Kompozycja: co wyświetlić w interfejsie. Compose uruchamia funkcje typu „composable” i tworzy opis interfejsu użytkownika.
- Układ: miejsce, w którym umieścić interfejs. Ta faza składa się z 2 etapów: pomiar i rozmieszczenie. Elementy układu dokonują pomiarów i umieszczają siebie oraz wszystkie elementy podrzędne w układzie 2D w przypadku każdego węzła w drzewie układu.
- Rysunek: sposób wyświetlania. Elementy interfejsu są rysowane na płótnie, zwykle na ekranie urządzenia.
Kolejność tych faz jest zazwyczaj taka sama, co umożliwia przepływ danych w jednym kierunku – od kompozycji do układu, a na końcu do rysunku, aby wygenerować ramkę (znany też jako jednokierunkowy przepływ danych).
BoxWithConstraints
i LazyColumn
oraz LazyRow
to ważne wyjątki, w których przypadku 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żdego klatki, ale aby zoptymalizować wydajność, kompozytor unika powtarzania działań, które obliczają te same wyniki z tych samych danych wejściowych we wszystkich tych fazach. Compose omija wykonywanie funkcji kompozytowej, jeśli może 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 użytkownika. Ta optymalizacja jest możliwa, ponieważ Compose śledzi odczyty stanu w różnych fazach.
Omówienie faz
Z tej sekcji dowiesz się więcej o tym, jak w przypadku komponentów są wykonywane 3 fazy usługi Compose.
Kompozycja
W fazie kompilacji środowisko wykonawcze Compose wykonuje funkcje typu „composable” i wyprowadza strukturę drzewiastą, która reprezentuje interfejs użytkownika. To drzewo interfejsu użytkownika składa się z węzłów układu, które zawierają wszystkie informacje potrzebne w kolejnych fazach, jak pokazano w tym filmie:
Rysunek 2. Drzewo reprezentujące interfejs użytkownika, które jest tworzone w fazie tworzenia kompozycji.
Podzbiór drzewa kodu i interfejsu użytkownika wygląda tak:
W tych przykładach każda funkcja składana w kodzie jest mapowana na pojedynczy węzeł układu w drzewie interfejsu. W bardziej złożonych przykładach komponenty mogą zawierać logikę i przepływ sterowania oraz generować różne drzewa w zależności od różnych stanów.
Układ
W etapu układu Compose używa jako danych wejściowych drzewa interfejsu użytkownika utworzonego w etapu tworzenia. 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 rozmieszczenie każdego węzła układu w drzewie interfejsu użytkownika na etapie układu;
W fazie układu drzewo jest przeszukiwane za pomocą tego algorytmu o 3 krokach:
- Pomiar podrzędnych: węzeł mierzy swoje podrzędne, jeśli takie istnieją.
- Decide own size (określ własny rozmiar): na podstawie tych pomiarów węzeł określa swój rozmiar.
- Umieszczanie podrzędnych węzłów: każdy podrzędny węzeł jest umieszczany względem pozycji węzła.
Na końcu tej fazy każdy węzeł układu ma:
- Przypisana szerokość i wysokość.
- współrzędne x i y, w których ma być wyświetlony element
Przypomnij sobie drzewo interfejsu z poprzedniej sekcji:
W przypadku tego drzewa algorytm działa w ten sposób:
Row
mierzy dzieci,Image
iColumn
.- Wartość
Image
jest mierzona. Nie ma żadnych elementów podrzędnych, więc określa swoją wielkość i przekazuje ją doRow
. - Następnie mierzona jest
Column
. Najpierw mierzy swoje elementy podrzędne (2 komponentyText
). - Pomiar pierwszego
Text
. Nie ma żadnych podrzędnych węzłów, więc określa swoją wielkość i przekazuje ją węzłowiColumn
.- Drugi pomiar
Text
. Nie ma żadnych elementów podrzędnych, więc określa swoją wielkość i przekazuje ją doColumn
.
- Drugi pomiar
Column
używa wymiarów podrzędnego obiektu, aby określić swój rozmiar. Używa ona maksymalnej szerokości elementu podrzędnego i sumy wysokości jego elementów.- Element
Column
umieszcza swoje elementy podrzędne względem siebie, umieszczając je jeden pod drugim w pionie. Row
używa wymiarów podrzędnego obiektu, aby określić swój rozmiar. Używa ona maksymalnej wysokości podrzędnego i sumy szerokości jego podrzędnych. Następnie umieszcza je w ich komórkach.
Pamiętaj, że każdy węzeł został odwiedzony tylko raz. Runtime usługi Compose wymaga tylko jednego przejścia przez drzewo interfejsu użytkownika w celu zmierzenia i rozmieszczenia wszystkich węzłów, co poprawia wydajność. Gdy rośnie liczba węzłów w drzewie, czas potrzebny na jego przeszukiwanie wzrasta w sposób liniowy. Jeśli natomiast każdy węzeł był odwiedzany wielokrotnie, czas przeszukiwania wzrasta wykładniczo.
Rysunek
W fazie rysowania drzewo jest ponownie przemierzane od góry do dołu, a każdy węzeł jest kolejno rysowany na ekranie.
Rysunek 5. W fazie rysowania piksele są wyświetlane na ekranie.
W przypadku poprzedniego przykładu treść drzewa jest wyświetlana w ten sposób:
- Element
Row
wyświetla wszystkie treści, które może zawierać, np. kolor tła. Image
sam się rysuje.Column
sam się rysuje.- Pierwszy i drugi
Text
rysują odpowiednio siebie.
Rysunek 6. Drzewo interfejsu użytkownika i jego graficzne przedstawienie.
Stan odczytu
Gdy odczytasz wartość stanu zrzutu w jednym z wymienionych powyżej etapów, Compose automatycznie rejestruje, co robiło, gdy odczytano wartość. To śledzenie umożliwia elementowi kompozycyjnemu ponowne wykonanie czytnika po zmianie wartości stanu. Jest to podstawa obserwowalności stanu w komponencie.
Stan jest zwykle tworzony za pomocą funkcji mutableStateOf()
, a dostęp do niego uzyskuje się na 1 z 2 sposobów: przez bezpośredni dostęp do właściwości value
lub za pomocą delegata właściwości w Kotlinie. Więcej informacji na ich temat znajdziesz w artykule Stan w komponowanych komponentach. W tym przewodniku „odczyt stanu” oznacza jedną 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 ramach delegowania właściwości funkcje „getter” i „setter” służą do uzyskiwania dostępu do stanu value
i jego aktualizowania. Te funkcje getter i setter są wywoływane tylko wtedy, gdy odwołujesz się do właściwości jako wartości, a nie podczas jej tworzenia. Dlatego te 2 metody są równoważne.
Każdy blok kodu, który może zostać ponownie wykonany po zmianie stanu odczytu, jest zakresem restartu. Compose śledzi zmiany wartości stanu i ponowne uruchamianie zakresów w różnych fazach.
Czytanie stanu w ramach faz
Jak już wspomnieliśmy, w Compose występują 3 główne fazy, a Compose śledzi, w jakim stanie jest czytanie w każdej z nich. Dzięki temu Compose może wysyłać powiadomienia tylko do konkretnych faz, które muszą wykonać pracę związaną z każdym z dotkniętych elementów interfejsu użytkownika.
Przeanalizujmy po kolei każdą fazę i spójrzmy, co się dzieje, gdy w jej ramach odczytana jest wartość stanu.
Etap 1. Skład
Czytania stanu w funkcji @Composable
lub bloku lambda wpływają na kompozycję i potencjalnie na kolejne fazy. Gdy wartość stanu ulegnie zmianie, rekompilator zaplanować ponowne wykonanie wszystkich funkcji składanych, które odczytują tę wartość stanu. Pamiętaj, że środowisko uruchomieniowe może pominąć niektóre lub wszystkie funkcje kompozytowe, 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 wykonuje fazy układu i rysowania. Może pominąć te etapy, 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
Faza układu składa się z 2 etapów: pomiaru i rozmieszczenia. Krok pomiaru uruchamia funkcję lambda pomiaru przekazaną do komponentu Layout
, metody MeasureScope.measure
interfejsu LayoutModifier
itd.
Etap umieszczania uruchamia blok umieszczania funkcji layout
, blok lambda funkcji Modifier.offset { … }
itd.
Czytanie stanu na każdym z tych kroków wpływa na układ i ewentualnie na fazę rysowania. Gdy wartość stanu się zmieni, interfejs tworzenia zaplanuje fazę układu. Jeśli rozmiar lub położenie się zmieniły, przeprowadza też fazę rysowania.
Aby być bardziej precyzyjnym, należy pamiętać, że krok pomiaru i krok umieszczenia mają oddzielne zakresy ponownego uruchamiania, co oznacza, że odczyty stanu w kroku umieszczenia nie wywołują ponownie kroku pomiaru. Te 2 kroki są jednak często powiązane, więc stan odczytywany na etapie miejsca docelowego może wpływać na inne zakresy ponownego uruchamiania, które należą 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
Stany odczytywane podczas wykonywania kodu rysunku wpływają na fazę rysowania. Typowe przykłady to Canvas()
, Modifier.drawBehind
i Modifier.drawWithContent
. Gdy wartość stanu ulegnie zmianie, 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) }
Optymalizacja odczytu stanu
Ponieważ Compose wykonuje śledzenie odczytu stanu w ramach lokalizacji, możemy zminimalizować ilość pracy wykonywanej przez odczyt każdego stanu w odpowiedniej fazie.
Spójrzmy na przykład. Tutaj mamy element Image()
, który używa modyfikatora offsetu do przesunięcia swojej 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 nie zapewnia optymalnej wydajności. W obecnej postaci kod odczytuje wartość stanu firstVisibleItemScrollOffset
i przekaże ją do funkcji Modifier.offset(offset: Dp)
. Gdy użytkownik przewija stronę, wartość firstVisibleItemScrollOffset
się zmienia. Jak wiemy, Compose śledzi wszystkie odczyty stanu, aby móc ponownie uruchomić (ponownie wywołać) kod odczytu, który w naszym przykładzie jest zawartością funkcji Box
.
To przykład stanu odczytywanego w fazie kompozycji. Nie jest to wcale złe, a w istocie jest to podstawa rekompozycji, która umożliwia zmianom danych emitowanie nowego interfejsu użytkownika.
W tym przykładzie jest to jednak nieoptymalne rozwiązanie, ponieważ każde zdarzenie przewijania spowoduje ponowną ocenę całego treściwego komponentu, a następnie jego zmierzenie, rozmieszczenie i na koniec wyświetlenie. Fazę tworzenia uruchamiamy przy każdym przewinięciu, mimo że co wyświetlamy, nie zmieniło się, tylko gdzie się wyświetla. Możemy zoptymalizować odczyt stanu, aby ponownie wywołać tylko fazę układania.
Dostępna jest też inna wersja modyfikatora przesunięcia: Modifier.offset(offset: Density.() -> IntOffset)
.
Ta wersja przyjmuje parametr lambda, w którym zwracany jest przesunięcie zwracane przez blok lambda. Zaktualizujmy 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 wydajniejsze? Blok lambda, który udostępniamy modyfikatorowi, jest wywoływany w fazie układania (szczególnie na etapie umieszczania w ramach tej fazy), co oznacza, że stan firstVisibleItemScrollOffset
nie jest już odczytywany podczas tworzenia. Ponieważ kompozycja śledzi, kiedy stan jest odczytywany, ta zmiana oznacza, że jeśli zmieni się wartość firstVisibleItemScrollOffset
, kompozycja musi tylko ponownie uruchomić fazy układu i rysowania.
W tym przykładzie użyliśmy różnych modyfikatorów przesunięcia, aby móc zoptymalizować powstały kod, ale ogólna zasada jest taka sama: staraj się ograniczać odczyty stanu do możliwie najniższych faz, aby umożliwić narzędziu Compose wykonywanie jak najmniejszej ilości pracy.
Oczywiście często konieczne jest odczytanie stanów w etapie tworzenia. Mimo to w niektórych przypadkach możemy zminimalizować liczbę rekompozycji, filtrując zmiany stanu. Więcej informacji na ten temat znajdziesz w artykule derivedStateOf: konwertowanie jednego lub wielu obiektów stanu na inny stan.
Pętla rekompozycji (cykliczna zależność fazy)
Wspominaliśmy już, że fazy tworzenia są zawsze wywoływane w tym samym porządku i że nie można cofnąć się do poprzedniego kadru w ramach tego samego kadru. Nie oznacza to jednak, że aplikacje nie mogą tworzyć pętli kompozycyjnych w różnych ramach. 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() } ) ) }
Tutaj mamy (źle) zaimplementowaną kolumnę pionową z obrazem u góry i tekstem pod nim. Używamy wartości Modifier.onSizeChanged()
, aby poznać rozmiar obrazu po przetworzeniu, a następnie używamy wartości Modifier.padding()
w tekście, aby przesunąć go w dół. Nienaturalna konwersja z Px
na Dp
wskazuje, że w kodzie występuje jakiś problem.
W tym przykładzie problem polega na tym, że nie osiągamy „końcowego” układu w ramach pojedynczej ramki. Kod korzysta z wielu klatek, co wykonuje niepotrzebne operacje i powoduje przeskakiwanie interfejsu na ekranie użytkownika.
Przyjrzyjmy się poszczególnym klatkom, aby zobaczyć, co się dzieje:
W etapie tworzenia pierwszego kadru wartość imageHeightPx
wynosi 0, a tekst jest dostarczany z użyciem Modifier.padding(top = 0)
. Następnie następuje faza układu, w której wywoływany jest modyfikator onSizeChanged
.
Wtedy wartość imageHeightPx
jest aktualizowana do rzeczywistej wysokości obrazu.
Skomponuj harmonogramy rekompozycji na potrzeby następnej klatki. W fazie rysowania tekst jest renderowany z odstępem 0, ponieważ zmiana wartości nie została jeszcze uwzględniona.
Składanie rozpoczyna następnie drugą zaplanowaną klatkę zgodnie ze zmianą wartości imageHeightPx
. Stan jest odczytywany w bloku treści Box i jest wywoływany w fazie tworzenia. Tym razem tekst jest wyświetlany z odstępem odpowiadającym wysokości obrazu. W fazie układu kod ponownie ustawia wartość imageHeightPx
, ale nie planuje ponownego tworzenia kompozycji, ponieważ wartość pozostaje taka sama.
Ostatecznie uzyskujemy pożądane wypełnienie tekstu, ale nieoptymalne jest poświęcanie dodatkowego klatki na przekazanie wartości wypełnienia z powrotem do innej fazy. Spowoduje to wygenerowanie klatki z nakładającymi się treściami.
Ten przykład może wydawać się wymyślony, ale uważaj na ten ogólny schemat:
Modifier.onSizeChanged()
,onGloballyPositioned()
lub inne operacje dotyczące układu.- Zmień stan
- Użyj tego stanu jako wejścia dla modyfikatora układu (
padding()
,height()
lub podobnego). - potencjalnie powtarzające się,
W przypadku tego przykładu należy użyć odpowiednich prymitywów układu. Powyższy przykład można zaimplementować za pomocą prostego elementu Column()
, ale może się zdarzyć, że będziesz potrzebować bardziej złożonego przykładu, który wymaga użycia niestandardowego elementu. W takim przypadku musisz napisać niestandardowy układ. Więcej informacji znajdziesz w przewodniku po układach niestandardowych.
Ogólna zasada polega na tym, aby mieć jedno źródło wiarygodnych danych dotyczących wielu elementów interfejsu użytkownika, które należy mierzyć i umieszczać w zależności od siebie. Użycie odpowiedniego prymitywu układu lub utworzenie niestandardowego układu oznacza, że minimalny udostępniony 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ę.
Polecane dla Ciebie
- Uwaga: tekst linku jest wyświetlany, gdy obsługa JavaScript jest wyłączona
- Stan i Jetpack Compose
- Listy i siatki
- Kotlin w Jetpack Compose