Podstawy aplikacji

Przenoszenie stanu Compose poza interfejs za pomocą Jetpack AppState

Głośniki

  • Ben Sagmoe

    Inżynier ds. relacji z deweloperami

  • Elif Bilgin Morris

    Inżynier oprogramowania

Informacje

Informacje o wystąpieniu

Środowisko wykonawcze migawki Compose, automatyczne śledzenie zależności i aktualizacje atomowe nie muszą kończyć się na warstwie interfejsu. Jetpack AppState umożliwia przenoszenie stanu Compose w górę strumienia – usprawnia to, co dociera do ekranów, przez podnoszenie i wyodrębnianie stanu za pomocą systemu migawek, zanim trafi on do interfejsu.

Podczas tej sesji dowiesz się więcej o 2 podstawowych bibliotekach AppState. Omówimy StateStore do podnoszenia stanu i Transform do używania kompozycji do uzyskiwania stanu za pomocą prostego przepływu sterowania Kotlin – eliminując złożone łańcuchy łączenia przepływów i zapewniając bezproblemową synchronizację interfejsu.

Podsumowanie

  • Środowisko wykonawcze Compose działa jako mechanizm zarządzania stanem do zwykłych obciążeń bez interfejsu Compose UI. System zrzutów dynamicznie śledzi odczyty i zapewnia spójność równoczesnych aktualizacji we wszystkich usługach.
  • Jetpack AppState Transform oblicza i udostępnia stan Compose z bloków funkcji kompozycyjnych bez interfejsu graficznego. Zastępuje złożone łańcuchy operatorów Flow, takie jak combine, a jednocześnie obsługuje wczesne zwracanie i zagnieżdżone pochodne stanu.
  • Transform używa zegara klatek bez interfejsu graficznego, aby uruchamiać przekształcenia poza wątkiem UI. Zegar zatrzymuje się, gdy stany oglądania pozostają niezmienione, co pozwala uniknąć niepotrzebnej pracy.
  • Komponent StateStore w Jetpack AppState udostępnia stan Compose różnym komponentom, takim jak aktywności i usługi w tle. Integracja z DataStore pozwala poszczególnym kluczom przetrwać śmierć procesu bez niestandardowego kodu stałego.

Zasoby

Gemini Notebook

Tematy

  • jetpack-compose
  • jetpack-appstate
  • zarządzanie stanem
  • android-architecture
  • compose-runtime

Transkrypcja

Pobierz transkrypcję

Wprowadzenie

  • Cześć, witamy na szkoleniu „Przenoszenie stanu Compose poza interfejs za pomocą Jetpack AppState”.

  • Nazywam się Ben i jestem inżynierem ds. relacji z deweloperami, który pracuje nad bibliotekami architektury Jetpack.

  • A ja jestem Elif i jestem jedną z inżynierek, które pracują nad bibliotekami architektury aplikacji.

  • Dzisiaj chcemy porozmawiać o przyszłości, w której Compose wykracza poza interfejs użytkownika i obejmuje warstwy danych i domen aplikacji.

  • Jak już pewnie wiecie, Compose to zalecany zestaw narzędzi do tworzenia interfejsu na Androidzie.

  • Od telefonów i tabletów po widżety, zegarki, telewizory i urządzenia z XR – Compose obsługuje je wszystkie.

  • Jednak w miarę jak aplikacja rozrasta się o te interfejsy i komponenty działające w tle, zarządzanie stanem staje się trudniejsze, zwłaszcza w przypadku 2 częstych problemów.

  • Pierwszy to uzyskiwanie złożonego stanu.

  • Łączenie wielu strumieni danych, danych wejściowych lokalnego interfejsu i stanu otoczenia, np. połączenia sieciowego, często prowadzi do powstania plątaniny operatorów, takich jak combine, flatMapLatest i distinctUntilChanged.

  • Drugi to udostępnianie stanu między komponentami: synchronizowanie aktywności, usługi, widżetu aplikacji i innych elementów oraz dbanie o to, aby stan mógł przetrwać śmierć procesu, gdy jest to konieczne.

  • Gdy przyjrzeliśmy się tym wyzwaniom, zaczęliśmy analizować, jak jest zbudowany sam Compose.

  • Większość osób uważa Compose za zestaw narzędzi UI, ale w rzeczywistości składa się on z 2 odrębnych warstw: Compose UI na górze i Compose Runtime na dole.

  • Podczas gdy Compose UI obsługuje układ, rysowanie i wprowadzanie danych, Compose Runtime jest silnikiem ogólnego przeznaczenia do zarządzania stanem i rekompozycji.

  • Nie jest zależny od interfejsu.

  • To spostrzeżenie doprowadziło nas do stworzenia Jetpack AppState.

  • Jest to rodzina bibliotek, które pozwalają maksymalnie wykorzystać stan Compose poza interfejsem.

Czym jest stan tworzenia?

  • Zanim jednak przejdziemy do AppState, upewnijmy się, że rozumiemy stan Compose w ten sam sposób.

  • Odpowiedź to androidx.compose.runtime.State.

    /**
     * A value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value.
     */
    @Stable
    public interface State<out T> {
        public val value: T
    }
  • Interfejs, który prawdopodobnie znasz lepiej, to MutableState.

    /**
     * A mutable value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
     * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
     * will be scheduled. If [value] is written to with the same value, no recompositions will be
     * scheduled.
     */
    @Stable
    public interface MutableState<T> : State<T> {
        override var value: T
        public operator fun component1(): T
        public operator fun component2(): (T) -> Unit
    }
  • Zasadniczo są to tylko podstawowe kontenery.

  • To, co sprawia, że działają, to system migawek Compose.

  • Gdy mówimy o stanie Compose, zwykle mamy na myśli stan Snapshot.

  • Gdy wywołujesz mutableStateOf, tworzysz kontener, który kieruje odczyty i zapisy przez system zrzutów.

  • Gdy kod jest uruchamiany w obserwatorze migawki, np. w kompozycji, otrzymujesz 2 właściwości, których nie zapewnia strumień taki jak Flow.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
    }
  • Pierwsza to automatyczne i dynamiczne śledzenie odczytu.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
  • Automatyczne oznacza, że nie trzeba niczego subskrybować ani zbierać.

  • Subskrypcja obejmuje tylko czytanie książki .value.

  • Jeśli więc spojrzymy na Counter, odczytanie count.value bezpośrednio w funkcji kompozycyjnej zgłasza ten odczyt do kompozycji i rejestruje Counter na liście czytników.

  • A teraz spójrz na AddButton.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
    
    @Composable
    fun AddButton(onClick: () -> Unit) {
        Button(onClick = onClick) {
            Text("Add")
        }
    }
  • Zapisuje tylko dane o kliknięciu.

  • Nie jest to więc czytnik.

  • Gdy użytkownik kliknie AddButton i zmieni liczbę, system migawek śledzi ten zapis, dzięki czemu kompozycja wie, że należy unieważnić Counter, ale nie AddButton.

  • Dynamiczne oznacza, że śledzenie odczytu odbywa się w czasie działania na podstawie ścieżek kodu, które są faktycznie uruchamiane.

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(true) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • W tym przypadku tekst licznika jest chroniony przez flagę isVisible.

  • Gdy właściwość isVisible ma wartość „true”, odczytywane są zarówno właściwość isVisible, jak i liczba.

  • Counter jest więc zarejestrowany jako czytnik obu tych usług.

  • Gdy isVisible ma wartość false, gałąź if nie jest uruchamiana, a wartość count nie jest odczytywana, więc Counter jest zarejestrowany tylko jako czytnik isVisible, a nie count.

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(false) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • Dlatego gdy AddButton zapisuje dane w liczbie, nie ma już nic do unieważnienia.

  • Druga właściwość to brak konieczności wyboru między spójnością a szczegółowością.

  • Spójność oznacza, że gdy aktualizujesz wiele wartości w migawce, zmiany te pozostają odizolowane podczas działania kodu, nawet jeśli inny wątek zapisuje je w tym samym czasie.

  • Te aktualizacje stają się widoczne jednocześnie po zakończeniu tworzenia zrzutu.

  • Szczegółowość oznacza, że oddzielne usługi można zadeklarować i śledzić indywidualnie, zwłaszcza gdy nie są ze sobą powiązane.

  • Dlatego aktualizacja jednej właściwości unieważnia tylko te czytniki, które od niej zależą.

  • Przyjrzyjmy się temu na przykładzie prawdziwego kodu.

  • Załóżmy, że mamy grę, która ma 3 stany: nazwę poziomu, czas przetrwania i wynik.

    val levelName: String = "Level 1 - Stay Alive!"
    val timeSurvived: Int = 0
    val score: Double = 0.0
  • Zacznijmy od przyjrzenia się implementacji Flow, która wybiera spójność kosztem szczegółowości, łącząc wszystko w klasę GameUiState.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
  • Oznacza to, że przy każdym uruchomieniu funkcji updateScore kopiujesz cały obiekt GameUiState, dzięki czemu zarówno timeSurvived, jak i wynik mogą być aktualizowane jednocześnie.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
  • W interfejsie użytkownika GameScreen zbiera stan interfejsu i odczytuje go, aby przekazać odpowiednie właściwości do komponentów Header i ScoreBadge.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state by viewModel.uiState.collectAsStateWithLifecycle()
        Column {
            Header(levelName = state.levelName)
            ScoreBadge(
                timeSurvived = state.timeSurvived,
                score = state.score
            )
        }
    }
  • GameScreen odczytuje każdy stan, więc każde wywołanie updateScore unieważnia GameScreen, mimo że dane nagłówka nigdy się nie zmieniły.

  • Możesz teraz podzielić każdą właściwość na osobny przepływ, aby uzyskać szczegółowość, ale stracisz spójność, ponieważ inny wątek może zaktualizować wartość, gdy działa inny blok aktualizacji.

  • Teraz rozważmy alternatywną implementację opartą na stanach.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
  • W tym przypadku ponownie używamy GameUiState.

  • W tym przypadku jest jednak nieco inaczej, ponieważ każda właściwość jest delegowana do mutableStateOf.

  • W tym przypadku updateScore opakowuje zapisy w withMutableSnapshot, co zapewnia spójność zapisów.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
  • Następnie w interfejsie GameScreen przekazuje stan bezpośrednio do nagłówka i ScoreBadge.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state = viewModel.uiState
        Column {
            Header(state = state)
            ScoreBadge(state = state)
        }
    }
  • Nie odczytuje żadnych właściwości, więc gdy updateScore jest uruchamiany, GameScreen nie jest czytnikiem.

  • Unieważniany jest tylko rzeczywisty komponent, w tym przypadku ScoreBadge.

  • Zwróć uwagę, że żadna z tych czynności nie jest powiązana z interfejsem.

  • Interfejs jest tylko jednym z miejsc, w których te właściwości naprawdę się sprawdzają.

  • Dziękujemy za udział w naszej prezentacji.

  • OK, żarty na bok. Przekazuję głos Elif, która omówi pierwszą bibliotekę, Transform.

Przedstawiamy Transform

  • OK.

  • Dzięki, Ben.

  • Jak zauważył Ben, stan zrzutu Compose zapewnia automatyczne i dynamiczne śledzenie odczytu, spójność i szczegółowość. To prowadzi do pytania: co by było, gdyby przepływy danych mogły być również sterowane przez zrzut stanu Compose – stan zrzutu?

  • Jeśli stan jest naszym modelem danych, to jak właściwie obliczamy i przekształcamy stan dla wszystkich jego konsumentów?

  • Właśnie w tym celu powstała nowa biblioteka Jetpack AppState Transform.

  • Zanim przejdziemy do samej biblioteki, musimy porozmawiać o tym, jak powstała.

  • Dlatego pomysł wykorzystania silnika Compose, który jest tutaj środowiskiem wykonawczym i kompilatorem, do generowania wartości stanu w kompozycji bez interfejsu nie jest nowy.

  • Wielu z Was może już używać biblioteki Molecule lub ją znać.

  • W tym przypadku „bezgłowy” oznacza brak interfejsu użytkownika.

  • Molecule był pionierem w demonstrowaniu możliwości środowiska wykonawczego Compose. Pokazał nam w praktyce, że funkcja typu „composable” nie musi emitować węzłów interfejsu i może być używana do pisania funkcji typu „composable”, które zwracają czysty model danych.

  • W przypadku Molecule był to Kotlin Flow lub StateFlow.

  • Oznaczało to, że zamiast łączyć operatory reaktywne, takie jak „combine”, można było pisać zwykły kod w języku Kotlin.

  • A gdyby zamiast przepływów użyć modelu danych State, skoro interfejs jest już w Compose?

  • W przypadku przekształcenia modelem danych jest stan.

  • Przyjrzyjmy się interfejsowi API.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R>
  • To cały interfejs API.

  • To bardzo proste, jedna funkcja.

  • Zwraca zwykły obserwowalny stan, a nie przepływ ani StateFlow.

  • Osoba, która z niego korzysta, nie wie, że został wygenerowany przez przekształcenie, i nie ma to dla niej znaczenia.

  • Najbardziej interesujący jest ten blok.

  • Jest kompozycyjny.

  • Oznacza to, że wszystko, co już wiesz, działa w tym środowisku.

  • Pamiętaj, że LaunchedEffect, collectAsState, wczesne zwracanie, zwykłe instrukcje if, wszystko, co można odczytać z wnętrza funkcji kompozycyjnej, można uzyskać w ramach przekształcenia.

  • Warto zauważyć, że wszystkie interfejsy API, które widzimy dzisiaj w tej prezentacji, są w wersji alfa i mogą ulec zmianie, ale mimo to przyjrzyjmy się przykładowi.

  • Weźmy na przykład bardzo prostą, niewielką aplikację z minutnikiem do skupienia uwagi.

  • Wybierasz zadanie, ustawiasz minutnik na 25 minut i zegar zaczyna odliczać czas.

  • Za każdym razem, gdy tworzysz pojedynczy ekran, nawet tak prosty jak ten, prawdopodobnie musisz połączyć wiele niezależnych strumieni danych i nimi zarządzać, aby złożyć stan pod maską.

  • Jeśli chodzi o ekran skupienia, załóżmy, że mamy 2 ekrany: samą sesję skupienia, która będzie określać, w jakiej fazie się znajdujemy, czyli czy jest wstrzymana, czy nie, ile sekund zostało, a wszystko to będzie pochodzić z silnika minutnika jako StateFlow, który będzie tykać powiedzmy co sekundę.

  • Drugi strumień danych będzie zawierał bieżące zadanie, nad którym pracuje użytkownik, i będzie pochodził z bazy danych Room jako Flow.

  • Ostatecznie mamy więc 2 strumienie danych i 1 stan ekranu.

  • ViewModel to miejsce, w którym zostaną połączone, a jego dane wyjściowe, które nazwiemy podsumowaniem, będą fragmentem kodu, który napiszemy w następnej kolejności.

  • Tradycyjnie oznaczałoby to, że w ViewModel musisz połączyć te przepływy za pomocą operatorów łańcuchowych.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: StateFlow<FocusSummary> =
            combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find { it.id == session.activeTaskId }
                    FocusSummary.Active(...)
                }
            }.stateIn(
                    scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = FocusSummary.Idle,
    )
    }
  • Połącz więc te 2 przepływy w strumienie, rozgałęzienie wewnątrz funkcji lambda, czyli instrukcję if-else, a na końcu stan.

  • Dzięki funkcji Transform możemy usunąć cały ten powtarzalny kod.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
    initialValue = FocusSummary.Idle,
    scope = viewModelScope,
    container = handle.getOrCreateContainer("transform")
    ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
  • To wywołanie wygląda znajomo, ponieważ mamy wartość początkową w zakresie, który jest przekazywany, ale nie ma niczego nadrzędnego do połączenia, a pochodna jest samym blokiem.

  • W odróżnieniu od stateIn, gdzie prawdopodobnie trzeba skonfigurować czas oczekiwania WhileSubscribed, aby wstrzymać i wznowić strumienie nadrzędne, przekształcenie działa nieprzerwanie przez cały czas istnienia tego zakresu.

  • Naszymi źródłami są przepływy, a przepływy są mile widziane.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
                initialValue = FocusSummary.Idle,
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                         FocusSummary.Active(...)
            }
    }
  • Każdy z nich połączymy z collectAsState, a sesja to StateFlow, więc nie potrzebujemy wartości początkowej, ale Room Flow jest zimny, więc podajemy mu wartość.

  • To wszystko, co musisz wiedzieć o subskrypcjach.

  • Odczyt to subskrypcja.

  • To automatyczne śledzenie odczytu, o którym Ben mówił wcześniej w przykładzie, ale w ViewModel.

  • Możemy nawet wprowadzić wcześniejsze powroty, takie jak ten, gdy jesteśmy w stanie bezczynności.

  • Załóżmy, że nie mamy nowych zadań, więc nie będziemy nawet przetwarzać pozostałych.

  • To kolejny przykład na to, jak dynamiczne śledzenie odczytu przynosi korzyści.

  • Gdy nie korzystamy z urządzenia, dane Room Flow nie są w ogóle zbierane.

  • Na koniec generujemy podsumowanie.

  • Jeśli więc użytkownik wstrzyma zadanie lub licznik czasu się zmieni, wszystko zostanie automatycznie zaktualizowane.

Zagnieżdżone przekształcenia

  • Sytuacja nieco się komplikuje, gdy wyjdziemy poza przykład z jednym elementem ViewModel.

  • Załóżmy, że ta sama sesja musi pojawić się na 2 dodatkowych powierzchniach: na pasku minizegara, który znajduje się u dołu ekranu aplikacji na każdym ekranie, oraz w widżecie na ekranie głównym.

  • Oba będą potrzebować tych samych informacji o podsumowaniu sesji, fazie, wstrzymaniu lub nie, oraz o tym, ile czasu pozostało na minutniku.

  • Oznacza to, że gdyby każda powierzchnia miała własną kopię, byłoby to to samo wyprowadzenie w 3 różnych miejscach, które musiałyby być ze sobą zgodne.

  • Zamiast tego musimy wygenerować podsumowanie raz w zakresie aplikacji, a następnie wszystko, co znajduje się poniżej, będzie z niego korzystać.

  • Jak więc wdrożyć takie rozwiązanie?

  • Nasza implementacja będzie prawdopodobnie wymagać 3 elementów.

    class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
        ...
    }
    
    class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
        ...
    }
    
    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(context: Context, id: GlanceId) {
            provideContent {
                ...
            }
        }
    }
  • Menedżer sesji w zakresie aplikacji, który będzie miejscem, gdzie generowane jest podsumowanie, minizegar ViewModel i widżet podsumowania, które będą korzystać z podsumowania wygenerowanego w tym menedżerze sesji skupienia.

  • Utwórzmy każdy z nich w tradycyjny sposób, a następnie za pomocą funkcji Przekształć.

  • Tradycyjnie zaczęlibyśmy od jednorazowego wygenerowania podsumowania w zakresie aplikacji jako StateFlow.

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: StateFlow<FocusSummary> =
            combine(
                sessionFlow,
                taskRepository.tasksFlow
            ) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find {
                        it.id == session.activeTaskId
                    }
                    FocusSummary.Active(...)
                }
            }.stateIn(
    scope = appScope,
    started = SharingStarted.WhileSubscribed(5_000),
    initialValue = FocusSummary.Idle
    )
    }
  • Są to te same wywołania funkcji łączenia i stateIn, które widzieliśmy wcześniej w przykładzie ViewModel, ale przeniesione o poziom wyżej.

  • W przypadku przekształcenia możemy też ponownie użyć tej samej deklaracji z ViewModel, którą widzieliśmy wcześniej.

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
               initialValue = FocusSummary.Idle,
    scope = scope,
    container = handle.getOrCreateContainer("transform")
            ) {
                val session by sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow
                    .collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
    }
  • Jedyną zmianą jest to, że teraz znajduje się on w zakresie aplikacji.

  • Następnie mamy ViewModel miniodliczania.

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: StateFlow<MiniTimerUiState> =
            combine(
                focusManager.summary,
                isExpanded
            ) { summary, expanded ->
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = MiniTimerUiState()
    )
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • Załóżmy, że chcemy sobie trochę utrudnić zadanie.

  • Załóżmy, że w minutniku oprócz 2 strumieni danych, które już mamy, czyli StateFlow i Room Flow, chcemy też śledzić, czy pasek minutnika jest rozwinięty.

  • W tym celu używamy MutableStateFlow.

  • Aby to zrobić, musimy użyć innego połączenia do śledzenia przepływu podsumowania i isExpanded StateFlow.

  • I mamy drugi stan.

  • Dzięki funkcji Transform nie musimy już używać żadnych z tych połączonych operatorów, aby synchronizować strumienie danych.

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: State<MiniTimerUiState> =
            transform(
                initialValue = MiniTimerUiState(),
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val summary by focusManager.summary
                       val expanded by isExpanded.collectAsState()
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • Możemy jednak zrobić coś naprawdę fajnego – przekształcenia zagnieżdżone.

  • W tym przypadku ViewModel odczytuje podsumowanie, które jest wynikiem przekształcenia nadrzędnego, tak jak każdy inny stan, a następnie zbiera isExpanded za pomocą collectAsState.

  • Możemy więc połączyć stan nadrzędny i przepływ podrzędny w StateFlow w tym samym bloku.

  • Pamiętaj też o szczegółowości.

  • Jeśli więc przełączysz isExpanded, ponownie uruchomiony zostanie tylko ten blok.

  • Blokada na poziomie aplikacji i widżet nie muszą nawet tego zauważyć.

  • Ostatni element to widżet.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                    .collectAsState()
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Glance to Compose, więc możemy zbierać StateFlow za pomocą collectAsState w provideContent, tak jak w przypadku każdego innego elementu Composable.

  • Dzięki funkcji Transform jest to jeszcze prostsze.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Nasz menedżer zaznaczenia udostępnia nam stan Compose, więc nie musimy niczego konwertować.

  • Ostatecznie pasek minizegara i widżet na ekranie głównym nigdy nie będą się różnić, ponieważ odczytują ten sam stan.

  • Co więcej, każda zmiana wprowadzona w tym stanie zostanie odzwierciedlona u wszystkich użytkowników tego stanu.

  • Co to znaczy?

  • Jeśli więc wstrzymasz odtwarzanie w widżecie, informacja o tym zostanie przekazana przez menedżera do strumienia sesji, a następnie do podsumowania. Wszystkie 3 ekrany zostaną zsynchronizowane.

  • Skoro już wiemy, jak korzystać z narzędzia Transform, przyjrzyjmy się mu bliżej.

  • Interfejs Transform API, który widzieliśmy wcześniej, jest w rzeczywistości funkcją pomocniczą używaną do tworzenia podstawowego zarządzanego przekształcenia.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R> {
            val transformScope = TransformScope()
        val transform = transformScope
    .launchTransform(initialValue, onUpdate)
        scope.launch(
    dispatcher,
    start = CoroutineStart.UNDISPATCHED
     ) {
            try { transformScope.run() } finally {
                transform.dispose()
            }
        }
        return transform
    }
  • W rzeczywistości używa funkcji rozszerzenia TransformScope i launchTransform, która uruchamia przekształcenie Composable w tym TransformScope i zwraca stan jednorazowy.

  • Gdy przeczytasz go od góry do dołu, zobaczysz, że robi wszystko za Ciebie.

  • Tworzy TransformScope i uruchamia w nim przekształcenie, które będzie stanem, który otrzymasz z powrotem.

  • Następnie uruchamia też funkcję run w TransformScope w podanym przez Ciebie dyspozytorze, dzięki czemu czas życia zakresu i wątek należą do Ciebie.

  • Na koniec, gdy zakres się kończy, usuwa przekształcenie.

  • To jest cały cykl życia funkcji Transform.

  • Przyjrzyjmy się szybko interfejsowi TransformScope.

    public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
    
    public abstract class TransformScope {
            public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
    public abstract suspend fun run()
    }
    
    public fun <T> TransformScope.launchTransform(
    initialState: T,
    onUpdate: @Composable (T) -> T,
    ): DisposableState<T>
    
    public interface DisposableState<T> : State<T> {
    public fun dispose()
    }
  • Widzimy, że zawiera funkcję createTransform, która utworzy przekształcenie dołączone do tego TransformScope.

  • Ma też funkcję zawieszania, która uruchamia TransformScope i rozpoczyna proces aktualizowania przekształceń stanu obecnego.

  • Co to znaczy?

  • Funkcja run konfiguruje więc Recomposer i kompozycję.

  • Każda funkcja Transform w zakresie znajduje się wewnątrz tej kompozycji i ma własny zakres rekompozycji.

  • Dlatego chcemy udostępniać zakres i dlatego mamy TransformScope.

  • Dzięki temu po zmianie stanu ponownie uruchamiane są tylko te przekształcenia, które go odczytują.

  • Jak wspomnieliśmy wcześniej, run jest funkcją zawieszenia.

    override suspend fun run() {
            val context = currentCoroutineContext()
            val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
            ...
            val recomposer = Recomposer(context)
            val composition = Composition(UnitApplier, recomposer)
            try {
                withContext(clock) {
                    if (clock is HeadlessTransformClock) {
                        launch(start = CoroutineStart.UNDISPATCHED) {
                            clock.runClock()
                        }
                    }
                    ...
                }
            } finally {
                // dispose of composition, cancel the clock, close the recomposer
            }
        }
  • Działa więc w dowolnej współprogramie, z którego jest wywoływana, dopóki ten współprogram nie zostanie anulowany.

  • Możesz się zastanawiać, czy Recomposer potrzebuje zegara klatek, który informuje go, kiedy ma wygenerować klatkę.

  • W interfejsie Compose jest to wyświetlacz, ale jak wspomnieliśmy wcześniej, działamy w środowisku bez interfejsu, więc nie ma interfejsu użytkownika.

  • Jeśli nie dostarczysz nam zegara, utworzymy własny.

  • Dzięki oddzieleniu rekompozycji od zegara klatek wyświetlania Transform wykonuje wszystkie przekształcenia i pochodne stanu poza wątkiem UI.

  • Aby to umożliwić, stworzyliśmy specjalną HeadlessTransformClock.

  • Zegar klatek przekształcenia.

  • Ponieważ widżet, usługa lub inne przekształcenie na dalszym etapie może oczekiwać na wartość z przekształcenia, nasz zegar jest niecierpliwy.

  • Oznacza to, że w momencie, gdy zmieni się stan, który jest wykorzystywany przez przekształcenie, natychmiast tworzona jest nowa klatka.

  • Jest to niewielka maszyna stanowa bez blokad, więc żądanie ramki jest prostą operacją porównania i ustawienia.

  • Gdy stan się nie zmienia, zegar się zatrzymuje.

  • Dlatego przekształcenie, które wykorzystuje stan, który nie został zmieniony, nic nie kosztuje.

  • Zanim oddam głos Benowi, warto też wspomnieć, że Transform obsługuje teraz rememberSaveable, dzięki czemu dane są zapisywane podczas zmian konfiguracji.

  • Dzieje się tak dzięki nowemu interfejsowi API SavedStateContainer, który jest teraz podstawą, na której opierają się SaveableStateRegistry, SavedStateHandle i SavedStateRegistry w Compose.

  • Możesz przekształcić dowolny z nich w SavedStateContainer lub utworzyć kontenery podrzędne i przekazać je do przekształcenia, aby mieć pewność, że dane zostaną zapisane.

  • Na koniec przypominamy, że wszystkie interfejsy API, o których dzisiaj mówimy, są w wersji alfa, więc mogą ulec zmianie.

  • Teraz oddaję głos Benowi.

Stan Compose poza interfejsem

  • OK.

  • Dobrze, dziękuję, Elif.

  • Skoro już wiemy, jak potężny jest stan Compose i jak Transform pozwala nam go uzyskać poza interfejsem, warto zauważyć, że w dotychczasowym przykładzie nasze dane wejściowe były przepływami, które połączyliśmy za pomocą collectAsState.

  • Czy jednak zawsze jest to konieczne?

  • Dlaczego nie używamy stanu Compose bezpośrednio w naszych zmiennych stanu?

  • Aby odpowiedzieć na to pytanie, warto podzielić stan na 3 szerokie kategorie.

  • Pierwszy to stan aktywny, który jest przechowywany tylko w pamięci.

  • Może to być na przykład stan połączenia sieciowego lub Bluetooth.

  • Nie chcesz używać wartości trwałej.

  • Drugi to stan przejściowy, który powinien przetrwać śmierć procesu, ale nie musi być przechowywany bezterminowo.

  • Chodzi o takie elementy jak stan interfejsu, np. czy karta jest rozwinięta, oraz stan sesji, np. stos wsteczny nawigacji lub wersja robocza formularza.

  • Trzeci to stan stały, który jest przechowywany bezterminowo.

  • Mogą to być na przykład preferencje użytkownika lub biblioteka multimediów w bazie danych.

  • Zacznijmy od tego, jak można używać stanu Compose zarówno w przypadku stanu aktywnego, jak i przejściowego.

  • Do tej pory nasze wskazówki dotyczące architektury zalecały przechowywanie stanu Compose w warstwie interfejsu, a czasami w ViewModel, i używanie Flow we wszystkich innych miejscach.

  • Dlaczego tak się stało?

  • Dzieje się tak, ponieważ gdy tylko trzeba połączyć właściwość stanu z Flow, trzeba ją przekonwertować z powrotem za pomocą snapshotFlow.

  • Dzięki Transform nie jest to jednak konieczne.

  • Zobaczmy, jak to wygląda i co to oznacza, zaczynając od ViewModel.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(
        private val tasksRepository: TasksRepository,
        private val networkMonitor: NetworkMonitor,
        savedStateHandle: SavedStateHandle,
    ) : ViewModel() {
        ...
    }
  • W przypadku lokalnych danych wejściowych ekranu, takich jak selectedTaskIds, możesz zadeklarować zwykłą właściwość mutableStateOf zamiast MutableStateFlow.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    }
  • Ponieważ jest to coś, czego niekoniecznie chcesz zachować po śmierci procesu, nie ma problemu, że nie jest to tak, że używa tylko mutableStateOf.

  • W przypadku elementów, które mają przetrwać śmierć procesu, takich jak zapytanie, możesz bezpośrednio zapisać ich stan zmienny za pomocą savedStateHandle.saveable.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    }
  • Podobnie jak w przypadku updateScore we wcześniejszym przykładzie z grą, gdy metoda aktualizuje wiele wartości stanu naraz, opakowanie zapisów w withMutableSnapshot zapewnia spójność.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    
        fun updateSearchQuery(query: String) {
            Snapshot.withMutableSnapshot {
                searchQuery = query
                selectedTaskIds = emptySet()
            }
        }
    }
  • Następnie, aby połączyć searchQuery z Room Flow, wystarczy odczytać go bezpośrednio w Transform podczas zbierania Flow.

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • A co ze stanem na żywo, np. połączeniem sieciowym?

    @Singleton
    class NetworkMonitor @Inject constructor(
        @ApplicationContext context: Context,
    ) {
        private val connectivityManager =
            context.getSystemService(ConnectivityManager::class.java)
        ...
    }
  • Zwykle znajduje się on w klasie Singleton, ponieważ jest odczytywany na wielu ekranach, a nawet przez wiele komponentów aplikacji, takich jak usługi.

  • Nie oznacza to jednak, że nie możesz używać stanu Compose do przechowywania tych danych.

  • W rzeczywistości jest to często prostsze niż wersja oparta na przepływie.

  • Nie potrzebujesz przepływu wywołania zwrotnego ani niczego innego, aby przeprowadzić adaptację. Wystarczy, że zaktualizujesz wartość kropki bezpośrednio w wywołaniach zwrotnych.

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    }
  • Jeśli konsument nadal potrzebuje przepływu, możesz użyć snapshotFlow, aby wrócić do poprzedniego stanu.

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    
        private val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) { _isOnline.value = true }
            override fun onLost(network: Network) { _isOnline.value = false }
        }
    
        init {
            connectivityManager.registerDefaultNetworkCallback(callback)
        }
    }
  • Zobaczmy, jak to wygląda w ViewModel.

  • Możesz odczytywać isOnline bezpośrednio w obiekcie Transform, a gdy wartość się zmieni, zostanie on ponownie skomponowany.

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        if (!networkMonitor.isOnline.value) {
            return@transform TaskListState.Offline
        }
    
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • Wszystko to dzieje się bezpośrednio. Nie ma żadnego zakresu aplikacji ani zakresu współprogramu, który trzeba podać, aby zaktualizować stan. To po prostu bezpośrednia, synchroniczna aktualizacja.

  • Dzięki funkcji Transform ViewModel może łączyć stan z menedżerów Singleton, stan w ViewModel i przepływy z repozytoriów w jednym bloku Transform.

  • Wróćmy teraz do stanu przejściowego.

  • Wcześniej wspomnieliśmy o 2 typach: stanie interfejsu i stanie sesji.

  • W przypadku stanu interfejsu, a nawet stanu sesji, który znajduje się w jednej aktywności, ViewModel i SavedState sprawdzają się świetnie.

  • Właśnie do tego zostały stworzone.

  • Problemy zaczynają się, gdy stan sesji musi być udostępniany wielu różnym interfejsom i komponentom aplikacji.

  • Gdy ten stan wykracza poza ViewModel, nasze obecne interfejsy API i wskazówki nie są już wystarczające.

  • Najpierw interfejsy API SavedState zapisują dane w pakiecie aktywności, ale nie są one dostępne w innych komponentach aplikacji, takich jak usługa.

  • Po drugie, mutableStateOf we wzorcu Singleton, który istnieje tylko w pamięci, więc nie przetrwa śmierci procesu.

Przedstawiamy StateStore

  • Tę lukę wypełnia Jetpack AppState, StateStore.

  • Jest to mapa stanu Compose z zabezpieczeniami wpisywania, którą można udostępniać między komponentami aplikacji i która jest obsługiwana przez DataStore, aby zapewnić przetrwanie śmierci procesu.

  • Możesz samodzielnie podłączyć DataStore do każdego obiektu Singleton, ale oznacza to konieczność pisania kodu szablonowego do zapisywania i przywracania każdej właściwości.

  • Dzięki integracji StateStore DataStore podłączasz DataStore raz, a potem akceptujesz każdy element stanu, który chcesz zachować.

  • Zbudujmy więc tę funkcję w naszej aplikacji z licznikiem czasu skupienia.

  • Zaczniemy od pamięci, a potem dodamy trwałość DataStore.

  • Na początek modelujemy FocusSession jako zapieczętowany interfejs ze stanami bezczynności, działania i wstrzymania.

    sealed interface FocusSession {
        data object Idle : FocusSession
        data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
  • Aby zapisać ten stan w StateStore, zdefiniowaliśmy StateStoreKey z typem FocusSession i wartością domyślną „idle”.

    sealed interface FocusSession { ... }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
  • Następnie udostępniamy singleton StateStore w Hilt, który możemy wstrzykiwać do różnych komponentów, w których jest potrzebny, np. do menedżera sesji skupienia.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(): StateStore = StateStore()
    }
    
    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        ...
    }
  • Czyli podobnie jak ViewModel wstrzykuje SavedStateHandle.

  • OK.

  • Mamy już StateStore, ale jak go używać?

  • Na szczęście jest to mały interfejs API.

  • Ma tylko 3 metody, więc omówimy je po kolei.

  • Pierwsza to setState, która zapisuje nową wartość klucza.

    fun startFocus(taskId: String, durationMinutes: Int = 25) {
        val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
        stateStore.setState(
            FocusSessionKey,
            FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
        )
    }
    
    fun endFocus() {
        stateStore.setState(FocusSessionKey, FocusSession.Idle)
    }
  • Gdy sesja się rozpoczyna, ustawiamy jej stan na „uruchomiona”, a gdy się kończy, ustawiamy jej stan na „bezczynna”.

  • To wszystko.

  • Po drugie, nadal mamy updateState, który pozwala obliczyć nową wartość na podstawie bieżącej wartości, np. obliczyć pozostały czas podczas przełączania się między uruchomieniem a wstrzymaniem.

    fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Running) return@updateState session
            FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
        }
    }
    
    fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Paused) return@updateState session
            FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
        }
    }
  • Na koniec mamy getState, która zwraca stan tylko do odczytu dla klucza i ma opcję zastąpienia wartości domyślnej, jeśli chcesz ją zmienić w określonym miejscu wywołania.

    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
        ...
    }
  • Udostępnienie stanu FocusSession w tym menedżerze oznacza, że konsumenci nigdy nie muszą uzyskiwać bezpośredniego dostępu do StateStore ani jego kluczy.

  • Możesz zachować enkapsulację zapisu i odczytu.

  • Następnie zamiast konsumentów wstrzykują Focus Session Manager, który prawdopodobnie już mają, ponieważ muszą kontrolować sesję, a potem mogą odczytać stan.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() { ... }
    
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface WidgetEntryPoint {
        fun focusSessionManager(): FocusSessionManager
    }
  • Następnie te czytniki mogą bezpośrednio ponownie udostępnić stan lub użyć przekształcenia, aby utworzyć nowy, zaktualizowany stan, podobnie jak w przykładzie z minutnikiem ViewModel.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
  • Następnie elementy kompozycyjne interfejsu mogą odczytywać ten stan tak samo jak każdy inny stan Compose i w razie potrzeby przeprowadzać rekompozycję.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
    
    @Composable
    fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
        val session by viewModel.session
        if (session !is FocusSession.Idle) {
            TimerReadout(session = session)
        }
    }
  • Jednak do tej pory FocusSession był przechowywany tylko w pamięci.

  • Przyjrzyjmy się temu, co jest potrzebne, aby przetrwać śmierć procesu.

  • Najpierw musimy przeprowadzić jednorazową konfigurację, uruchamiając syncToDataStore podczas tworzenia sklepu.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(
            @ApplicationContext context: Context,
            @ApplicationScope scope: CoroutineScope,
        ): StateStore = StateStore().apply {
            val path = context.dataStoreFile("app_state.json").absolutePath
            scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
        }
    }
  • Następnie akceptujemy każdy klucz, który chcemy trwale przechowywać.

    @Serializable
    sealed interface FocusSession {
        @Serializable data object Idle : FocusSession
        @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
        PersistToDataStore
  • Wystarczy upewnić się, że typ jest możliwy do serializacji, a klucz implementuje interfejs PersistToDataStore.

  • Możesz pomyśleć: „To wydaje się proste.

  • Co właściwie robi, aby zachować klucz?

  • W tle syncToDataStore używa interfejsu API detektora przekształcenia, który działa dokładnie tak samo jak przekształcenie, ale nic nie zwraca, tylko służy do obserwacji i efektów ubocznych.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener { … }
    }
  • Następnie w ramach tego detektora odczytuje każdy klucz w sklepie i sprawdza, czy jest to klucz PersistToDataStore.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener {
            for (key in keys) {
                if (key is PersistToDataStore) {
                    val value = getState(key).value
    
                    LaunchedEffect(key) {
                        // Restore saved value from dataStore on first access
                    }
    
                    LaunchedEffect(key, value) {
                        // Flush updated value to dataStore on change
                    }
                }
            }
        }
    }
  • Jeśli tak, używa LaunchedEffect do synchronizowania tych wartości z DataStore za każdym razem, gdy ulegną zmianie.

  • Chcę podkreślić, że standardowy kontener, taki jak StateStore, umożliwia nam udostępnianie tych integracji typu drop-in.

  • DataStore to tylko pierwszy z nich.

Podsumowanie kategorii stanu tworzenia

  • Wracając do kategorii stanów, mamy teraz dość jasny obraz tego, jak można używać stanu Compose w całej aplikacji.

  • Od mutableStateOf dla stanu aktywnego, przez interfejsy API SavedState dla stanu prezentacji lokalnej na ekranie i StateStore dla stanu sesji między komponentami, aż po collectAsState do współdziałania z przepływami.

  • Jednak interoperacyjność nie ogranicza się do jednej kategorii. Możesz używać tych funkcji pomocniczych, takich jak collectAsState, gdy chcesz połączyć typy obserwowalne ze stanem Compose.

  • Jeśli chodzi o stan stały, rozważamy też sposoby współpracy z Compose lub DataStore i Room, aby bezpośrednio udostępniać stan Compose, dzięki czemu nie trzeba będzie pisać własnej, ładniejszej składni.

  • Z przyjemnością ogłaszam, że Transform, StateStore i integracja z DataStore są już dostępne w wersji alfa. Opublikowaliśmy też całą dokumentację na stronie developer.android.com i mamy repozytorium AppState Recipes na GitHubie, które pokazuje przykłady użycia zarówno StateStore, jak i Transform.

    [versions]
    appstate = "1.0.0-alpha01"
    ...
    
    [libraries]
    ...
    transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
    transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
    statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
    statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }
  • Wypróbuj je w swojej aplikacji.

  • Prześlij nam opinię.

  • Jesteśmy w fazie alfa, więc możemy wprowadzać zmiany, które będą odpowiadać Twoim przypadkom użycia.

Kod

Fragmenty kodu

Fragment kodu w języku Kotlin w 01:56 w filmie „What is Compose State?”
/**
 * A value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value.
 */
@Stable
public interface State<out T> {
    public val value: T
}
Fragment kodu w Kotlinie w 02:00 w filmie „What is Compose State?”
/**
 * A mutable value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
 * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
 * will be scheduled. If [value] is written to with the same value, no recompositions will be
 * scheduled.
 */
@Stable
public interface MutableState<T> : State<T> {
    override var value: T
    public operator fun component1(): T
    public operator fun component2(): (T) -> Unit
}
Fragment kodu w języku Kotlin w 02:23 w filmie „What is Compose State?”
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
}
Fragment kodu w języku Kotlin w 02:32 w filmie „What is Compose State?”
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}
Fragment kodu w języku Kotlin w filmie „What is Compose State?” (02:52)
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}

@Composable
fun AddButton(onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("Add")
    }
}
Fragment kodu w języku Kotlin w filmie „What is Compose State?” (03:08)
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(true) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
Fragment kodu w Kotlinie w 03:26 w filmie „What is Compose State?”
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(false) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
Fragment kodu w języku Kotlin w 04:15 w filmie „What is Compose State?”
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0
Fragment kodu w języku Kotlin w 04:23 w filmie „What is Compose State?”
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)
Fragment kodu w języku Kotlin w 04:33 w filmie „What is Compose State?”
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}
Fragment kodu w języku Kotlin w filmie „What is Compose State?” (Czym jest stan Compose?) w 04:42
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    Column {
        Header(levelName = state.levelName)
        ScoreBadge(
            timeSurvived = state.timeSurvived,
            score = state.score
        )
    }
}
Fragment kodu w języku Kotlin w 05:09 w filmie „What is Compose State?”
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}
Fragment kodu w języku Kotlin w 05:21 w filmie „What is Compose State?”
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}
Fragment kodu w języku Kotlin w 05:30 w filmie „What is Compose State?”
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state = viewModel.uiState
    Column {
        Header(state = state)
        ScoreBadge(state = state)
    }
}
Fragment kodu w języku Kotlin w 07:35 w filmie „Introducing Transform”
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R>
Fragment kodu w języku Kotlin w 09:20 w filmie „Introducing Transform”
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: StateFlow<FocusSummary> =
        combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
            }
        }.stateIn(
                scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = FocusSummary.Idle,
)
}
Fragment kodu w języku Kotlin w 09:37 w filmie „Introducing Transform”
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find { it.id == session.activeTaskId }
            FocusSummary.Active(...)
Fragment kodu w Kotlinie w 10:01 w filmie „Introducing Transform”
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
            initialValue = FocusSummary.Idle,
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
                     FocusSummary.Active(...)
        }
}
Fragment kodu w języku Kotlin w 11:39 w filmie „Nested transforms”
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
    ...
}

class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
    ...
}

class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            ...
        }
    }
}
Fragment kodu w Kotlinie w 12:03 w filmie „Nested transforms”
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: StateFlow<FocusSummary> =
        combine(
            sessionFlow,
            taskRepository.tasksFlow
        ) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
        }.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}
Fragment kodu w języku Kotlin w 12:17 w sekcji „Przekształcenia zagnieżdżone”
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
           initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
        ) {
            val session by sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow
                .collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
            FocusSummary.Active(...)
        }
}
Fragment kodu w języku Kotlin w 12:28 w filmie „Nested transforms”
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: StateFlow<MiniTimerUiState> =
        combine(
            focusManager.summary,
            isExpanded
        ) { summary, expanded ->
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = MiniTimerUiState()
)

    fun toggleExpanded() { isExpanded.update { !it } }
}
Fragment kodu w języku Kotlin w 12:56 w sekcji „Przekształcenia zagnieżdżone”
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: State<MiniTimerUiState> =
        transform(
            initialValue = MiniTimerUiState(),
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val summary by focusManager.summary
                   val expanded by isExpanded.collectAsState()
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }

    fun toggleExpanded() { isExpanded.update { !it } }
}
Fragment kodu w języku Kotlin w 13:37 w sekcji „Przekształcenia zagnieżdżone”
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
                .collectAsState()
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
Fragment kodu w języku Kotlin w 13:47 w sekcji „Przekształcenia zagnieżdżone”
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
Fragment kodu w języku Kotlin w 14:33 w filmie „Nested transforms”
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R> {
        val transformScope = TransformScope()
    val transform = transformScope
.launchTransform(initialValue, onUpdate)
    scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
 ) {
        try { transformScope.run() } finally {
            transform.dispose()
        }
    }
    return transform
}
Fragment kodu w języku Kotlin w 15:18 w filmie „Nested transforms”
public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope

public abstract class TransformScope {
        public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}

public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>

public interface DisposableState<T> : State<T> {
public fun dispose()
}
Fragment kodu w języku Kotlin w 16:02 w filmie „Nested transforms”
override suspend fun run() {
        val context = currentCoroutineContext()
        val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
        ...
        val recomposer = Recomposer(context)
        val composition = Composition(UnitApplier, recomposer)
        try {
            withContext(clock) {
                if (clock is HeadlessTransformClock) {
                    launch(start = CoroutineStart.UNDISPATCHED) {
                        clock.runClock()
                    }
                }
                ...
            }
        } finally {
            // dispose of composition, cancel the clock, close the recomposer
        }
    }
Fragment kodu w języku Kotlin w 19:12 w filmie „Compose State outside of the UI”
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    private val networkMonitor: NetworkMonitor,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    ...
}
Fragment kodu w języku Kotlin w 19:17 w filmie „Compose State outside of the UI”
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set
}
Fragment kodu w języku Kotlin w 19:33 w filmie „Compose State outside of the UI”
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set
}
Fragment kodu w Kotlinie w 19:41 w filmie „Compose State outside of the UI”
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set

    fun updateSearchQuery(query: String) {
        Snapshot.withMutableSnapshot {
            searchQuery = query
            selectedTaskIds = emptySet()
        }
    }
}
Fragment kodu w Kotlinie w 19:52 w filmie „Compose State outside of the UI”
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
Fragment kodu w języku Kotlin w 20:00 w filmie „Compose State outside of the UI”
@Singleton
class NetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)
    ...
}
Fragment kodu w języku Kotlin w 20:17 w filmie „Compose State outside of the UI”
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline
}
Fragment kodu w Kotlinie w 20:23 w filmie „Compose State outside of the UI”
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline

    private val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) { _isOnline.value = true }
        override fun onLost(network: Network) { _isOnline.value = false }
    }

    init {
        connectivityManager.registerDefaultNetworkCallback(callback)
    }
}
Fragment kodu w Kotlinie w 20:32 w filmie „Compose State outside of the UI”
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    if (!networkMonitor.isOnline.value) {
        return@transform TaskListState.Offline
    }

    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
Fragment kodu w języku Kotlin w 22:07 w filmie „Introducing StateStore”
sealed interface FocusSession {
    data object Idle : FocusSession
    data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
Fragment kodu w języku Kotlin w 22:14 w filmie „Introducing StateStore”
sealed interface FocusSession { ... }

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
Fragment kodu w Kotlinie w 22:23 w filmie „Introducing StateStore”
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(): StateStore = StateStore()
}

@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    ...
}
Fragment kodu w języku Kotlin w 22:42 w filmie „Introducing StateStore”
fun startFocus(taskId: String, durationMinutes: Int = 25) {
    val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
    stateStore.setState(
        FocusSessionKey,
        FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
    )
}

fun endFocus() {
    stateStore.setState(FocusSessionKey, FocusSession.Idle)
}
Fragment kodu w języku Kotlin w 22:52 w filmie „Introducing StateStore”
fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Running) return@updateState session
        FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
    }
}

fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Paused) return@updateState session
        FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
    }
}
Fragment kodu w Kotlinie w 23:03 w filmie „Introducing StateStore”
@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
    ...
}
Fragment kodu w Kotlinie w 23:22 w filmie „Introducing StateStore”
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
    fun focusSessionManager(): FocusSessionManager
}
Fragment kodu w Kotlinie w 23:33 w filmie „Introducing StateStore”
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}
Fragment kodu w Kotlinie w 23:45 w filmie „Introducing StateStore”
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}

@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
    val session by viewModel.session
    if (session !is FocusSession.Idle) {
        TimerReadout(session = session)
    }
}
Fragment kodu w Kotlinie w 23:58 w filmie „Introducing StateStore”
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(
        @ApplicationContext context: Context,
        @ApplicationScope scope: CoroutineScope,
    ): StateStore = StateStore().apply {
        val path = context.dataStoreFile("app_state.json").absolutePath
        scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
    }
}
Fragment kodu w Kotlinie w 24:04 w filmie „Introducing StateStore”
@Serializable
sealed interface FocusSession {
    @Serializable data object Idle : FocusSession
    @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
    PersistToDataStore
Fragment kodu w języku Kotlin w 24:22 w filmie „Introducing StateStore”
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener { … }
}
Fragment kodu w języku Kotlin w 24:33 w filmie „Introducing StateStore”
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener {
        for (key in keys) {
            if (key is PersistToDataStore) {
                val value = getState(key).value

                LaunchedEffect(key) {
                    // Restore saved value from dataStore on first access
                }

                LaunchedEffect(key, value) {
                    // Flush updated value to dataStore on change
                }
            }
        }
    }
}
Fragment TOML w 25:39 w sekcji „Podsumowanie kategorii stanu Compose”
[versions]
appstate = "1.0.0-alpha01"
...

[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }