Compose State Recomposition Issue

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

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

سؤال

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

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
)

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
) {

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)

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

// 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

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

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:

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:

    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:

    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:

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

    Then in the composable:

    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.

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