State i Jetpack Compose

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

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

  • Pasek informacji, który wyświetla się, gdy nie można nawiązać połączenia z siecią.
  • Post na blogu i powiązane komentarze.
  • Animacje falowania przycisków, które odtwarzają się po kliknięciu przez użytkownika.
  • Naklejki, które użytkownik może narysować na obrazie.

Jetpack Compose pomaga określić, gdzie i jak przechowywać stan oraz jak go używać w aplikacji na Androida. Ten przewodnik skupia się na związku między stanem a komponowalnymi komponentami oraz na interfejsach API, które Jetpack Compose udostępnia, aby ułatwić pracę ze stanem.

Stan i skład

Funkcja kompozytorna jest deklaratywna, więc jedynym sposobem na jej zaktualizowanie jest wywołanie tej samej funkcji kompozytowanej z nowymi argumentami. Te argumenty są reprezentacją stanu interfejsu użytkownika. Za każdym razem, gdy stan jest aktualizowany, następuje rekompozycja. W efekcie takie elementy jak TextField nie są aktualizowane automatycznie tak jak w wymiarach na podstawie XML. Aby komponent mógł się odpowiednio zaktualizować, musi być 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, nic się nie stanie. Dzieje się tak, ponieważ usługa TextField nie aktualizuje się sama, tylko wtedy, gdy zmieni się jej parametr value. Wynika to z tego, jak działają funkcje tworzenia i ponownego tworzenia w Compose.

Więcej informacji o pierwotnej i ponownej kompozycji znajdziesz w artykule Myślenie w komponowaniu.

Stan w funkcjach kompozytywnych

Funkcje składane mogą używać interfejsu remember API do przechowywania obiektu w pamięci. Wartość obliczona przez funkcję remember jest przechowywana w kompozycji podczas początkowego tworzenia kompozycji, a przechowywana wartość jest zwracana podczas ponownego tworzenia kompozycji. remember można używać do przechowywania zarówno obiektów zmiennych, jak i niezmiennych.

mutableStateOf tworzy observable MutableState<T>, który jest typem observable zintegrowanym z runtimem Compose.

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

Wszelkie zmiany w value powodują ponowne skompilowanie wszystkich funkcji składanych, które odczytują value.

Istnieją 3 sposoby deklarowania obiektu MutableState w komponowalnym:

  • 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łużą jako element ułatwiający stosowanie różnych stanów. Wybierz ten, który generuje kod najłatwiejszy do odczytania w komponowalnym elemencie, który piszesz.

Składnia delegata by wymaga tych importów:

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

Zapamiętaną wartość możesz użyć jako parametru dla innych komponentów lub nawet jako logiki w oświadczeniach, aby zmienić wyświetlane komponenty. Jeśli np. nie chcesz wyświetlać pozdrowienia, gdy nazwa jest pusta, użyj stanu w oświadczeniu 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") }
        )
    }
}

Funkcja remember pomaga zachować stan podczas rekompozycji, ale nie jest on zachowywany podczas zmian konfiguracji. W tym celu musisz użyć rememberSaveable. rememberSaveable automatycznie zapisuje wszystkie wartości, które można zapisać w Bundle. W przypadku innych wartości możesz podać obiekt niestandardowego obiektu zapisu.

Inne obsługiwane typy stanów

W komponowaniu nie musisz używać MutableState<T> do przechowywania stanu. Obsługuje ono inne obserwowalne typy. Zanim odczytasz inny typ obserwowalny w komponencie, musisz go przekonwertować na State<T>, aby komponenty mogły automatycznie się ponownie skompilować po zmianie stanu.

Compose jest dostarczany z funkcjami umożliwiającymi tworzenie State<T> na podstawie typów obserwowalnych powszechnie używanych w aplikacjach na Androida. Zanim użyjesz tych integracji, dodaj odpowiednie elementy w sposób opisany poniżej:

  • Flow: collectAsStateWithLifecycle()

    collectAsStateWithLifecycle() zbiera wartości z Flow w sposób uwzględniający cykl życia, co pozwala aplikacji oszczędzać zasoby. Reprezentuje ona ostatnią wyemitowaną wartość z funkcji Compose State. Zalecamy korzystanie z tego interfejsu API do zbierania danych w aplikacjach na Androida.

    W pliku build.gradle (powinien być w wersji 2.6.0-beta01 lub nowszej) wymagana jest ta zależność:

Kotlin

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

Groovy

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

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

    Zamiast collectAsStateWithLifecycle, który jest przeznaczony tylko na Androida, używaj kodu niezależnego od platformy (collectAsState).

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

  • LiveData: observeAsState()

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

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

Kotlin

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

Groovy

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

Kotlin

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

Groovy

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

Kotlin

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

Groovy

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

Stanowa a bezstanowa

Składowa, która używa remember do przechowywania obiektu, tworzy stan wewnętrzny, dzięki czemu składowa staje się stanowa. HelloContent jest przykładem kompozytu stanu, ponieważ przechowuje i modyfikuje stan name wewnętrznie. Może to być przydatne w sytuacjach, gdy dzwoniący nie musi kontrolować stanu i może go używać bez konieczności samodzielnego zarządzania nim. Jednak komponenty z wewnętrznym stanem są zwykle mniej wielokrotnego użytku i trudniejsze do przetestowania.

Komponentem bezstanowym jest komponent, który nie przechowuje żadnego stanu. Prostym sposobem na osiągnięcie stanu bezstanowego jest użycie hoistingu stanu.

Podczas tworzenia wielokrotnego użytku komponentów często chcesz udostępnić wersję z stanem i bezstanową tego samego komponentu. Wersja ze stanem jest wygodna dla wywołujących, którym stan nie jest potrzebny, a wersja bezstanowa jest niezbędna dla wywołujących, którzy muszą kontrolować stan lub go podnosić.

Przenoszenie stanu

Przenoszenie stanu w komponowalnym to wzór przenoszenia stanu do wywołującego komponentu, aby uczynić go stanem bezstanowym. Ogólny wzorzec podnoszenia 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 prosi o zmianę wartości, gdzie T to proponowana nowa wartość

Nie musisz jednak ograniczać się do onValueChange. Jeśli w przypadku kompozytowanego elementu odpowiednie są bardziej szczegółowe zdarzenia, zdefiniuj je za pomocą funkcji lambda.

Stan podniesiony w ten sposób ma kilka ważnych właściwości:

  • Jedno źródło wiarygodnych danych: przeniesienie stanu zamiast jego powielania zapewnia, że istnieje tylko jedno źródło wiarygodnych danych. Pomaga to uniknąć błędów.
  • Opakowane: stan mogą modyfikować tylko komponenty stateful. Jest to całkowicie wewnętrzna zmiana.
  • Możliwość udostępniania: stan zasobów może być udostępniany wielu elementom. Jeśli chcesz odczytać name w innej składanej, możesz to zrobić dzięki funkcji podnoszenia.
  • Możliwość przechwycenia: wywołujący bezstanowe komponenty mogą zignorować lub zmodyfikować zdarzenia przed zmianą stanu.
  • Rozdzielone: stan bezstanowych komponentów może być przechowywany w dowolnym miejscu. Teraz można na przykład przenieść name do ViewModel.

W tym przykładzie z komponentu HelloContent wyodrębniasz elementy nameonValueChange, a potem przenosisz je wyżej w drzewie do kompozytowego komponentu HelloScreen, który wywołuje komponent 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 wyodrębnieniu stanu z poziomu HelloContent łatwiej jest analizować komponent, używać go ponownie w różnych sytuacjach i testować. HelloContent jest odłączony od sposobu przechowywania stanu. Odłączenie oznacza, że jeśli zmodyfikujesz lub zastąpisz HelloScreen, nie musisz zmieniać sposobu implementacji HelloContent.

Wzór, w którym stan maleje, a liczba zdarzeń rośnie, nazywa się jednokierunkowym przepływem danych. W tym przypadku stan zmienia się z HelloScreen na HelloContent, a zdarzenia z HelloContent na HelloScreen. Dzięki jednokierunkowemu przepływowi danych możesz odłączyć komponenty wyświetlające stan w interfejsie od części aplikacji, które przechowują i zmieniają stan.

Więcej informacji znajdziesz na stronie Gdzie można umieścić stan.

Przywracanie stanu w sekcji Tworzenie

Interfejs rememberSaveable działa podobnie do interfejsu remember, ponieważ zachowuje stan podczas rekompozycji, a także podczas odtwarzania 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 do wyboru kilka opcji.

Parcelize

Najprostszym rozwiązaniem jest dodanie do obiektu adnotacji @Parcelize. Obiekt staje się podzielny i można go spakować. Na przykład ten kod tworzy typ danych City, który można podzielić, 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ć funkcji mapSaver, aby zdefiniować własną regułę konwertowania obiektu na zestaw wartości, które system może zapisać w atrybucie 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ć funkcji listSaveri jej 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 edytorze

Prostym przenoszeniem stanu można zarządzać w samych składanych funkcjach. Jeśli jednak ilość stanów, które należy śledzić, rośnie lub jeśli w funkcjach składanych pojawia się logika do wykonania, warto delegować logikę i odpowiedzialnie za stany innym klasom: holderom stanu.

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

Ponowne wywoływanie obliczeń pamiętania po zmianie kluczy

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

var name by remember { mutableStateOf("") }

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

Ogólnie funkcja remember przyjmuje parametr calculation lambda. Gdy funkcja rememberzostaje uruchomiona po raz pierwszy, wywołuje funkcję lambda calculation i przechowuje jej wynik. Podczas rekompozycji funkcja remember zwraca ostatnio zapisaną wartość.

Oprócz buforowania stanu możesz też używać funkcji remember do przechowywania dowolnego obiektu lub wyniku operacji w kompozycji, którego inicjowanie lub obliczanie jest kosztowne. Możesz nie chcieć powtarzać tego obliczenia przy każdym ponownym sformułowaniu. Przykładem jest tworzenie obiektu ShaderBrush, który jest kosztowną operacją:

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

remember zapisuje wartość do momentu jej opuszczenia 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óryś z tych kluczy ulegnie zmianie, następnym razem, gdy funkcja zostanie ponownie skompilowana, remember unieważni pamięć podręczną i ponownie wykona obliczenia bloku lambda. Ten mechanizm umożliwia kontrolowanie czasu trwania obiektu w kompozycji. Obliczenia pozostają ważne do momentu zmiany danych wejściowych, a nie do momentu, gdy zapamiętana wartość opuści kompozycję.

Przykłady poniżej pokazują, jak działa ten mechanizm.

W tym fragmencie kodu tworzony jest obiekt ShaderBrush, który jest używany jako tło do malowania w komponowalnym elemencie Box. remember przechowuje instancję ShaderBrush, ponieważ jej odtworzenie jest kosztowne, jak wyjaśniono wcześniej. Funkcja remember przyjmuje parametr avatarRes jako key1, czyli wybrany obraz tła. Jeśli avatarRes ulegnie zmianie, pędzel zostanie ponownie skomponowany z nowym obrazem i ponownie zastosowany do Box. Może się to zdarzyć, gdy użytkownik wybierze inny obraz jako tło w selektorze.

@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 przeniesiony do zwykłej klasy uchwytu stanu MyAppState. Udostępnia on funkcję rememberMyAppState, która inicjuje wystąpienie klasy za pomocą funkcji remember. Wyświetlanie takich funkcji w celu utworzenia instancji, która przetrwa ponowne skompilowanie, jest typowym wzorcem w Compose. Funkcja rememberMyAppState otrzymuje parametr windowSizeClass, który służy jako parametr key funkcji remember. Jeśli ten parametr ulegnie zmianie, aplikacja musi ponownie utworzyć prostą klasę uchwytu stanu z najnowszą wartością. Może się tak zdarzyć, jeśli 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 się zmienił, i unieważnić przechowywaną wartość.

Przechowywanie stanu za pomocą kluczy poza rekompozycją

Interfejs API rememberSaveable to otoczka dla 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 zakończenia procesu przez system. rememberSaveable otrzymuje parametry input w tym samym celu, w jakim remember otrzymuje parametry keys. Pamięć podręczna jest unieważniana, gdy zmieni się dowolny z danych wejściowych. Gdy funkcja zostanie ponownie skompilowana, rememberSaveable ponownie wykona blok funkcji lambda obliczeń.

W tym przykładzie zmienna rememberSaveable przechowuje wartość zmiennej userTypedQuery, dopóki zmienna typedQuery nie 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 dodatkowych materiałach.

Próbki

Ćwiczenia z programowania

Filmy

Blogi