Stosowanie sprawdzonych metod

Możesz napotkać typowe pułapki związane z Compose. Te błędy mogą spowodować, że kod będzie działać wystarczająco dobrze, ale może to negatywnie wpłynąć na wydajność interfejsu. Aby zoptymalizować aplikację w Compose, postępuj zgodnie ze sprawdzonymi metodami.

Używaj funkcji remember, aby zminimalizować kosztowne obliczenia

Funkcje kompozycyjne mogą być uruchamiane bardzo często, nawet dla każdej klatki 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 tylko raz, a wyniki można pobrać w dowolnym momencie.

Oto na przykład kod, który wyświetla posortowaną listę nazwisk, ale sortuje ją 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 ponownie komponowana, cała lista kontaktów jest sortowana od nowa, mimo że nie uległa zmianie. Jeśli użytkownik przewija listę, funkcja kompozycyjna jest ponownie komponowana za każdym razem, gdy pojawi się nowy wiersz.

Aby rozwiązać ten problem, posortuj listę poza funkcją LazyColumn i przechowuj 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 tylko raz, gdy funkcja ContactList jest komponowana po raz pierwszy. Jeśli kontakty lub komparator ulegną zmianie, posortowana lista zostanie wygenerowana ponownie. W przeciwnym razie funkcja kompozycyjna może nadal korzystać z posortowanej listy w pamięci podręcznej.

Używaj kluczy układu leniwego

Układy leniwe efektywnie ponownie wykorzystują elementy, ponownie generując lub komponując je tylko wtedy, gdy jest to konieczne. Możesz jednak pomóc w optymalizacji układów leniwych pod kątem rekompozycji.

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, przy czym najnowsza notatka znajduje się na górze.

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

Ten kod ma jednak problem. Załóżmy, że zmieniono notatkę na dole. Jest to teraz najnowsza 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 zdaje sobie sprawy, że niezmienione elementy są tylko przenoszone na liście. Zamiast tego Compose uważa, że stary „element 2” został usunięty, a dla elementu 3, elementu 4 i tak dalej utworzono nowy. W rezultacie Compose ponownie komponuje każdy element na liście, mimo że tylko jeden z nich uległ zmianie.

Rozwiązaniem jest podanie kluczy elementów. Podanie stabilnego klucza dla każdego elementu pozwala Compose uniknąć niepotrzebnych ponownych kompozycji. W tym przypadku Compose może stwierdzić, że element znajdujący się teraz na pozycji 3 jest tym samym elementem, który wcześniej znajdował się na pozycji 2. Ponieważ żadne dane tego elementu nie uległy zmianie, Compose nie musi go ponownie komponować.

@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)
        }
    }
}

Używaj funkcji derivedStateOf, aby ograniczyć ponowne komponowanie

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ć ponownie komponowany częściej niż to konieczne. Załóżmy na przykład, że wyświetlasz listę z możliwością przewijania. 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 jeśli użytkownik przewija listę, stan listState ciągle się zmienia, gdy użytkownik przeciąga palcem. Oznacza to, że lista jest ciągle ponownie komponowana. Nie musisz jednak tak często ponownie komponować listy – nie musisz tego robić, dopóki na dole nie pojawi się nowy element. To dużo dodatkowych obliczeń, które negatywnie wpływają na wydajność interfejsu.

Rozwiązaniem jest użycie stanu pochodnego. Stan pochodny pozwala określić, które zmiany stanu powinny wywoływać rekompozycję. W tym przypadku określ, że interesuje Cię zmiana pierwszego widocznego elementu. Gdy ta wartość stanu się zmieni, interfejs musi zostać ponownie skomponowany, ale jeśli użytkownik nie przewinął jeszcze wystarczająco, aby nowy element pojawił się na górze, nie musi tego robić.

val listState = rememberLazyListState()

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

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

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Odłóż odczyty tak długo, jak to możliwe

Gdy zostanie zidentyfikowany problem z wydajnością, odłożenie odczytów stanu może pomóc. Odłożenie odczytów stanu zapewni, że Compose ponownie uruchomi minimalną możliwą ilość kodu podczas rekompozycji. Jeśli na przykład interfejs ma stan, który jest przenoszony wysoko w drzewie kompozycyjnym, a stan jest odczytywany w kompozycji podrzędnej, możesz opakować odczyt stanu w funkcję lambda. Dzięki temu odczyt nastąpi tylko wtedy, gdy będzie to rzeczywiście konieczne. Informacje znajdziesz w implementacji w Jetsnack przykładowej aplikacji. Jetsnack implementuje efekt zwijania paska narzędzi na ekranie szczegółów. Aby dowiedzieć się, dlaczego ta technika działa, przeczytaj post na blogu Jetpack Compose: Debugging Recomposition.

Aby uzyskać ten efekt, funkcja kompozycyjna Title potrzebuje przesunięcia przewijania, aby przesunąć się za pomocą funkcji 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ę zmieni, Compose unieważnia najbliższy zakres rekompozycji elementu nadrzędnego. W tym przypadku najbliższy zakres to funkcja kompozycyjna SnackDetail. Pamiętaj, że Box to funkcja wbudowana, a więc nie jest zakresem ponownego komponowania. Dlatego Compose ponownie komponuje funkcję SnackDetail i wszystkie funkcje kompozycyjne w niej.SnackDetail Jeśli zmienisz kod tak, aby odczytywał stan tylko tam, gdzie jest on rzeczywiście używany, możesz zmniejszyć liczbę elementów, które trzeba ponownie komponować.

@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 przewijania jest teraz lambdą. Oznacza to, że funkcja Title może nadal odwoływać się do przeniesionego stanu, ale wartość jest odczytywana tylko w funkcji Title, gdzie jest rzeczywiście potrzebna. W rezultacie, gdy wartość przewijania się zmieni, najbliższym zakresem ponownego komponowania będzie teraz funkcja kompozycyjna Title – Compose nie musi już ponownie komponować całego elementu Box.

To dobra poprawa, ale możesz zrobić więcej. Jeśli powodujesz ponowne komponowanie tylko po to, aby ponownie ułożyć lub narysować funkcję kompozycyjną, powinieneś/powinnaś zachować ostrożność. W tym przypadku zmieniasz tylko przesunięcie funkcji kompozycyjnej Title, co można zrobić w fazie układu.

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

Wcześniej kod używał 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 odczytuje stan przewijania w fazie układu. W rezultacie, gdy stan przewijania się zmieni, Compose może całkowicie pominąć fazę kompozycji i przejść bezpośrednio do fazy układu. Gdy przekazujesz często zmieniające się zmienne stanu do modyfikatorów, używaj wersji lambda modyfikatorów, jeśli to możliwe.

Oto kolejny przykład tego 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 przełącza się między 2 kolorami. Ten stan zmienia się więc bardzo często. Funkcja kompozycyjna odczytuje ten stan w modyfikatorze tła. W rezultacie pole musi być ponownie komponowane w każdej klatce, ponieważ kolor zmienia się w każdej klatce.

Aby to poprawić, użyj modyfikatora opartego na lambdzie – w tym przypadku drawBehind. Oznacza to, że stan koloru jest odczytywany tylko w fazie rysowania. W rezultacie Compose może całkowicie pominąć fazy kompozycji i układu – gdy kolor się zmieni, Compose przejdzie bezpośrednio do fazy rysowania.

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

Unikaj zapisów wstecznych

Compose zakłada, że nigdy nie będziesz zapisywać stanu, który został już odczytany. Gdy to zrobisz, nazywa się to zapisem wstecznym i może powodować rekompozycję w każdej klatce w nieskończoność.

Poniższa funkcja kompozycyjna pokazuje przykład tego rodzaju 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 ponowne komponowanie, licznik szybko zwiększa się w nieskończonej pętli, ponieważ Compose ponownie komponuje tę funkcję kompozycyjną, widzi nieaktualny odczyt stanu i dlatego planuje kolejne ponowne komponowanie.

Możesz całkowicie uniknąć zapisów wstecznych, nigdy nie zapisując stanu w kompozycji. Jeśli to możliwe, zawsze zapisuj stan w odpowiedzi na zdarzenie i w lambdzie, tak jak w poprzednim przykładzie onClick.

Dodatkowe materiały