Produktion des UI-Status

Moderne Benutzeroberflächen sind selten statisch. Der Status der Benutzeroberfläche ändert sich, wenn der Nutzer mit der Benutzeroberfläche interagiert oder wenn die Anwendung neue Daten anzeigen muss.

In diesem Dokument sind Richtlinien für die Erstellung und Verwaltung des UI-Status aufgeführt. Am Ende sollten Sie Folgendes tun:

  • Informieren Sie sich darüber, welche APIs Sie zum Erzeugen des UI-Zustands verwenden sollten. Dies hängt von der Art der Quellen von Statusänderungen ab, die in Ihrem Bundesstaat zur Verfügung stehen. Dabei werden die Prinzipien des unidirektionalen Datenflusses berücksichtigt.
  • Sie sollten wissen, wie Sie die Erzeugung des UI-Zustands planen sollten, um Systemressourcen zu berücksichtigen.
  • Informieren Sie sich, wie Sie den UI-Status für die Nutzung durch die UI verfügbar machen sollten.

Im Grunde ist die Zustandsproduktion die schrittweise Anwendung dieser Änderungen auf den Status der Benutzeroberfläche. Der Status ist immer vorhanden und ändert sich als Ergebnis von Ereignissen. Die Unterschiede zwischen Ereignissen und Status sind in der folgenden Tabelle zusammengefasst:

Veranstaltungen Bundesland
Vorübergehend, unvorhersehbar und für einen begrenzten Zeitraum vorhanden. Immer vorhanden.
Die Eingaben der staatlichen Produktion. Das Ergebnis einer staatlichen Produktion.
Das Produkt der Benutzeroberfläche oder anderer Quellen. Sie wird von der UI genutzt.

Ein gutes Gedächtnis, das zusammenfasst, ist der Zustand: Ereignisse finden statt. Im folgenden Diagramm können Sie Statusänderungen bei Ereignissen auf einer Zeitachse visualisieren. Jedes Ereignis wird vom entsprechenden Statusinhaber verarbeitet und führt zu einer Statusänderung:

Ereignisse im Vergleich zu Status
Abbildung 1: Ereignisse führen zu einer Statusänderung

Ereignisse können aus folgenden Quellen stammen:

  • Nutzer: Wenn sie mit der Benutzeroberfläche der App interagieren.
  • Weitere Quellen von Statusänderungen: APIs, die App-Daten aus UI-, Domain- oder Datenschichten darstellen, z. B. Snackbar-Zeitüberschreitungsereignisse, Anwendungsfälle oder Repositories.

Produktionspipeline für den UI-Status

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

  • Eingaben: Die Quellen von Statusänderungen. Beispiele:
    • Lokal auf der UI-Ebene: Dies können Nutzerereignisse sein, z. B. wenn ein Nutzer einen Titel für eine Aufgabe in eine Aufgabenverwaltungs-App eingibt, oder APIs, die Zugriff auf die UI-Logik bieten, die Änderungen des UI-Status vorantreiben. Beispiel: Durch Aufrufen der Methode open für DrawerState in Jetpack Compose.
    • Außerhalb der UI-Ebene: Dies sind Quellen aus der Domain- oder Datenebene, die Änderungen des UI-Status verursachen. z. B. Nachrichten, die aus einem NewsRepository oder anderen Ereignissen vollständig geladen wurden.
    • Eine Mischung aus den oben genannten Funktionen.
  • Staatsinhaber: Typen, die Geschäftslogik und/oder UI-Logik auf Quellen von Statusänderungen anwenden und Nutzerereignisse verarbeiten, um einen UI-Status zu erzeugen.
  • Ausgabe: Der UI-Status, den die App rendern kann, um Nutzern die benötigten Informationen bereitzustellen.
Die Zustandsproduktionspipeline
Abbildung 2: Produktionspipeline für den Zustand

Produktions-APIs des Status

In der Zustandsproduktion werden zwei Haupt-APIs verwendet, je nachdem, in welcher Phase der Pipeline Sie sich befinden:

Pipelinephase API
Eingang Sie sollten asynchrone APIs verwenden, um Arbeiten außerhalb des Benutzeroberflächen-Threads auszuführen, damit die Benutzeroberfläche länger dauert. Beispiele: Koroutinen oder Abläufe in Kotlin und RxJava oder Callbacks in der Programmiersprache Java.
Ausgang Sie sollten APIs für beobachtbare Dateninhaber verwenden, um die UI zu entwerten und neu zu rendern, wenn sich der Status ändert. Zum Beispiel „StateFlow“, „Compose State“ oder „LiveData“. Beobachtbare Dateninhaber garantieren, dass die UI immer einen UI-Status hat, der auf dem Bildschirm angezeigt werden kann.

Von den beiden Optionen hat die Wahl der asynchronen API für die Eingabe einen größeren Einfluss auf die Art der Zustandsproduktionspipeline als die Auswahl einer beobachtbaren API für die Ausgabe. Dies liegt daran, dass die Eingaben die Art der Verarbeitung bestimmen, die auf die Pipeline angewendet werden kann.

Zusammensetzung der Produktionspipeline Zustands

In den nächsten Abschnitten werden Techniken der Zustandsproduktion beschrieben, die am besten für verschiedene Eingaben geeignet sind, und die zugehörigen Ausgabe-APIs. Jede Produktionspipeline für den Zustand ist eine Kombination aus Ein- und Ausgaben und sollte:

  • Lebenszyklusbewusst: Wenn die UI nicht sichtbar oder aktiv ist, sollte die Statusproduktionspipeline keine Ressourcen verbrauchen, sofern dies nicht ausdrücklich erforderlich ist.
  • Einfach zu nutzen: Die UI sollte den erzeugten UI-Status problemlos rendern können. Überlegungen für die Ausgabe der Zustandsproduktionspipeline sind für verschiedene View APIs wie das View-System oder Jetpack Compose unterschiedlich.

Eingaben in staatlichen Produktionspipelines

Eingaben in einer Produktionspipeline für den Zustand können die Quellen der Statusänderungen entweder über Folgendes bereitstellen:

  • One-Shot-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 Zustand zusammenstellen.

One-Shot APIs als Quellen von Statusänderungen

Verwenden Sie die MutableStateFlow API als beobachtbaren, änderbaren Zustandscontainer. In Jetpack Compose-Anwendungen ist mutableStateOf auch für die Arbeit mit Compose-Text-APIs hilfreich. 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 oder nicht.

Stellen Sie sich beispielsweise Statusaktualisierungen in einer einfachen Würfel-App vor. Jeder Würfel-Wurf des Nutzers ruft die synchrone Random.nextInt()-Methode auf und das Ergebnis wird in den UI-Zustand geschrieben.

Statusfluss

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

Erstellungsstatus

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

UI-Status von asynchronen Aufrufen ändern

Starten Sie bei Statusänderungen, die ein asynchrones Ergebnis erfordern, eine Koroutine im entsprechenden CoroutineScope. Dadurch kann die App die Arbeit verwerfen, wenn CoroutineScope abgebrochen wird. Der Statusinhaber schreibt dann das Ergebnis des Aufrufs der Methode „sperren“ in die beobachtbare API, die zum Bereitstellen des UI-Status verwendet wird.

Sehen Sie sich beispielsweise AddEditTaskViewModel im Architekturbeispiel an. Wenn die anhaltende Methode saveTask() eine Aufgabe asynchron speichert, leitet die Methode update für MutableStateFlow die Statusänderung an den UI-Status weiter.

Statusfluss

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

Erstellungsstatus

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

UI-Status aus Hintergrundthreads ändern

Zur Erzeugung des UI-Status sollten Koroutinen besser auf dem Haupt-Dispatcher gestartet werden. Das ist außerhalb des withContext-Blocks in den Code-Snippets unten. Wenn Sie den UI-Status jedoch in einem anderen Hintergrund aktualisieren müssen, können Sie dies mit den folgenden APIs tun:

  • Verwenden Sie die Methode withContext, um Koroutinen in einem anderen Kontext gleichzeitig auszuführen.
  • Verwenden Sie bei MutableStateFlow wie gewohnt die Methode update.
  • Wenn Sie „Compose State“ verwenden, verwenden Sie Snapshot.withMutableSnapshot, um atomare Updates für „State“ im gleichzeitigen Kontext zu garantieren.

Nehmen wir beispielsweise im folgenden DiceRollViewModel-Snippet an, dass SlowRandom.nextInt() eine rechenintensive suspend-Funktion ist, die von einer CPU-gebundenen Koroutine aufgerufen werden muss.

Statusfluss

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

Erstellungsstatus

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

APIs als Quellen von Statusänderungen streamen

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

Bei Verwendung von Kotlin-Abläufen können Sie dies mit der combine-Funktion erreichen. Ein Beispiel hierfü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
    )
}

Wenn Sie den Operator stateIn zum Erstellen von StateFlows verwenden, kann die UI die Aktivität der Zustandsproduktionspipeline genauer steuern, 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 beim Erfassen des Ablaufs unter Berücksichtigung des Lebenszyklus sichtbar ist.
  • Verwenden Sie SharingStarted.Lazily, wenn die Pipeline aktiv sein soll, solange der Nutzer zur UI zurückkehren kann, d. h. sie sich im Backstack oder auf einem anderen Tab außerhalb des Bildschirms befindet.

In Fällen, in denen das Aggregieren streambasierter Zustandsquellen nicht zutrifft, bieten Stream-APIs wie Kotlin-Abläufe eine Vielzahl von Transformationen wie Zusammenführen, Vereinfachen usw. zur Unterstützung der Verarbeitung der Streams im UI-Zustand.

One-Shot und Stream-APIs als Quellen von Statusänderungen

Für den Fall, dass die Zustandsproduktionspipeline sowohl von One-Shot-Aufrufen als auch von Streams als Quellen von Statusänderungen abhängt, sind Streams die definierende Einschränkung. Konvertieren Sie daher die One-Shot-Aufrufe in Streams-APIs oder leiten Sie deren Ausgabe in Streams ein und setzen Sie die Verarbeitung wie oben im Abschnitt zu Streams beschrieben fort.

Bei Abläufen bedeutet dies in der Regel, dass eine oder mehrere private MutableStateFlow-Instanzen erstellt werden, um Statusänderungen zu übernehmen. Sie können auch im Status „Compose“ Snapshot-Abläufe erstellen.

Sehen Sie sich die TaskDetailViewModel aus dem Repository architecture-sample an:

Statusfluss

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

Erstellungsstatus

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 Zustandsproduktionspipelines

Die Auswahl der Ausgabe-API für den UI-Status und die Art ihrer Darstellung hängt weitgehend von der API ab, die Ihre App zum Rendern der UI verwendet. In Android-Apps können Sie Views oder Jetpack Compose verwenden. Folgendes sollte dabei berücksichtigt werden:

In der folgenden Tabelle wird zusammengefasst, welche APIs für die Zustandsproduktionspipeline für die einzelnen Eingaben und Nutzer verwendet werden sollen:

Eingang Nutzer Ausgang
One-Shot-APIs Aufrufe StateFlow oder LiveData
One-Shot-APIs Schreiben StateFlow oder „Schreiben“ State
Stream-APIs Aufrufe StateFlow oder LiveData
Stream-APIs Schreiben StateFlow
One-Shot und Stream-APIs Aufrufe StateFlow oder LiveData
One-Shot und Stream-APIs Schreiben StateFlow

Initialisierung der Produktionspipeline des Status

Zum Initialisieren von Statusproduktionspipelines werden die Anfangsbedingungen für die Ausführung der Pipeline festgelegt. Dazu kann die Bereitstellung erster Eingabewerte erforderlich sein, die für den Start der Pipeline entscheidend sind, z. B. ein id für die Detailansicht eines Nachrichtenartikels oder das Starten eines asynchronen Ladevorgangs.

Sie sollten die Produktionspipeline für den Zustand nach Möglichkeit verzögert initialisieren, um Systemressourcen zu sparen. In der Praxis bedeutet dies oft, zu warten, bis ein Nutzer der Ausgabe vorhanden ist. Flow APIs ermöglichen dies mit dem Argument started in der Methode stateIn. Sollte dies nicht möglich sein, definieren Sie eine idempotente initialize()-Funktion, um die Zustandsproduktionspipeline explizit zu starten, wie im folgenden 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
        }
    }
}

Produktproben

Die folgenden Google-Beispiele zeigen die Erzeugung des Zustands auf der UI-Ebene. Sehen Sie sich diese Tipps in der Praxis an: