Gdzie podnieść stan

W aplikacji Compose miejsce, w którym podnosisz stan interfejsu, zależy od tego, czy wymaga tego logika interfejsu czy logika biznesowa. W tym dokumencie opisujemy te 2 główne scenariusze.

Sprawdzona metoda

Stan interfejsu użytkownika należy przenieść do najniższego wspólnego przodka wszystkich funkcji kompozycyjnych, które go odczytują i zapisują. Stan powinien być przechowywany jak najbliżej miejsca, w którym jest używany. Z poziomu właściciela stanu udostępniaj konsumentom niezmienny stan i zdarzenia, aby go modyfikować.

Najniższy wspólny przodek może też znajdować się poza funkcją kompozycyjną. Na przykład podczas przenoszenia stanu w ViewModel, ponieważ wymaga tego logika biznesowa.

Na tej stronie szczegółowo opisujemy tę sprawdzoną metodę i podajemy zastrzeżenie, o którym należy pamiętać.

Typy stanu i logiki interfejsu

Poniżej znajdziesz definicje typów stanu i logiki interfejsu, które są używane w tym dokumencie.

Stan UI

Stan interfejsu to właściwość, która opisuje interfejs. Istnieją 2 rodzaje stanu interfejsu:

  • Stan interfejsu ekranu to to, co musisz wyświetlić na ekranie. Na przykład klasa NewsUiState może zawierać artykuły z wiadomościami i inne informacje potrzebne do renderowania interfejsu. Ten stan jest zwykle połączony z innymi warstwami hierarchii, ponieważ zawiera dane aplikacji.
  • Stan elementu interfejsu odnosi się do właściwości wewnętrznych elementów interfejsu, które wpływają na sposób ich renderowania. Element interfejsu może być widoczny lub ukryty i może mieć określoną czcionkę, rozmiar czcionki lub kolor czcionki. W Jetpack Compose stan jest zewnętrzny względem funkcji typu „composable” i można go nawet przenieść z bezpośredniego sąsiedztwa funkcji typu „composable” do wywołującej funkcji typu „composable” lub zmiennej stanu. Przykładem jest ScaffoldState dla funkcji kompozycyjnej Scaffold.

Operatory logiczne

Logika w aplikacji może być logiką biznesową lub logiką interfejsu:

  • Logika biznesowa to implementacja wymagań dotyczących usług w odniesieniu do danych aplikacji. Na przykład dodanie artykułu do zakładek w aplikacji do czytania wiadomości, gdy użytkownik kliknie przycisk. Logika zapisywania zakładki w pliku lub bazie danych jest zwykle umieszczana w warstwach domeny lub danych. Zmienna stanu zwykle przekazuje tę logikę do tych warstw, wywołując metody, które udostępniają.
  • Logika interfejsu jest związana z tym, jak wyświetlać stan interfejsu na ekranie. Na przykład uzyskanie odpowiedniej podpowiedzi na pasku wyszukiwania, gdy użytkownik wybierze kategorię, przewijanie do określonego elementu na liście lub logika nawigacji do określonego ekranu, gdy użytkownik kliknie przycisk.

Logika interfejsu

Gdy logika interfejsu musi odczytać lub zapisać stan, należy ograniczyć zakres stanu do interfejsu zgodnie z jego cyklem życia. Aby to osiągnąć, musisz przenieść stan na odpowiedni poziom w funkcji typu „composable”. Możesz też zrobić to w zwykłej klasie zmiennej stanu, która również jest ograniczona do cyklu życia interfejsu.

Poniżej znajdziesz opis obu rozwiązań i wyjaśnienie, kiedy należy ich używać.

Komponenty jako właściciel stanu

Umieszczanie logiki i stanu elementów interfejsu w funkcjach kompozycyjnych jest dobrym podejściem, jeśli stan i logika są proste. Możesz pozostawić stan wewnętrzny w funkcji kompozycyjnej lub wyciągnąć go w razie potrzeby.

Nie trzeba przenosić stanu

Przenoszenie stanu nie zawsze jest wymagane. Stan może być przechowywany wewnętrznie w funkcji kompozycyjnej, gdy żadna inna funkcja kompozycyjna nie musi go kontrolować. W tym fragmencie kodu znajduje się funkcja kompozycyjna, która rozwija się i zwija po kliknięciu:

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

Zmienna showDetails to stan wewnętrzny tego elementu interfejsu. Jest odczytywany i modyfikowany tylko w tej funkcji kompozycyjnej, a zastosowana do niego logika jest bardzo prosta. W tym przypadku przeniesienie stanu nie przyniosłoby więc wielu korzyści, dlatego możesz pozostawić go jako wewnętrzny. Dzięki temu ten element kompozycyjny będzie właścicielem i jedynym źródłem wiarygodnych danych o stanie rozwinięcia.

Windowanie w funkcjach kompozycyjnych

Jeśli chcesz udostępnić stan elementu interfejsu innym elementom kompozycyjnym i zastosować do niego logikę interfejsu w różnych miejscach, możesz przenieść go wyżej w hierarchii interfejsu. Dzięki temu elementy kompozycyjne są bardziej wielokrotnego użytku i łatwiejsze do przetestowania.

Poniższy przykład to aplikacja do obsługi czatu, która implementuje 2 funkcje:

  • Przycisk JumpToBottom przewija listę wiadomości w dół. Przycisk wykonuje logikę interfejsu na stanie listy.
  • Po wysłaniu przez użytkownika nowych wiadomości lista MessagesList przewija się do dołu. UserInput wykonuje logikę interfejsu na stanie listy.
Aplikacja do obsługi czatu z przyciskiem JumpToBottom i przewijaniem do dołu przy nowych wiadomościach
Rysunek 1. Aplikacja Google Chat z przyciskiem JumpToBottom i przewijaniem do dołu przy nowych wiadomościach

Hierarchia funkcji kompozycyjnych wygląda tak:

Drzewo funkcji kompozycyjnych czatu
Rysunek 2. Drzewo funkcji kompozycyjnych czatu

Stan LazyColumn jest przenoszony na ekran rozmowy, dzięki czemu aplikacja może wykonywać logikę interfejsu i odczytywać stan ze wszystkich funkcji kompozycyjnych, które tego wymagają:

Przenoszenie stanu LazyColumn z LazyColumn do ConversationScreen
Rysunek 3. Przenoszenie stanu LazyColumn z LazyColumn do ConversationScreen

Ostatecznie elementy kompozycyjne to:

Drzewo funkcji kompozycyjnych czatu z LazyListState przeniesionym do ConversationScreen
Rysunek 4. Drzewo funkcji kompozycyjnych czatu z LazyListState przeniesionym 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
        }
    })
}

LazyListState jest podnoszony tak wysoko, jak wymaga tego logika interfejsu, którą trzeba zastosować. Ponieważ jest inicjowany w funkcji typu „composable”, jest przechowywany w kompozycji i podlega jej cyklowi życia.

Zauważ, że zmienna lazyListState jest zdefiniowana w metodzie MessagesList z wartością domyślną rememberLazyListState(). To typowy wzorzec w Compose. Dzięki temu elementy kompozycyjne są bardziej elastyczne i można ich używać wielokrotnie. Następnie możesz używać funkcji kompozycyjnej w różnych częściach aplikacji, które mogą nie wymagać kontrolowania stanu. Zwykle dzieje się tak podczas testowania lub wyświetlania podglądu funkcji kompozycyjnej. Właśnie tak LazyColumn definiuje swój stan.

Najniższy wspólny przodek dla LazyListState to ConversationScreen
Rysunek 5. Najniższy wspólny przodek dla LazyListState to ConversationScreen

Zwykła klasa zmiennej stanu jako właściciel stanu

Gdy element kompozycyjny zawiera złożoną logikę interfejsu, która obejmuje co najmniej 1 pole stanu elementu interfejsu, powinien przekazać to zadanie zmiennym stanu, np. zwykłej klasie zmiennej stanu. Dzięki temu logika funkcji kompozycyjnej jest łatwiejsza do przetestowania w izolacji i mniej złożona. Takie podejście jest zgodne z zasadą rozdzielenia zagadnień: funkcja kompozycyjna odpowiada za emitowanie elementów interfejsu, a zmienna stanu zawiera logikę interfejsu i stan elementu interfejsu.

Zwykłe klasy zmiennych stanu udostępniają wygodne funkcje elementom wywołującym funkcję typu „composable”, dzięki czemu nie muszą one same pisać tej logiki.

Te zwykłe klasy są tworzone i zapamiętywane w kompozycji. Ponieważ są zgodne z cyklem życia funkcji kompozycyjnej, mogą przyjmować typy udostępniane przez bibliotekę Compose, takie jak rememberNavController() lub rememberLazyListState().

Przykładem jest zwykła klasa zmiennej stanu LazyListState zaimplementowana w Compose, która pozwala kontrolować złożoność interfejsu 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 przechowywany jest scrollPosition dla tego elementu interfejsu. Udostępnia też metody modyfikowania pozycji przewijania, np. przez przewijanie do danego elementu.

Jak widać, zwiększenie zakresu odpowiedzialności funkcji kompozycyjnej zwiększa potrzebę użycia zmiennej stanu. Odpowiedzialność może dotyczyć logiki interfejsu lub tylko ilości stanu do śledzenia.

Innym popularnym wzorcem jest używanie zwykłej klasy zmiennej stanu do obsługi złożoności funkcji typu „composable” na poziomie głównym w aplikacji. Możesz użyć takiej klasy do hermetyzacji stanu na poziomie aplikacji, takiego jak stan nawigacji i rozmiar ekranu. Pełny opis znajdziesz na stronie poświęconej logice interfejsu i zmiennej stanu.

Logika biznesowa

Jeśli funkcje kompozycyjne i proste klasy zmiennych stanu odpowiadają za logikę interfejsu i stan elementów interfejsu, zmienna stanu na poziomie ekranu odpowiada za te zadania:

  • Zapewnianie dostępu do logiki biznesowej aplikacji, która zwykle znajduje się w innych warstwach hierarchii, takich jak warstwy biznesowe i danych.
  • przygotowanie danych aplikacji do prezentacji na konkretnym ekranie, co staje się stanem interfejsu ekranu;

ViewModels jako właściciel stanu

Zalety modeli widoku AAC w programowaniu na Androida sprawiają, że nadają się one do zapewniania dostępu do logiki biznesowej i przygotowywania danych aplikacji do wyświetlenia na ekranie.

Gdy przenosisz stan interfejsu w ViewModel, przenosisz go poza kompozycję.

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

ViewModels nie są przechowywane jako część kompozycji. Są one dostarczane przez platformę i ograniczone do ViewModelStoreOwner, którym może być aktywność, fragment, wykres nawigacyjny lub miejsce docelowe wykresu nawigacyjnego. Więcej informacji o zakresach ViewModel znajdziesz w dokumentacji.

Wtedy ViewModel jest źródłem prawdy i najniższym wspólnym przodkiem stanu interfejsu.

Stan interfejsu ekranu

Zgodnie z powyższymi definicjami stan interfejsu ekranu jest generowany przez zastosowanie reguł biznesowych. Ponieważ odpowiada za to zmienna stanu na poziomie ekranu, oznacza to, że stan interfejsu ekranu jest zwykle przenoszony do zmiennej stanu na poziomie ekranu, w tym przypadku do ViewModel.

Weź pod uwagę ConversationViewModel aplikacji do obsługi czatu i sposób, w jaki ujawnia ona stan interfejsu ekranu i zdarzenia, które go modyfikują:

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

Komponenty korzystają ze stanu interfejsu ekranu przeniesionego do ViewModel. Aby zapewnić dostęp do logiki biznesowej, musisz wstrzyknąć instancję ViewModel do funkcji kompozycyjnych na poziomie ekranu.

Poniżej znajdziesz przykład elementu ViewModel używanego w funkcji kompozycyjnej na poziomie ekranu. W tym przypadku funkcja kompozycyjna ConversationScreen() wykorzystuje stan interfejsu ekranu przeniesiony do 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)
    /* ... */
}

Przechodzenie do szczegółów właściwości

„Przekazywanie właściwości” odnosi się do przekazywania danych przez kilka zagnieżdżonych komponentów podrzędnych do lokalizacji, w której są odczytywane.

Typowym przykładem sytuacji, w której w Compose może wystąpić przekazywanie właściwości, jest wstrzyknięcie zmiennej stanu na poziomie ekranu na najwyższym poziomie i przekazanie stanu oraz zdarzeń do podrzędnych funkcji kompozycyjnych. Może to dodatkowo spowodować przeciążenie sygnatur funkcji typu „composable”.

Nawet jeśli udostępnianie zdarzeń jako pojedynczych parametrów lambda może przeciążyć sygnaturę funkcji, maksymalizuje to widoczność obowiązków funkcji typu „composable”. Możesz od razu zobaczyć, co robi.

Przekazywanie właściwości jest lepsze niż tworzenie klas otoki do hermetyzacji stanu i zdarzeń w jednym miejscu, ponieważ zmniejsza widoczność funkcji kompozycyjnych. Dzięki temu, że nie masz klas otoki, jest też bardziej prawdopodobne, że będziesz przekazywać funkcjom kompozycyjnym tylko te parametry, których potrzebują, co jest sprawdzoną metodą.

Ta sama sprawdzona metoda ma zastosowanie, jeśli te zdarzenia są zdarzeniami nawigacji. Więcej informacji na ten temat znajdziesz w dokumentacji dotyczącej nawigacji.

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

Stan elementu interfejsu

Możesz przenieść stan elementu interfejsu do zmiennej stanu na poziomie ekranu, jeśli istnieje logika biznesowa, która musi go odczytywać lub zapisywać.

Kontynuując przykład aplikacji do obsługi czatu, aplikacja wyświetla sugestie użytkowników na czacie grupowym, gdy użytkownik wpisze @ i podpowiedź. Te sugestie pochodzą z warstwy danych, a logika obliczania listy sugestii użytkownika jest uważana 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ź

Plik ViewModel implementujący 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 wpisuje nowe dane, aplikacja wywołuje logikę biznesową, aby wygenerować suggestions.

suggestions to stan interfejsu ekranu, który jest wykorzystywany przez interfejs Compose przez zbieranie z StateFlow.

Caveat

W przypadku niektórych stanów elementów interfejsu Compose przenoszenie do ViewModel może wymagać szczególnej uwagi. Na przykład niektóre zmienne stanu elementów interfejsu Compose udostępniają metody modyfikowania stanu. Niektóre z nich mogą być funkcjami zawieszenia, które wywołują animacje. Te funkcje zawieszenia mogą zgłaszać wyjątki, jeśli wywołasz je z CoroutineScope, który nie jest ograniczony do kompozycji.

Załóżmy, że zawartość panelu aplikacji jest dynamiczna i po jego zamknięciu musisz ją pobrać i odświeżyć z warstwy danych. Stan panelu należy przenieść do ViewModel, aby można było wywołać zarówno interfejs, jak i logikę biznesową tego elementu z poziomu właściciela stanu.

Jednak wywołanie metody close() interfejsu DrawerState przy użyciu viewModelScope z interfejsu Compose powoduje wyjątek czasu działania typu IllegalStateException z komunikatem „ MonotonicFrameClock is not available in this CoroutineContext”” ( nie jest dostępny w tym ).

Aby rozwiązać ten problem, użyj CoroutineScope o zakresie ograniczonym do kompozycji. Zapewnia MonotonicFrameClock w CoroutineContext, który jest niezbędny do działania funkcji zawieszenia.

Aby naprawić tę awarię, przełącz CoroutineContext współprogramu w ViewModel na taki, który jest ograniczony 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 materiałach:

Przykłady

Codelabs

Filmy