Produkcja stanu UI

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

Ten dokument zawiera wytyczne dotyczące tworzenia stanu interfejsu i zarządzania nim. Ma on pomóc Ci zrozumieć:

  • których interfejsów API używać do tworzenia stanu interfejsu. Zależy to od charakteru źródeł zmian stanu dostępnych w zmiennych stanu, zgodnie z zasadami jednokierunkowego przepływu danych;
  • jak ograniczyć tworzenie stanu interfejsu, aby uwzględnić zasoby systemowe;
  • jak udostępnić stan interfejsu do wykorzystania przez interfejs.

Zasadniczo tworzenie stanu polega na stopniowym stosowaniu tych zmian do stanu interfejsu. Stan zawsze istnieje i zmienia się w wyniku zdarzeń. Różnice między zdarzeniami a stanem zostały podsumowane w tabeli poniżej:

Wydarzenia Stan
Przejściowe, nieprzewidywalne i istniejące przez ograniczony czas. Zawsze istnieje.
Dane wejściowe do tworzenia stanu. Dane wyjściowe z tworzenia stanu.
Wynik interfejsu lub innych źródeł. Jest wykorzystywany przez interfejs.

Dobrym sposobem na zapamiętanie powyższych informacji jest stan jest, zdarzenia się dzieją. Diagram poniżej pomaga wizualizować zmiany stanu w miarę występowania zdarzeń na osi czasu. Każde zdarzenie jest przetwarzane przez odpowiednią zmienną stanu i powoduje zmianę stanu:

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

Zdarzenia mogą pochodzić z tych źródeł:

  • Użytkownicy: gdy wchodzą w interakcję z interfejsem aplikacji.
  • Inne źródła zmian stanu: interfejsy API, które prezentują dane aplikacji z warstwy interfejsu, domeny lub danych, np. zdarzenia przekroczenia limitu czasu Snackbar, przypadki użycia lub repozytoria.

Potok tworzenia stanu interfejsu

Tworzenie stanu w aplikacjach na Androida można traktować jako potok przetwarzania, który obejmuje te elementy:

  • Dane wejściowe: źródła zmian stanu. Mogą to być:
    • lokalne w warstwie interfejsu: mogą to być zdarzenia użytkownika, np. wpisanie tytułu zadania w aplikacji do zarządzania zadaniami, lub interfejsy API, które zapewniają dostęp do logiki interfejsu, która powoduje zmiany stanu interfejsu, np. wywołanie metody open w DrawerState w Jetpack Compose;
    • zewnętrzne w warstwie interfejsu: są to źródła z warstwy domeny lub danych, które powodują zmiany stanu interfejsu, np. wiadomości, które zostały wczytane z NewsRepository, lub inne zdarzenia;
    • połączenie powyższych.
  • Zmienne stanu: typy, które stosują logikę biznesową i logikę interfejsu do źródeł zmian stanu oraz przetwarzają zdarzenia użytkownika w celu utworzenia stanu interfejsu.
  • Dane wyjściowe: stan interfejsu, który aplikacja może renderować, aby przekazywać użytkownikom potrzebne informacje.
Potok produkcji stanu
Rysunek 2: Potok tworzenia stanu

Interfejsy API do tworzenia stanu

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

Etap potoku Interfejs API
Dane wejściowe Używaj asynchronicznych interfejsów API, takich jak współprogramy i przepływy, aby wykonywać pracę poza wątkiem UI i zapewnić płynność interfejsu.
Dane wyjściowe Używaj interfejsów API zmiennych stanu, takich jak stan Compose lub StateFlow, aby unieważniać i ponownie renderować interfejs, gdy zmieni się stan. Zmienne stanu zapewniają, że interfejs zawsze ma stan interfejsu do wyświetlenia na ekranie.

Wybór asynchronicznego interfejsu API dla danych wejściowych ma większy wpływ na charakter potoku tworzenia stanu niż wybór interfejsu API do obserwacji dla danych wyjściowych. Dzieje się tak, ponieważ dane wejściowe określają rodzaj przetwarzania, które można zastosować do potoku.

Tworzenie potoku tworzenia stanu

W kolejnych sekcjach omówimy techniki tworzenia stanu najlepiej dostosowane do różnych danych wejściowych oraz pasujące do nich interfejsy API danych wyjściowych. Każdy potok tworzenia stanu jest połączeniem danych wejściowych i wyjściowych i musi spełniać te warunki:

  • Świadomość cyklu życia: jeśli interfejs nie jest widoczny ani aktywny, potok tworzenia stanu nie może zużywać żadnych zasobów, chyba że jest to wyraźnie wymagane.
  • Łatwość wykorzystania: interfejs musi być w stanie łatwo renderować utworzony stan interfejsu. W Jetpack Compose wykorzystanie stanu jest kluczowe dla interfejsu, ponieważ elementy kompozycyjne mogą się aktualizować na podstawie zmian stanu.

Dane wejściowe w potokach tworzenia stanu

Dane wejściowe w potoku tworzenia stanu udostępniają swoje źródła zmian stanu w tych sposób:

  • Operacje jednorazowe, które mogą być synchroniczne lub asynchroniczne, np. wywołania funkcji suspend.
  • Interfejsy API strumieni, np. Flows.
  • Wszystkie powyższe.

W kolejnych sekcjach dowiesz się, jak utworzyć potok tworzenia stanu dla każdego z powyższych danych wejściowych.

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

Zarządzaj stanem za pomocą danych dostępnych do obserwacji. Używaj interfejsu API mutableStateOf, zwłaszcza podczas pracy z interfejsami API tekstu Compose. W przypadku bardziej złożonego zarządzania stanem lub integracji z innymi komponentami architektury używaj interfejsu API MutableStateFlow. Oba interfejsy API oferują metody, które umożliwiają bezpieczne, niepodzielne aktualizacje wartości, które przechowują, niezależnie od tego, czy aktualizacje są synchroniczne czy asynchroniczne.

Rozważmy na przykład aktualizacje stanu w prostej aplikacji do rzucania kostką. Każdy rzut kostką przez użytkownika wywołuje synchroniczną metodę Random.nextInt, a wynik jest zapisywany w stanie interfejsu.

Stan Compose

@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
    }
}

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

Modyfikowanie stanu interfejsu za pomocą wywołań asynchronicznych

W przypadku zmian stanu, które wymagają wyniku asynchronicznego, uruchom współprogram w odpowiednim CoroutineScope. Umożliwia to aplikacji odrzucenie pracy, gdy CoroutineScope zostanie anulowany. Zmienna stanu zapisuje wynik wywołania metody suspend w interfejsie API do obserwacji używanym do udostępniania stanu interfejsu.

Rozważmy na przykład AddEditTaskViewModel w przykładzie architektury. Gdy metoda saveTask zapisuje zadanie asynchronicznie, metoda update w MutableStateFlow propaguje zmianę stanu do stanu interfejsu.

Stan Compose

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

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

Modyfikowanie stanu interfejsu za pomocą wątków w tle

W przypadku tworzenia stanu interfejsu lepiej jest uruchamiać współprogramy w głównym dyspozytorze, czyli poza blokiem withContext w poniższych fragmentach kodu. Jeśli jednak musisz zaktualizować stan interfejsu w innym kontekście w tle, możesz zrobić to w ten sposób:

  • Użyj metody withContext, aby uruchamiać współprogramy w innym kontekście współbieżnym.
  • Gdy używasz MutableStateFlow, używaj metody update jak zwykle.
  • Gdy używasz stanu Compose, używaj Snapshot.withMutableSnapshot metody, aby zagwarantować niepodzielne aktualizacje stanu w kontekście współbieżnym.

Załóżmy na przykład, że w poniższym fragmencie kodu DiceRollViewModel funkcja SlowRandom.nextInt jest wymagającą obliczeniowo funkcją suspend, którą należy wywołać ze współprogramu związanego z procesorem.

Stan Compose

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

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

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

W przypadku źródeł zmian stanu, które z czasem generują wiele wartości w strumieniach, prostym podejściem do tworzenia stanu jest agregowanie danych wyjściowych wszystkich źródeł w spójną całość.

Gdy używasz przepływów Kotlin, możesz to osiągnąć za pomocą funkcji combine. Przykład tego można zobaczyć w przykładzie "Now in Android" w InterestsViewModel:

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 tworzenia StateFlows daje interfejsowi większą kontrolę nad aktywnością potoku tworzenia stanu, ponieważ może on być aktywny tylko wtedy, gdy interfejs jest widoczny.

  • Użyj SharingStarted.WhileSubscribed, jeśli potok ma być aktywny tylko wtedy, gdy interfejs jest widoczny podczas zbierania przepływu w sposób uwzględniający cykl życia.
  • Użyj SharingStarted.Lazily, jeśli potok ma być aktywny tak długo, jak długo użytkownik może wrócić do interfejsu, czyli gdy interfejs znajduje się na stosie wstecznym lub na innej karcie poza ekranem.

W przypadkach, gdy agregowanie źródeł stanu opartych na strumieniach nie ma zastosowania, interfejsy API strumieni, takie jak przepływy Kotlin, oferują bogaty zestaw przekształceń, takich jak łączenie, spłaszczanie itp., które pomagają w przetwarzaniu strumieni na stan interfejsu.

Interfejsy API jednorazowe i strumieniowe jako źródła zmian stanu

Jeśli potok tworzenia stanu zależy zarówno od wywołań jednorazowych, jak i strumieni jako źródeł zmian stanu, strumienie są ograniczeniem definiującym. Dlatego przekonwertuj wywołania jednorazowe na interfejsy API strumieni lub przekieruj ich dane wyjściowe do strumieni i kontynuuj przetwarzanie zgodnie z opisem w sekcji dotyczącej strumieni powyżej.

W przypadku przepływów zwykle oznacza to utworzenie co najmniej 1 prywatnej instancji MutableStateFlow do propagowania zmian stanu. Możesz też tworzyć przepływy migawek ze stanu Compose.

Rozważmy TaskDetailViewModel z architecture-samples repozytorium. Stan interfejsu zależy od strumienia bieżącego zadania (_task) i źródła jednorazowego (_isTaskDeleted), które aktualizuje się po usunięciu zadania. Ta flaga jest niezbędna do odróżnienia sytuacji, w której zadanie nie zostało znalezione w bazie danych z powodu nieprawidłowego identyfikatora, od sytuacji, w której nie zostało znalezione, ponieważ użytkownik je usunął:

Stan Compose

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, taskAsync ->
        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
    }
}

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, taskAsync ->
        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 }
    }
}

Typy danych wyjściowych w potokach tworzenia stanu

Wybór interfejsu API danych wyjściowych dla stanu interfejsu i charakter jego prezentacji zależą w dużej mierze od interfejsu API, którego aplikacja używa do renderowania interfejsu, np. Compose. Jetpack Compose to zalecany nowoczesny zestaw narzędzi do tworzenia natywnego interfejsu. Należy wziąć pod uwagę te kwestie:

W tabeli poniżej podsumowano, których interfejsów API używać w potoku tworzenia stanu podczas korzystania z Jetpack Compose:

Dane wejściowe Dane wyjściowe
Interfejsy API jednorazowe StateFlow lub Compose State
Interfejsy API strumieni StateFlow
Interfejsy API jednorazowe i strumieniowe StateFlow

Inicjowanie potoku tworzenia stanu

Inicjowanie potoków tworzenia stanu polega na ustawieniu warunków początkowych, w których potok ma działać. Może to obejmować podanie początkowych wartości wejściowych, które są niezbędne do uruchomienia potoku, np. id widoku szczegółów artykułu lub rozpoczęcie asynchronicznego wczytywania.

Jeśli to możliwe, inicjuj potok tworzenia stanu leniwie, aby oszczędzać zasoby systemowe. W praktyce często oznacza to czekanie, aż pojawi się odbiorca danych wyjściowych. Flow interfejsy API umożliwiają to dzięki argumentowi started w metodzie stateIn. W przypadkach, gdy nie ma to zastosowania, zdefiniuj an idempotentną initialize funkcję, aby wyraźnie uruchomić potok tworzenia stanu , jak pokazano w tym fragmencie kodu:

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

Przykłady

W tych przykładach Google pokazujemy, jak tworzyć stan w warstwie interfejsu. Zapoznaj się z nimi, aby zobaczyć, jak te wskazówki działają w praktyce:

Dodatkowe materiały

Więcej informacji o stanie interfejsu znajdziesz w tych dodatkowych materiałach:

Dokumentacja

Treści dotyczące widoków