Stosowanie sprawdzonych metod

Możesz napotkać typowe problemy związane z Compose. Te błędy mogą spowodować, że kod będzie działać pozornie dobrze, ale wpłynie to negatywnie na wydajność interfejsu. Postępuj zgodnie ze sprawdzonymi metodami, aby zoptymalizować aplikację w Compose.

Używaj remember, aby minimalizować kosztowne obliczenia

Funkcje kompozycyjne mogą być uruchamiane bardzo często, nawet w przypadku 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żesz pobierać w razie potrzeby.

Oto przykład kodu, który wyświetla posortowaną listę nazw, 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 ContactsList jest ponownie komponowany, cała lista kontaktów jest ponownie sortowana, mimo że nie uległa zmianie. Jeśli użytkownik przewinie listę, element Composable zostanie ponownie skomponowany za każdym razem, gdy pojawi się nowy wiersz.

Aby rozwiązać ten problem, posortuj listę poza LazyColumn i zapisz posortowaną listę za pomocą 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 po raz pierwszy zostanie utworzona wartość ContactList. Jeśli zmienią się kontakty lub komparator, posortowana lista zostanie wygenerowana ponownie. W przeciwnym razie funkcja kompozycyjna może nadal korzystać z zapisanej w pamięci podręcznej posortowanej listy.

Używanie klawiszy układu leniwego

Układy leniwe efektywnie ponownie wykorzystują elementy, odtwarzając lub ponownie komponując je tylko wtedy, gdy jest to konieczne. Możesz jednak zoptymalizować leniwe układy 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 na górze znajduje się notatka zmodyfikowana najpóźniej.

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

Wystąpił jednak problem z tym kodem. Załóżmy, że dolna nuta została zmieniona. Jest to teraz ostatnio zmodyfikowana notatka, więc trafia na początek listy, a każda inna notatka przesuwa się o jedno miejsce w dół.

Bez Twojej pomocy funkcja tworzenia nie wie, że niezmienione elementy są tylko przesuwane na liście. Zamiast tego funkcja Compose uzna, że stary „element 2” został usunięty, a nowy został utworzony dla elementu 3, elementu 4 i wszystkich kolejnych. W rezultacie funkcja Compose ponownie renderuje każdy element na liście, mimo że tylko jeden z nich uległ zmianie.

Rozwiązaniem jest podanie kluczy elementów. Zapewnienie stabilnego klucza dla każdego elementu pozwala uniknąć niepotrzebnych ponownych kompozycji. W takim przypadku Compose może stwierdzić, że element, który znajduje się teraz na pozycji 3, to ten sam element, który wcześniej był 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żyj parametru derivedStateOf, aby ograniczyć ponowne kompozycje

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ż jest 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ę, wartość listState ciągle się zmienia podczas przesuwania palcem. Oznacza to, że lista jest stale przekształcana. Nie musisz jednak tak często ponownie komponować listy – wystarczy to zrobić, gdy na dole 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 zależy Ci na tym, kiedy zmieni się pierwszy widoczny element. Gdy zmieni się wartość stanu tego, interfejs musi zostać ponownie skomponowany, ale jeśli użytkownik nie przewinął jeszcze wystarczająco, aby przenieść nowy element na górę, 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 wykryty problem z wydajnością, odroczenie odczytu stanu może pomóc. Odroczenie odczytu stanu zapewni, że podczas rekompozycji Compose ponownie uruchomi minimalną możliwą ilość kodu. Jeśli na przykład interfejs użytkownika ma stan, który jest przenoszony wysoko w drzewie funkcji kompozycyjnych, a stan jest odczytywany w podrzędnej funkcji kompozycyjnej, możesz opakować odczyt stanu w funkcji lambda. Dzięki temu odczyt następuje tylko wtedy, gdy jest to rzeczywiście potrzebne. Więcej informacji znajdziesz w implementacji w przykładowej aplikacji Jetsnack. W aplikacji Jetsnack na ekranie szczegółów zastosowano efekt podobny do zwijającego się paska narzędzi. Aby dowiedzieć się, dlaczego ta technika działa, przeczytaj post na blogu Jetpack Compose: Debugging Recomposition (w języku angielskim).

Aby uzyskać ten efekt, funkcja kompozycyjna 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ę zmieni, Compose unieważni najbliższy zakres rekompozycji elementu nadrzędnego. W tym przypadku najbliższy zakres to SnackDetailfunkcja kompozycyjna. Pamiętaj, że Box to funkcja wbudowana, więc nie jest zakresem ponownego komponowania. Dlatego funkcja Compose ponownie komponuje SnackDetail i wszystkie funkcje kompozycyjne w SnackDetail. Jeśli zmienisz kod tak, aby odczytywał tylko stan, w którym jest używany, możesz zmniejszyć liczbę elementów, które wymagają ponownego komponowania.

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

To dobra poprawa, ale stać Cię na więcej. Powinno to wzbudzić Twoje podejrzenia, jeśli powodujesz rekompozycję tylko po to, aby ponownie rozmieścić lub narysować funkcję kompozycyjną. 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ł funkcji Modifier.offset(x: Dp, y: Dp), która jako parametr przyjmuje przesunięcie. Przełączając się na wersję lambda modyfikatora, możesz mieć pewność, że funkcja odczytuje stan przewijania w fazie układu. Dzięki temu, gdy zmieni się stan przewijania, Compose może całkowicie pominąć fazę kompozycji 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 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 zmienia się między dwoma 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 funkcji lambda – w tym przypadku drawBehind. Oznacza to, że stan koloru jest odczytywany tylko w fazie rysowania. Dzięki temu Compose może całkowicie pominąć fazy kompozycji i układu – gdy zmieni się kolor, Compose przechodzi bezpośrednio do fazy rysowania.

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

Unikaj zapisów wstecznych

Funkcja pisania ma podstawowe założenie, że nigdy nie będziesz pisać do stanu, który został już odczytany. W takim przypadku mówimy o zapisie wstecznym, który może powodować nieustanną rekompozycję w każdej klatce.

Poniższy komponent 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, które powoduje ponowne komponowanie, licznik szybko rośnie w nieskończonej pętli, ponieważ Compose ponownie komponuje ten komponent, widzi odczyt stanu, który jest nieaktualny, i planuje kolejne ponowne komponowanie.

Aby całkowicie uniknąć zapisywania wstecznego, nigdy nie zapisuj stanu w kompozycji. Jeśli to możliwe, zawsze pisz do stanu w odpowiedzi na zdarzenie i w funkcji lambda, tak jak w poprzednim przykładzie onClick.

Szczegółowy przewodnik po zapisach wstecznych, pętlach rekompozycji i koordynacji faz znajdziesz w artykule Zapisy wsteczne w Compose.

Dodatkowe materiały