Architektura interfejsu Compose

W Compose interfejs jest niezmienny – nie można go zaktualizować po narysowaniu. Możesz kontrolować stan interfejsu. Za każdym razem, gdy stan interfejsu się zmienia, Compose odtwarza te części drzewa interfejsu, które uległy zmianie. Elementy kompozycyjne mogą akceptować stan i udostępniać zdarzenia. Na przykład element TextField akceptuje wartość i udostępnia wywołanie zwrotne onValueChange, które prosi o zmianę wartości.

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

Ponieważ elementy kompozycyjne akceptują stan i udostępniają zdarzenia, wzorzec jednokierunkowego przepływu danych dobrze pasuje do Jetpack Compose. Ten przewodnik koncentruje się na tym, jak zaimplementować wzorzec jednokierunkowego przepływu danych w Compose, jak zaimplementować zdarzenia i kontenery stanu oraz jak pracować z ViewModelami w Compose.

Jednokierunkowy przepływ danych

Jednokierunkowy przepływ danych (UDF) to wzorzec projektowy, w którym stan przepływa w dół, a zdarzenia w górę. Dzięki jednokierunkowemu przepływowi danych możesz oddzielić elementy kompozycyjne, które wyświetlają stan w interfejsie, od tych części aplikacji, które przechowują i zmieniają stan.

Pętla aktualizacji interfejsu aplikacji korzystającej z jednokierunkowego przepływu danych wygląda tak:

  1. Zdarzenie: część interfejsu generuje zdarzenie i przekazuje je w górę, np. kliknięcie przycisku przekazywane do ViewModela w celu obsługi, lub zdarzenie jest przekazywane z innych warstw aplikacji, np. informujące o wygaśnięciu sesji użytkownika.
  2. Aktualizuj stan: moduł obsługi zdarzeń może zmienić stan.
  3. Wyświetl stan: kontener stanu przekazuje stan w dół, a interfejs go wyświetla.
Zdarzenia przepływają z interfejsu do obiektu przechowującego stan, a stan przepływa z obiektu przechowującego stan do interfejsu.
Rysunek 1. Jednokierunkowy przepływ danych.

Stosowanie tego wzorca podczas korzystania z Jetpack Compose ma kilka zalet:

  • Możliwość testowania: oddzielenie stanu od interfejsu, który go wyświetla, ułatwia testowanie obu tych elementów w izolacji.
  • Hermetyzacja stanu: ponieważ stan można aktualizować tylko w jednym miejscu i istnieje tylko jedno źródło informacji o stanie elementu kompozycyjnego, jest mniej prawdopodobne, że utworzysz błędy spowodowane niespójnymi stanami.
  • Spójność interfejsu: wszystkie aktualizacje stanu są natychmiast odzwierciedlane w interfejsie dzięki użyciu obserwowalnych kontenerów stanu, takich jak StateFlow czy LiveData.

Jednokierunkowy przepływ danych w Jetpack Compose

Elementy kompozycyjne działają na podstawie stanu i zdarzeń. Na przykład element TextField jest aktualizowany tylko wtedy, gdy aktualizowany jest jego parametr value, i udostępnia wywołanie zwrotne onValueChange – zdarzenie, które prosi o zmianę wartości na nową. Compose definiuje obiekt State jako kontener wartości, a zmiany wartości stanu powodują rekompozycję. Stan możesz przechowywać w remember { mutableStateOf(value) } lub rememberSaveable { mutableStateOf(value) w zależności od tego, jak długo musisz pamiętać wartość.

Typ wartości elementu kompozycyjnego TextField to String, więc może ona pochodzić z dowolnego miejsca – z zakodowanej na stałe wartości, z ViewModela lub z elementu kompozycyjnego nadrzędnego. Nie musisz przechowywać jej w obiekcie State, ale musisz zaktualizować wartość, gdy zostanie wywołana funkcja onValueChange.

Definiowanie parametrów elementu kompozycyjnego

Podczas definiowania parametrów stanu elementu kompozycyjnego pamiętaj o tych kwestiach:

  • Jak bardzo elastyczny lub wielokrotnego użytku jest element kompozycyjny?
  • Jak parametry stanu wpływają na wydajność tego elementu kompozycyjnego?

Aby promować oddzielanie i ponowne użycie, każdy element kompozycyjny powinien zawierać jak najmniej informacji. Na przykład podczas tworzenia elementu kompozycyjnego, który ma zawierać nagłówek artykułu, lepiej jest przekazywać tylko informacje, które mają być wyświetlane, a nie cały artykuł:

@Composable
fun Header(title: String, subtitle: String) {
    // Recomposes when title or subtitle have changed.
}

@Composable
fun Header(news: News) {
    // Recomposes when a new instance of News is passed in.
}

Czasami używanie poszczególnych parametrów poprawia też wydajność. Jeśli na przykład News zawiera więcej informacji niż tylko title i subtitle, za każdym razem, gdy do Header(news) zostanie przekazana nowa instancja News, element kompozycyjny zostanie ponownie skomponowany, nawet jeśli title i subtitle się nie zmieniły.

Dokładnie rozważ liczbę przekazywanych parametrów. Funkcja z zbyt dużą liczbą parametrów jest mniej ergonomiczna, dlatego w tym przypadku lepiej jest pogrupować je w klasie.

Zdarzenia w Compose

Każde dane wejściowe w aplikacji powinny być reprezentowane jako zdarzenie: dotknięcia, zmiany tekstu, a nawet timery lub inne aktualizacje. Ponieważ te zdarzenia zmieniają stan interfejsu, ViewModel powinien je obsługiwać i aktualizować stan interfejsu.

Warstwa interfejsu nigdy nie powinna zmieniać stanu poza modułem obsługi zdarzeń, ponieważ może to spowodować niespójności i błędy w aplikacji.

W przypadku lambd stanu i modułów obsługi zdarzeń preferuj przekazywanie wartości niezmiennych. Takie podejście ma te zalety:

  • Zwiększasz możliwość ponownego użycia.
  • Sprawdzasz, czy interfejs nie zmienia bezpośrednio wartości stanu.
  • Unikasz problemów z współbieżnością, ponieważ masz pewność, że stan nie jest modyfikowany z innego wątku.
  • Często zmniejszasz złożoność kodu.

Na przykład element kompozycyjny, który akceptuje jako parametry String i lambdę, może być wywoływany z wielu kontekstów i jest bardzo uniwersalny. Załóżmy, że górny pasek aplikacji zawsze wyświetla tekst i ma przycisk Wstecz. Możesz zdefiniować bardziej ogólny element kompozycyjny MyAppTopAppBar, który jako parametry przyjmuje tekst i moduł obsługi przycisku Wstecz:

@Composable
fun MyAppTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) {
    TopAppBar(
        title = {
            Text(
                text = topAppBarText,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .fillMaxSize()
                    .wrapContentSize(Alignment.Center)
            )
        },
        navigationIcon = {
            IconButton(onClick = onBackPressed) {
                Icon(
                    Icons.AutoMirrored.Filled.ArrowBack,
                    contentDescription = localizedString
                )
            }
        },
        // ...
    )
}

ViewModele, stany i zdarzenia – przykład

Jeśli spełniony jest jeden z tych warunków, możesz też wprowadzić jednokierunkowy przepływ danych w aplikacji za pomocą ViewModel i mutableStateOf:

  • Stan interfejsu jest udostępniany za pomocą obserwowalnych kontenerów stanu, takich jak StateFlow czy LiveData.
  • ViewModel obsługuje zdarzenia pochodzące z interfejsu lub innych warstw aplikacji i aktualizuje kontener stanu na podstawie tych zdarzeń.

Na przykład podczas implementowania ekranu logowania dotknięcie przycisku Zaloguj się powinno spowodować wyświetlenie przez aplikację spinnera postępu i wykonanie połączenia sieciowego. Jeśli logowanie się powiedzie, aplikacja przejdzie do innego ekranu. W przypadku błędu aplikacja wyświetli Snackbar. Oto jak możesz modelować stan ekranu i zdarzenie:

Ekran ma 4 stany:

  • Wylogowano: gdy użytkownik nie jest jeszcze zalogowany.
  • W toku: gdy aplikacja próbuje zalogować użytkownika, wykonując połączenie sieciowe.
  • Błąd: gdy podczas logowania wystąpił błąd.
  • Zalogowano: gdy użytkownik jest zalogowany.

Te stany możesz modelować jako klasę sealed. ViewModel udostępnia stan jako State, ustawia stan początkowy i aktualizuje stan w razie potrzeby. ViewModel obsługuje też zdarzenie logowania, udostępniając metodę onSignIn().

class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(UiState.SignedOut)
    val uiState: State<UiState>
        get() = _uiState

    // ...
}

Oprócz interfejsu API mutableStateOf Compose udostępnia rozszerzenia dla LiveData, Flow i Observable, które umożliwiają zarejestrowanie się jako odbiorca i reprezentowanie wartości jako stanu.

class MyViewModel : ViewModel() {
    private val _uiState = MutableLiveData<UiState>(UiState.SignedOut)
    val uiState: LiveData<UiState>
        get() = _uiState

    // ...
}

@Composable
fun MyComposable(viewModel: MyViewModel) {
    val uiState = viewModel.uiState.observeAsState()
    // ...
}

Więcej informacji

Więcej informacji o architekturze w Jetpack Compose znajdziesz w tych materiałach:

Przykłady