Okresy istnienia stanu w Compose

W Jetpack Compose funkcje typu „composable” często przechowują stan za pomocą funkcji remember. Zapamiętane wartości można ponownie wykorzystać w różnych rekompozycjach, jak wyjaśniono w artykule Stan i Jetpack Compose.

remember służy jako narzędzie do zachowywania wartości między rekompozycjami, ale stan często musi istnieć dłużej niż kompozycja. Na tej stronie wyjaśniamy różnice między interfejsami API remember, retain, rememberSaveable i rememberSerializable, kiedy wybrać dany interfejs API oraz jakie są sprawdzone metody zarządzania zapamiętanymi i zachowanymi wartościami w Compose.

Wybierz prawidłową długość życia

W Compose jest kilka funkcji, których możesz używać do zachowywania stanu w kompozycjach i poza nimi: remember, retain, rememberSaveable i rememberSerializable. Te funkcje różnią się czasem działania i semantyką, a każda z nich nadaje się do przechowywania określonych rodzajów stanu. Różnice zostały opisane w tabeli poniżej:

remember

retain

rememberSaveable, rememberSerializable

Czy wartości przetrwają rekompozycje?

✅

✅

✅

Czy wartości przetrwają odtworzenie aktywności?

❌

✅

Zawsze będzie zwracana ta sama instancja (===)

✅

Zostanie zwrócony równoważny obiekt (==), prawdopodobnie zdeserializowana kopia

Czy wartości przetrwają śmierć procesu?

❌

❌

✅

Obsługiwane typy danych

Wszystkie

Nie może odwoływać się do żadnych obiektów, które zostałyby utracone, gdyby aktywność została zniszczona.

Musi być możliwa serializacja
(za pomocą niestandardowego Saver lub kotlinx.serialization)

Przypadki użycia

  • obiekty, które są ograniczone do kompozycji;
  • Obiekty konfiguracji dla funkcji kompozycyjnych
  • stan, który można odtworzyć bez utraty wierności interfejsu;
  • Pamięci podręczne
  • Obiekty długotrwałe lub „menedżerskie”
  • Dane wejściowe użytkownika
  • stan, którego aplikacja nie może odtworzyć, w tym dane wejściowe w polu tekstowym, stan przewijania, przełączniki itp.;

remember

remember to najczęstszy sposób przechowywania stanu w Compose. Gdy funkcja remember jest wywoływana po raz pierwszy, wykonywane jest dane obliczenie, które jest zapamiętywane, co oznacza, że jest przechowywane przez Compose do ponownego użycia przez funkcję kompozycyjną. Gdy element kompozycyjny jest ponownie komponowany, wykonuje swój kod jeszcze raz, ale wszystkie wywołania remember zwracają wartości z poprzedniej kompozycji, zamiast ponownie wykonywać obliczenia.

Każda instancja funkcji typu „composable” ma własny zestaw zapamiętanych wartości, co nazywa się pozycyjną memoizacją. Gdy zapamiętane wartości są zapamiętywane do użycia w różnych rekompozycjach, są one powiązane z ich pozycją w hierarchii kompozycji. Jeśli funkcja kompozycyjna jest używana w różnych lokalizacjach, każda instancja w hierarchii kompozycji ma własny zestaw zapamiętanych wartości.

Gdy zapamiętana wartość nie jest już używana, zostaje zapomniana, a jej rekord zostaje odrzucony. Zapamiętane wartości są zapominane, gdy zostaną usunięte z hierarchii kompozycji (w tym gdy wartość zostanie usunięta i ponownie dodana, aby przenieść ją do innej lokalizacji bez użycia funkcji kompozycyjnej key lub MovableContent) lub wywołane z innymi parametrami key.

Spośród dostępnych opcji remember ma najkrótszy czas życia i najszybciej zapomina wartości ze wszystkich 4 funkcji zapamiętywania opisanych na tej stronie. Dzięki temu najlepiej nadaje się do:

  • tworzenie obiektów stanu wewnętrznego, takich jak pozycja przewijania lub stan animacji;
  • Unikanie kosztownego odtwarzania obiektów przy każdej rekompozycji

Unikaj jednak:

  • Przechowywanie danych wprowadzonych przez użytkownika za pomocą remember, ponieważ zapamiętane obiekty są zapominane w przypadku zmian konfiguracji aktywności i śmierci procesu zainicjowanej przez system.

rememberSaveable i rememberSerializable

rememberSaveable i rememberSerializable są oparte na remember. Mają najdłuższy okres ważności spośród funkcji zapamiętywania omówionych w tym przewodniku. Oprócz pozycyjnego zapamiętywania obiektów w ramach rekompozycji może też zapisywać wartości, aby można je było przywrócić podczas ponownego tworzenia aktywności, w tym w przypadku zmian konfiguracji i śmierci procesu (gdy system zabija proces aplikacji, gdy działa ona w tle, zwykle w celu zwolnienia pamięci dla aplikacji na pierwszym planie lub gdy użytkownik odbierze uprawnienia aplikacji, gdy jest ona uruchomiona).

rememberSerializable działa tak samo jak rememberSaveable, ale automatycznie obsługuje utrwalanie typów złożonych, które można serializować za pomocą biblioteki kotlinx.serialization. Wybierz rememberSerializable, jeśli Twój typ jest (lub może być) oznaczony jako @Serializable, a w pozostałych przypadkach wybierz rememberSaveable.

Dzięki temu zarówno rememberSaveable, jak i rememberSerializable doskonale nadają się do przechowywania stanu powiązanego z danymi wejściowymi użytkownika, w tym wpisów w polach tekstowych, pozycji przewijania, stanów przełączników itp. Ten stan należy zapisać, aby użytkownik nigdy nie stracił swojej pozycji. Ogólnie rzecz biorąc, do zapamiętywania stanu, którego aplikacja nie może pobrać z innego trwałego źródła danych, takiego jak baza danych, należy używać rememberSaveable lub rememberSerializable.

Pamiętaj, że rememberSaveable i rememberSerializable zapisują zapamiętane wartości, serializując je do Bundle. Ma to 2 konsekwencje:

  • Wartości, które zapamiętujesz, muszą być reprezentowane przez co najmniej 1 z tych typów danych: elementy podstawowe (w tym Int, Long, Float, Double), String lub tablice dowolnego z tych typów.
  • Po przywróceniu zapisanej wartości będzie to nowa instancja, która jest równa (==), ale nie jest tym samym odwołaniem (===), którego kompozycja używała wcześniej.

Aby przechowywać bardziej skomplikowane typy danych bez używania kotlinx.serialization, możesz zaimplementować niestandardowy Saver, który będzie serializować i deserializować obiekt do obsługiwanych typów danych. Pamiętaj, że Compose od razu rozpoznaje typowe typy danych, takie jak State, List, Map, Set itp., i automatycznie konwertuje je na obsługiwane typy. Poniżej znajdziesz przykład Saver dla klasy Size. Jest to realizowane przez spakowanie wszystkich właściwości Size do listy za pomocą listSaver.

data class Size(val x: Int, val y: Int) {
    object Saver : androidx.compose.runtime.saveable.Saver<Size, Any> by listSaver(
        save = { listOf(it.x, it.y) },
        restore = { Size(it[0], it[1]) }
    )
}

@Composable
fun rememberSize(x: Int, y: Int) {
    rememberSaveable(x, y, saver = Size.Saver) {
        Size(x, y)
    }
}

retain

Interfejs API retain znajduje się między remember a rememberSaveable/rememberSerializable pod względem czasu zapamiętywania wartości. Nazwa jest inna, ponieważ zachowane wartości mają też inny cykl życia niż ich zapamiętane odpowiedniki.

Gdy wartość jest zachowywana, jest zarówno zapamiętywana pozycyjnie, jak i zapisywana w dodatkowej strukturze danych, która ma oddzielny cykl życia powiązany z cyklem życia aplikacji. Zachowana wartość może przetrwać zmiany konfiguracji bez serializacji, ale nie przetrwa śmierci procesu. Jeśli wartość nie zostanie użyta po odtworzeniu hierarchii kompozycji, zostanie wycofana (co w przypadku retain jest odpowiednikiem zapomnienia).

W zamian za ten krótszy niż rememberSaveable cykl życia retain może zachowywać wartości, których nie można serializować, takie jak wyrażenia lambda, przepływy i duże obiekty, np. mapy bitowe. Możesz na przykład użyć retain do zarządzania odtwarzaczem multimediów (takim jak ExoPlayer), aby zapobiec przerwom w odtwarzaniu podczas zmiany konfiguracji.

@Composable
fun MediaPlayer() {
    // Use the application context to avoid a memory leak
    val applicationContext = LocalContext.current.applicationContext
    val exoPlayer = retain { ExoPlayer.Builder(applicationContext).apply { /* ... */ }.build() }
    // ...
}

retain – ViewModel

Zasadniczo zarówno retain, jak i ViewModel oferują podobne funkcje w najczęściej używanej możliwości zachowywania instancji obiektów w przypadku zmian konfiguracji. Wybór między retain a ViewModel zależy od typu wartości, którą chcesz zachować, jej zakresu oraz tego, czy potrzebujesz dodatkowych funkcji.

ViewModel to obiekty, które zwykle zawierają komunikację między interfejsem aplikacji a warstwami danych. Umożliwiają one przeniesienie logiki poza funkcje typu „composable”, co ułatwia testowanie. Obiekty ViewModel są zarządzane jako singletony w ramach ViewModelStore i mają inny okres ważności niż zachowane wartości. ViewModel pozostanie aktywny, dopóki nie zostanie zniszczony jego ViewModelStore, ale zachowane wartości są wycofywane, gdy treść zostanie trwale usunięta z kompozycji (na przykład w przypadku zmiany konfiguracji oznacza to, że zachowana wartość jest wycofywana, jeśli hierarchia interfejsu zostanie odtworzona, a zachowana wartość nie zostanie wykorzystana po odtworzeniu kompozycji).

ViewModel zawiera też gotowe integracje do wstrzykiwania zależności z Daggerem i Hilt, integrację z SavedState oraz wbudowaną obsługę współprogramów do uruchamiania zadań w tle. Dzięki temu ViewModel jest idealnym miejscem do uruchamiania zadań w tle i żądań sieciowych, interakcji z innymi źródłami danych w projekcie oraz opcjonalnego przechwytywania i utrzymywania stanu interfejsu o kluczowym znaczeniu, który powinien być zachowywany podczas zmian konfiguracji w ViewModel i nie powinien być usuwany w przypadku śmierci procesu.

retain najlepiej sprawdza się w przypadku obiektów, które są ograniczone do konkretnych instancji funkcji kompozycyjnych i nie wymagają ponownego użycia ani udostępniania między funkcjami kompozycyjnymi na tym samym poziomie. ViewModel jest dobrym miejscem do przechowywania stanu interfejsu i wykonywania zadań w tle, natomiast retain dobrze nadaje się do przechowywania obiektów związanych z interfejsem, takich jak pamięć podręczna, moduł do śledzenia wyświetleń i analityka, zależności od AndroidView oraz inne obiekty, które wchodzą w interakcję z systemem operacyjnym Android lub zarządzają bibliotekami innych firm, takimi jak firmy obsługujące płatności lub rozgłaszanie.

Zaawansowani użytkownicy, którzy projektują niestandardowe wzorce architektury aplikacji na Androida poza zaleceniami dotyczącymi architektury aplikacji Modern Android, mogą też używać retain do tworzenia wewnętrznego interfejsu API podobnego do ViewModel. Chociaż obsługa współprogramów i stanu zapisanego nie jest oferowana od razu, retain może służyć jako element składowy cyklu życia takich elementów podobnych do ViewModel z tymi funkcjami. Szczegółowe informacje o tym, jak zaprojektować taki komponent, wykraczają poza zakres tego przewodnika.

retain

ViewModel

Określanie zakresu

Brak wartości udostępnianych. Każda wartość jest zachowywana i powiązana z określonym punktem w hierarchii kompozycji. Zachowanie tego samego typu w innej lokalizacji zawsze działa na nowej instancji.

ViewModel to pojedyncze elementy w ramach ViewModelStore

Zniszczenie

Podczas trwałego opuszczania hierarchii kompozycji

Kiedy ViewModelStore zostanie wyczyszczony lub zniszczony

Dodatkowe funkcje

Może otrzymywać wywołania zwrotne, gdy obiekt znajduje się w hierarchii kompozycji lub nie.

Wbudowany coroutineScope, obsługa SavedStateHandle, możliwość wstrzykiwania za pomocą Hilt

Właściciel

RetainedValuesStore

ViewModelStore

Use cases

  • Utrzymywanie wartości interfejsu lokalnych dla poszczególnych instancji funkcji kompozycyjnych
  • śledzenie wyświetleń, np. za pomocą RetainedEffect;
  • Element składowy do definiowania niestandardowego komponentu architektury „podobnego do ViewModel”
  • wyodrębnianie interakcji między interfejsem a warstwami danych do osobnej klasy, zarówno na potrzeby organizacji kodu, jak i testowania;
  • Przekształcanie obiektów Flow w obiekty State i wywoływanie funkcji zawieszenia, których nie powinny przerywać zmiany konfiguracji
  • udostępnianie stanów na dużych obszarach interfejsu, np. na całych ekranach;
  • Interoperacyjność z: View

Połącz retain i rememberSaveable lub rememberSerializable

Czasami obiekt musi mieć hybrydowy cykl życia, który obejmuje zarówno retained, jak i rememberSaveable lub rememberSerializable. Może to oznaczać, że obiekt powinien być obiektem ViewModel, który może obsługiwać zapisany stan, jak opisano w przewodniku po module zapisanego stanu dla ViewModel.

Można używać retain i rememberSaveable lub rememberSerializable jednocześnie. Prawidłowe połączenie obu cykli życia znacznie zwiększa złożoność. Zalecamy stosowanie tego wzorca jako części bardziej zaawansowanych i niestandardowych wzorców architektury i tylko wtedy, gdy spełnione są wszystkie te warunki:

  • definiujesz obiekt składający się z różnych wartości, które muszą zostać zachowane lub zapisane (np. obiekt, który śledzi dane wejściowe użytkownika, i pamięć podręczna pamięci RAM, której nie można zapisać na dysku);
  • Stan jest ograniczony do funkcji kompozycyjnej i nie nadaje się do zakresu singletonu ani cyklu życia ViewModel

W takim przypadku zalecamy podzielenie klasy na 3 części: zapisane dane, zachowane dane i obiekt „pośredniczący”, który nie ma własnego stanu i deleguje zadania do zachowanych i zapisanych obiektów, aby odpowiednio aktualizować stan. Ten wzorzec ma następującą postać:

@Composable
fun rememberAndRetain(): CombinedRememberRetained {
    val saveData = rememberSerializable(serializer = serializer<ExtractedSaveData>()) {
        ExtractedSaveData()
    }
    val retainData = retain { ExtractedRetainData() }
    return remember(saveData, retainData) {
        CombinedRememberRetained(saveData, retainData)
    }
}

@Serializable
data class ExtractedSaveData(
    // All values that should persist process death should be managed by this class.
    var savedData: AnotherSerializableType = defaultValue()
)

class ExtractedRetainData {
    // All values that should be retained should appear in this class.
    // It's possible to manage a CoroutineScope using RetainObserver.
    // See the full sample for details.
    var retainedData = Any()
}

class CombinedRememberRetained(
    private val saveData: ExtractedSaveData,
    private val retainData: ExtractedRetainData,
) {
    fun doAction() {
        // Manipulate the retained and saved state as needed.
    }
}

Dzięki rozdzieleniu stanu według okresu ważności podział obowiązków i miejsca na dane staje się bardzo wyraźny. To, że danych zapisu nie można modyfikować za pomocą danych przechowywania, jest zamierzone, ponieważ zapobiega to próbie aktualizacji danych zapisu, gdy pakiet savedInstanceState został już przechwycony i nie można go zaktualizować. Umożliwia też testowanie scenariuszy odtwarzania przez testowanie konstruktorów bez wywoływania funkcji Compose lub symulowania odtwarzania aktywności.

Pełny przykład (RetainAndSaveSample.kt) pokazuje, jak można wdrożyć ten wzorzec.

Pozycyjna memoizacja i układy adaptacyjne

Aplikacje na Androida mogą obsługiwać wiele formatów, w tym telefony, urządzenia składane, tablety i komputery. Aplikacje często muszą przechodzić między tymi formatami za pomocą układów adaptacyjnych. Na przykład aplikacja działająca na tablecie może wyświetlać dwukolumnową szczegółową listę, ale na mniejszym ekranie telefonu może przechodzić między listą a stroną z informacjami.

Ponieważ zapamiętane i zachowane wartości są zapamiętywane pozycyjnie, są używane ponownie tylko wtedy, gdy pojawiają się w tym samym punkcie hierarchii kompozycji. Gdy układy dostosowują się do różnych formatów, mogą zmieniać strukturę hierarchii kompozycji i prowadzić do zapomnianych wartości.

W przypadku gotowych komponentów, takich jak ListDetailPaneScaffold i NavDisplay (z Jetpack Navigation 3), nie stanowi to problemu, a stan zostanie zachowany podczas zmian układu. W przypadku komponentów niestandardowych, które dostosowują się do formatów, upewnij się, że zmiany układu nie wpływają na stan, wykonując jedną z tych czynności:

  • Upewnij się, że stanowe elementy kompozycyjne są zawsze wywoływane w tym samym miejscu w hierarchii kompozycji. Wdrażaj układy adaptacyjne, zmieniając logikę układu, zamiast przenosić obiekty w hierarchii kompozycji.
  • Użyj MovableContent, aby płynnie przenosić komponenty stanowe. Instancje MovableContent mogą przenosić zapamiętane i zachowane wartości ze starych do nowych lokalizacji.

Pamiętaj funkcje fabryczne

Chociaż interfejsy Compose składają się z funkcji typu „composable”, do tworzenia i organizowania kompozycji wykorzystywanych jest wiele obiektów. Najczęstszym przykładem są złożone obiekty kompozycyjne, które definiują własny stan, takie jak LazyList, który akceptuje LazyListState.

Podczas definiowania obiektów skoncentrowanych na Compose zalecamy utworzenie funkcji remember, aby określić zamierzone zachowanie zapamiętywania, w tym zarówno czas życia, jak i kluczowe dane wejściowe. Dzięki temu użytkownicy Twojego stanu mogą bez obaw tworzyć instancje w hierarchii kompozycji, które będą zachowywać się i unieważniać zgodnie z oczekiwaniami. Podczas definiowania funkcji fabrycznej elementu kompozycyjnego postępuj zgodnie z tymi wytycznymi:

  • Dodaj prefiks remember do nazwy funkcji. Opcjonalnie, jeśli implementacja funkcji zależy od obiektu retained, a interfejs API nigdy nie będzie opierać się na innej odmianie remember, użyj prefiksu retain.
  • Jeśli wybrano trwałość stanu i można napisać prawidłową implementację Saver, użyj rememberSaveable lub rememberSerializable.
  • Unikaj efektów ubocznych i inicjowania wartości na podstawie CompositionLocal, które mogą nie być istotne dla danego zastosowania. Pamiętaj, że miejsce, w którym tworzony jest stan, może nie być miejscem, w którym jest on używany.

@Composable
fun rememberImageState(
    imageUri: String,
    initialZoom: Float = 1f,
    initialPanX: Int = 0,
    initialPanY: Int = 0
): ImageState {
    return rememberSaveable(imageUri, saver = ImageState.Saver) {
        ImageState(
            imageUri, initialZoom, initialPanX, initialPanY
        )
    }
}

data class ImageState(
    val imageUri: String,
    val zoom: Float,
    val panX: Int,
    val panY: Int
) {
    object Saver : androidx.compose.runtime.saveable.Saver<ImageState, Any> by listSaver(
        save = { listOf(it.imageUri, it.zoom, it.panX, it.panY) },
        restore = { ImageState(it[0] as String, it[1] as Float, it[2] as Int, it[3] as Int) }
    )
}