Gdzie podnieść stan

w aplikacji Compose, w której należy przenieść stan interfejsu, zależy od tego, czy wymaga tego logika UI czy logika biznesowa. Niniejszy dokument przedstawia te 2 główne scenariusze.

Sprawdzona metoda

Musisz przenosić stan interfejsu do najniższego wspólnego elementu nadrzędnego spośród wszystkich funkcji kompozycyjnych, które go odczytują i zapisują. Zadbaj o to, aby stan był najbliższy miejscu, w którym jest używany. Od właściciela stanu możesz udostępnić konsumentom stały stan i zdarzenia w celu jego modyfikacji.

Najniższy wspólny przodek może też znajdować się poza kompozycją. Dotyczy to na przykład sytuacji, w których trzeba podnosić stan w elemencie ViewModel z powodu logiki biznesowej.

Na tej stronie znajdziesz szczegółowe omówienie tych sprawdzonych metod oraz zastrzeżenie, o których warto pamiętać.

Rodzaje stanu interfejsu i logiki UI

Poniżej znajdziesz definicje typów stanu i logiki UI używane w tym dokumencie.

Stan interfejsu

Stan interfejsu użytkownika to właściwość opisująca interfejs użytkownika. Są 2 rodzaje stanu interfejsu:

  • Stan interfejsu ekranu to element, który ma być widoczny na ekranie. Klasa NewsUiState może na przykład zawierać artykuły z wiadomościami i inne informacje potrzebne do renderowania interfejsu użytkownika. Ten stan jest zwykle połączony z innymi warstwami hierarchii, ponieważ zawiera dane aplikacji.
  • Stan elementu interfejsu odnosi się do właściwości niepowiązanych z elementami interfejsu, które wpływają na sposób ich renderowania. Element interfejsu może być wyświetlany lub ukryty i może mieć określoną czcionkę oraz jej rozmiar i kolor. W widokach danych Androida widok danych samodzielnie zarządza tym stanem, ponieważ jest on z założenia stanowy i udostępnia metody do modyfikowania stanu lub wysyłania do niego zapytań. Przykładami są metody get i set klasy TextView dotyczącej tekstu. W Jetpack Compose stan jest spoza elementu kompozycyjnego i można przenieść go nawet w sąsiedztwo obiektu kompozycyjnego do wywołującego funkcję kompozycyjnego lub do elementu stanu. Przykładem może być ScaffoldState dla funkcji kompozycyjnej Scaffold.

Logiczna

Logika logiczna aplikacji może być zarówno logiką biznesową, jak i logiką UI:

  • Logika biznesowa to implementacja wymagań dotyczących usługi w przypadku danych aplikacji. Przykładem może być dodanie artykułu do zakładek w aplikacji czytnika wiadomości, gdy użytkownik kliknie przycisk. Ta logika zapisywania zakładek do pliku lub bazy danych jest zwykle umieszczana w domenie lub warstwach danych. Właściciel stanu zazwyczaj przekazuje tę logikę do tych warstw, wywołując udostępniane przez nie metody.
  • Logika interfejsu jest powiązana ze sposobem wyświetlania stanu interfejsu na ekranie. Może to być na przykład uzyskanie odpowiedniej podpowiedzi na pasku wyszukiwania, gdy użytkownik wybierze kategorię, przewinięcie listy do konkretnego elementu na liście lub logika nawigacji po kliknięciu przycisku przez użytkownika.

Logika interfejsu

Jeśli logika interfejsu użytkownika musi odczytywać lub zapisywać stan, ustaw zakres stanu na interfejs użytkownika zgodnie z jego cyklem życia. Aby to zrobić, w funkcji kompozycyjnej musisz przenieść stan na odpowiednim poziomie. Możesz też zrobić to w klasie posiadacza zwykłego stanu, której zakres jest także ograniczony do cyklu życia interfejsu użytkownika.

Poniżej znajdziesz opis obu rozwiązań i wyjaśnienie, których używać.

Elementy kompozycyjne jako właściciel stanu

Stosowanie logiki UI i stanu elementu interfejsu w komponentach to dobre podejście, jeśli stan i logika są proste. W razie potrzeby możesz pozostawić stan wewnętrzny w ramach funkcji kompozycyjnej lub podnośnika.

Nie trzeba przenosić stanu

Stan podniesienia nie zawsze jest wymagany. Stan można zachować wewnętrznie w komponencie, gdy żaden inny element kompozycyjny nie potrzebuje jego kontroli. Ten fragment kodu zawiera funkcję kompozycyjną, która rozwija się i zwija po dotknięciu:

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

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails } // Apply simple UI logic
    )

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

Zmienna showDetails to stan wewnętrzny tego elementu interfejsu. Jest on odczytywany i modyfikowany tylko w tym elemencie kompozycyjnym, a stosowana do niego logika jest bardzo prosta. W tym przypadku podnoszenie stanu nie przyniesie zbyt wielu korzyści, więc można zająć się tym procesem wewnętrznym. W ten sposób kompozycja ta staje się właścicielem i jednym źródłem wiarygodnych danych stanu rozwiniętego.

Przenoszenie elementów kompozycyjnych

Jeśli chcesz udostępnić stan elementu interfejsu innym obiektom kompozycyjnym i zastosować do niego logikę UI w różnych miejscach, możesz przenieść go wyżej w hierarchii interfejsu. Dzięki temu obiekty kompozycyjne można łatwiej wykorzystywać i testować.

Oto przykład aplikacji do obsługi czatu, która ma 2 funkcje:

  • Przycisk JumpToBottom przewija listę wiadomości na sam dół. Przycisk działa w interfejsie w stanie listy.
  • Gdy użytkownik wyśle nowe wiadomości, lista MessagesList przewija się na dół. UserInput wykonuje logikę interfejsu użytkownika w stanie listy.
Aplikacja do obsługi czatu z przyciskiem JumpToDół i przewijaniem na sam dół w przypadku nowych wiadomości
Rysunek 1. Aplikacja do obsługi czatu z przyciskiem JumpToBottom i przewijaniem w dół po nowych wiadomościach

Hierarchia kompozycyjna wygląda tak:

Drzewo kompozycyjnego czatu
Rysunek 2. Drzewo kompozycyjne Google Chat

Stan LazyColumn jest przenoszony do ekranu rozmowy, aby aplikacja mogła wykonywać logikę interfejsu użytkownika i odczytywać stan ze wszystkich elementów kompozycyjnych, które go wymagają:

Przenoszenie stanu LazyColumn z LazyColumn na ekran rozmowy
Rysunek 3. Przenoszę stan LazyColumn z: LazyColumn do: ConversationScreen

Wreszcie funkcja kompozycyjna:

Drzewo kompozycyjne czatu ze stanem LazyListState przeniesionym do ConversationScreen
Rysunek 4. Drzewo kompozycyjne czatu z usługą LazyListState przeniesiono do ConversationScreen

Kod wygląda tak:

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

Obiekt LazyListState jest podnoszony tak wysoko, jak jest to wymagane przez logikę interfejsu, która musi zostać zastosowana. Ponieważ zainicjowano ją w funkcji kompozycyjnej, jest przechowywana w kompozycji zgodnie ze swoim cyklem życia.

Pamiętaj, że właściwość lazyListState jest definiowana w metodzie MessagesList, a wartość domyślna to rememberLazyListState(). Jest to często spotykany wzorzec podczas tworzenia wiadomości. Dzięki temu elementy kompozycyjne są bardziej elastyczne i wielokrotnego użytku. Następnie można używać funkcji kompozycyjnej w różnych częściach aplikacji, które mogą nie wymagać kontrolowania stanu. Dzieje się tak zwykle podczas testowania funkcji kompozycyjnej lub wyświetlania jej podglądu. Tak określa ona stan LazyColumn.

Najniższym wspólnym elementem nadrzędnym LazyListState jest ConversationScreen
Rysunek 5. Najniższy wspólny przodek elementu LazyListState to ConversationScreen

Klasa posiadacza zwykłego stanu jako właściciel stanu

Gdy funkcja kompozycyjna zawiera złożoną logikę interfejsu, która obejmuje jedno lub wiele pól stanu elementu interfejsu, powinien on przekazać tę odpowiedzialność podmiotom stanowym (np. klasa posiadacza zwykłego stanu). Dzięki temu logika kompozycji jest bardziej izolowana, co zmniejsza jej złożoność. To podejście preferuje zasadę rozdziału potencjalnych problemów: element kompozycyjny odpowiada za wysyłanie elementów interfejsu, a właściciel stanu zawiera logikę UI i stan elementu UI.

klasy posiadaczy plików ze zwykłym stanem udostępniają wygodne funkcje wywołujące funkcję kompozycyjną, dzięki czemu nie muszą oni samodzielnie pisać tej logiki.

Takie proste klasy są tworzone i zapamiętywane w kompozycji. Ponieważ są one zgodne z cyklem życia elementu kompozycyjnego, mogą przyjmować typy podane przez bibliotekę tworzenia, takie jak rememberNavController() lub rememberLazyListState().

Przykładem może być klasa prostego operatora LazyListState zaimplementowana w komponencie Tworzenie, by kontrolować złożoność UI LazyColumn lub LazyRow.

// 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 zawiera stan LazyColumn, w którym jest przechowywany scrollPosition dla tego elementu interfejsu. Udostępnia też metody modyfikowania pozycji przewijania, np. przez przewijanie do wybranego elementu.

Jak widać, zwiększenie zakresu obowiązków w elemencie kompozycyjnym zwiększa potrzebę sprawowania władzy państwowej. Odpowiedzialność może dotyczyć logiki interfejsu lub samego stanu.

Innym typowym wzorcem jest używanie zwykłej klasy posiadacza stanu do obsługi złożoności funkcji kompozycyjnych w aplikacji głównej. Tej klasy można używać do herbaty na poziomie aplikacji, np. stanu nawigacji czy rozmiaru ekranu. Pełny opis tej funkcji znajdziesz na stronie funkcji interfejsu i jego stanu.

Logika biznesowa

Jeśli za logikę i stan elementów interfejsu użytkownika odpowiadają klasy obiektów kompozycyjnych i klasy prostego stanu, to za te działania odpowiada osoba odpowiedzialna za te zadania:

  • Zapewnia dostęp do logiki biznesowej aplikacji umieszczonej zwykle w innych warstwach hierarchii, np. w warstwie biznesowej i danych.
  • Przygotowanie danych aplikacji do prezentacji na określonym ekranie, który staje się stanem interfejsu ekranu.

ViewModels jako właściciel stanu,

Dzięki zaletom modeli AAC ViewModele podczas programowania na Androida są one odpowiednie do zapewniania dostępu do logiki biznesowej i przygotowywania danych aplikacji do prezentacji na ekranie.

Gdy przeciągniesz stan interfejsu do elementu ViewModel, przeniesiesz go poza kompozycję.

Stan przeniesiony do modelu ViewModel jest przechowywany poza kompozycją.
Rysunek 6. Stan przeniesiony do ViewModel jest przechowywany poza kompozycją.

Modele ViewModel nie są przechowywane w ramach kompozycji. Są one dostarczane przez platformę i mają zakres ograniczony do elementu ViewModelStoreOwner, który może być aktywnością, fragmentem, wykresem nawigacyjnym lub miejscem docelowym wykresu nawigacyjnego. Więcej informacji o zakresach ViewModel znajdziesz w dokumentacji.

Następnie ViewModel jest źródłem wiarygodnych danych i najniższym wspólnym przodkiem stanu interfejsu.

Stan interfejsu ekranu

Zgodnie z powyższymi definicjami stan interfejsu ekranu jest określany przez zastosowanie reguł biznesowych. Biorąc pod uwagę, że za to odpowiedzialny jest właściciel stanu na poziomie ekranu, oznacza to, że stan interfejsu użytkownika jest zwykle przenoszony do rejestrującego stan na poziomie ekranu, w tym przypadku do elementu ViewModel.

Przyjrzyjmy się elementowi ConversationViewModel aplikacji do obsługi czatu oraz temu, w jaki sposób określa on stan interfejsu ekranu i zdarzenia w celu jego modyfikacji:

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) { /* ... */ }
}

Elementy kompozycyjne wykorzystują stan interfejsu ekranu podany w elemencie ViewModel. Zalecamy wstrzyknięcie instancji ViewModel w kompozycje na poziomie ekranu, aby zapewnić dostęp do logiki biznesowej.

Poniżej znajdziesz przykład uprawnienia ViewModel używanego w funkcji kompozycyjnej na poziomie ekranu. W tym przypadku kompozycyjny ConversationScreen() korzysta ze stanu interfejsu ekranu podanego w ViewModel:

@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)
    /* ... */
}

Odwierty nieruchomości

Pozyskiwanie szczegółowych informacji o usłudze oznacza przekazywanie danych przez kilka zagnieżdżonych komponentów podrzędnych do miejsca, w którym zostaną odczytane.

Typowym przykładem sytuacji, w której wyszukiwanie właściwości może pojawić się w sekcji Utwórz, jest wstrzyknięcie operatora stanu na poziomie ekranu na najwyższym poziomie i przekazanie stanu i zdarzeń do podrzędnych elementów kompozycyjnych. Może to dodatkowo spowodować przeciążenie podpisów funkcji kompozycyjnych.

Ujawnienie zdarzeń jako poszczególnych parametrów lambda może przeciążyć podpis funkcji, ale maksymalizuje widoczność zadań funkcji kompozycyjnej. Wystarczy rzut oka, żeby sprawdzić, do czego służy.

Prowadzenie szczegółowego widoku usługi jest lepsze niż tworzenie klas opakowań do przechowywania stanu i zdarzeń w jednym miejscu, ponieważ zmniejsza to widoczność kompozycyjnych zakresów. Jeśli nie będziesz mieć klas kodu, z większym prawdopodobieństwem będziesz przekazywać obiekty kompozycyjne tylko te parametry, których są im potrzebne. Jest to najlepsza metoda.

Ta sama sprawdzona metoda dotyczy zdarzeń związanych z nawigacją. Więcej informacji na ten temat znajdziesz w dokumentach nawigacyjnych.

Jeśli wykryjesz problem z wydajnością, możesz też opóźnić odczyt stanu. Więcej informacji znajdziesz w dokumentacji dotyczącej skuteczności.

Stan elementu interfejsu

Możesz przenieść stan elementu interfejsu do właściciela stanu na poziomie ekranu, jeśli istnieje logika biznesowa, która musi go odczytać lub zapisać.

Nawiązując do przykładu aplikacji do obsługi czatu – aplikacja wyświetla sugestie użytkowników na czacie grupowym, gdy wpisze on @ oraz podpowiedź. Sugestie te pochodzą z warstwy danych, a logika obliczania listy sugestii użytkowników jest uznawana za logikę biznesową. Funkcja wygląda tak:

Funkcja, która wyświetla sugestie użytkowników na czacie grupowym, gdy użytkownik wpisze „@” i podpowiedź
Rysunek 7. Funkcja, która wyświetla sugestie użytkowników na czacie grupowym, gdy użytkownik wpisze @ i podpowiedź

Zasób (ViewModel), który implementuje tę funkcję, wyglądałby tak:

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 to zmienna przechowująca stan TextField. Za każdym razem, gdy użytkownik wpisze nowe dane wejściowe, aplikacja wywołuje logikę biznesową, aby wygenerować suggestions.

suggestions to stan interfejsu ekranu. Jest wykorzystywany z interfejsu tworzenia wiadomości przez zbieranie danych z StateFlow.

Zastrzeżenie

W przypadku niektórych stanów elementów interfejsu tworzenia wiadomości przeniesienie do ViewModel może wymagać specjalnych uwag. Na przykład niektórzy właściciele stanów elementów interfejsu tworzenia wiadomości ujawniają metody modyfikowania stanu. Mogą to być na przykład funkcje zawieszania, które uruchamiają animacje. Te funkcje zawieszania mogą zgłaszać wyjątki, jeśli wywołasz je z obiektu CoroutineScope, który nie jest ograniczony do kompozycji.

Załóżmy, że zawartość panelu aplikacji jest dynamiczna i trzeba ją pobrać i odświeżyć z warstwy danych po zamknięciu. Przenieś stan panelu do obiektu ViewModel, aby móc wywoływać w tym elemencie zarówno interfejs użytkownika, jak i logikę biznesową od właściciela stanu.

Wywołanie metody close() w usłudze DrawerState za pomocą elementu viewModelScope z poziomu interfejsu tworzenia powoduje jednak wystąpienie wyjątku środowiska wykonawczego typu IllegalStateException z komunikatem „MonotonicFrameClockCoroutineContext”

Aby rozwiązać ten problem, użyj uprawnienia CoroutineScope o zakresie na poziomie kompozycji. Udostępnia w elemencie CoroutineContext element MonotonicFrameClock, który jest niezbędny do działania funkcji zawieszania.

Aby naprawić tę awarię, przełącz CoroutineContext współpracy w ViewModel na taką, która jest przypisana do kompozycji. Może to wyglądać tak:

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

Więcej informacji

Więcej informacji o stanie i Jetpack Compose znajdziesz w tych dodatkowych materiałach.

Próbki

Ćwiczenia z programowania

Filmy