Stosowanie sprawdzonych metod

Możesz napotkać typowe problemy z Compose. Te błędy mogą skutkować kodem, który wydaje się działać wystarczająco dobrze, ale może obniżyć wydajność interfejsu. Postępuj zgodnie ze sprawdzonymi metodami, aby zoptymalizować aplikację w Compose.

Używaj remember, aby ograniczyć kosztowne obliczenia

Funkcje typu „composable” mogą być uruchamiane bardzo często, nawet w każdej klatce animacji. Z tego powodu w treści funkcji kompozycyjnej należy wykonywać jak najmniej obliczeń.

Ważną techniką jest przechowywanie wyników obliczeń za pomocą remember. Dzięki temu obliczenia są wykonywane raz, a wyniki można pobrać w razie potrzeby.

Oto przykład kodu, który wyświetla posortowaną listę nazw, ale sortowanie odbywa się w bardzo kosztowny sposób:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

Za każdym razem, gdy funkcja ContactsList jest rekomponowana, cała lista kontaktów jest sortowana od nowa, mimo że się nie zmieniła. Jeśli użytkownik przewija listę, funkcja kompozycyjna jest rekomponowana za każdym razem, gdy pojawia się nowy wiersz.

Aby rozwiązać ten problem, posortuj listę poza funkcją LazyColumn i zapisz posortowaną listę za pomocą funkcji remember:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, comparator) {
        contacts.sortedWith(comparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
            // ...
        }
    }
}

Teraz lista jest sortowana raz, gdy ContactList jest tworzony po raz pierwszy. Jeśli kontakty lub komparator ulegną zmianie, posortowana lista zostanie wygenerowana ponownie. W przeciwnym razie funkcja kompozycyjna może nadal używać posortowanej listy z pamięci podręcznej.

Zawsze przekazuj wszystkie dane wejściowe, do których odwołujesz się w bloku obliczeniowym remember, jako klucze, aby wartość w pamięci podręcznej była aktualizowana, gdy te dane wejściowe ulegną zmianie. Ponieważ remember przechowuje klucze i wyniki z pamięci podręcznej w tabeli slotów i porównuje klucze za pomocą Object.equals() przy każdym przebiegu rekompozycji, używaj remember tylko wtedy, gdy chcesz zapisać w pamięci podręcznej kosztowne obliczenia lub duże alokacje obiektów (np. formatowanie daty, shadery lub sortowanie list) albo gdy musisz zachować tożsamość obiektu.

Zachowaj ostrożność, gdy przekazujesz dużą kolekcję lub obiekt z kosztowną implementacją equals() jako klucz do remember w celu wykonania taniego obliczenia, ponieważ porównanie klucza może kosztować więcej niż bezpośrednie wykonanie obliczenia:

@Composable
fun ContactHeader(contacts: List<Contact>) {
    // Avoid: Comparing the `contacts` list with .equals() on recomposition
    // is more expensive than reading contacts.size or contacts.firstOrNull() directly.
    val firstContactName = remember(contacts) {
        contacts.firstOrNull()?.name.orEmpty()
    }
    Text(firstContactName)
}

Używanie kluczy i typów treści leniwego układu

Leniwe układy efektywnie wykorzystują elementy, generując je lub rekomponując tylko wtedy, gdy jest to konieczne. Możesz jednak zoptymalizować leniwe układy pod kątem rekompozycji i ponownego użycia slotów.

Załóżmy, że działanie użytkownika powoduje przeniesienie elementu na liście. Załóżmy na przykład, że wyświetlasz listę notatek posortowanych według czasu modyfikacji, z najnowszą notatką na górze.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

Z tym kodem jest jednak pewien problem. Załóżmy, że dolna nuta ulegnie zmianie. Jest to teraz ostatnio zmodyfikowana notatka, więc trafia na górę listy, a wszystkie pozostałe notatki przesuwają się o jedno miejsce w dół.

Bez Twojej pomocy Compose nie wie, że niezmienione elementy są po prostu przenoszone na liście. Zamiast tego Compose uzna, że stary „element 2” został usunięty, a nowy został utworzony dla elementu 3, elementu 4 itd. W rezultacie Compose ponownie komponuje każdy element na liście, mimo że zmienił się tylko jeden z nich.

Rozwiązaniem jest podanie kluczy elementów. Podanie stabilnego klucza dla każdego elementu pozwala Compose uniknąć niepotrzebnych rekompozycji. W tym przypadku Compose może określić, że element, który znajduje się teraz na pozycji 3, jest tym samym elementem, który znajdował się na pozycji 2. Żadne dane tego elementu nie uległy zmianie, więc Compose nie musi go ponownie tworzyć.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
            key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

Podczas pracy z układami leniwymi (LazyColumn, LazyRow, LazyGrid) pamiętaj też o tych wytycznych:

  • Używaj unikalnych kluczy, które można zapisać w pakiecie: klucze muszą być unikalne, deterministyczne i możliwe do zapisania w obiekcie Bundle na Androidzie, ponieważ układy leniwe używają wewnętrznie rememberSaveable. Preferuj typy podstawowe, takie jak Int, Long lub String, i nigdy nie używaj indeksów list ani hashCode() jako kluczy.
  • Zawsze określaj contentType w przypadku list heterogenicznych: przekaż contentType = { it.type } do items lub itemsIndexed, aby Compose ponownie używał tylko slotów kompozycji między elementami, które mają tę samą strukturę wizualną podczas przewijania.
  • Używaj itemsIndexed zamiast indexOf: unikaj wywoływania list.indexOf(item) wewnątrz bloku items, co powoduje, że układ jest skanowany O(N²). Zamiast tego używaj itemsIndexed.
  • Przenoszenie stanu udostępnionego i odczytów CompositionLocal: zbierz udostępnione instancje Flow i odczytaj wartości CompositionLocal (takie jak LocalContext.current lub LocalDensity.current) raz w nadrzędnym elemencie kompozycyjnym nad układem leniwym i przekaż rozwiązane wartości do elementów kompozycyjnych.
  • Używaj Row lub Column w przypadku małych, ograniczonych list: w przypadku małych statycznych lub ograniczonych kolekcji (np. od 1 do 10 elementów) standardowy element Row lub Column z forEach pozwala uniknąć narzutu związanego z leniwą wirtualizacją i pobieraniem z wyprzedzeniem.

Używaj computedStateOf lub derivedStateOf, aby ograniczyć rekompozycje

Jednym z zagrożeń związanych z używaniem stanu w kompozycjach jest to, że jeśli stan zmienia się szybko, interfejs może być rekomponowany częściej, niż jest to konieczne. Załóżmy na przykład, że wyświetlasz listę, którą można przewijać. Sprawdzasz stan listy, aby zobaczyć, który element jest pierwszym widocznym elementem na liście:

val listState = rememberLazyListState()

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

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Problem polega na tym, że gdy użytkownik przewija listę, listState stale się zmienia, gdy użytkownik przesuwa palcem. Oznacza to, że lista jest stale aktualizowana. Nie musisz jednak tak często go rekomponować – wystarczy, że zrobisz to, gdy u góry pojawi się nowy element. To dużo dodatkowych obliczeń, które negatywnie wpływają na wydajność interfejsu.

Rozwiązaniem jest użycie stanu pochodnego (lub computedStateOf w Compose Runtime w wersji 1.13 lub nowszej). Stan pochodny pozwala poinformować Compose, które zmiany stanu powinny wywołać rekompozycję. W takim przypadku określ, że interesuje Cię zmiana pierwszego widocznego elementu. Gdy wartość tego stanu się zmieni, interfejs musi zostać ponownie skomponowany, ale jeśli użytkownik nie przewinął jeszcze wystarczająco dużo, aby wyświetlić nowy element u góry, nie trzeba go ponownie komponować.

val listState = rememberLazyListState()

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

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Wybierz odpowiednie podejście do wyprowadzania stanu na podstawie wersji Compose i kosztu obliczeń:

  • remember { computedStateOf { ... } } (Compose Runtime 1.13 i nowsze wersje): ponownie ocenia obliczenia przy każdym odczycie bez zapisywania wyniku w pamięci podręcznej i unieważnia kompozycję tylko wtedy, gdy obliczony wynik ulegnie zmianie. Ponieważ pozwala uniknąć narzutu związanego z księgowaniem pamięci podręcznej i zapisywaniem zwrotnym unieważnienia, używaj remember { computedStateOf { listState.firstVisibleItemIndex > 0 } } do podstawowych kontroli warunków i lekkich operacji pochodnych opartych na szybko zmieniających się stanach (takich jak przesunięcia przewijania).
  • remember { derivedStateOf { ... } }: zapisuje w pamięci podręcznej wynik obliczeń w wielu odczytach. Używaj derivedStateOf w wersjach Compose starszych niż 1.13 lub gdy obliczenia są kosztowne i odczytywane wielokrotnie podczas tworzenia kompozycji. Nie przekazuj wartości stanu migawki odczytanych wewnątrz derivedStateOf (takich jak listState.firstVisibleItemIndex) jako kluczy remember, ponieważ spowoduje to ponowne utworzenie DerivedState przy każdej zmianie stanu i uniemożliwi pominięcie.
  • Zwykłe metody pobierające w języku Kotlin (get()) i bezpośrednie odczyty właściwości: w przypadku prostych wyrażeń logicznych, które nie wymagają tłumienia unieważnienia, lepiej używać zwykłej niestandardowej metody pobierającej i odczytywać kolekcję .size bezpośrednio, zamiast opakowywać ją w derivedStateOf. Unikaj używania produceState z snapshotFlow do synchronicznego wyprowadzania stanu, ponieważ powoduje to niepotrzebne obciążenie związane z wysyłaniem współprogramów.

Odrocz odczyty tak długo, jak to możliwe

Jeśli zostanie wykryty problem z wydajnością, pomocne może być odroczenie odczytów stanu. Odroczenie odczytów stanu sprawi, że Compose będzie ponownie uruchamiać minimalną możliwą ilość kodu podczas rekompozycji. Jeśli na przykład interfejs użytkownika ma stan, który jest podniesiony wysoko w drzewie funkcji kompozycyjnych, a odczytujesz go w podrzędnej funkcji kompozycyjnej, możesz opakować odczyt stanu w funkcję lambda. Dzięki temu odczyt następuje tylko wtedy, gdy jest to naprawdę konieczne. Więcej informacji znajdziesz w implementacji w przykładowej aplikacji Jetsnack. Jetsnack implementuje efekt podobny do zwijanego paska narzędzi na ekranie szczegółów. Aby dowiedzieć się, dlaczego ta technika działa, przeczytaj post na blogu Jetpack Compose: Debugging Recomposition (Jetpack Compose: debugowanie rekompozycji).

Aby uzyskać ten efekt, element kompozycyjny Title potrzebuje przesunięcia przewijania, aby przesunąć się za pomocą Modifier. Oto uproszczona wersja kodu Jetsnack przed optymalizacją:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

Gdy stan przewijania się zmienia, Compose unieważnia najbliższy nadrzędny zakres rekompozycji. W tym przypadku najbliższym zakresem jest funkcja kompozycyjna SnackDetail. Pamiętaj, że Box jest funkcją wbudowaną, więc nie jest zakresem rekompozycji. Compose ponownie tworzy element SnackDetail i wszystkie elementy kompozycyjne wewnątrz elementu SnackDetail. Jeśli zmienisz kod tak, aby odczytywał stan tylko tam, gdzie jest on faktycznie używany, możesz zmniejszyć liczbę elementów, które muszą zostać ponownie skomponowane.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

Parametr scroll jest teraz funkcją lambda. Oznacza to, że Title nadal może odwoływać się do stanu podniesionego, ale wartość jest odczytywana tylko wewnątrz Title, gdzie jest faktycznie potrzebna. W rezultacie, gdy wartość przewijania się zmienia, najbliższym zakresem rekompozycji jest teraz funkcja kompozycyjna Title – Compose nie musi już rekomponować całej funkcji Box.

To dobra poprawa, ale możesz osiągnąć jeszcze lepszy wynik. Jeśli powodujesz rekompozycję tylko po to, aby zmienić układ lub ponownie narysować element kompozycyjny, powinno to wzbudzić Twoją czujność. W tym przypadku zmieniasz tylko przesunięcie elementu kompozycyjnego Title, co można zrobić na etapie układu.

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(x = 0, y = scrollProvider()) }
    ) {
        // ...
    }
}

Wcześniej kod używał funkcji Modifier.offset(x: Dp, y: Dp), która przyjmuje przesunięcie jako parametr. Przechodząc na wersję lambda modyfikatora, możesz mieć pewność, że funkcja odczyta stan przewijania w fazie układu. Dzięki temu, gdy stan przewijania się zmienia, Compose może całkowicie pominąć fazę tworzenia i przejść bezpośrednio do fazy układu. Jeśli przekazujesz do modyfikatorów często zmieniające się zmienne stanu, w miarę możliwości używaj wersji lambda modyfikatorów.

Oto kolejny przykład takiego podejścia. Ten kod nie został jeszcze zoptymalizowany:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(
    Modifier
        .fillMaxSize()
        .background(color)
)

W tym przypadku kolor tła pola szybko zmienia się między 2 kolorami. Ten stan zmienia się więc bardzo często. Następnie funkcja kompozycyjna odczytuje ten stan w modyfikatorze tła. W rezultacie pole musi być rekomponowane w każdej klatce, ponieważ kolor zmienia się w każdej klatce.

Aby to poprawić, użyj modyfikatora opartego na funkcji lambda – w tym przypadku drawBehind. Oznacza to, że stan koloru jest odczytywany tylko podczas fazy rysowania. Dzięki temu Compose może całkowicie pominąć fazy kompozycji i układu – gdy kolor się zmienia, Compose przechodzi bezpośrednio do fazy rysowania.

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
    Modifier
        .fillMaxSize()
        .drawBehind {
            drawRect(color)
        }
)

Unikanie zapisów wstecznych

Compose opiera się na założeniu, że nigdy nie będziesz zapisywać stanu, który został już odczytany. Nazywa się to zapisem wstecznym i może powodować ponowne komponowanie w każdej klatce, bez końca.

Poniższy element kompozycyjny pokazuje przykład takiego błędu.

@Composable
fun BadComposable() {
    var count by remember { mutableIntStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read</b>
}

Ten kod aktualizuje liczbę na końcu funkcji kompozycyjnej po odczytaniu jej w poprzednim wierszu. Jeśli uruchomisz ten kod, zobaczysz, że po kliknięciu przycisku, co powoduje rekompozycję, licznik szybko rośnie w nieskończonej pętli, ponieważ Compose rekomponuje ten element, widzi odczyt stanu, który jest nieaktualny, i planuje kolejną rekompozycję.

Możesz całkowicie uniknąć zapisów wstecznych, jeśli nigdy nie będziesz zapisywać stanu w kompozycji. Jeśli to możliwe, zawsze zapisuj stan w odpowiedzi na zdarzenie i w wyrażeniu lambda, jak w poprzednim przykładzie onClick.

Szczegółowe informacje o zapisach wstecznych, pętlach rekompozycji i koordynacji faz znajdziesz w artykule Zapisy wsteczne w Compose.

Dodatkowe materiały