Compose State Recomposition Issue

تم الإنشاء في: ١٧ يناير ٢٠٢٥

تمت الإجابة باستخدام O1 بواسطة Chat01

سؤال

@Composable
fun ISSMComposableStatistics(
navController: NavController? = null,
premiseViewModel: PremiseViewmodel,
tariffViewModel: TariffViewModel
) {

text
val context = LocalContext.current val view = LocalView.current val window = (view.context as Activity).window window.statusBarColor = White.toArgb() val scrollState = rememberScrollState() WindowInsetsControllerCompat(window, view).isAppearanceLightStatusBars = true val statisticsViewModel: ISSMStatisticsViewModel = hiltViewModel() val navigationViewModel: ISSMNavigationViewModel = hiltViewModel() val connectivityViewModel: ConnectivityViewModel = hiltViewModel() val dbOrderViewModel: DBOrderViewModel = hiltViewModel() LaunchedEffect(Unit) { window?.let { WindowCompat.setDecorFitsSystemWindows(it, true) } resetMissingConnectionStateState() statisticsViewModel.observeConnectivityState( connectivityViewModel.isOnline, connectivityViewModel::isNetworkAvailable ) statisticsViewModel.getBothStats(_id) } DisposableEffect(Unit) { onDispose { statisticsViewModel.cleanup() } } val state = statisticsViewModel.combinedStats.observeAsState() when (val statsState = state.value) { is CombinedStatisticsUIState.Loading -> { ShimmerAnimationStatistics() } is CombinedStatisticsUIState.Success -> { Timber.tag("ListApi").d("loadPremise & loadTariffs called from Stats Screen") premiseViewModel.loadPremise() tariffViewModel.loadTariffs() Column( modifier = Modifier .fillMaxSize() .background(White) ) { Box( modifier = Modifier .fillMaxSize() .background(color = Color.Transparent) ){ Column(modifier = Modifier.padding(16.dp).verticalScroll(scrollState)) { UserInfoCard( connectivityViewModel = connectivityViewModel ) var selectedTabIndex by remember { mutableStateOf(0) } val tabs = listOf("Current Statistics", "Overall Statistics") Column { TabRow( selectedTabIndex = selectedTabIndex, containerColor = White, contentColor = Color(0xFFE91E63), // Pink color from the image indicator = { tabPositions -> SecondaryIndicator( modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), height = 2.dp, color = Color(0xFFE91E63) ) } ) { tabs.forEachIndexed { index, title -> Tab( selected = selectedTabIndex == index, onClick = { selectedTabIndex = index }, text = { Text( text = title, color = if (selectedTabIndex == index) Color(0xFFE91E63) else Color.Gray ) } ) } } when (selectedTabIndex) { 0 -> { val totalPending = statsState.currentStats.pendingInitialReadOrders + statsState.currentStats.pendingCheckReadOrders TotalOrdersCard( totalOrders = statsState.currentStats.totalOrders, initialReadOrders = statsState.currentStats.pendingInitialReadOrders, checkReadOrders = statsState.currentStats.pendingCheckReadOrders, pendingOrders = totalPending, employeeDoneOrders = statsState.currentStats.employeeDone, postedToPortalOrders = statsState.currentStats.postedToPortal, ) Spacer(modifier = Modifier.height(10.dp)) Column( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp), horizontalAlignment = Alignment.Start ) { Text( text = "Status", fontSize = 18.sp, fontFamily = Assistant, fontWeight = FontWeight.SemiBold, color = Color.Black ) } // Status Card val validProgress = if (statsState.currentStats.progress == "NaN") "0.0" else statsState.currentStats.progress StatusCard(progress = validProgress) Spacer(modifier = Modifier.height(8.dp)) // View Order Button Box( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { ISSM_Button( heading = "View Current Orders", onClicked = { navigationViewModel.navigateToScreen( navController = navController, route = NavScreen.ScreenSubOffice.route ) } ) } } 1 -> { val totalPending = statsState.overallStats.pendingInitialReadOrders + statsState.overallStats.pendingCheckReadOrders TotalOrdersCard( totalOrders = statsState.overallStats.totalOrders, initialReadOrders = statsState.overallStats.pendingInitialReadOrders, checkReadOrders = statsState.overallStats.pendingCheckReadOrders, pendingOrders = totalPending, employeeDoneOrders = statsState.overallStats.employeeDone, postedToPortalOrders = statsState.overallStats.postedToPortal, picturesTaken = statsState.overallStats.pictureTaken, ) } } } } Box( modifier = Modifier.align(Alignment.TopStart) ) { DrawerButton(navController = navController) } } } } is CombinedStatisticsUIState.Error -> { ISSM_Logout_Dialog( onDismissRequest = { }, onConfirmation = { PreferencesManager.removeAccessToken(context = context) PreferencesManager.removeEmployeeIdToken(context = context) navigationViewModel.navigateToScreen( navController = navController, route = NavScreen.ScreenLogin.route, popUpTo = null, inclusive = true, launchSingleTop = true, popUpToId = 0 ) dbOrderViewModel.deleteAllData() val activity = context as? MainActivity activity?.checkAndStopLocationServiceIfLoggedOut() }, onRetry = { statisticsViewModel.getBothStats(_id) }, icon = 0, type = "Logout", heading = statsState.error, subHeading = "Try logging in again!" ) } null -> Text("Waiting for Data...") }

}

@Composable
fun TotalOrdersCard(
totalOrders: Int,
initialReadOrders: Int,
checkReadOrders: Int,
pendingOrders: Int,
employeeDoneOrders: Int,
postedToPortalOrders: Int,
picturesTaken: Int? = null,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Total Orders Count
Text(
text = totalOrders.toString(),
fontSize = 42.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.SansSerif,
color = Color(0xFFD2386C) // Pink Color
)
Text(
text = "Total Orders",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.SansSerif,
color = Color.Gray
)

text
Spacer(modifier = Modifier.height(22.dp)) // Reads Row Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) // Adjust spacing ) { Column( modifier = Modifier .background(color = Color(0xFFFBFBFB), shape = RoundedCornerShape(8.dp)) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = "Initial Read", fontSize = 14.sp, fontWeight = FontWeight.Medium, fontFamily = FontFamily.SansSerif, color = Color.Gray ) Text( text = initialReadOrders.toString(), fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.SansSerif, color = Color.Black ) } Column( modifier = Modifier .background(color = Color(0xFFFBFBFB), shape = RoundedCornerShape(8.dp)) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = "Check Read", fontSize = 14.sp, fontWeight = FontWeight.Medium, fontFamily = FontFamily.SansSerif, color = Color.Gray ) Text( text = checkReadOrders.toString(), fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.SansSerif, color = Color.Black ) } } Spacer(modifier = Modifier.height(16.dp)) // Additional Info Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(9.dp) ) { DetailRow(label = "Pending", value = pendingOrders.toString()) DetailRow(label = "Employee Done", value = employeeDoneOrders.toString()) DetailRow(label = "Posted to portal", value = postedToPortalOrders.toString()) if(picturesTaken != null){ DetailRow(label = "Pictures Taken", value = picturesTaken.toString()) } } } }

}

@Composable
fun DetailRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.SansSerif,
color = Color.Gray
)
Text(
text = value,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.SansSerif,
color = Color.Black
)
}
}

@Composable
fun StatusCard(
progress: String
) {

text
Card( modifier = Modifier .fillMaxWidth() .padding(16.dp), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = Color.White), elevation = CardDefaults.cardElevation(2.dp) ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(14.dp)) Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { CircularProgressBarWithPercentage( progress = progress.toDouble(), // Replace with your dynamic value circleColor = PrimaryColor, trackColor = Color.LightGray, textColor = Color.Black ) } Spacer(modifier = Modifier.height(18.dp)) Text( text = "You have achieved", fontSize = 16.sp, fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, color = Color.Black ) Text( text = "${progress}% of your goal today", fontSize = 16.sp, fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, color = Color.Black ) } }

}

@Composable
fun UserInfoCard(
connectivityViewModel: ConnectivityViewModel
) {
val userDetails = ISSMSingleton.getLoggedUser()
val calendar = Calendar.getInstance()
val currentDay = calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.getDefault()) ?: "Unknown"
val formatter = SimpleDateFormat("dd-MM-yyyy", Locale.getDefault())
val formattedDate = formatter.format(calendar.time)

text
val isOnline by connectivityViewModel.isOnline.collectAsState() val loggedUser = ISSMSingleton.getLoggedUser() Row( modifier = Modifier .fillMaxWidth() .padding(top = 22.dp, start = 66.dp, bottom = 16.dp, end = 16.dp), horizontalArrangement = Arrangement.SpaceBetween ) { Column( modifier = Modifier.weight(1f) ) { Text( text = userDetails.userFirstName + " " + userDetails.userLastName, fontSize = 24.sp, fontFamily = Assistant, fontWeight = FontWeight.Bold, color = Color.Black ) if (userDetails.userPhone.isNotEmpty()){ Text( text = userDetails.userPhone, fontSize = 16.sp, fontFamily = Assistant, color = Color.Gray ) } Text( text = "$currentDay - $formattedDate", fontSize = 12.sp, fontFamily = Assistant, color = Color.Gray ) } Box( modifier = Modifier.size(50.dp) // Size of the entire container ) { // Circular user image GlideImage( imageRes = loggedUser.userImage, contentDescription = Const_Profile_Image_Desc, modifier = Modifier .size(50.dp) .clip(CircleShape) // Clip to the circle shape .background(Color.Gray) ) // Online/Offline indicator Box( modifier = Modifier .size(12.dp) // Size of the indicator .clip(CircleShape) // Circular shape .background(if (isOnline) Color.Green else Color.Red) // Dynamic color .align(Alignment.TopEnd) // Position at the top-right corner .padding(4.dp) // Optional: Adjust padding as needed ) } }

}

@Composable
fun CircularProgressBarWithPercentage(
progress: Double,
circleColor: Color = Color(0xFF6200EE), // Customize as needed
trackColor: Color = Color(0xFFEEEEEE),
textColor: Color = Color.Black,
modifier: Modifier = Modifier.size(100.dp) // Default size of the circle
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
// Track (background circle)
Canvas(modifier = Modifier.fillMaxSize()) {
drawArc(
color = trackColor,
startAngle = -90f, // Start from top
sweepAngle = 360f, // Full circle
useCenter = false,
style = Stroke(width = 12.dp.toPx(), cap = StrokeCap.Round)
)
}

text
// Progress (foreground circle) Canvas(modifier = Modifier.fillMaxSize()) { drawArc( color = circleColor, startAngle = -90f, // Start from top sweepAngle = (progress / 100 * 360).toFloat(), // Calculate the angle based on progress useCenter = false, style = Stroke(width = 12.dp.toPx(), cap = StrokeCap.Round) ) } // Percentage Text Text( text = "${progress}%", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = textColor ) }

}


the logcat shows

2025-01-17 22:15:29.904 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise & loadTariffs called from Stats Screen
2025-01-17 22:15:29.904 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise called
2025-01-17 22:15:29.907 28058-28058 ListApi ai.issm.issm_fsm_app D loadTariffs called
2025-01-17 22:15:30.110 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise & loadTariffs called from Stats Screen
2025-01-17 22:15:30.110 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise called
2025-01-17 22:15:30.113 28058-28058 ListApi ai.issm.issm_fsm_app D loadTariffs called
2025-01-17 22:15:32.552 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise & loadTariffs called from Stats Screen
2025-01-17 22:15:32.552 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise called
2025-01-17 22:15:32.556 28058-28058 ListApi ai.issm.issm_fsm_app D loadTariffs called

meaning the code

text
premiseViewModel.loadPremise() tariffViewModel.loadTariffs()

gets calls again although the CombinedStatisticsUIState.Success happens once. I have noticed that when i perform an action inside the statistics screen eg click on the View Current Orders button to naviagte to the next screen, then it calls the

text
premiseViewModel.loadPremise() tariffViewModel.loadTariffs()

again

الفكرة

Balancing recurrence

It's worth noting that in Compose, functions like loadPremise() or loadTariffs() might be called with each composable recomposition, potentially causing multiple executions even within a single block.

Thought about repeated view model calls for 9 seconds

الإجابة

In Jetpack Compose, any code inside your when block (or more generally, any composable) can be re-run on every recomposition. Whenever you navigate away and come back, or trigger a state change that causes recomposition, the CombinedStatisticsUIState.Success -> { ... } block may re-run and therefore call:

kotlin
premiseViewModel.loadPremise() tariffViewModel.loadTariffs()

multiple times.


Why it happens

  • Recomposition: Jetpack Compose uses a declarative UI paradigm. Whenever a state change is observed, the composables that read that state get recomposed. If your code is placed inside a composable (or a branch of a when statement in a composable) without guards, it will be re-invoked each time that composable is recomposed.

  • Navigation triggers: If you navigate to another screen and back, you can also trigger a recomposition of the previous screen. In your logs, it looks like each time you navigate (or do some action that changes UI state), the composable is recomposed, and your when block is called again, hence the repeated logs.


How to fix it

  1. Move side-effect calls into a LaunchedEffect:

    The most direct Compose-friendly solution is to move calls like premiseViewModel.loadPremise() and tariffViewModel.loadTariffs() into a side-effect that runs once when the CombinedStatisticsUIState.Success state is first observed.

    For example, if your combined stats have a stable id or something you can key off (or you just want to run once on success), you could do something like:

    kotlin
    when (val statsState = state.value) { is CombinedStatisticsUIState.Success -> { // Moved these calls into a LaunchedEffect so that // they don't get triggered on every recomposition. LaunchedEffect(Unit) { Timber.tag("ListApi").d("loadPremise & loadTariffs called from Stats Screen") premiseViewModel.loadPremise() tariffViewModel.loadTariffs() } Column(/* ... UI ... */) { // ... } } // ... }

    Since LaunchedEffect(Unit) has a stable key (the same value of Unit each time), it will run exactly once for that part of the composition's lifecycle. If you only want it to re-run when your statsState changes, you could do:

    kotlin
    when (val statsState = state.value) { is CombinedStatisticsUIState.Success -> { LaunchedEffect(statsState) { premiseViewModel.loadPremise() tariffViewModel.loadTariffs() } // ... } // ... }

    This will run whenever statsState changes, but not on any other re-compositions triggered by local state changes (like clicking a button).

  2. Guard with a boolean flag in the ViewModel:

    Another approach is to have some boolean in your premiseViewModel or tariffViewModel that tracks whether the data has already been loaded. For example, your premiseViewModel could have something like:

    kotlin
    private var premiseLoaded = false fun loadPremiseIfNeeded() { if (!premiseLoaded) { premiseLoaded = true loadPremise() // your existing logic } }

    Then in the composable:

    kotlin
    when (val statsState = state.value) { is CombinedStatisticsUIState.Success -> { premiseViewModel.loadPremiseIfNeeded() tariffViewModel.loadTariffsIfNeeded() // ... } }

    This ensures each is only triggered once, no matter how many recompositions occur.

  3. Dispatch from the ViewModel:

    Instead of making the call from the UI, you could design your ViewModel to load premises and tariffs automatically once the stats are successfully fetched (i.e., chain your asynchronous events). In other words, once you get the success result in your ISSMStatisticsViewModel, you can tell your premiseViewModel and tariffViewModel to fetch their data, removing the need for the UI to call them at all. This can keep your UI stateless and side-effect-free.


Which approach to choose?

  • LaunchedEffect is the most direct, Compose-centric way. It makes it explicit that “once I have these success stats, run this side-effect once.”
  • A boolean guard in the ViewModel helps you avoid repeated calls if the ViewModel is shared across composables or if you’re returning to the same screen often.
  • Dispatching from the ViewModel centralizes the business logic in the ViewModel, keeping UI as a passive viewer of state.

Any of these solutions will eliminate the repeated calls you see in Logcat and ensure loadPremise() and loadTariffs() only happen once unless you explicitly want them to re-run.

مشاركة هذا السؤال والجواب