Produktion des UI-Status

Moderne UIs sind selten statisch. Der Status der UI ändert sich, wenn der Nutzer mit der UI interagiert oder wenn die App neue Daten anzeigen muss.

In diesem Dokument werden Richtlinien für die Erstellung und Verwaltung des UI-Status beschrieben. Am Ende sollten Sie Folgendes wissen:

  • Welche APIs Sie verwenden sollten, um den UI-Status zu erstellen. Das hängt von der Art der Quellen für Statusänderungen ab, die in Ihren Status-Holdern verfügbar sind, wobei die Prinzipien des unidirektionalen Datenflusses gelten.
  • Wie Sie die Erstellung des UI-Status so gestalten sollten, dass Sie die Systemressourcen im Blick behalten.
  • Wie Sie den UI-Status für die Nutzung durch die UI bereitstellen sollten.

Grundsätzlich ist die Statuserstellung die schrittweise Anwendung dieser Änderungen auf den UI-Status. Der Status ist immer vorhanden und ändert sich aufgrund von Ereignissen. Die Unterschiede zwischen Ereignissen und Status sind in der folgenden Tabelle zusammengefasst:

Ereignisse Status
Vorübergehend, unvorhersehbar und nur für einen begrenzten Zeitraum vorhanden. Immer vorhanden.
Die Eingaben der Statuserstellung. Die Ausgabe der Statuserstellung.
Das Produkt der UI oder anderer Quellen. Wird von der UI verwendet.

Eine gute Merkhilfe, die das oben Genannte zusammenfasst, lautet: Status ist; Ereignisse passieren. Das folgende Diagramm veranschaulicht Änderungen am Status, wenn Ereignisse in einer Zeitachse auftreten. Jedes Ereignis wird vom entsprechenden Status-Holder verarbeitet und führt zu einer Statusänderung:

Ereignisse im Vergleich zum Status
Abbildung 1: Ereignisse führen zu Statusänderungen

Ereignisse können aus folgenden Quellen stammen:

  • Nutzer: Wenn sie mit der UI der App interagieren.
  • Andere Quellen für Statusänderungen: APIs, die App-Daten aus der UI-, Domain- oder Datenschicht präsentieren, z. B. Ereignisse für das Snackbar-Timeout, Anwendungsfälle oder Repositories.

Die Produktionspipeline für den UI-Status

Die Statuserstellung in Android-Apps kann als Verarbeitungspipeline betrachtet werden, die Folgendes umfasst:

  • Eingaben: Die Quellen für Statusänderungen. Dazu gehören:
    • Lokal in der UI-Schicht: Das können Nutzerereignisse sein, z. B. wenn ein Nutzer in einer Aufgabenverwaltungs-App einen Titel für eine Aufgabe eingibt, oder APIs, die Zugriff auf die UI-Logik bieten, die Änderungen am UI-Status bewirkt. Beispiel: Aufrufen der open Methode für DrawerState in Jetpack Compose.
    • Extern in der UI-Schicht: Das sind Quellen aus der Domain- oder Datenschicht, die Änderungen am UI-Status verursachen. Beispiel: Nachrichten, die aus einem NewsRepository geladen wurden, oder andere Ereignisse.
    • Eine Mischung aus allen oben genannten Optionen.
  • Status-Holder: Typen, die Geschäftslogik und/oder UI-Logik auf Quellen für Statusänderungen anwenden und Nutzerereignisse verarbeiten, um den UI-Status zu erstellen.
  • Ausgabe: Der UI-Status, den die App rendern kann, um Nutzern die benötigten Informationen zu liefern.
Produktionspipeline für den Status
Abbildung 2: Die Produktionspipeline für den Status

APIs für die Statuserstellung

Je nach Phase der Pipeline werden zwei Haupt-APIs für die Statuserstellung verwendet:

Pipelinephase API
Eingabe Sie sollten asynchrone APIs verwenden, um Aufgaben außerhalb des UI-Threads auszuführen, damit die UI nicht ruckelt. Beispiele: Coroutines oder Flows in Kotlin und RxJava oder Callbacks in der Programmiersprache Java.
Ausgabe Sie sollten APIs für beobachtbare Daten-Holder verwenden, um die UI bei Statusänderungen zu invalidieren und neu zu rendern. Beispiele: StateFlow, Compose State oder LiveData. Beobachtbare Daten-Holder sorgen dafür, dass die UI immer einen UI-Status hat, der auf dem Bildschirm angezeigt werden kann.

Die Wahl der asynchronen API für die Eingabe hat einen größeren Einfluss auf die Art der Produktionspipeline für den Status als die Wahl der beobachtbaren API für die Ausgabe. Das liegt daran, dass die Eingaben die Art der Verarbeitung bestimmen, die auf die Pipeline angewendet werden kann.

Zusammenstellung der Produktionspipeline für den Status

In den nächsten Abschnitten werden Techniken zur Statuserstellung beschrieben, die für verschiedene Eingaben am besten geeignet sind, sowie die entsprechenden Ausgabe-APIs. Jede Produktionspipeline für den Status ist eine Kombination aus Eingaben und Ausgaben und sollte folgende Eigenschaften haben:

  • Lebenszyklusbewusst: Wenn die UI nicht sichtbar oder aktiv ist, sollte die Produktionspipeline für den Status keine Ressourcen verbrauchen, es sei denn, dies ist ausdrücklich erforderlich.
  • Einfach zu verwenden: Die UI sollte den erstellten UI Status einfach rendern können. Die Überlegungen zur Ausgabe der Produktionspipeline für den Status variieren je nach View-API, z. B. dem View-System oder Jetpack Compose.

Eingaben in Produktionspipelines für den Status

Eingaben in einer Produktionspipeline für den Status können ihre Quellen für Statusänderungen über Folgendes bereitstellen:

  • Einmalige Vorgänge, die synchron oder asynchron sein können, z. B. Aufrufe von suspend-Funktionen.
  • Stream-APIs, z. B. Flows.
  • Alle oben genannten Optionen.

In den folgenden Abschnitten wird beschrieben, wie Sie für jede der oben genannten Eingaben eine Produktionspipeline für den Status zusammenstellen können.

Einmalige APIs als Quellen für Statusänderungen

Verwenden Sie die MutableStateFlow-API als beobachtbaren, veränderlichen Container für den Status. In Jetpack Compose-Apps können Sie auch mutableStateOf verwenden, insbesondere bei der Arbeit mit Compose-Text-APIs. Beide APIs bieten Methoden, die sichere atomare Aktualisierungen der von ihnen gehosteten Werte ermöglichen, unabhängig davon, ob die Aktualisierungen synchron oder asynchron sind.

Betrachten Sie beispielsweise Statusaktualisierungen in einer einfachen Würfel-App. Jeder Würfelwurf von den Würfeln des Nutzers ruft die synchrone Random.nextInt() Methode auf und das Ergebnis wird in den UI-Status geschrieben.

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

Compose State

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

Ändern des UI-Status über asynchrone Aufrufe

Bei Statusänderungen, die ein asynchrones Ergebnis erfordern, starten Sie eine Coroutine im entsprechenden CoroutineScope. So kann die App die Arbeit verwerfen, wenn CoroutineScope abgebrochen wird. Der State Holder schreibt dann das Ergebnis des Methodenaufrufs der suspend-Methode in die beobachtbare API, die zum Bereitstellen des UI-Status verwendet wird.

Betrachten Sie beispielsweise die AddEditTaskViewModel im Architekturbeispiel. Wenn die suspend-Methode saveTask() eine Aufgabe asynchron speichert, überträgt die update Methode für den MutableStateFlow die Statusänderung an den UI-Status.

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

Compose State

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

Ändern des UI-Status über Hintergrundthreads

Es ist besser, Coroutines im Haupt-Dispatcher für die Erstellung des UI-Status zu starten. Das heißt, außerhalb des withContext-Blocks in den Code-Snippets unten. Wenn Sie den UI-Status jedoch in einem anderen Hintergrundkontext aktualisieren müssen, können Sie die folgenden APIs verwenden:

  • Verwenden Sie die withContext Methode, um Coroutines in einem anderen parallelen Kontext auszuführen.
  • Bei Verwendung von MutableStateFlow verwenden Sie die update Methode wie gewohnt.
  • Bei Verwendung von Compose State verwenden Sie die Snapshot.withMutableSnapshot um atomare Aktualisierungen des Status im parallelen Kontext zu garantieren.

Angenommen, in dem Code-Snippet DiceRollViewModel unten ist SlowRandom.nextInt() eine rechenintensive suspend-Funktion, die von einer CPU-gebundenen Coroutine aufgerufen werden muss.

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

Compose State

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

Stream-APIs als Quellen für Statusänderungen

Bei Quellen für Statusänderungen, die im Laufe der Zeit mehrere Werte in Streams erzeugen, ist das Aggregieren der Ausgaben aller Quellen zu einem zusammenhängenden Ganzen ein einfacher Ansatz für die Statuserstellung.

Wenn Sie Kotlin Flows verwenden, können Sie dies mit der combine Funktion erreichen. Ein Beispiel dafür finden Sie im Beispiel „Now in Android“ in 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
    )
}

Die Verwendung des Operators stateIn zum Erstellen von StateFlows gibt der UI eine genauere Kontrolle über die Aktivität der Produktionspipeline für den Status, da sie möglicherweise nur aktiv sein muss, wenn die UI sichtbar ist.

  • Verwenden Sie SharingStarted.WhileSubscribed(), wenn die Pipeline nur aktiv sein soll, wenn die UI sichtbar ist, während der Flow lebenszyklusbewusst erfasst wird.
  • Verwenden Sie SharingStarted.Lazily, wenn die Pipeline so lange aktiv sein soll, wie der Nutzer zur UI zurückkehren kann, d. h. wenn sich die UI im Backstack oder auf einem anderen Tab außerhalb des Bildschirms befindet.

In Fällen, in denen die Aggregation von stream-basierten Statusquellen nicht anwendbar ist, bieten Stream APIs wie Kotlin Flows eine Vielzahl von Transformationen wie Zusammenführen, Reduzieren usw., um die Streams in den UI-Status zu verarbeiten.

Einmalige und Stream-APIs als Quellen für Statusänderungen

Wenn die Produktionspipeline für den Status sowohl von einmaligen Aufrufen als auch von Streams als Quellen für Statusänderungen abhängt, sind Streams die definierende Einschränkung. Wandeln Sie daher die einmaligen Aufrufe in Stream-APIs um oder leiten Sie ihre Ausgabe in Streams weiter und setzen Sie die Verarbeitung wie oben im Abschnitt zu Streams beschrieben fort.

Bei Flows bedeutet das in der Regel, dass eine oder mehrere private unterstützende MutableStateFlow-Instanzen erstellt werden, um Statusänderungen zu übertragen. Sie können auch Snapshot-Flows aus dem Compose-Status erstellen.

Betrachten Sie das TaskDetailViewModel aus dem architecture-samples Repository unten:

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

Compose State

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

Ausgabetypen in Produktionspipelines für den Status

Die Wahl der Ausgabe-API für den UI-Status und die Art der Darstellung hängen weitgehend von der API ab, die Ihre App zum Rendern der UI verwendet. In Android-Apps können Sie Views oder Jetpack Compose verwenden. Dabei sind folgende Punkte zu beachten:

In der folgenden Tabelle ist zusammengefasst, welche APIs für Ihre Produktionspipeline für den Status für eine bestimmte Eingabe und einen bestimmten Consumer verwendet werden sollten:

Eingabe Consumer Ausgabe
Einmalige APIs Views StateFlow oder LiveData
Einmalige APIs Compose StateFlow oder Compose State
Stream-APIs Views StateFlow oder LiveData
Stream-APIs Compose StateFlow
Einmalige und Stream-APIs Views StateFlow oder LiveData
Einmalige und Stream-APIs Compose StateFlow

Initialisierung der Produktionspipeline für den Status

Bei der Initialisierung von Produktionspipelines für den Status werden die Anfangsbedingungen für die Ausführung der Pipeline festgelegt. Dazu kann es erforderlich sein, anfängliche Eingabewerte anzugeben, die für den Start der Pipeline entscheidend sind, z. B. eine id für die Detailansicht eines Nachrichtenartikels, oder einen asynchronen Ladevorgang zu starten.

Sie sollten die Produktionspipeline für den Status nach Möglichkeit verzögert initialisieren, um Systemressourcen zu sparen. In der Praxis bedeutet das oft, dass Sie warten, bis ein Consumer der Ausgabe vorhanden ist. Flow APIs ermöglichen dies mit dem started Argument in der stateIn Methode. In den Fällen, in denen dies nicht anwendbar ist, definieren Sie eine idempotente initialize() Funktion, um die Produktionspipeline für den Status explizit zu starten, wie im folgenden Code-Snippet gezeigt:

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

Beispiele

In den folgenden Google-Beispielen wird die Erstellung des Status in der UI-Schicht veranschaulicht. Sehen Sie sich die Beispiele an, um die Anleitung in der Praxis zu sehen: