NavHost при переключении экранов делает несколько рекомпозиций подряд

Есть вот такой кусок кода как минимальный пример для воспроизведения

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Button
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.krokosha.focusrequester2.ui.theme.Test_delete_itTheme

private const val FIRST_SCREEN_ROUTE = "first_screen"
private const val SECOND_SCREEN_ROUTE = "second_screen"

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Test_delete_itTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    shape = RectangleShape
                ) {
                    Greeting()
                }
            }
        }
    }
}

@Composable
fun Greeting() {
    val navigator: NavHostController = rememberNavController()

    NavHost(
        navController = navigator,
        startDestination = FIRST_SCREEN_ROUTE
    ) {
        composable(FIRST_SCREEN_ROUTE) {
            Log.e("HERE", "1 SCREEN")
            FirstScreen(onClick = { navigator.navigate(SECOND_SCREEN_ROUTE) }) }
        composable(SECOND_SCREEN_ROUTE) {
            Log.e("HERE", "2 SCREEN")
            SecondScreen() }
    }
}

@Composable
fun SecondScreen() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Red.copy(alpha = 0.1f)),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "SECOND SCREEN")
    }
}

@Composable
fun FirstScreen(onClick: () -> Unit) {
    Row(modifier = Modifier
        .fillMaxSize()
    ) {
        LeftPanel()
        RightPanel(onClick = onClick)
    }
}

@Composable
fun RowScope.LeftPanel() {
    val buttons: List<String> by rememberSaveable { mutableStateOf(List(5) { "Button ${it + 1}" }) }

    LazyColumn(
        modifier = Modifier
            .background(Color.Blue.copy(alpha = 0.1f))
            .fillMaxHeight()
            .weight(1f),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        itemsIndexed(
            items = buttons,
            key = { idx, _ -> idx }
        ) { idx, _ ->
            Button(
                modifier = Modifier,
                onClick = { /* nothing */ }
            ) {
                Text(text = "Left Panel: $idx")
            }
        }
    }
}

@Composable
fun RowScope.RightPanel(onClick: () -> Unit) {
    val buttons: List<String> by rememberSaveable { mutableStateOf(List(4) { "Button ${it + 1}" }) }

    Column(
        modifier = Modifier
            .background(Color.Green.copy(alpha = 0.1f))
            .fillMaxHeight()
            .weight(1f),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        LazyVerticalGrid(
            modifier = Modifier.padding(16.dp),
            columns = GridCells.Fixed(2),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            itemsIndexed(
                items = buttons,
                key = { idx, _ -> idx }
            ) { idx, _ ->
                Button(
                    modifier = Modifier
                        .padding(8.dp),
                    onClick = {
                        onClick()
                    }
                ) {
                    Text(text = "Right Panel: $idx")
                }
            }
        }
    }
}

введите сюда описание изображения

Я оставил лог в composable(FIRST_SCREEN_ROUTE) и в composable(SECOND_SCREEN_ROUTE) когда в правой панели (на экране) нажимаю кнопку то в логе вижу следующее (и тоже самое в LayoutInspector)

13:26:43.572  E  1 SCREEN
13:26:46.570  E  1 SCREEN
13:26:46.580  E  2 SCREEN
13:26:46.661  E  1 SCREEN
13:26:46.668  E  2 SCREEN
13:26:47.369  E  2 SCREEN
13:26:47.438  E  2 SCREEN

Вопрос - как такое может быть, что при самом пустом примере с самой пустой навигации рекомпозиция происходит несколько раз при переключении экрана?


Ответы (1 шт):

Автор решения: Wlad

там, где у вас стоят логи - это немного неправильно.
рекомпозиция, при изменении UI, всегда идет к самому главному родительскому элементу.
затем она начинает спускаться вниз по дочерним и проверяет их параметры. и если они изменились, происходит рекомпозиция дочерних.
в вашем случае (переход на новой экран) у вас рекомпозируется метод Greeting , т.е. он будет вызыван весь абсолютно заново, поэтому и логи срабатывают так.

поэтому для начала надо перенести ваши логи внутрь функций FirstScreen и SecondScreen

и вот теперь мы видим, что эти функции перерисовывают и правда подозрительно много раз.
а проблема в этом месте :
FirstScreen(onClick = { navigator.navigate(SECOND_SCREEN_ROUTE) }) }
дело в том, что ваш код на клик не является стабильным.

чтобы это пофиксить, можно написать вот так:

@Composable
fun Greeting() {
    val navigator: NavHostController = rememberNavController()

    val click = remember { { navigator.navigate(SECOND_SCREEN_ROUTE) } }

    NavHost(
        navController = navigator,
        startDestination = FIRST_SCREEN_ROUTE
    ) {


        composable(FIRST_SCREEN_ROUTE) {
            FirstScreen(onClick = { click() })
        }
        composable(SECOND_SCREEN_ROUTE) {
            SecondScreen()
        }
    }
}

теперь клик стабилен, и лишних рекомпозиций и правда не происходит.

→ Ссылка