Jetpack Compose to nowoczesny, deklaratywny zestaw narzędzi do tworzenia interfejsu na Androidzie. Compose upraszcza pisanie i utrzymywanie interfejsu aplikacji, ponieważ udostępnia deklaratywny interfejs API, który umożliwia renderowanie interfejsu aplikacji bez konieczności imperatywnego modyfikowania widoków frontendu. Ta terminologia wymaga wyjaśnienia, ale jej implikacje są ważne dla projektu aplikacji.
Deklaratywny paradygmat programowania
Historycznie hierarchię widoków Androida można było przedstawić jako drzewo widżetów interfejsu. Gdy stan aplikacji zmienia się na skutek interakcji użytkownika, hierarchia interfejsu musi zostać zaktualizowana, aby wyświetlać aktualne dane.
Najczęstszym sposobem aktualizowania interfejsu jest przechodzenie przez drzewo za pomocą funkcji takich jak
findViewById() i zmienianie
węzłów przez wywoływanie metod takich jak button.setText(String),
container.addChild(View), czy img.setImageBitmap(Bitmap). Metody te zmieniają wewnętrzny stan widżetu.
Ręczne manipulowanie widokami zwiększa prawdopodobieństwo wystąpienia błędów. Jeśli fragment danych jest renderowany w kilku miejscach, możesz zapomnieć o zaktualizowaniu jednego z widoków, w którym się wyświetla. Może to też prowadzić do nieprawidłowych stanów, gdy 2 aktualizacje w nieoczekiwany sposób ze sobą kolidują. 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 rzecz biorąc, złożoność utrzymania 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 ponownym generowaniu całego ekranu od zera, a następnie stosowaniu tylko niezbędnych zmian. Takie podejście pozwala uniknąć złożoności ręcznego aktualizowania hierarchii widoków ze stanem. Compose to deklaratywny framework interfejsu.
Jednym z wyzwań 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 należy w danym momencie ponownie narysować. Ma to pewne implikacje dla sposobu projektowania komponentów interfejsu, o czym piszemy w sekcji Rekompozycja.
Przykład funkcji typu „composable”
Za pomocą Compose możesz tworzyć 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 komunikat powitalny.
Kilka ważnych informacji o tej funkcji:
- Adnotacja: funkcja jest oznaczona adnotacją
@Composable. Wszystkie funkcje typu „composable” muszą mieć tę adnotację. Informuje ona kompilator Compose, że ta funkcja ma przekształcać dane w interfejs. - Dane wejściowe: funkcja przyjmuje dane. Funkcje typu „composable” mogą przyjmować parametry, które pozwalają logice aplikacji opisywać interfejs. W tym przypadku nasz widżet przyjmuje
String, aby mógł powitać użytkownika po imieniu. - Wyświetlanie interfejsu: funkcja wyświetla tekst w interfejsie. Robi to, wywołując funkcję typu „composable”
Text(), która faktycznie tworzy element tekstowy interfejsu. Funkcje typu „composable” emitują hierarchię interfejsu, wywołując inne funkcje typu „composable”. - Brak wartości zwracanej: funkcja niczego nie zwraca. Funkcje Compose, które emitują interfejs, nie muszą niczego zwracać, ponieważ opisują stan ekranu docelowego zamiast tworzyć 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 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 uwzględnieniem tych właściwości z powodów omówionych w sekcji Rekompozycja.
- 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 przypadku wielu imperatywnych, obiektowych zestawów narzędzi do tworzenia interfejsu inicjujesz interfejs, tworząc instancję drzewa widżetów. Często robisz to, rozwijając plik 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ą stosunkowo bezstanowe i nie udostępniają funkcji ustawiających ani pobierających. Widżety nie są też udostępniane jako obiekty.
Aby zaktualizować interfejs, wywołujesz tę samą funkcję typu „composable” z innymi argumentami. Upraszcza to udostępnianie stanu wzorcom architektonicznym, takim jak
a ViewModel, co opisujemy w
przewodniku po architekturze aplikacji. Następnie elementy typu „composable” odpowiadają za przekształcanie bieżącego stanu aplikacji w interfejs za każdym razem, gdy aktualizują się dane obserwowalne.
Gdy użytkownik wchodzi w interakcję z interfejsem, interfejs generuje zdarzenia takie jak onClick.
Zdarzenia te powinny powiadamiać logikę aplikacji, która może wtedy zmienić stan aplikacji.
Gdy stan się zmieni, funkcje typu „composable” są wywoływane ponownie z nowymi danymi. Powoduje to ponowne narysowanie elementów interfejsu – ten proces nazywa się rekompozycją.
Zawartość dynamiczna
Ponieważ funkcje typu „composable” są pisane w Kotlinie, a nie w XML, mogą być tak dynamiczne jak każdy inny kod 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 przyjmuje listę nazw i generuje powitanie dla każdego użytkownika.
Funkcje typu „composable” mogą być dość złożone. Możesz używać instrukcji if, aby zdecydować, czy chcesz wyświetlić określony element interfejsu. Możesz używać pętli. Możesz wywoływać funkcje pomocnicze. Masz pełną elastyczność języka bazowego.
Ta moc i elastyczność to jedna z głównych zalet Jetpack Compose.
Rekompozycja
W imperatywnym modelu interfejsu, aby zmienić widżet, wywołujesz na nim metodę ustawiającą, która zmienia jego stan wewnętrzny. W Compose ponownie wywołujesz funkcję typu „composable” z nowymi danymi. Powoduje to rekompozycję funkcji – widżety emitowane przez funkcję są w razie potrzeby ponownie rysowane z nowymi danymi. Framework 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, wywołujący aktualizuje wartość clicks.
Compose ponownie wywołuje lambdę 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, rekompozycja całego drzewa interfejsu może być kosztowna pod względem obliczeniowym, co zużywa moc obliczeniową i baterię. Compose rozwiązuje ten problem dzięki inteligentnej rekompozycji.
Rekompozycja to proces ponownego wywoływania funkcji typu „composable”, gdy zmieniają się dane wejściowe. Gdy Compose rekomponuje na podstawie nowych danych wejściowych, wywołuje tylko te funkcje lub lambdy, które mogły ulec zmianie, a resztę pomija. Pomijając funkcje lub lambdy z niezmienionymi parametrami, Compose rekomponuje wydajnie.
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ć w aplikacji dziwne i nieprzewidywalne zachowanie. Efekt uboczny to każda zmiana widoczna dla reszty aplikacji. Na przykład te działania to niebezpieczne efekty uboczne:
- Zapisywanie we właściwości obiektu współdzielonego.
- Aktualizowanie obserwowalnego w
ViewModel. - Aktualizowanie ustawień współdzielonych.
Funkcje typu „composable” mogą być ponownie wykonywane tak często jak każda klatka, np. podczas renderowania animacji. Funkcje typu „composable” powinny być szybkie, aby uniknąć zacinania się animacji. Jeśli musisz wykonywać kosztowne operacje, takie jak odczytywanie ustawień współdzielonych, zrób to w współprogramie w tle i przekaż wynikową wartość do funkcji typu „composable” jako parametr.
Na przykład ten kod tworzy element typu „composable”, który aktualizuje wartość w SharedPreferences. Element typu „composable” nie powinien samodzielnie odczytywać ani zapisywać ustawień współdzielonych. Zamiast tego ten kod przenosi odczyt i zapis do ViewModel w korutynie w tle. Logika aplikacji przekazuje bieżącą wartość z wywołaniem zwrotnym, aby wywołać 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:
- Rekompozycja pomija jak najwięcej funkcji typu „composable” i lambd.
- Rekompozycja jest optymistyczna i może zostać anulowana.
- Funkcja typu „composable” może być uruchamiana dość często, nawet co klatkę 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 najlepszym rozwiązaniem jest, aby funkcje typu „composable” były szybkie, idempotentne i bez efektów ubocznych.
Rekompozycja pomija jak najwięcej
Gdy części interfejsu są nieprawidłowe, Compose stara się rekomponować tylko te części, które wymagają aktualizacji. Oznacza to, że może pominąć ponowne uruchomienie elementu typu „composable” pojedynczego Button bez wykonywania żadnych elementów typu „composable” wyższych lub niższych w drzewie interfejsu.
Każda funkcja typu „composable” i lambda może się rekomponować samodzielnie. Poniższy przykład pokazuje, jak rekompozycja może pomijać 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, Compose może pominąć lambdę Column bez wykonywania żadnych jej elementów nadrzędnych. A podczas wykonywania Column, Compose może pominąć elementy LazyColumn, jeśli names się nie zmieniły.
Powtarzamy, że wszystkie funkcje typu „composable” lub lambdy powinny być bez efektów ubocznych. Jeśli musisz wykonać efekt uboczny, wywołaj go z wywołania zwrotnego.
Rekompozycja jest optymistyczna
Rekompozycja rozpoczyna się, gdy Compose uzna, że parametry elementu typu „composable” mogły ulec zmianie. Rekompozycja jest optymistyczna, co oznacza, że Compose oczekuje, że zakończy rekompozycję, zanim parametry ponownie się zmienią. Jeśli parametr zmieni się przed zakończeniem rekompozycji, Compose może ją anulować i ponownie uruchomić z nowym parametrem.
Gdy rekompozycja zostanie anulowana, Compose odrzuci drzewo interfejsu z rekompozycji. Jeśli masz efekty uboczne, które zależą od wyświetlania interfejsu, efekt uboczny zostanie zastosowany nawet wtedy, gdy kompozycja zostanie anulowana. Może to prowadzić do niespójnego stanu aplikacji.
Aby obsługiwać optymistyczną rekompozycję, sprawdź, czy wszystkie funkcje typu „composable” i lambdy są idempotentne i bez efektów ubocznych.
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 odczytywanie z pamięci urządzenia, może powodować zacinanie się interfejsu.
Jeśli na przykład widżet próbował odczytać ustawienia urządzenia, mógłby to robić setki razy na sekundę, co miałoby katastrofalny wpływ na wydajność aplikacji.
Jeśli funkcja typu „composable” potrzebuje danych, zdefiniuj dla nich parametry. Następnie możesz przenieść kosztowne zadania 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ą być uruchamiane równolegle
Compose może optymalizować rekompozycję, uruchamiając funkcje typu „composable” równolegle. Dzięki temu Compose może wykorzystywać wiele rdzeni i uruchamiać funkcje typu „composable” nie na ekranie z niższym priorytetem.
Ta optymalizacja oznaczałaby, że funkcja typu „composable” może być wykonywana w puli wątków w tle.
Jeśli funkcja typu „composable” wywołuje funkcję w 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 lambdzie typu „composable” – zarówno dlatego, że taki kod nie jest bezpieczny dla wątków, jak i dlatego, że jest niedozwolonym efektem ubocznym lambdy typu „composable”.
Oto przykład pokazujący element typu „composable”, który wyświetla listę i jej liczbę:
@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 w zmiennej lokalnej, ten kod nie będzie bezpieczny dla wątków 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 aktualizacja listy. W każdym przypadku interfejs będzie wyświetlać nieprawidłową liczbę. Z tego powodu zapisy takie jak ten nie są obsługiwane w Compose. Zakazując tych zapisów, umożliwiamy frameworkowi zmianę wątków w celu wykonywania lambd typu „composable”.
Funkcje typu „composable” mogą być wykonywane w dowolnej kolejności
Jeśli przyjrzysz się kodowi funkcji typu „composable”, możesz założyć, że kod jest uruchamiany w kolejności, w jakiej się pojawia. Niekoniecznie jest to jednak prawda. Jeśli funkcja typu „composable” zawiera wywołania innych funkcji typu „composable”, te funkcje mogą być uruchamiane 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, który rysuje 3 ekrany w układzie kart:
@Composable fun ButtonRow() { MyFancyNavigation { StartScreen() MiddleScreen() EndScreen() } }
Wywołania StartScreen, MiddleScreen i EndScreen mogą następować w dowolnej kolejności. Oznacza to, że nie możesz na przykład ustawić w StartScreen() zmiennej globalnej (efekt uboczny) 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 dla Jetpack Compose
- Stan i Jetpack Compose
- Warstwy architektury Jetpack Compose