Jetpack Compose to nowoczesny deklaratywny zestaw narzędzi interfejsu na Androida. Compose upraszcza pisanie i utrzymywanie interfejsu aplikacji, udostępniając deklaratywny interfejs API, który umożliwia renderowanie interfejsu aplikacji bez konieczności imperatywnego mutowania widoków frontendu. Ta terminologia wymaga wyjaśnienia, ale jej konsekwencje są ważne dla projektowania aplikacji.
Paradygmat programowania deklaratywnego
W przeszłości hierarchię widoków Androida można było przedstawić jako drzewo widżetów interfejsu. Gdy stan aplikacji zmienia się np. w wyniku interakcji użytkownika, hierarchia interfejsu musi zostać zaktualizowana, aby wyświetlać aktualne dane.
Najczęstszym sposobem aktualizowania interfejsu jest przechodzenie po drzewie za pomocą funkcji takich jak findViewById() i modyfikowanie węzłów przez wywoływanie metod takich jak button.setText(String), container.addChild(View) lub img.setImageBitmap(Bitmap). Te metody zmieniają wewnętrzny stan widżetu.
Ręczne manipulowanie widokami zwiększa prawdopodobieństwo wystąpienia błędów. Jeśli dany element danych jest renderowany w kilku miejscach, możesz zapomnieć o zaktualizowaniu jednego z widoków, w których się wyświetla. Może to również prowadzić do nieprawidłowych stanów, gdy 2 aktualizacje powodują konflikt w nieoczekiwany sposób. Na przykład aktualizacja może próbować ustawić wartość węzła, który został właśnie usunięty z interfejsu. Ogólnie złożoność konserwacji oprogramowania rośnie wraz z liczbą widoków, które wymagają aktualizacji.
W ciągu ostatnich kilku lat cała branża zaczęła przechodzić na deklaratywny model interfejsu, który upraszcza tworzenie i aktualizowanie interfejsów użytkownika.
Technika ta polega na koncepcyjnym odtworzeniu całego ekranu od podstaw, a następnie zastosowaniu tylko niezbędnych zmian. Takie podejście pozwala uniknąć złożoności ręcznego aktualizowania hierarchii widoków stanowych. Compose to deklaratywna platforma UI.
Jednym z problemów związanych z ponownym generowaniem całego ekranu jest to, że może to być kosztowne pod względem czasu, mocy obliczeniowej i zużycia baterii. Aby zmniejszyć ten koszt, Compose inteligentnie wybiera, które części interfejsu użytkownika muszą zostać ponownie narysowane w danym momencie. Ma to pewien wpływ na sposób projektowania komponentów interfejsu, co zostało omówione w sekcji Rekonstrukcja.
Przykładowa funkcja typu „composable”
Za pomocą Compose możesz utworzyć interfejs użytkownika, definiując zestaw funkcji typu „composable”, które przyjmują dane i emitują elementy interfejsu. Przykładem jest widżet Greeting, który przyjmuje String i emituje widżet Text wyświetlający wiadomość powitalną.
Kilka ważnych informacji o tej funkcji:
- Adnotacja: funkcja jest oznaczona adnotacją
@Composable. Wszystkie funkcje typu „composable” muszą mieć tę adnotację. Ta adnotacja informuje kompilator Compose, że ta funkcja ma konwertować dane na interfejs. - Dane wejściowe: funkcja przyjmuje dane. Funkcje typu „composable” mogą akceptować parametry, które pozwalają logice aplikacji opisywać interfejs. W tym przypadku nasz widżet akceptuje
String, aby mógł powitać użytkownika po imieniu. - Wyświetlanie w interfejsie: funkcja wyświetla tekst w interfejsie. Robi to, wywołując funkcję typu „composable”
Text(), która tworzy element interfejsu tekstowego. Funkcje typu „composable” emitują hierarchię UI, wywołując inne funkcje tego typu. - Brak wartości zwracanej: funkcja nic nie zwraca. Funkcje Compose, które emitują interfejs, nie muszą niczego zwracać, ponieważ opisują stan ekranu docelowego, a nie tworzą widżety interfejsu.
Właściwości: ta funkcja jest szybka, idempotentna i nie ma efektów ubocznych.
- Funkcja zachowuje się tak samo, gdy jest wywoływana wielokrotnie z tym samym argumentem, i nie używa innych wartości, takich jak zmienne globalne czy wywołania
random(). - Funkcja opisuje interfejs bez żadnych efektów ubocznych, takich jak modyfikowanie właściwości czy zmiennych globalnych.
Ogólnie rzecz biorąc, wszystkie funkcje typu „composable” muszą być napisane z tymi właściwościami z powodów omówionych w sekcji Rekonstrukcja.
- Funkcja zachowuje się tak samo, gdy jest wywoływana wielokrotnie z tym samym argumentem, i nie używa innych wartości, takich jak zmienne globalne czy wywołania
Zmiana paradygmatu deklaratywnego
W wielu imperatywnych, obiektowych zestawach narzędzi UI interfejs inicjuje się przez utworzenie instancji drzewa widżetów. Często robi się to przez rozszerzenie pliku układu XML. Każdy widżet utrzymuje własny stan wewnętrzny i udostępnia metody pobierające i ustawiające, które umożliwiają logice aplikacji interakcję z widżetem.
W deklaratywnym podejściu Compose widżety są względnie bezstanowe i nie udostępniają funkcji setter ani getter. Widżety nie są widoczne jako obiekty.
Interfejs użytkownika aktualizujesz, wywołując tę samą funkcję typu „composable” z innymi argumentami. Ułatwia to przekazywanie stanu do wzorców architektury, takich jak ViewModel, zgodnie z opisem w Przewodniku po architekturze aplikacji. Następnie funkcje kompozycyjne odpowiadają za przekształcanie bieżącego stanu aplikacji w interfejs użytkownika za każdym razem, gdy dane dostępne do obserwacji zostaną zaktualizowane.
Gdy użytkownik wchodzi w interakcję z interfejsem, interfejs wywołuje zdarzenia takie jak onClick.
Te zdarzenia powinny powiadamiać logikę aplikacji, która może następnie zmienić stan aplikacji.
Gdy stan się zmienia, funkcje typu „composable” są wywoływane ponownie z nowymi danymi. Powoduje to ponowne narysowanie elementów interfejsu – ten proces nazywa się rekompozycją.
Zawartość dynamiczna
Funkcje typu „composable” są pisane w języku Kotlin, a nie XML, więc mogą być tak dynamiczne jak każdy inny kod w języku Kotlin. Załóżmy na przykład, że chcesz utworzyć interfejs, który wita listę użytkowników:
@Composable fun Greeting(names: List<String>) { for (name in names) { Text("Hello $name") } }
Ta funkcja pobiera listę imion i nazwisk oraz generuje powitanie dla każdego użytkownika.
Funkcje typu „composable” mogą być dość skomplikowane. Możesz użyć instrukcji if, aby zdecydować, czy chcesz wyświetlić dany element interfejsu. Możesz używać pętli. Możesz wywoływać funkcje pomocnicze. Masz pełną elastyczność języka, na którym opiera się ta funkcja.
Ta moc i elastyczność to jedna z głównych zalet Jetpack Compose.
Rekompozycja
W imperatywnym modelu interfejsu, aby zmienić widżet, wywołujesz metodę ustawiającą, która zmienia jego stan wewnętrzny. W Compose ponownie wywołujesz funkcję typu „composable” z nowymi danymi. Spowoduje to rekompozycję funkcji – widżety emitowane przez funkcję zostaną w razie potrzeby ponownie narysowane z nowymi danymi. Platforma Compose może inteligentnie rekomponować tylko te komponenty, które uległy zmianie.
Weźmy na przykład tę funkcję typu „composable”, która wyświetla przycisk:
@Composable fun ClickCounter(clicks: Int, onClick: () -> Unit) { Button(onClick = onClick) { Text("I've been clicked $clicks times") } }
Za każdym razem, gdy klikniesz przycisk, element wywołujący zaktualizuje wartość clicks.
Compose ponownie wywołuje funkcję lambda z funkcją Text, aby wyświetlić nową wartość.
Ten proces nazywa się rekompozycją. Inne funkcje, które nie zależą od wartości, nie są rekomponowane.
Jak już wspomnieliśmy, ponowne komponowanie całego drzewa interfejsu może być kosztowne pod względem obliczeniowym, co zużywa moc obliczeniową i baterię. Compose rozwiązuje ten problem dzięki inteligentnej rekompozycji.
Rekompilacja to proces ponownego wywoływania funkcji typu „composable” po zmianie danych wejściowych. Gdy Compose przeprowadza rekompozycję na podstawie nowych danych wejściowych, wywołuje tylko te funkcje lub lambdy, które mogły ulec zmianie, a pozostałe pomija. Pomijając funkcje lub lambdy z niezmienionymi parametrami, Compose skutecznie przeprowadza rekompozycję.
Nigdy nie polegaj na efektach ubocznych wykonywania funkcji typu „composable”, ponieważ rekompozycja funkcji może zostać pominięta. Jeśli to zrobisz, użytkownicy mogą zauważyć dziwne i nieprzewidywalne zachowanie aplikacji. Efekt uboczny to każda zmiana, która jest widoczna w pozostałej części aplikacji. Na przykład te działania są niebezpiecznymi efektami ubocznymi:
- Zapisywanie we właściwości obiektu udostępnionego
- Aktualizowanie obiektu obserwowalnego w:
ViewModel - Aktualizowanie udostępnionych ustawień
Funkcje typu „composable” mogą być wykonywane ponownie nawet w każdej klatce, np. podczas renderowania animacji. Funkcje typu „composable” powinny być szybkie, aby uniknąć zacinania się animacji. Jeśli musisz wykonać kosztowne operacje, takie jak odczyt z udostępnionych preferencji, zrób to w współprogramie działającym w tle i przekaż wynik wartości do funkcji typu „composable” jako parametr.
Ten kod tworzy na przykład funkcję kompozycyjną, która aktualizuje wartość w SharedPreferences. Funkcja kompozycyjna nie powinna odczytywać ani zapisywać danych z udostępnionych preferencji. Zamiast tego ten kod przenosi odczyt i zapis do ViewModel w współprogramie działającym w tle. Logika aplikacji przekazuje bieżącą wartość za pomocą wywołania zwrotnego, aby aktywować aktualizację.
@Composable fun SharedPrefsToggle( text: String, value: Boolean, onValueChanged: (Boolean) -> Unit ) { Row { Text(text) Checkbox(checked = value, onCheckedChange = onValueChanged) } }
W tym dokumencie omawiamy kilka kwestii, o których należy pamiętać podczas korzystania z Compose:
- Rekonstrukcja pomija jak najwięcej funkcji typu „composable” i wyrażeń lambda.
- Rekompilacja jest optymistyczna i może zostać anulowana.
- Funkcja typu „composable” może być uruchamiana dość często, nawet w każdej klatce animacji.
- Funkcje typu „composable” mogą być wykonywane równolegle.
- Funkcje typu „composable” mogą być wykonywane w dowolnej kolejności.
W kolejnych sekcjach dowiesz się, jak tworzyć funkcje typu „composable”, które obsługują rekompozycję. W każdym przypadku sprawdzoną metodą jest dbanie o to, aby funkcje typu „composable” były szybkie, idempotentne i nie powodowały efektów ubocznych.
Rekonstrukcja pomija jak najwięcej elementów
Gdy części interfejsu są nieprawidłowe, Compose stara się ponownie skomponować tylko te części, które wymagają aktualizacji. Oznacza to, że może pominąć ponowne uruchomienie pojedynczego elementu kompozycyjnego Button bez wykonywania żadnych elementów kompozycyjnych znajdujących się wyżej lub niżej w drzewie interfejsu.
Każda funkcja typu „composable” i wyrażenie lambda mogą zostać ponownie skomponowane. Poniższy przykład pokazuje, jak rekompozycja może pominąć niektóre elementy podczas renderowania listy:
/** * Display a list of names the user can click with a header */ @Composable fun NamePicker( header: String, names: List<String>, onNameClicked: (String) -> Unit ) { Column { // this will recompose when [header] changes, but not when [names] changes Text(header, style = MaterialTheme.typography.bodyLarge) HorizontalDivider() // LazyColumn is the Compose version of a RecyclerView. // The lambda passed to items() is similar to a RecyclerView.ViewHolder. LazyColumn { items(names) { name -> // When an item's [name] updates, the adapter for that item // will recompose. This will not recompose when [header] changes NamePickerItem(name, onNameClicked) } } } } /** * Display a single name the user can click. */ @Composable private fun NamePickerItem(name: String, onClicked: (String) -> Unit) { Text(name, Modifier.clickable(onClick = { onClicked(name) })) }
Każdy z tych zakresów może być jedynym elementem wykonywanym podczas rekompozycji.
Gdy zmieni się header, funkcja Compose może przejść do funkcji lambda Column bez wykonywania żadnych funkcji nadrzędnych. Podczas wykonywania funkcji Column Compose może pominąć elementy LazyColumn, jeśli names nie uległo zmianie.
Przypominamy, że wszystkie funkcje typu „composable” i lambdy powinny być wolne od efektów ubocznych. Jeśli chcesz wywołać efekt uboczny, uruchom go z wywołania zwrotnego.
Rekonstrukcja jest optymistyczna
Rekonstrukcja rozpoczyna się, gdy Compose uzna, że parametry funkcji kompozycyjnej mogły ulec zmianie. Rekonstrukcja jest optymistyczna, co oznacza, że Compose oczekuje, że zakończy się ona, zanim parametry ponownie się zmienią. Jeśli parametr ulegnie zmianie przed zakończeniem rekompozycji, Compose może anulować rekompozycję i uruchomić ją ponownie z nowym parametrem.
Gdy rekompozycja zostanie anulowana, Compose odrzuci drzewo UI z rekompozycji. Jeśli masz efekty uboczne, które zależą od wyświetlanego interfejsu, zostaną one zastosowane, nawet jeśli kompozycja zostanie anulowana. Może to prowadzić do niespójnego stanu aplikacji.
Sprawdź, czy wszystkie funkcje typu „composable” i wyrażenia lambda są idempotentne i nie mają efektów ubocznych, aby umożliwić optymistyczną rekompozycję.
Funkcje typu „composable” mogą być uruchamiane dość często
W niektórych przypadkach funkcja typu „composable” może być uruchamiana dla każdej klatki animacji interfejsu. Jeśli funkcja wykonuje kosztowne operacje, takie jak odczyt z pamięci urządzenia, może powodować zacinanie się interfejsu.
Jeśli na przykład widżet spróbuje odczytać ustawienia urządzenia, może to zrobić setki razy na sekundę, co będzie miało katastrofalny wpływ na wydajność aplikacji.
Jeśli funkcja typu „composable” potrzebuje danych, zdefiniuj parametry dla tych danych. Możesz wtedy przenieść kosztowne zadanie do innego wątku, poza kompozycję, i przekazać wynikową wartość do funkcji typu „composable” jako parametr za pomocą mutableStateOf lub LiveData.
Funkcje typu „composable” mogą działać równolegle
Compose może optymalizować rekompozycję, uruchamiając funkcje typu „composable” równolegle. Dzięki temu Compose może korzystać z wielu rdzeni i uruchamiać funkcje typu „composable”, które nie są widoczne na ekranie, z niższym priorytetem.
Ta optymalizacja oznacza, że funkcja typu „composable” może być wykonywana w puli wątków w tle.
Jeśli funkcja typu „composable” wywołuje funkcję w obiekcie ViewModel,
Compose może wywołać tę funkcję z kilku wątków jednocześnie.
Aby sprawdzić, czy aplikacja działa prawidłowo, wszystkie funkcje typu „composable” nie powinny mieć efektów ubocznych. Zamiast tego wywołuj efekty uboczne z wywołań zwrotnych, takich jak onClick, które zawsze są wykonywane w wątku UI.
Gdy wywoływana jest funkcja typu „composable”, wywołanie może nastąpić w innym wątku niż wywołujący. Oznacza to, że należy unikać kodu, który modyfikuje zmienne w funkcji kompozycyjnej, ponieważ taki kod nie jest wątkowo bezpieczny i jest niedopuszczalnym efektem ubocznym funkcji kompozycyjnej.
Oto przykład funkcji kompozycyjnej, która wyświetla listę i liczbę elementów:
@Composable fun ListComposable(myList: List<String>) { Row(horizontalArrangement = Arrangement.SpaceBetween) { Column { for (item in myList) { Text("Item: $item") } } Text("Count: ${myList.size}") } }
Ten kod nie ma efektów ubocznych i przekształca listę wejściową w interfejs. To świetny kod do wyświetlania krótkiej listy. Jeśli jednak funkcja zapisuje dane w zmiennej lokalnej, ten kod nie będzie bezpieczny wątkowo ani prawidłowy:
@Composable fun ListWithBug(myList: List<String>) { var items = 0 Row(horizontalArrangement = Arrangement.SpaceBetween) { Column { for (item in myList) { Card { Text("Item: $item") items++ // Avoid! Side-effect of the column recomposing. } } } Text("Count: $items") } }
W tym przykładzie items jest modyfikowany przy każdej rekompozycji. Może to być każda klatka animacji lub moment aktualizacji listy. W obu przypadkach interfejs wyświetli nieprawidłową liczbę. Z tego powodu takie zapisy nie są obsługiwane w Compose. Uniemożliwiając je, pozwalamy platformie na zmianę wątków w celu wykonywania funkcji kompozycyjnych.
Funkcje typu „composable” mogą być wykonywane w dowolnej kolejności
Jeśli spojrzysz na kod funkcji typu „composable”, możesz założyć, że jest on wykonywany w kolejności, w jakiej się pojawia. Nie musi to być jednak prawdą. Jeśli funkcja typu „composable” zawiera wywołania innych funkcji tego typu, mogą one zostać uruchomione w dowolnej kolejności. Compose może rozpoznawać, że niektóre elementy interfejsu mają wyższy priorytet niż inne, i rysować je jako pierwsze.
Załóżmy na przykład, że masz taki kod do rysowania 3 ekranów w układzie kart:
@Composable fun ButtonRow() { MyFancyNavigation { StartScreen() MiddleScreen() EndScreen() } }
Wywołania funkcji StartScreen, MiddleScreen i EndScreen mogą nastąpić w dowolnej kolejności. Oznacza to, że nie możesz na przykład ustawić zmiennej globalnej (efekt uboczny) w StartScreen() i wykorzystać tej zmiany w MiddleScreen(). Zamiast tego każda z tych funkcji musi być samodzielna.
Więcej informacji
Więcej informacji o tym, jak myśleć w Compose i funkcjach typu „composable”, znajdziesz w tych materiałach.
Filmy
Polecane dla Ciebie
- Uwaga: tekst linku jest wyświetlany, gdy język JavaScript jest wyłączony.
- Kotlin w Jetpack Compose
- State i Jetpack Compose
- Warstwy architektury Jetpack Compose