State i Jetpack Compose

Stan w aplikacji to dowolna wartość, która może się zmieniać w czasie. Jest to bardzo szeroka definicja, która obejmuje wszystko, od bazy danych Room po zmienną w klasie.

Wszystkie aplikacje na Androida wyświetlają użytkownikowi stan. Oto kilka przykładów stanu w aplikacjach na Androida:

  • Pasek powiadomień, który wyświetla się, gdy nie można nawiązać połączenia sieciowego.
  • post na blogu i powiązane z nim komentarze;
  • animacje falowania na przyciskach, które odtwarzają się, gdy użytkownik je kliknie;
  • naklejki, na których użytkownik może rysować;

Jetpack Compose pomaga precyzyjnie określić, gdzie i jak przechowujesz i wykorzystujesz stan w aplikacji na Androida. Ten przewodnik skupia się na połączeniu między stanem a funkcjami kompozytowymi oraz na interfejsach API, które Jetpack Compose oferuje do pracy ze stanem.

Stan i skład

Compose jest deklaratywny, więc jedynym sposobem na jego aktualizację jest wywołanie tej samej funkcji kompozycyjnej z nowymi argumentami. Te argumenty reprezentują stan interfejsu. Za każdym razem, gdy stan jest aktualizowany, następuje rekompozycja. W rezultacie elementy takie jak TextField nie są automatycznie aktualizowane, tak jak w przypadku widoków opartych na imperatywnym języku XML. Aby element kompozycyjny mógł się odpowiednio zaktualizować, musi zostać wyraźnie poinformowany o nowym stanie.

@Composable
private fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello!",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(
            value = "",
            onValueChange = { },
            label = { Text("Name") }
        )
    }
}

Jeśli uruchomisz ten kod i spróbujesz wpisać tekst, zobaczysz, że nic się nie dzieje. Dzieje się tak, ponieważ TextField nie aktualizuje się samoczynnie, lecz tylko wtedy, gdy zmienia się jego parametr value. Wynika to ze sposobu działania kompozycji i rekompozycji w Compose.

Początkowa kompozycja: utworzenie kompozycji przez pierwsze uruchomienie funkcji kompozycyjnych.

Rekompilacja: ponowne uruchamianie funkcji kompozycyjnych w celu zaktualizowania kompozycji po zmianie danych.

Więcej informacji o początkowym tworzeniu i ponownym tworzeniu znajdziesz w artykule Myślenie w Compose.

Stan w funkcjach kompozycyjnych

Funkcje typu „composable” mogą używać interfejsu API remember do przechowywania obiektu w pamięci. Wartość obliczona przez remember jest przechowywana w kompozycji podczas początkowej kompozycji, a przechowywana wartość jest zwracana podczas rekompozycji. W usłudze remember można przechowywać zarówno obiekty zmienne, jak i niezmienne.

mutableStateOf tworzy obiekt obserwacyjny MutableState<T>, który jest typem obiektu obserwacyjnego zintegrowanym ze środowiskiem wykonawczym Compose.

interface MutableState<T> : State<T> {
    override var value: T
}

Wszelkie zmiany w value powodują ponowne komponowanie funkcji typu „composable”, które odczytują value.

Obiekt MutableState można zadeklarować w funkcji kompozycyjnej na 3 sposoby:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

Te deklaracje są równoważne i są udostępniane jako ułatwienie składniowe dla różnych zastosowań stanu. Wybierz tę, która zapewnia najłatwiejszy do odczytania kod w tworzonym elemencie kompozycyjnym.

Składnia delegata by wymaga tych importów:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

Zapamiętanej wartości możesz użyć jako parametru innych funkcji kompozycyjnych, a nawet jako logiki w instrukcjach, aby zmienić wyświetlane funkcje kompozycyjne. Jeśli na przykład nie chcesz wyświetlać powitania, gdy nazwa jest pusta, użyj stanu w instrukcji if:

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by remember { mutableStateOf("") }
        if (name.isNotEmpty()) {
            Text(
                text = "Hello, $name!",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

remember pomaga zachować stan między rekompozycjami, ale nie między zmianami konfiguracji. W tym celu musisz użyć rememberSaveable. rememberSaveable automatycznie zapisuje każdą wartość, którą można zapisać w Bundle. W przypadku innych wartości możesz przekazać niestandardowy obiekt zapisu.

Inne obsługiwane typy stanów

Compose nie wymaga używania MutableState<T> do przechowywania stanu – obsługuje inne typy obiektów możliwych do obserwowania. Zanim odczytasz inny typ obserwowalny w Compose, musisz przekonwertować go na State<T>, aby funkcje kompozycyjne mogły automatycznie przeprowadzać rekompozycję po zmianie stanu.

Compose zawiera funkcje do tworzenia State<T> z typowych typów obserwowalnych używanych w aplikacjach na Androida. Zanim użyjesz tych integracji, dodaj odpowiednie artefakty w ten sposób:

Kotlin

dependencies {
      ...
      implementation("androidx.lifecycle:lifecycle-runtime-compose:2.11.0")
}

Dynamiczny

dependencies {
      ...
      implementation "androidx.lifecycle:lifecycle-runtime-compose:2.11.0"
}
  • Flow: collectAsState()

    collectAsState jest podobny do collectAsStateWithLifecycle, ponieważ również zbiera wartości z Flow i przekształca je w State Compose.

    Używaj collectAsState w przypadku kodu niezależnego od platformy zamiast collectAsStateWithLifecycle, który jest przeznaczony tylko na Androida.

    W przypadku collectAsState dodatkowe zależności nie są wymagane, ponieważ jest on dostępny w compose-runtime.

  • LiveData: observeAsState()

    observeAsState() zaczyna obserwować ten LiveData i reprezentuje jego wartości za pomocą State.

    W pliku build.gradle wymagana jest ta zależność:

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-livedata:1.12.1")
}

Dynamiczny

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-livedata:1.12.1"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava2:1.12.1")
}

Dynamiczny

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava2:1.12.1"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava3:1.12.1")
}

Dynamiczny

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava3:1.12.1"
}

Stanowe i bezstanowe

Funkcja kompozycyjna, która używa remember do przechowywania obiektu, tworzy stan wewnętrzny, co czyni ją stanową. HelloContent to przykład stanowego elementu kompozycyjnego, ponieważ przechowuje i modyfikuje on stan name wewnętrznie. Może to być przydatne w sytuacjach, gdy element wywołujący nie musi kontrolować stanu i może go używać bez konieczności samodzielnego zarządzania nim. Jednak komponenty kompozycyjne ze stanem wewnętrznym są mniej przydatne i trudniejsze do przetestowania.

Bezstanowy element kompozycyjny to taki, który nie przechowuje żadnego stanu. Typowym sposobem na osiągnięcie bezstanowości jest przenoszenie stanu.

Podczas tworzenia funkcji kompozycyjnych wielokrotnego użytku często chcesz udostępnić zarówno stanową, jak i bezstanową wersję tej samej funkcji kompozycyjnej. Wersja stanowa jest wygodna dla obiektów wywołujących, które nie dbają o stan, a wersja bezstanowa jest niezbędna dla obiektów wywołujących, które muszą kontrolować lub podnosić stan.

Przenoszenie stanu

Przenoszenie stanu w Compose to wzorzec przenoszenia stanu do elementu wywołującego funkcję kompozycyjną, aby uczynić ją bezstanową. Ogólny wzorzec przenoszenia stanu w Jetpack Compose polega na zastąpieniu zmiennej stanu 2 parametrami:

  • value: T: bieżąca wartość do wyświetlenia.
  • onValueChange: (T) -> Unit: zdarzenie, które żąda zmiany wartości, gdzie T to proponowana nowa wartość.

Nie musisz jednak ograniczać się do onValueChange. Jeśli do funkcji kompozycyjnej pasują bardziej szczegółowe zdarzenia, zdefiniuj je za pomocą funkcji lambda.

Stan, który jest przenoszony w ten sposób, ma kilka ważnych właściwości:

  • Jedno źródło wiarygodnych danych: przenosząc stan zamiast go duplikować, zapewniamy, że istnieje tylko jedno źródło wiarygodnych danych. Pomaga to uniknąć błędów.
  • Hermetyzacja: tylko komponenty stanowe mogą modyfikować swój stan. Jest całkowicie wewnętrzny.
  • Możliwość udostępniania: stan podniesiony można udostępniać wielu elementom kompozycyjnym. Jeśli chcesz odczytać name w innym elemencie kompozycyjnym, możesz to zrobić dzięki przeniesieniu.
  • Możliwość przechwytywania: elementy wywołujące bezstanowe elementy kompozycyjne mogą ignorować lub modyfikować zdarzenia przed zmianą stanu.
  • Rozdzielenie: stan funkcji kompozycyjnych bezstanowych może być przechowywany w dowolnym miejscu. Możesz na przykład przenieść name do ViewModel.

W przykładowym przypadku wyodrębniasz name i onValueChange z HelloContent i przenosisz je w górę drzewa do funkcji kompozycyjnej HelloScreen, która wywołuje HelloContent.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
    }
}

Dzięki przeniesieniu stanu z funkcji HelloContent łatwiej jest zrozumieć funkcję kompozycyjną, używać jej w różnych sytuacjach i testować. HelloContent jest oddzielony od sposobu przechowywania jego stanu. Rozdzielenie oznacza, że jeśli zmodyfikujesz lub zastąpisz HelloScreen, nie musisz zmieniać sposobu implementacji HelloContent.

Jednokierunkowy przepływ danych

Wzorzec, w którym stan jest przekazywany w dół, a zdarzenia w górę, nazywa się jednokierunkowym przepływem danych. W takim przypadku stan zmienia się z HelloScreen na HelloContent, a zdarzenia z HelloContent na HelloScreen. Dzięki jednokierunkowemu przepływowi danych możesz oddzielić elementy kompozycyjne, które wyświetlają stan w interfejsie, od części aplikacji, które przechowują i zmieniają stan.

  1. Stan powinien być podniesiony do co najmniej najniższego wspólnego elementu nadrzędnego wszystkich funkcji kompozycyjnych, które używają tego stanu (odczyt).
  2. Stan powinien być podniesiony do co najmniej najwyższego poziomu, na którym może zostać zmieniony (zapis).
  3. Jeśli 2 stany zmieniają się w odpowiedzi na te same zdarzenia, powinny być wyniesione razem.

Możesz podnieść stan wyżej, niż wymagają tego te reguły, ale zbyt niskie podniesienie stanu utrudnia lub uniemożliwia stosowanie jednokierunkowego przepływu danych.

Więcej informacji znajdziesz na stronie Gdzie przenieść stan.

Przywracanie stanu w Compose

Interfejs API rememberSaveable działa podobnie do remember, ponieważ zachowuje stan między rekompozycjami, a także między ponownym tworzeniem aktywności lub procesu za pomocą mechanizmu zapisanego stanu instancji. Dzieje się tak na przykład, gdy ekran jest obracany.

Sposoby przechowywania stanu

Wszystkie typy danych dodane do Bundle są zapisywane automatycznie. Jeśli chcesz zapisać coś, czego nie można dodać do Bundle, masz kilka opcji.

Parcelize

Najprostszym rozwiązaniem jest dodanie do obiektu adnotacji @Parcelize. Obiekt staje się możliwy do spakowania i można go dołączyć do pakietu. Ten kod na przykład tworzy typ danych City, który można spakować, i zapisuje go w stanie.

@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

MapSaver

Jeśli z jakiegoś powodu @Parcelize nie jest odpowiedni, możesz użyć mapSaver, aby zdefiniować własną regułę konwertowania obiektu na zestaw wartości, które system może zapisać w Bundle.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

ListSaver

Aby uniknąć konieczności definiowania kluczy mapy, możesz też użyć listSaver i użyć jego indeksów jako kluczy:

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

Zmienne stanu w Compose

Prostym przenoszeniem stanu można zarządzać w samych funkcjach typu „composable”. Jeśli jednak ilość stanu do śledzenia wzrośnie lub pojawi się logika do wykonania w funkcjach typu „composable”, dobrą praktyką jest przekazanie odpowiedzialności za logikę i stan innym klasom: zmiennym stanu.

Pamiętaj, że w innych materiałach zmienne stanu są też nazywane obiektami stanu podniesionego.

Więcej informacji znajdziesz w dokumentacji dotyczącej przenoszenia stanu w Compose lub na stronie Zmienne stanu i stan interfejsu w przewodniku po architekturze.

Ponowne uruchamianie obliczeń zapamiętywania po zmianie klawiszy

Interfejs API remember jest często używany razem z MutableState:

var name by remember { mutableStateOf("") }

W tym przypadku użycie funkcji remember sprawia, że wartość MutableState przetrwa rekompozycje.

Ogólnie rzecz biorąc, remember przyjmuje parametr lambda calculation. Gdy funkcja remember jest uruchamiana po raz pierwszy, wywołuje funkcję lambda calculation i zapisuje jej wynik. Podczas rekompozycji remember zwraca ostatnio zapisaną wartość.

Oprócz buforowania stanu możesz też używać remember do przechowywania dowolnego obiektu lub wyniku operacji w kompozycji, której inicjowanie lub obliczanie jest kosztowne. Możesz nie chcieć powtarzać tego obliczenia przy każdej rekompozycji. Przykładem jest utworzenie obiektu ShaderBrush, co jest kosztowną operacją:

val brush = remember {
    ShaderBrush(
        BitmapShader(
            ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
            Shader.TileMode.REPEAT,
            Shader.TileMode.REPEAT
        )
    )
}

remember przechowuje wartość, dopóki nie opuści kompozycji. Istnieje jednak sposób na unieważnienie wartości z pamięci podręcznej. Interfejs API remember przyjmuje też parametr key lub keys. Jeśli którykolwiek z tych kluczy ulegnie zmianie, przy następnym rekomponowaniu funkcji remember unieważni pamięć podręczną i ponownie wykona blok lambda obliczeń. Ten mechanizm zapewnia kontrolę nad czasem życia obiektu w kompozycji. Obliczenia pozostają ważne do momentu zmiany danych wejściowych, a nie do momentu, gdy zapamiętana wartość opuści kompozycję.

Poniższe przykłady pokazują, jak działa ten mechanizm.

W tym fragmencie kodu tworzony jest obiekt ShaderBrush, który jest używany jako tło funkcji kompozycyjnej Box. remember przechowuje instancję ShaderBrush, ponieważ jej ponowne utworzenie jest kosztowne, jak wyjaśniono wcześniej. remember przyjmuje avatarRes jako parametr key1, który jest wybranym obrazem tła. Jeśli avatarRes ulegnie zmianie, pędzel zostanie ponownie skomponowany z nowym obrazem i ponownie zastosowany do Box. Może się tak zdarzyć, gdy użytkownik wybierze inny obraz tła z selektora.

@Composable
private fun BackgroundBanner(
    @DrawableRes avatarRes: Int,
    modifier: Modifier = Modifier,
    res: Resources = LocalContext.current.resources
) {
    val brush = remember(key1 = avatarRes) {
        ShaderBrush(
            BitmapShader(
                ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
                Shader.TileMode.REPEAT,
                Shader.TileMode.REPEAT
            )
        )
    }

    Box(
        modifier = modifier.background(brush)
    ) {
        /* ... */
    }
}

W następnym fragmencie kodu stan jest przenoszony do zwykłej klasy zmiennej stanu MyAppState. Udostępnia funkcję rememberMyAppState, która inicjuje instancję klasy za pomocą remember. Udostępnianie takich funkcji w celu utworzenia instancji, która przetrwa rekompozycje, jest powszechnym wzorcem w Compose. Funkcja rememberMyAppState otrzymuje windowSizeClass, który służy jako parametr key dla remember. Jeśli ten parametr się zmieni, aplikacja musi ponownie utworzyć zwykłą klasę zmiennej stanu z najnowszą wartością. Może się tak zdarzyć, jeśli na przykład użytkownik obróci urządzenie.

@Composable
private fun rememberMyAppState(
    windowSizeClass: WindowSizeClass
): MyAppState {
    return remember(windowSizeClass) {
        MyAppState(windowSizeClass)
    }
}

@Stable
class MyAppState(
    private val windowSizeClass: WindowSizeClass
) { /* ... */ }

Compose używa implementacji equals klasy, aby określić, czy klucz uległ zmianie, i unieważnić zapisaną wartość.

Przechowywanie stanu z kluczami poza rekompozycją

Interfejs API rememberSaveable to otoka interfejsu remember, która może przechowywać dane w Bundle. Ten interfejs API pozwala zachować stan nie tylko podczas rekompozycji, ale też podczas ponownego tworzenia aktywności i śmierci procesu zainicjowanej przez system. rememberSaveable otrzymuje parametry input w tym samym celu, w którym remember otrzymuje parametry keys. Pamięć podręczna jest unieważniana, gdy zmienią się jakiekolwiek dane wejściowe. Gdy funkcja zostanie ponownie skomponowana, rememberSaveable ponownie wykona blok lambda obliczeń.

W tym przykładzie rememberSaveable przechowuje userTypedQuery do momentu, gdy typedQuery ulegnie zmianie:

var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
        TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
}

Więcej informacji

Więcej informacji o stanie i Jetpack Compose znajdziesz w tych materiałach.

Przykłady

Codelabs

Filmy

Blogi