Zapisywanie wstecz

Proces tworzenia kompozycji przebiega w 3 ściśle uporządkowanych fazach:

Trzy fazy: 1. Kompozycja, 2. Układ, 3. Rysunek
Rysunek 1. Trzy fazy ramki Compose
  1. Kompozycja: uruchamia funkcje @Composable, aby tworzyć i aktualizować drzewo interfejsu.
  2. Układ: mierzy elementy podrzędne, a następnie je umieszcza.
  3. Rysowanie: wysyła polecenia rysowania na płótnie, aby renderować piksele na ekranie.

Gdy podczas dowolnej fazy nastąpi odczyt funkcji Compose State, Compose automatycznie rejestruje zależność między tym stanem a odpowiednią fazą.


Co sprawia, że zapis jest „odwrotny”?

Zapis wsteczny występuje, gdy stan jest modyfikowany w późniejszej fazie (lub w zakresie podrzędnym – czyli w zakresie funkcji kompozycyjnej wykonywanym później w kolejności w ramach tego samego przebiegu kompozycji) niż w miejscu, w którym został odczytany. Wymusza to ponowne utworzenie kompozycji przez Compose poprzez zaplanowanie ponownego uruchomienia wcześniejszej fazy lub funkcji kompozycyjnej. Zapisywanie wsteczne to niezoptymalizowana pętla rekompozycji.

Przykład zapisu wstecznego w fazach pętli rekompozycji
Rysunek 2. Przykład zapisu wstecznego w pętli rekompozycji fazy

Konsekwencje zapisów wstecznych

Zapisywanie wsteczne nie jest koniecznie czymś złym i nie zawsze powoduje awarie, ale jest nieefektywne i może na kilka sposobów pogorszyć wydajność aplikacji:

  • Dodatkowe renderowanie klatek i pomijanie klatek: zapis wsteczny wymusza na Compose wykonywanie zbędnych przebiegów kompozycji w kolejnych klatkach, co powoduje marnowanie zasobów procesora i GPU oraz może powodować zacinanie się animacji.
  • Problemy z poprawnością pierwszej klatki: jeśli komponent wymaga zapisu wstecznego, aby określić ostateczne wymiary lub stan, pierwsza klatka jest renderowana z nieprawidłowymi, domyślnymi lub nieustalonymi danymi (np. o zerowym rozmiarze lub z nieprawidłowym przesunięciem). Powoduje to widoczne skoki lub migotanie układu, gdy renderowana jest druga klatka.
  • Nieskończone pętle ponownego komponowania: jeśli zmiana stanu zmienia rozmiar układu, a rozmiar układu ciągle zapisuje nową wartość w stanie, możesz stworzyć nieskończoną pętlę klatek, w której ekran ciągle ponownie komponuje każdą klatkę, nie stabilizując się.

Przechodzenie do przodu przez etapy

Zmiany stanu powinny zawsze przebiegać w kierunku kolejnych etapów:

Faza odczytu Write Context Dozwolone? Dlaczego
Układ (Modifier.offset { }) Kompozycja Tak Kompozycja aktualizuje stan → układ odczytuje go później w tej samej ramce bez ponownego komponowania.
Remis (graphicsLayer { }, drawBehind { }) Kompozycja Tak Kompozycja aktualizuje stan → rysowanie odczytuje go w ostatniej fazie. Kompozycja i układ są całkowicie pomijane.
Rysuj Układ Tak Układ aktualizuje stan, a funkcja rysowania go odczytuje. Jest to prawidłowy przepływ.
Kompozycja Wywołanie zwrotne zdarzenia (onClick, onValueChange) powodujące zmianę stanu jazdy. Uwaga: wywołania zwrotne układu nie są traktowane jako zdarzenia. Tak Zdarzenie zmienia stan, który jest używany do tworzenia kompozycji. Jeśli zdarzenie występuje poza ramką (nie w sekcji Kompozycja, Układ ani Rysowanie), jest to prawidłowe.
Kompozycja Współprogram (LaunchedEffect) Tak – z zachowaniem ostrożności Asynchronicznie aktualizuje stan w odpowiedzi na cykl życia lub zdarzenia. Zapisy z efektów mogą być prawidłowe, ale mogą wskazywać na nieefektywne warstwy stanu. Należy ich unikać, jeśli to możliwe.
Miejsce docelowe (w układzie) Pomiary (w układzie) Tak W przypadku układu dopuszczalne jest zaktualizowanie stanu, a następnie odczytanie go w miejscu docelowym.
Pomiary (w układzie) Miejsce docelowe (w układzie) Nie – pisanie od tyłu Zapisywanie stanu umieszczenia, który następnie jest odczytywany, powoduje pętlę ponownego pomiaru.
Kompozycja Układ (onSizeChanged, LayoutModifier) Niepisanie od prawej do lewej Układ unieważnia kompozycję → pętla ponownej kompozycji.
Kompozycja Remis (drawWithContent, Canvas) Nie – pisanie od tyłu Rysowanie unieważnia kompozycję → pętla ponownej kompozycji.

Kombinacje faz: wsteczna i do przodu

Poniżej znajdziesz przykłady pisania od prawej do lewej w Compose i sposoby ich rozwiązania.

Wstecz: czytanie w widoku Kompozycja, pisanie w widoku Układ

  • Co się dzieje: kompozycja odczytuje componentHeight, aby określić, który interfejs użytkownika ma zostać wyemitowany. W dalszej części ramki faza układu mierzy lub umieszcza widoki i zapisuje nową wartość w componentHeight (np. za pomocą onSizeChanged, onGloballyPositioned lub niestandardowego LayoutModifier).
  • Wynik: zmiana componentHeight w układzie unieważnia właśnie ukończony etap kompozycji. Pamiętaj, że onSizeChangedraporty rozmiar po zakończeniu pomiaru układu. Jeśli zaktualizowana wartość stanu ustabilizuje się w następnym przebiegu, rekompozycja może się zatrzymać po jednej dodatkowej klatce. Jeśli jednak nowa wartość nadal będzie zmieniać rozmiar, spowoduje to nieskończoną pętlę klatek. Ponadto onGloballyPositioned jest wykonywana po układzie i umiejscowieniu, co sprawia, że zapisywanie stanu w jej wnętrzu jest jeszcze bardziej podatne na ciągłe pętle rekompozycji i ponownego układania w kolejnych klatkach.

// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged)
@Composable
fun BadAspectRatioImage(painter: Painter) {
    var calculatedHeight by remember { mutableStateOf(0.dp) }
    val density = LocalDensity.current

    // State read during COMPOSITION:
    Image(
        painter = painter,
        contentDescription = "Dynamic Image",
        modifier = Modifier
            .fillMaxWidth()
            .height(calculatedHeight)
            .onSizeChanged { size ->
                // State write during LAYOUT phase!
                // Triggers backwards write and recomposition pass
                val aspectRatio = 16f / 9f
                val widthDp = with(density) { size.width.toDp() }
                calculatedHeight = widthDp / aspectRatio
            }
    )
}

// ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition
@Composable
fun GoodAspectRatioImage(
    painter: Painter,
    aspectRatio: Float = 16f / 9f,
    modifier: Modifier = Modifier
) {
    Layout(
        content = {
            Image(
                painter = painter,
                contentDescription = "Dynamic Image"
            )
        },
        modifier = modifier
    ) { measurables, constraints ->
        val width = constraints.maxWidth
        val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio()
        val imageConstraints = constraints.copy(
            minWidth = width,
            maxWidth = width,
            minHeight = height,
            maxHeight = height
        )
        val placeable = measurables.first().measure(imageConstraints)
        layout(width, height) {
            placeable.placeRelative(0, 0)
        }
    }
}

Wstecz: czytanie w kompozycji, pisanie w rysunku

  • Co się dzieje: stan jest odczytywany w treści funkcji Composable (faza kompozycji), ale zmieniany w funkcjach Modifier.drawWithContent, Modifier.drawBehind lub Canvas (faza rysowania).
  • Wynik: faza rysowania zmienia stan → kompozycja unieważniona → nieskończona pętla.

// ❌ BAD: Read in Composition, Written in Draw ()
@Composable
fun BadBackwardsWriteDraw() {
    var componentHeight by remember { mutableStateOf(0.dp) }
    // State read during COMPOSITION:
    Text(
        text = "Height is: $componentHeight",
        modifier = Modifier.drawBehind {
            // State write during the DRAW phase!
            // Invalidates Composition -> triggers recomposition loop!
            componentHeight = size.height.dp
        }
    )
}

Wstecz: czytanie w trybie kompozycji, pisanie w trybie kompozycji (ta sama faza)

  • Co się dzieje: odczytanie wartości count w funkcji typu „composable” i zmodyfikowanie jejcount bezpośrednio w innym slocie treści funkcji kompozycyjnej po odczytaniu.
  • Wynik: system migawek rejestruje odczyt i kolejny zapis w ramach tej samej kompozycji, natychmiast unieważniając bieżący zakres.

// ❌ BAD: Direct write in Composable body after read
@Composable
fun BadCounter() {
    var count by remember { mutableIntStateOf(0) }
    Text("Count: $count") // State read in Composition
    Button(onClick = {}) {
        count++ // State write in Composition (Backwards write!)
    }
}
// Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read
@Composable
fun OkCounter() {
    var count by remember { mutableIntStateOf(0) }
    Button(onClick = {}) {
        count++ // State  write in Composition
    }
    Text("Count: $count") // State read in Composition
}


Kluczowe reguły zapobiegające zapisywaniu wstecznemu

  1. Nie pisz w stanie onGloballyPositioned, onSizeChanged ani LayoutModifier, jeśli ten stan jest odczytywany w kompozycji, ponieważ powoduje to problem z poprawnością pierwszej klatki.
    • Jeśli współrzędne lub rozmiary układu są potrzebne tylko do rysowania niestandardowego, odczytuj je bezpośrednio w fazie rysowania lub układu (np. za pomocą Modifier.drawWithCache lub Modifier.layout).
    • W przypadku określania rozmiaru na poziomie okna (WindowWidthSizeClass): przenieś obserwację rozmiaru podnośnika na poziom okna. Kompozycja rozgałęzia się na klasy rozmiaru okna przed pomiarem lokalnym.
    • Zachowaj jednolitość kompozycji: używaj jednego niestandardowego układu lub komponentów, takich jak FlowRow lub LazyVerticalGrid, które dostosowują pomiary i umiejscowienie w fazie 2 bez zmiany kompozycji ani stanu używanego w kompozycji.
    • Użyj podkompozycji: użyj BoxWithConstraints lub SubcomposeLayout, gdy kompozycje podrzędne muszą się rozgałęziać na podstawie lokalnej szerokości lub wysokości. Zachowaj ostrożność: podkompozycja wiąże się z kosztem wydajności i zwykle można jej uniknąć.
    • W ostateczności: zezwól na nieprawidłowość pierwszej klatki, zapisując rozmiar w  onSizeChanged, aby wywołać ponowne złożenie drugiej klatki. Powoduje to widoczne przeskakiwanie układu, zacinanie się i ryzyko nieskończonych pętli.
  2. Nie zmieniaj stanu po pierwszym odczytaniu w kompozycji:
    • Chociaż podczas tworzenia wiadomości możesz bezpiecznie zapisywać obiekty MutableState poza SideEffect, zachowaj szczególną ostrożność, aby nie zapisywać stanu, który mógł zostać wcześniej odczytany podczas tworzenia wiadomości. Zalecamy używanie rememberUpdatedState, gdy zajdzie potrzeba zapisania stanu w kompozycji. Zapisywanie stanu podczas kompozycji w inny sposób jest zwykle oznaką braku efektu lub nieodpowiednio zaprojektowanego stanu lub komponentu. Pamiętaj, że kompozycja jest optymistyczna i zawsze wykonuje się z najnowszą wartością stanu, więc w ponownych kompozycjach możesz nie widzieć wszystkich zmian stanu. Aktualizacje interfejsu nie powinny być używane do obsługi jednorazowych zdarzeń, dlatego rzadko zdarza się, aby wartość stanu wymagała aktualizacji w wyniku ponownego komponowania.
    • Unikaj modyfikowania stanów obserwowanych poza kompozycją (np. pól ViewModel lub flag isVisible). Stan, który wpływa na kompozycję, powinien być zapisywany w funkcjach lambda zdarzeń (onClick), korutynach (LaunchedEffect) lub efektach ubocznych (SideEffect). Wyjątkiem jest rememberUpdatedState, ponieważ jest on przeznaczony do zmiany stanu, który jest używany tylko w treści @Composable.
  3. Odłóż odczyty stanu do ostatniej możliwej fazy:
    • Odczytywanie stanów w programie Draw (Modifier.graphicsLayer { alpha = ... }) lub Layout (Modifier.offset { IntOffset(...) }) zapewnia, że zmiany unieważniają tylko fazę 2 lub 3, całkowicie pomijając fazę 1 (kompozycja).