Wo soll die Winden festgehalten werden?

In einer Compose-Anwendung hängt es davon ab, ob UI-Logik oder Geschäftslogik dies erfordert, wo Sie den UI-Zustand hochstufen. In diesem Dokument werden diese beiden Hauptszenarien beschrieben.

Best Practice

Der UI-Status sollte zum niedrigsten gemeinsamen Vorfahren aller komponierbaren Funktionen hochgestuft werden, die ihn lesen und schreiben. Der Status sollte so nah wie möglich an der Stelle gespeichert werden, an der er verwendet wird. Stellen Sie den Nutzern über den Statusinhaber unveränderlichen Status und Ereignisse zum Ändern des Status zur Verfügung.

Der niedrigste gemeinsame Vorfahre kann sich auch außerhalb der Komposition befinden. Das ist beispielsweise der Fall, wenn der Status in einem ViewModel hochgestuft wird, weil Geschäftslogik beteiligt ist.

Auf dieser Seite wird diese Best Practice im Detail erläutert und es wird auf eine wichtige Einschränkung hingewiesen.

Arten von UI-Zustand und UI-Logik

Im Folgenden finden Sie Definitionen für Arten von UI-Status und ‑Logik, die in diesem Dokument verwendet werden.

UI-Status

UI-Status ist die Property, die die Benutzeroberfläche beschreibt. Es gibt zwei Arten von UI-Zuständen:

  • Der UI-Status des Displays gibt an, was auf dem Display angezeigt werden muss. Eine NewsUiState-Klasse kann beispielsweise die Nachrichtenartikel und andere Informationen enthalten, die zum Rendern der Benutzeroberfläche erforderlich sind. Dieser Status ist in der Regel mit anderen Ebenen der Hierarchie verbunden, da er App-Daten enthält.
  • Der Status von UI-Elementen bezieht sich auf Eigenschaften, die UI-Elementen innewohnen und die beeinflussen, wie sie gerendert werden. Ein UI-Element kann ein- oder ausgeblendet werden und eine bestimmte Schriftart, Schriftgröße oder Schriftfarbe haben. In Jetpack Compose ist der Status extern vom Composable. Sie können ihn sogar aus der unmittelbaren Nähe des Composables in die aufrufende Composable-Funktion oder einen State Holder verschieben. Ein Beispiel dafür ist ScaffoldState für die zusammensetzbare Funktion Scaffold.

Logik

Die Logik in einer Anwendung kann entweder Geschäftslogik oder UI-Logik sein:

  • Die Geschäftslogik ist die Implementierung von Produktanforderungen für App-Daten. Beispiel: Ein Artikel wird in einer Newsreader-App mit einem Lesezeichen versehen, wenn der Nutzer auf die Schaltfläche tippt. Diese Logik zum Speichern eines Lesezeichens in einer Datei oder Datenbank befindet sich in der Regel in den Domain- oder Datenschichten. Der State-Holder delegiert diese Logik in der Regel an diese Ebenen, indem er die von ihnen bereitgestellten Methoden aufruft.
  • Die UI-Logik bezieht sich darauf, wie der UI-Status auf dem Bildschirm angezeigt wird. Beispiele hierfür sind das Abrufen des richtigen Hinweises für die Suchleiste, wenn der Nutzer eine Kategorie ausgewählt hat, das Scrollen zu einem bestimmten Element in einer Liste oder die Navigationslogik zu einem bestimmten Bildschirm, wenn der Nutzer auf eine Schaltfläche klickt.

UI-Logik

Wenn die UI-Logik den Status lesen oder schreiben muss, sollten Sie den Status auf die UI beschränken und dem Lebenszyklus der UI folgen. Dazu sollten Sie den Status auf der richtigen Ebene in einer zusammensetzbaren Funktion hochziehen. Alternativ können Sie dies in einer einfachen Status-Holder-Klasse tun, die ebenfalls auf den UI-Lebenszyklus beschränkt ist.

Im Folgenden finden Sie eine Beschreibung der beiden Lösungen und eine Erklärung, wann welche verwendet werden sollte.

Composables als State-Inhaber

Wenn der Status und die Logik einfach sind, ist es sinnvoll, die UI-Logik und den Status von UI-Elementen in kombinierbaren Funktionen zu haben. Sie können den Status nach Bedarf in einer Composable-Funktion belassen oder ihn nach oben verschieben.

Kein State Hoisting erforderlich

Das Hoisting ist nicht immer erforderlich. Der Status kann in einem Composable-Element intern bleiben, wenn kein anderes Composable-Element ihn steuern muss. In diesem Snippet gibt es eine Composable, die durch Tippen maximiert und minimiert wird:

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    Text(
        text = AnnotatedString(message.content),
        modifier = Modifier.clickable {
            showDetails = !showDetails // Apply UI logic
        }
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

Die Variable showDetails ist der interne Status für dieses UI-Element. Sie wird nur in dieser kombinierbaren Funktion gelesen und geändert und die darauf angewendete Logik ist sehr einfach. Das Hochziehen des Status in diesem Fall würde daher nicht viel bringen, sodass Sie ihn intern lassen können. Dadurch wird diese Komponente zum Inhaber und zur einzigen Quelle für den erweiterten Status.

Hoisting in Composables

Wenn Sie den Status Ihres UI-Elements für andere Composables freigeben und an verschiedenen Stellen UI-Logik darauf anwenden müssen, können Sie es in der UI-Hierarchie höher verschieben. Außerdem sind Ihre Composables so besser wiederverwendbar und einfacher zu testen.

Das folgende Beispiel zeigt eine Chat-App, die zwei Funktionen implementiert:

  • Mit der Schaltfläche JumpToBottom wird in der Nachrichtenliste ganz nach unten gescrollt. Der Button führt UI-Logik für den Listenstatus aus.
  • Die Liste MessagesList wird nach unten gescrollt, nachdem der Nutzer neue Nachrichten gesendet hat. UserInput führt die UI-Logik für den Listenstatus aus.
Chat-App mit der Schaltfläche „Zum Ende springen“ und Scrollen zum Ende bei neuen Nachrichten
Abbildung 1: Chat-App mit einer JumpToBottom-Schaltfläche und Scrollen zum Ende bei neuen Nachrichten

Die zusammensetzbare Hierarchie sieht so aus:

Zusammensetzbarer Chat-Baum
Abbildung 2: Composable-Baum für Chat

Der Status LazyColumn wird auf den Unterhaltungsbildschirm hochgeladen, damit die App die UI-Logik ausführen und den Status aus allen Composables lesen kann, die ihn benötigen:

LazyColumn-Status von der LazyColumn in den ConversationScreen verschieben
Abbildung 3: Hoisting des Status von LazyColumn vom LazyColumn zum ConversationScreen

Die Composables sind also:

Zusammensetzbarer Baum für den Chat mit LazyListState, der in ConversationScreen hochgeladen wird
Abbildung 4: Zusammensetzbarer Chat-Baum mit LazyListState, der zu ConversationScreen
hochgeladen wurde

Der Code lautet so:

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

LazyListState wird so hoch wie für die anzuwendende UI-Logik erforderlich verschoben. Da sie in einer zusammensetzbaren Funktion initialisiert wird, wird sie in der Komposition gespeichert und folgt ihrem Lebenszyklus.

lazyListState wird in der Methode MessagesList mit dem Standardwert rememberLazyListState() definiert. Das ist ein gängiges Muster in Compose. So werden Composables wiederverwendbarer und flexibler. Sie können die Composable dann in verschiedenen Teilen der App verwenden, in denen der Status möglicherweise nicht gesteuert werden muss. Das ist in der Regel beim Testen oder in der Vorschau einer Composable der Fall. Genau so definiert LazyColumn seinen Status.

Der niedrigste gemeinsame Vorfahre für LazyListState ist ConversationScreen.
Abbildung 5. Der kleinste gemeinsame Vorgänger für LazyListState ist ConversationScreen
.

Einfache State Holder-Klasse als State Owner

Wenn ein Composable komplexe UI-Logik enthält, die ein oder mehrere Statusfelder eines UI-Elements umfasst, sollte es diese Verantwortung an State-Holder wie eine einfache State-Holder-Klasse delegieren. Dadurch lässt sich die Logik des Composables besser isoliert testen und die Komplexität wird reduziert. Dieser Ansatz folgt dem Prinzip der Trennung von Belangen: Die zusammensetzbare Funktion ist für die Ausgabe von UI-Elementen zuständig und der State Holder enthält die UI-Logik und den Status der UI-Elemente.

Einfache Status-Holder-Klassen bieten Aufrufern Ihrer zusammensetzbaren Funktion praktische Funktionen, sodass sie diese Logik nicht selbst schreiben müssen.

Diese einfachen Klassen werden in der Komposition erstellt und gespeichert. Da sie dem Lebenszyklus von Composables folgen, können sie von der Compose-Bibliothek bereitgestellte Typen wie rememberNavController() oder rememberLazyListState() verwenden.

Ein Beispiel dafür ist die Klasse LazyListState, die in Compose implementiert wurde, um die Komplexität der Benutzeroberfläche von LazyColumn oder LazyRow zu steuern.

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

LazyListState kapselt den Status von LazyColumn und speichert die scrollPosition für dieses UI-Element. Außerdem werden Methoden zum Ändern der Scrollposition bereitgestellt, z. B. zum Scrollen zu einem bestimmten Element.

Wie Sie sehen, erhöht sich der Bedarf an einem State Holder, wenn die Verantwortlichkeiten eines Composables zunehmen. Die Verantwortlichkeiten können in der UI-Logik oder nur in der Menge des zu verfolgenden Status liegen.

Ein weiteres gängiges Muster ist die Verwendung einer einfachen State-Holder-Klasse, um die Komplexität der zusammensetzbaren Root-Funktionen in der App zu bewältigen. Mit einer solchen Klasse können Sie App-Status wie den Navigationsstatus und die Bildschirmgröße kapseln. Eine vollständige Beschreibung finden Sie auf der Seite UI-Logik und ihr State Holder.

Geschäftslogik

Wenn Composables und einfache State Holder-Klassen für die UI-Logik und den Status von UI-Elementen zuständig sind, übernimmt ein State Holder auf Bildschirmebene die folgenden Aufgaben:

  • Zugriff auf die Geschäftslogik der Anwendung, die sich normalerweise in anderen Ebenen der Hierarchie befindet, z. B. in der Geschäfts- und der Datenschicht.
  • Vorbereiten der Anwendungsdaten für die Darstellung auf einem bestimmten Bildschirm, der zum UI-Zustand des Bildschirms wird.

ViewModels als Statusinhaber

Die Vorteile von AAC-ViewModels in der Android-Entwicklung machen sie geeignet, um Zugriff auf die Geschäftslogik zu ermöglichen und die Anwendungsdaten für die Darstellung auf dem Bildschirm vorzubereiten.

Wenn Sie den UI-Status in ViewModel verschieben, wird er aus der Komposition herausgenommen.

Der in das ViewModel übertragene Status wird außerhalb der Komposition gespeichert.
Abbildung 6: Der Status, der an ViewModel übergeben wird, wird außerhalb der Komposition gespeichert.

ViewModels werden nicht als Teil der Komposition gespeichert. Sie werden vom Framework bereitgestellt und sind auf ein ViewModelStoreOwner beschränkt, das eine Aktivität, ein Fragment, ein Navigationsgraph oder ein Ziel eines Navigationsgraphen sein kann. Weitere Informationen zu ViewModel-Bereichen finden Sie in der Dokumentation.

ViewModel ist dann die Quelle der Wahrheit und der niedrigste gemeinsame Vorfahre für den UI-Status.

UI-Status des Displays

Gemäß den Definitionen oben wird der UI-Status des Bildschirms durch Anwenden von Geschäftsregeln erzeugt. Da der Status-Holder auf Bildschirmebene dafür verantwortlich ist, wird der UI-Status des Bildschirms in der Regel im State Holder auf Bildschirmebene, in diesem Fall einem ViewModel, nach oben verschoben.

Sehen Sie sich die ConversationViewModel einer Chat-App an und wie sie den UI-Status und die Ereignisse des Bildschirms verfügbar macht, um ihn zu ändern:

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

Komponierbare Funktionen verwenden den in der ViewModel hochgeladenen UI-Status des Bildschirms. Sie sollten die ViewModel-Instanz in Ihre Composables auf Bildschirmebene einfügen, um Zugriff auf die Geschäftslogik zu ermöglichen.

Das Folgende ist ein Beispiel für ein ViewModel, das in einem Composable auf Bildschirmebene verwendet wird. Hier wird der in ViewModel hochgeladene UI-Status des Bildschirms vom Composable ConversationScreen() verwendet:

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
    )
}

@Composable
private fun ConversationScreen(
    messages: List<Message>,
    onSendMessage: (Message) -> Unit
) {

    MessagesList(messages, onSendMessage)
    /* ... */
}

Bohrungen auf dem Grundstück

„Property Drilling“ bezieht sich auf das Übergeben von Daten durch mehrere verschachtelte untergeordnete Komponenten an den Ort, an dem sie gelesen werden.

Ein typisches Beispiel für das Property Drilling in Compose ist, wenn Sie den State Holder auf Bildschirmebene auf der obersten Ebene einschleusen und Status und Ereignisse an untergeordnete komponierbare Funktionen übergeben. Außerdem kann es zu einer Überlastung von Signaturen zusammensetzbarer Funktionen kommen.

Auch wenn das Bereitstellen von Ereignissen als einzelne Lambda-Parameter die Funktionssignatur überladen könnte, wird dadurch die Sichtbarkeit der Verantwortlichkeiten der zusammensetzbaren Funktion maximiert. Sie können auf einen Blick sehen, was die Funktion bewirkt.

Das Durchreichen von Properties ist besser als das Erstellen von Wrapper-Klassen, um Status und Ereignisse an einem Ort zu kapseln, da dadurch die Sichtbarkeit der Composable-Verantwortlichkeiten verringert wird. Wenn Sie keine Wrapper-Klassen haben, ist es auch wahrscheinlicher, dass Sie Composables nur die Parameter übergeben, die sie benötigen. Das ist eine Best Practice.

Dieselbe Best Practice gilt, wenn es sich bei diesen Ereignissen um Navigationsereignisse handelt. Weitere Informationen dazu finden Sie in der Navigationsdokumentation.

Wenn Sie ein Leistungsproblem erkannt haben, können Sie das Lesen des Status auch aufschieben. Weitere Informationen finden Sie in der Leistungsdokumentation.

Status von UI-Elementen

Sie können den Status von UI-Elementen in den Status-Holder auf Bildschirmebene verschieben, wenn Geschäftslogik ihn lesen oder schreiben muss.

Im Beispiel einer Chat-App werden Nutzervorschläge in einem Gruppenchat angezeigt, wenn der Nutzer @ und einen Hinweis eingibt. Diese Vorschläge stammen aus der Datenschicht und die Logik zur Berechnung einer Liste von Nutzervorschlägen wird als Geschäftslogik betrachtet. Die Funktion sieht so aus:

Funktion, mit der Nutzervorschläge in einem Gruppenchat angezeigt werden, wenn der Nutzer „@“ und einen Hinweis eingibt
Abbildung 7. Funktion, mit der Nutzervorschläge in einem Gruppenchat angezeigt werden, wenn der Nutzer @ eingibt und ein Hinweis

Die ViewModel, die diese Funktion implementiert, würde so aussehen:

class ConversationViewModel(/*...*/) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

inputMessage ist eine Variable, in der der Status TextField gespeichert wird. Jedes Mal, wenn der Nutzer neue Eingaben macht, ruft die App die Geschäftslogik auf, um suggestions zu generieren.

suggestions ist der UI-Zustand des Bildschirms und wird von Compose UI verwendet, indem Daten aus StateFlow abgerufen werden.

Einschränkungen

Für einige Compose-UI-Elementzustände kann das Hoisting zum ViewModel besondere Überlegungen erfordern. Einige Statusinhaber von Compose-UI-Elementen stellen beispielsweise Methoden zum Ändern des Status bereit. Einige davon sind möglicherweise Suspend-Funktionen, die Animationen auslösen. Diese suspend-Funktionen können Ausnahmen auslösen, wenn Sie sie aus einem CoroutineScope aufrufen, das nicht auf die Komposition beschränkt ist.

Angenommen, der Inhalt des App-Drawers ist dynamisch und Sie müssen ihn nach dem Schließen aus der Datenschicht abrufen und aktualisieren. Sie sollten den Schubladenstatus in ViewModel verschieben, damit Sie sowohl die UI- als auch die Geschäftslogik für dieses Element vom Statusinhaber aus aufrufen können.

Wenn Sie jedoch die Methode close() von DrawerState mit dem viewModelScope aus der Compose-Benutzeroberfläche aufrufen, wird eine Laufzeitausnahme vom Typ IllegalStateException mit der Meldung „a MonotonicFrameClock is not available in this CoroutineContext”“ ausgelöst.

Verwenden Sie dazu einen CoroutineScope, der auf die Komposition beschränkt ist. Sie stellt eine MonotonicFrameClock im CoroutineContext bereit, die für die Funktion von Suspend-Funktionen erforderlich ist.

Um diesen Absturz zu beheben, ändern Sie den CoroutineContext der Coroutine in ViewModel in einen, der auf die Komposition beschränkt ist. Das könnte so aussehen:

class ConversationViewModel(/*...*/) : ViewModel() {

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

Weitere Informationen

Weitere Informationen zu State und Jetpack Compose finden Sie in den folgenden zusätzlichen Ressourcen.

Beispiele

Codelabs

Videos