State i Jetpack Compose

Stan w aplikacji to dowolna wartość, która może się zmieniać z upływem czasu. 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:

  • Snackbar, który wyświetla się, gdy nie można nawiązać połączenia z siecią.
  • Post na blogu i powiązane z nim komentarze.
  • Animacje falowania na przyciskach, które są odtwarzane, gdy użytkownik je kliknie.
  • Naklejki, które użytkownik może narysować na obrazie.

Jetpack Compose pomaga Ci wyraźnie określić, gdzie i jak przechowujesz oraz używasz stanu w aplikacji na Androida. Ten przewodnik skupia się na połączeniu między stanem a funkcjami kompozycyjnymi oraz na interfejsach API, które Jetpack Compose oferuje do pracy ze stanem.

Stan i kompozycja

Compose jest deklaratywny, dlatego jedynym sposobem na jego aktualizację jest wywołanie tej samej funkcji kompozycyjnej z nowymi argumentami. Te argumenty są reprezentacjami stanu interfejsu. Za każdym razem, gdy stan jest aktualizowany, następuje rekompozycja. W rezultacie elementy takie jak TextField nie aktualizują się automatycznie, jak to ma miejsce w przypadku imperatywnych widoków opartych na XML. Aby funkcja kompozycyjna mogła się odpowiednio zaktualizować, musi wyraźnie otrzymać informację 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ę samodzielnie – aktualizuje się, gdy zmieni się jego parametr value. Wynika to ze sposobu, w jaki działa kompozycja i ponowne komponowanie w Compose.

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

Ponowne komponowanie to ponowne uruchomienie funkcji kompozycyjnych w celu zaktualizowania kompozycji, gdy zmienią się dane.

Więcej informacji o początkowej kompozycji i ponownym komponowaniu znajdziesz w artykule Myślenie w Compose.

Stan w funkcjach kompozycyjnych

Funkcje kompozycyjne mogą używać interfejsu remember API do przechowywania obiektu w pamięci. Wartość obliczona przez remember jest przechowywana w kompozycji podczas początkowej kompozycji, a przechowywana wartość jest zwracana podczas ponownego komponowania. Funkcji remember można używać do przechowywania zarówno obiektów modyfikowalnych, jak i niemodyfikowalnych.

mutableStateOf tworzy obserwowalny MutableState<T>, który jest obserwowalnym typem zintegrowanym z środowiskiem wykonawczym Compose.

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

Wszelkie zmiany w value powodują ponowne komponowanie wszystkich funkcji kompozycyjnych, które odczytują value.

Obiekt MutableState w funkcji kompozycyjnej można zadeklarować 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łużą jako uproszczenie składni w przypadku różnych zastosowań stanu. Wybierz tę, która sprawi, że kod w pisanej przez Ciebie funkcji kompozycyjnej będzie najłatwiejszy do odczytania.

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

Funkcja remember pomaga zachować stan podczas ponownego komponowania, ale nie zachowuje stanu podczas zmian konfiguracji. W tym celu musisz użyć funkcji rememberSaveable. rememberSaveable automatycznie zapisuje każdą wartość, którą można zapisać w Bundle. W przypadku innych wartości możesz przekazać niestandardowy obiekt zapisujący.

Inne obsługiwane typy stanu

Compose nie wymaga używania MutableState<T> do przechowywania stanu. Obsługuje inne typy obserwowalne. Zanim odczytasz inny typ obserwowalny w Compose, musisz przekonwertować go na State<T>, aby funkcje kompozycyjne mogły automatycznie ponownie się komponować, gdy zmieni się stan.

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

Kotlin

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

Dynamiczny

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

    collectAsState jest podobna do collectAsStateWithLifecycle, ponieważ też zbiera wartości z Flow i przekształca je w Compose State.

    Używaj collectAsState w przypadku kodu niezależnego od platformy zamiast collectAsStateWithLifecycle, która jest dostępna tylko w Androidzie.

    collectAsState nie wymaga dodatkowych zależności, ponieważ jest dostępna w compose-runtime.

  • LiveData: observeAsState()

    observeAsState() rozpoczyna obserwowanie tego 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.11.4")
}

Dynamiczny

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

Kotlin

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

Dynamiczny

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

Kotlin

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

Dynamiczny

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

Stanowa a bezstanowa

Funkcja kompozycyjna, która używa remember do przechowywania obiektu, tworzy stan wewnętrzny, co sprawia, że funkcja kompozycyjna jest stanowa. HelloContent jest przykładem funkcji kompozycyjnej stanowej, ponieważ przechowuje i modyfikuje swój stan name wewnętrznie. Może to być przydatne w sytuacjach, gdy wywołujący nie musi kontrolować stanu i może go używać bez konieczności samodzielnego zarządzania stanem. Jednak funkcje kompozycyjne ze stanem wewnętrznym są zwykle mniej użyteczne i trudniejsze do testowania.

Funkcja kompozycyjna bezstanowa to funkcja kompozycyjna, która nie przechowuje żadnego stanu. Częstym sposobem na osiągnięcie bezstanowości jest przenoszenie stanu.

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

Przenoszenie stanu

Przenoszenie stanu w Compose to wzorzec przenoszenia stanu do wywołującego funkcji kompozycyjnej, aby funkcja kompozycyjna była bezstanowa. 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 jest proponowaną nową wartością

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

Stan 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 funkcje kompozycyjne stanowe mogą modyfikować swój stan. Jest on całkowicie wewnętrzny.
  • Udostępnianie: przeniesiony stan można udostępniać wielu funkcjom kompozycyjnym. Jeśli chcesz odczytać name w innej funkcji kompozycyjnej, przenoszenie stanu Ci to umożliwi.
  • Przechwytywanie: wywołujący funkcje kompozycyjne bezstanowe mogą zignorować lub zmodyfikować zdarzenia przed zmianą stanu.
  • Oddzielenie: stan funkcji kompozycyjnych bezstanowych może być przechowywany w dowolnym miejscu. Na przykład teraz można przenieść name do ViewModel.

W tym przykładzie wyodrębnisz name i onValueChange z HelloContent i przeniesiesz 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 HelloContent łatwiej jest zrozumieć funkcję kompozycyjną, używać jej ponownie w różnych sytuacjach i testować. HelloContent jest oddzielona od sposobu przechowywania jej stanu. Oddzielenie oznacza, że jeśli zmodyfikujesz lub zastąpisz HelloScreen, nie musisz zmieniać sposobu implementacji HelloContent.

Jednokierunkowy przepływ danych

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

  1. Stan należy przenieść co najmniej do najniższego wspólnego elementu nadrzędnego wszystkich funkcji kompozycyjnych, które używają stanu (odczytują go).
  2. Stan należy przenieść co najmniej do najwyższego poziomu, na którym można go zmienić (zapis).
  3. Jeśli 2 stany zmieniają się w odpowiedzi na te same zdarzenia , należy je przenieść razem.

Stan możesz przenieść wyżej niż wymagają tego te reguły, ale przeniesienie stanu zbyt nisko utrudnia lub uniemożliwia stosowanie jednokierunkowego przepływu danych.

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

Przywracanie stanu w Compose

Interfejs rememberSaveable API działa podobnie do remember, ponieważ zachowuje stan podczas ponownego komponowania, a także podczas ponownego tworzenia 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 @Parcelize adnotacji. Obiekt staje się parcelable i można go spakować. Ten kod sprawia na przykład, że typ danych City jest parcelable 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 odpowiednie, 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 nie trzeba było definiować 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"))
    }
}

Kontenery stanu w Compose

Proste przenoszenie stanu można zarządzać w samych funkcjach kompozycyjnych. Jeśli jednak ilość stanu do śledzenia wzrośnie lub pojawi się logika do wykonania w funkcjach kompozycyjnych, warto przekazać logikę i obowiązki związane ze stanem innym klasom – kontenerom stanu.

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

Więcej informacji znajdziesz w dokumentacji przenoszenia stanu w Compose lub, ogólniej, na stronie Kontenery stanu i stan interfejsu w przewodniku po architekturze.

Ponowne wyzwalanie obliczeń remember, gdy zmienią się klucze

Interfejs remember API 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 ponowne komponowanie.

Ogólnie rzecz biorąc, remember przyjmuje parametr lambda calculation. Gdy remember zostanie uruchomiona po raz pierwszy, wywoła lambdę calculation i zapisze jej wynik. Podczas rekompozycji remember zwraca ostatnio zapisaną wartość.

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

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

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

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

W tym fragmencie kodu tworzony jest ShaderBrush, który jest używany jako tło farba 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 się zmieni, pędzel ponownie się skomponuje z nowym obrazem i ponownie zastosuje do Box. Może się to zdarzyć, gdy użytkownik wybierze inny obraz jako tło 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 kontenera stanu MyAppState. Udostępnia funkcję rememberMyAppState do inicjowania instancji klasy za pomocą remember. Udostępnianie takich funkcji do tworzenia instancji, która przetrwa ponowne komponowanie, jest powszechnym wzorcem w Compose. Funkcja rememberMyAppState otrzymuje windowSizeClass, która służy jako parametr key dla remember. Jeśli ten parametr się zmieni, aplikacja musi ponownie utworzyć zwykłą klasę kontenera stanu z najnowszą wartością. Może się to zdarzyć, gdy 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 klasy equals, aby określić, czy klucz się zmienił, i unieważnić zapisaną wartość.

Przechowywanie stanu za pomocą kluczy poza ponownym komponowaniem

Interfejs rememberSaveable API to otoka wokół remember, która może przechowywać dane w Bundle. Ten interfejs API umożliwia zachowanie stanu nie tylko podczas ponownego komponowania, ale także podczas ponownego tworzenia aktywności i zamykania procesu zainicjowanego przez system. rememberSaveable otrzymuje parametry input w tym samym celu, w jakim remember otrzymuje keys. Pamięć podręczna jest unieważniana, gdy zmieni się którykolwiek z danych wejściowych. Przy następnym ponownym komponowaniu funkcji rememberSaveable ponownie wykona blok lambda obliczeń.

W tym przykładzie rememberSaveable przechowuje userTypedQuery do momentu zmiany typedQuery:

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.

Przykłady

Codelabs

Filmy

Blogi