Produkcja stanu UI

Nowoczesne interfejsy użytkownika rzadko są statyczne. Stan interfejsu użytkownika zmienia się, gdy użytkownik wchodzi z nim w interakcję lub gdy aplikacja musi wyświetlić nowe dane.

Dokument zawiera wytyczne dotyczące tworzenia stanu UI i zarządzania nim. Na jego końcu wykonaj te czynności:

  • wiedzieć, których interfejsów API należy użyć do utworzenia stanu interfejsu użytkownika; Zależy to od charakteru źródeł zmian stanu dostępnych dla właścicieli stanów zgodnie z zasadami jednokierunkowego przepływu danych.
  • Dowiedz się, jak określić zakres tworzenia stanu interfejsu, aby mieć świadomość zasobów systemowych.
  • Dowiedz się, jak udostępnić stan interfejsu do wykorzystania przez interfejs użytkownika.

Zasadniczo produkcja stanowa to stopniowe stosowanie tych zmian w stanie interfejsu. Stan zawsze istnieje i zmienia się w wyniku zdarzeń. Różnice między zdarzeniami a stanem znajdziesz w tej tabeli:

Wydarzenia Region
Jest przejściowa, nieprzewidywalna i istnieje przez określony czas. Zawsze istnieje.
Dane produkcji stanowej. Wynik produkcji stanowej.
Produkt interfejsu użytkownika lub inne źródła. Jest zużywane przez interfejs użytkownika.

Świetna mnemotechnika podsumowująca powyższe stwierdzenia to „stan, zdarzenia się zdarzają. Na diagramie poniżej widać zmiany stanu w miarę występowania zdarzeń na osi czasu. Każde zdarzenie jest przetwarzane przez odpowiedniego właściciela stanu i prowadzi do zmiany stanu:

Zdarzenia a stan
Rysunek 1.: Zdarzenia powodują zmianę stanu

Zdarzenia mogą pochodzić z:

  • Użytkownicy: podczas korzystania z interfejsu aplikacji.
  • Inne źródła zmian stanu: interfejsy API, które prezentują dane aplikacji z interfejsu użytkownika, domeny lub warstw danych, np. zdarzenia limitu czasu paska powiadomień, przypadki użycia lub repozytoria.

Potok produkcyjny stanu interfejsu użytkownika

Stanową wersję produkcyjną aplikacji na Androida można określić jako potok przetwarzania składający się z:

  • Dane wejściowe: źródła zmian stanu. Mogą to być:
    • Lokalne w warstwie interfejsu: mogą to być zdarzenia dotyczące użytkownika, takie jak wpisanie tytułu zadania do wykonania w aplikacji do zarządzania zadaniami, lub interfejsy API zapewniające dostęp do logiki UI, które powodują zmiany stanu interfejsu. Może to być na przykład wywołanie metody open w narzędziu DrawerState w Jetpack Compose.
    • Zewnętrzne w warstwie interfejsu: są to źródła z domeny lub warstw danych, które powodują zmiany stanu interfejsu. Mogą to być na przykład wiadomości, które zakończyły wczytywanie z elementu NewsRepository, lub inne zdarzenia.
    • Połączenie wszystkich powyższych informacji.
  • Właściciele stanów: typy, które stosują logikę biznesową lub logikę interfejsu do źródeł zmian stanu oraz przetwarzają zdarzenia użytkowników w celu wygenerowania stanu interfejsu.
  • Dane wyjściowe: interfejs informujący o tym, że aplikacja może zostać wyrenderowana, aby dostarczyć użytkownikom potrzebne informacje.
Stanowy potok produkcyjny
Rys. 2. Stanowy potok produkcyjny

Interfejsy API środowiska produkcyjnego

W zależności od etapu potoku, na którym się znajdujesz, używane są 2 główne interfejsy API używane w stanowym środowisku produkcyjnym:

Etap potoku Interfejs API
Wprowadź tekst Aby wykonać pracę poza wątkiem UI i uniknąć zakłóceń w jej działaniu, należy używać asynchronicznych interfejsów API. np. korutyny lub przepływy w Kotlin, RxJava czy wywołania zwrotne w Java Programming Language.
Odpowiedź Do unieważnienia i ponownego wyrenderowania interfejsu po zmianie stanu należy użyć interfejsów API dostępnych do obserwacji. Na przykład StateFlow, Compose State lub LiveData. Obserwowalne posiadacze danych gwarantują, że interfejs użytkownika zawsze ma stan UI do wyświetlenia na ekranie

Z tych 2 metod wybór asynchronicznego interfejsu API na potrzeby danych wejściowych ma większy wpływ na charakter stanowego potoku produkcyjnego niż wybór obserwowalnego interfejsu API na potrzeby danych wyjściowych. Dzieje się tak, ponieważ dane wejściowe określają rodzaj przetwarzania, który można zastosować do potoku.

Zespół stanowego potoku produkcyjnego

W następnych sekcjach omawiamy techniki tworzenia stanu, które najlepiej sprawdzają się w przypadku różnych danych wejściowych, oraz pasujące wyjściowe interfejsy API. Każdy stanowy potok produkcyjny to kombinacja danych wejściowych i wyjściowych. Powinien wyglądać tak:

  • Uwzględnienie cyklu życia: jeśli interfejs użytkownika nie jest widoczny lub aktywny, stanowy potok produkcyjny nie powinien wykorzystywać żadnych zasobów, chyba że jest to wyraźnie wymagane.
  • Łatwa obsługa: interfejs powinien być w stanie łatwo renderować utworzony stan UI. Uwagi dotyczące danych wyjściowych stanowego potoku produkcyjnego będą się różnić w zależności od interfejsów API widoku danych, takich jak system View czy Jetpack Compose.

Dane wejściowe w stanowych potokach produkcyjnych

Dane wejściowe w stanowym potoku produkcyjnym mogą określać źródła zmian stanu:

  • Operacje one-shot, które mogą być synchroniczne lub asynchroniczne, np. wywołania funkcji suspend.
  • Interfejsy API strumieniowania, na przykład Flows.
  • Wszystkie powyższe odpowiedzi.

W sekcjach poniżej dowiesz się, jak utworzyć stanowy potok produkcyjny dla każdego z powyższych danych wejściowych.

Interfejsy API typu „One-shot” jako źródła zmian stanu

Interfejs API MutableStateFlow może być obserwowalnym, zmiennym kontenerem stanu. W aplikacjach Jetpack Compose możesz też korzystać z mutableStateOf, zwłaszcza w przypadku korzystania z interfejsów API tworzenia tekstu. Oba interfejsy API udostępniają metody umożliwiające bezpieczne, atomowe aktualizacje wartości, które hostują, niezależnie od tego, czy aktualizacje są synchroniczne czy asynchroniczne.

Przykładem może być aktualizacja stanu w prostej aplikacji do rzucania kością. Każdy rzut kostką użytkownika wywołuje metodę synchronicznej Random.nextInt(), a wynik jest zapisywany w stanie interfejsu użytkownika.

StateFlow,

data class DiceUiState(
    val firstDieValue: Int? = null,
    val secondDieValue: Int? = null,
    val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

    // Called from the UI
    fun rollDice() {
        _uiState.update { currentState ->
            currentState.copy(
            firstDieValue = Random.nextInt(from = 1, until = 7),
            secondDieValue = Random.nextInt(from = 1, until = 7),
            numberOfRolls = currentState.numberOfRolls + 1,
            )
        }
    }
}

Stan tworzenia

@Stable
interface DiceUiState {
    val firstDieValue: Int?
    val secondDieValue: Int?
    val numberOfRolls: Int?
}

private class MutableDiceUiState: DiceUiState {
    override var firstDieValue: Int? by mutableStateOf(null)
    override var secondDieValue: Int? by mutableStateOf(null)
    override var numberOfRolls: Int by mutableStateOf(0)
}

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

    // Called from the UI
    fun rollDice() {
        _uiState.firstDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.secondDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.numberOfRolls = _uiState.numberOfRolls + 1
    }
}

Mutacja stanu interfejsu z wywołań asynchronicznych

W przypadku zmian stanu, które wymagają asynchronicznego wyniku, uruchom kopertę w odpowiednim obiekcie CoroutineScope. Dzięki temu aplikacja może odrzucić pracę po anulowaniu polecenia CoroutineScope. Właściciel stanu zapisuje wynik wywołania metody zawieszenia w obserwowalnym interfejsie API służącym do ujawniania stanu interfejsu.

Przyjrzyjmy się np. AddEditTaskViewModel z przykładu architektury. Gdy metoda zawieszania saveTask() zapisuje zadanie asynchronicznie, metoda update w MutableStateFlow przekazuje zmianę stanu do stanu interfejsu użytkownika.

StateFlow,

data class AddEditTaskUiState(
    val title: String = "",
    val description: String = "",
    val isTaskCompleted: Boolean = false,
    val isLoading: Boolean = false,
    val userMessage: String? = null,
    val isTaskSaved: Boolean = false
)

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableStateFlow(AddEditTaskUiState())
   val uiState: StateFlow<AddEditTaskUiState> = _uiState.asStateFlow()

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.update {
                    it.copy(isTaskSaved = true)
                }
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.update {
                    it.copy(userMessage = getErrorMessage(exception))
                }
            }
        }
    }
}

Stan tworzenia

@Stable
interface AddEditTaskUiState {
    val title: String
    val description: String
    val isTaskCompleted: Boolean
    val isLoading: Boolean
    val userMessage: String?
    val isTaskSaved: Boolean
}

private class MutableAddEditTaskUiState : AddEditTaskUiState() {
    override var title: String by mutableStateOf("")
    override var description: String by mutableStateOf("")
    override var isTaskCompleted: Boolean by mutableStateOf(false)
    override var isLoading: Boolean by mutableStateOf(false)
    override var userMessage: String? by mutableStateOf<String?>(null)
    override var isTaskSaved: Boolean by mutableStateOf(false)
}

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableAddEditTaskUiState()
   val uiState: AddEditTaskUiState = _uiState

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.isTaskSaved = true
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.userMessage = getErrorMessage(exception))
            }
        }
    }
}

Mutowanie stanu interfejsu z wątków w tle

Aby utworzyć stan UI, zalecamy uruchamianie Coroutines w głównym dyspozytorze. czyli poza blokiem withContext w poniższych fragmentach kodu. Jeśli jednak musisz zaktualizować stan interfejsu w innym kontekście tła, możesz to zrobić za pomocą tych interfejsów API:

  • Użyj metody withContext, aby uruchamiać Coroutines w innym kontekście równoczesnym.
  • Jeśli używasz metody MutableStateFlow, użyj zwykłej metody update.
  • Jeśli używasz stanu tworzenia, użyj Snapshot.withMutableSnapshot, aby zagwarantować atomowe aktualizacje stanu w kontekście równoczesnym.

Załóżmy na przykład, że we fragmencie DiceRollViewModel poniżej SlowRandom.nextInt() jest funkcją suspend o dużej mocy obliczeniowej, która musi być wywoływana z korutyny powiązanej z procesorem.

StateFlow,

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                _uiState.update { currentState ->
                    currentState.copy(
                        firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        numberOfRolls = currentState.numberOfRolls + 1,
                    )
                }
            }
        }
    }
}

Stan tworzenia

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                Snapshot.withMutableSnapshot {
                    _uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.numberOfRolls = _uiState.numberOfRolls + 1
                }
            }
        }
    }
}

Interfejsy API strumieniowania jako źródła zmian stanu

W przypadku źródeł zmian stanu, które w ujęciu czasowym w strumieniach pojawiają się wiele wartości, łączenie danych wyjściowych ze wszystkich źródeł w spójną całość jest prostym podejściem do stanu produkcji.

Jeśli korzystasz z Kotlin Flows, możesz to osiągnąć za pomocą funkcji combine. Przykład można zobaczyć w przykładzie „Teraz na Androidzie” w modelu InterestViewModel:

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {

    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicsStream(),
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(
            authors = availableAuthors,
            topics = availableTopics
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
    )
}

Użycie operatora stateIn do utworzenia StateFlows daje interfejsowi dokładniejszą kontrolę nad aktywnością stanowego potoku produkcyjnego, ponieważ może on być aktywny tylko wtedy, gdy interfejs jest widoczny.

  • Użyj funkcji SharingStarted.WhileSubscribed(), jeśli potok powinien być aktywny tylko wtedy, gdy interfejs użytkownika jest widoczny podczas zbierania przepływu w sposób odzwierciedlający cykl życia.
  • Użyj funkcji SharingStarted.Lazily, jeśli potok powinien być aktywny, o ile użytkownik może wrócić do interfejsu (czyli w trybie wstecznym lub innej karcie poza ekranem).

Jeśli nie ma zastosowania agregacja źródeł stanu opartego na strumieniu, interfejsy API strumienia, takie jak Kotlin Flows, oferują bogaty zestaw przekształceń, takich jak scalanie, spłaszczanie itp., które ułatwiają przetwarzanie strumieni w stan interfejsu.

Interfejsy API typu „One-shot” i „Stream” jako źródła zmiany stanu

W sytuacji, gdy stanowy potok produkcyjny bazuje na zarówno wywołaniach jednorazowych, jak i strumieniach jako źródeł zmiany stanu, ograniczeniem definiującym strumienie. Dlatego przekonwertuj wywołania one-shot na interfejsy API strumieni lub umieść ich dane wyjściowe potokiem w strumieniach i wznów przetwarzanie w sposób opisany powyżej w sekcji dotyczącej strumieni.

W przypadku przepływów oznacza to zwykle utworzenie co najmniej 1 prywatnej instancji kopii zapasowej MutableStateFlow do rozpowszechniania zmian stanu. W stanie tworzenia możesz też tworzyć przepływy zrzutów.

Przeanalizujmy TaskDetailViewModel z poniższego repozytorium architecture-samples:

StateFlow,

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _isTaskDeleted = MutableStateFlow(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        _isTaskDeleted,
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted.update { true }
    }
}

Stan tworzenia

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private var _isTaskDeleted by mutableStateOf(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        snapshotFlow { _isTaskDeleted },
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted = true
    }
}

Typy danych wyjściowych w stanowych potokach produkcyjnych

Wybór wyjściowego interfejsu API dla stanu interfejsu oraz charakter jego prezentacji zależą w dużej mierze od interfejsu API, z którego korzysta aplikacja do renderowania UI. W aplikacjach na Androida możesz użyć widoków lub Jetpack Compose. Warto wziąć pod uwagę te kwestie:

W tabeli poniżej znajdziesz podsumowanie interfejsów API, których możesz używać na potrzeby potoku produkcyjnego dla określonych danych wejściowych i konsumenta:

Wprowadź tekst Konsument Odpowiedź
Interfejsy API One-shot Wyświetlenia StateFlow lub LiveData
Interfejsy API One-shot Utwórz StateFlow lub Utwórz State
Interfejsy API strumieniowania Wyświetlenia StateFlow lub LiveData
Interfejsy API strumieniowania Utwórz StateFlow
Interfejsy API typu „One-shot” i „stream” Wyświetlenia StateFlow lub LiveData
Interfejsy API typu „One-shot” i „stream” Utwórz StateFlow

Inicjowanie stanowego potoku produkcyjnego

Inicjowanie stanowych potoków produkcyjnych obejmuje ustawienie początkowych warunków uruchomienia potoku. Może to obejmować podanie początkowych wartości wejściowych mających kluczowe znaczenie dla uruchomienia potoku, np. id w widoku szczegółowym artykułu z wiadomościami lub uruchomienie wczytywania asynchronicznego.

Jeśli to możliwe, stanowy potok produkcyjny należy zainicjować leniwie, aby oszczędzać zasoby systemu. W praktyce często oznacza to oczekiwanie, aż pojawi się konsument. Interfejsy API Flow pozwalają na to z argumentem started w metodzie stateIn. W przypadkach, gdy nie ma to zastosowania, zdefiniuj funkcję idempotent initialize(), aby wyraźnie uruchamiać stanowy potok produkcyjny, jak pokazano w tym fragmencie:

class MyViewModel : ViewModel() {

    private var initializeCalled = false

    // This function is idempotent provided it is only called from the UI thread.
    @MainThread
    fun initialize() {
        if(initializeCalled) return
        initializeCalled = true

        viewModelScope.launch {
            // seed the state production pipeline
        }
    }
}

Próbki

Poniższe przykłady Google pokazują powstawanie stanu w warstwie interfejsu. Zapoznaj się z nimi, aby zastosować te wskazówki w praktyce: