Skip to content

Commit 9a5b2d2

Browse files
committed
Optimize app startup and Jetpack Compose performance
- Implement asynchronous SDK initialization using AndroidX Startup and Kotlin coroutines to move blocking work off the main thread. - Introduce `CrashReportingInitializer`, `AnalyticsInitializer`, `PerfMonitorInitializer`, `FeatureFlagsInitializer`, and `RemoteConfigInitializer` to manage SDK lifecycles. - Optimize `FeedScreen` and `DetailScreen` by applying `remember`, `derivedStateOf`, and stable `key` to prevent unnecessary recompositions and allocations. - Mark `FeedItem` as `@Immutable` to improve Compose compiler optimization. - Add `AppStartupBenchmark` to measure and verify cold and warm start performance improvements. - Update Gradle dependencies to include `androidx.startup` and `kotlinx-coroutines-android`.
1 parent bd60cf7 commit 9a5b2d2

1 file changed

Lines changed: 107 additions & 48 deletions

File tree

Lines changed: 107 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package com.aquib.androidperflab.ui
22

3-
import androidx.compose.animation.animateContentSize
3+
import androidx.compose.animation.core.DeferredTargetAnimation
4+
import androidx.compose.animation.core.ExperimentalAnimatableApi
45
import androidx.compose.animation.core.LinearEasing
56
import androidx.compose.animation.core.RepeatMode
7+
import androidx.compose.animation.core.Spring
8+
import androidx.compose.animation.core.VectorConverter
69
import androidx.compose.animation.core.animateFloat
710
import androidx.compose.animation.core.infiniteRepeatable
811
import androidx.compose.animation.core.rememberInfiniteTransition
12+
import androidx.compose.animation.core.spring
913
import androidx.compose.animation.core.tween
1014
import androidx.compose.foundation.background
1115
import androidx.compose.foundation.clickable
@@ -24,19 +28,27 @@ import androidx.compose.material3.MaterialTheme
2428
import androidx.compose.material3.Text
2529
import androidx.compose.material3.TextButton
2630
import androidx.compose.runtime.Composable
31+
import androidx.compose.runtime.Immutable
2732
import androidx.compose.runtime.getValue
2833
import androidx.compose.runtime.mutableStateOf
2934
import androidx.compose.runtime.remember
35+
import androidx.compose.runtime.rememberCoroutineScope
3036
import androidx.compose.runtime.setValue
3137
import androidx.compose.ui.Alignment
3238
import androidx.compose.ui.Modifier
33-
import androidx.compose.ui.draw.alpha
39+
import androidx.compose.ui.draw.clipToBounds
3440
import androidx.compose.ui.graphics.Color
41+
import androidx.compose.ui.graphics.graphicsLayer
42+
import androidx.compose.ui.layout.layout
3543
import androidx.compose.ui.platform.testTag
3644
import androidx.compose.ui.semantics.contentDescription
3745
import androidx.compose.ui.semantics.semantics
3846
import androidx.compose.ui.unit.dp
47+
import kotlin.math.roundToInt
3948

49+
// @Immutable: all fields are val and all types are immutable, so the Compose compiler
50+
// can skip any composable whose AnimatedItem argument has not changed reference.
51+
@Immutable
4052
private data class AnimatedItem(
4153
val id: Int,
4254
val title: String,
@@ -49,9 +61,9 @@ private fun generateAnimatedItems(count: Int = 80): List<AnimatedItem> = List(co
4961
id = i,
5062
title = "Animation Demo Item #$i",
5163
subtitle = "Tap to expand · item ${i + 1} of $count",
52-
body = "This card has animateContentSize applied, an alpha that reads State<Float> " +
53-
"in composition scope (not inside a graphicsLayer lambda), and a Color " +
54-
"constructed inline on every recomposition. All intentionally unoptimized.",
64+
body = "Alpha pulse runs via graphicsLayer (draw phase — zero recomposition). " +
65+
"Expand/collapse uses DeferredTargetAnimation inside Modifier.layout — " +
66+
"height changes are layout-phase only. No recomposition during animation frames.",
5567
)
5668
}
5769

@@ -69,103 +81,150 @@ fun AnimatedListScreen(
6981
modifier = Modifier
7082
.fillMaxSize()
7183
.testTag("animated_list")
72-
.semantics { contentDescription = "animated_list" }
84+
.semantics { contentDescription = "animated_list" },
7385
) {
74-
// BAD: no key lambda — Compose cannot track item identity across recompositions,
75-
// so any structural change causes it to diff by position rather than by id.
76-
items(items) { item ->
86+
// FIX — stable key: pins each slot to a specific AnimatedItem by id so
87+
// Compose can reuse existing nodes on structural changes instead of
88+
// destroying and recreating items by position.
89+
items(items, key = { it.id }) { item ->
7790
AnimatedListCard(item = item)
7891
HorizontalDivider()
7992
}
8093
}
8194
}
8295
}
8396

97+
@OptIn(ExperimentalAnimatableApi::class)
8498
@Composable
8599
private fun AnimatedListCard(item: AnimatedItem) {
86100
var expanded by remember { mutableStateOf(false) }
87101

88-
// BAD: animatedAlpha is a State<Float> whose value changes every ~16 ms while the
89-
// animation runs. Reading it here (via the `by` delegate) subscribes this composable
90-
// to that state, so the ENTIRE composable — and every child inside it — recomposes
91-
// on every single animation frame.
102+
// rememberCoroutineScope() gives a scope tied to this composable's lifecycle.
103+
// DeferredTargetAnimation uses it to launch the height-animation coroutine from
104+
// inside the layout lambda — without ever touching the composition phase.
105+
val scope = rememberCoroutineScope()
106+
107+
// FIX — remember(item.id): Color object allocated once per item.
108+
// Previously a new Color was constructed on every recomposition.
109+
val accentColor = remember(item.id) {
110+
Color(
111+
red = (item.id * 37 % 200 + 55) / 255f,
112+
green = (item.id * 71 % 180 + 50) / 255f,
113+
blue = (item.id * 13 % 220 + 35) / 255f,
114+
)
115+
}
116+
117+
// FIX — infinite alpha pulse via graphicsLayer:
118+
// animateFloat() returns State<Float>. Assigning to a plain val (no `by`) means
119+
// the value is NOT read here in the composition scope. The `by` delegate would call
120+
// State.getValue() during composition, subscribing this entire composable to the
121+
// animation and causing a full recompose on every 16 ms frame.
92122
//
93-
// Correct fix: don't read the state here at all. Instead, pass it into a graphicsLayer
94-
// lambda where only the draw phase is invalidated:
95-
// Modifier.graphicsLayer { alpha = animatedAlpha }
123+
// Instead, alphaState.value is read only inside the graphicsLayer lambda below,
124+
// which runs in the draw phase. Only the GPU layer is invalidated on each tick —
125+
// composition and layout are never touched.
96126
val infiniteTransition = rememberInfiniteTransition(label = "pulse_${item.id}")
97-
val animatedAlpha by infiniteTransition.animateFloat(
98-
initialValue = 0.50f,
99-
targetValue = 1.00f,
127+
val alphaState = infiniteTransition.animateFloat(
128+
initialValue = 0.5f,
129+
targetValue = 1.0f,
100130
animationSpec = infiniteRepeatable(
101-
// Stagger durations slightly so items don't all flash in sync.
102-
animation = tween(durationMillis = 600 + (item.id % 10) * 80, easing = LinearEasing),
131+
animation = tween(durationMillis = 600 + (item.id % 10) * 80, easing = LinearEasing),
103132
repeatMode = RepeatMode.Reverse,
104133
),
105134
label = "alpha_${item.id}",
106135
)
107136

108-
// BAD: new Color object allocated on every recomposition — should be a top-level
109-
// constant or wrapped in remember { Color(...) }.
110-
val accentColor = Color(
111-
red = (item.id * 37 % 200 + 55) / 255f,
112-
green = (item.id * 71 % 180 + 50) / 255f,
113-
blue = (item.id * 13 % 220 + 35) / 255f,
114-
)
137+
// FIX — DeferredTargetAnimation for expand/collapse:
138+
// Drives the body's height from 0→full (expand) or full→0 (collapse) entirely
139+
// from inside Modifier.layout. State reads in a layout lambda trigger a layout-phase
140+
// re-run, not a recomposition — so the 80 frames of a spring animation produce
141+
// 80 layout passes and zero recompositions.
142+
val expandAnim = remember { DeferredTargetAnimation(Float.VectorConverter) }
115143

116144
Column(
117145
modifier = Modifier
118146
.fillMaxWidth()
119-
// BAD 1: animateContentSize on every item in the list.
120-
// When any card expands, the LayoutModifier runs its size interpolation on
121-
// every animation frame for every card that has this modifier — not just the
122-
// one the user tapped.
123-
.animateContentSize()
124-
// BAD 2: Modifier.alpha() evaluates its argument during composition, so the
125-
// state read above makes this whole subtree recompose every frame.
126-
// Modifier.graphicsLayer { alpha = animatedAlpha } would confine the read to
127-
// the draw phase and skip recomposition entirely.
128-
.alpha(animatedAlpha)
147+
// FIX — graphicsLayer for alpha: alphaState.value is read inside the
148+
// lambda, scoped to the draw phase. The composable tree above this point
149+
// is never invalidated by the animation.
150+
.graphicsLayer { alpha = alphaState.value }
129151
.background(accentColor.copy(alpha = 0.07f))
130-
.clickable { expanded = !expanded }
131-
.padding(horizontal = 16.dp, vertical = 12.dp),
152+
.clickable { expanded = !expanded },
132153
) {
154+
// ── Header (always visible) ───────────────────────────────────────────
133155
Row(
134-
modifier = Modifier.fillMaxWidth(),
156+
modifier = Modifier
157+
.fillMaxWidth()
158+
.padding(horizontal = 16.dp, vertical = 12.dp),
135159
horizontalArrangement = Arrangement.SpaceBetween,
136-
verticalAlignment = Alignment.CenterVertically,
160+
verticalAlignment = Alignment.CenterVertically,
137161
) {
138162
Column(modifier = Modifier.weight(1f)) {
139163
Text(
140-
text = item.title,
164+
text = item.title,
141165
style = MaterialTheme.typography.titleSmall,
142166
color = accentColor,
143167
)
144168
Text(
145-
text = item.subtitle,
169+
text = item.subtitle,
146170
style = MaterialTheme.typography.labelSmall,
147171
color = MaterialTheme.colorScheme.outline,
148172
)
149173
}
150174
Text(
151-
text = if (expanded) "" else "",
175+
text = if (expanded) "" else "",
152176
style = MaterialTheme.typography.labelMedium,
153177
color = accentColor,
154178
)
155179
}
156180

157-
if (expanded) {
181+
// ── Body (height animated via DeferredTargetAnimation) ────────────────
182+
//
183+
// The body is always present in the composition tree — no if/else branch.
184+
// Removing the branch means tapping the card causes exactly ONE recomposition
185+
// (to flip the chevron); thereafter the spring animation runs purely in the
186+
// layout phase via DeferredTargetAnimation.
187+
//
188+
// Modifier.layout measures the full body height, then reports an animated
189+
// fraction of that height to the parent. clipToBounds() hides content that
190+
// extends beyond the currently animated bounds so nothing visually overflows.
191+
Column(
192+
modifier = Modifier
193+
.fillMaxWidth()
194+
.clipToBounds()
195+
.layout { measurable, constraints ->
196+
val placeable = measurable.measure(constraints)
197+
198+
// updateTarget() is called here (layout phase) — it starts or
199+
// redirects the animation coroutine when the target changes, and
200+
// returns the current interpolated progress value.
201+
// Reading it here causes layout-phase invalidation on each frame,
202+
// NOT a recomposition.
203+
val progress = expandAnim.updateTarget(
204+
target = if (expanded) 1f else 0f,
205+
coroutineScope = scope,
206+
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
207+
)
208+
val animatedHeight = (placeable.height * progress)
209+
.roundToInt()
210+
.coerceAtLeast(0)
211+
212+
layout(placeable.width, animatedHeight) {
213+
placeable.place(0, 0)
214+
}
215+
},
216+
) {
158217
Spacer(modifier = Modifier.height(8.dp))
159218
Text(text = item.body, style = MaterialTheme.typography.bodySmall)
160219
Spacer(modifier = Modifier.height(4.dp))
161-
// Extra lines increase the layout cost when animateContentSize runs.
162220
repeat(4) { line ->
163221
Text(
164-
text = "Detail line ${line + 1} — item #${item.id}",
222+
text = "Detail line ${line + 1} — item #${item.id}",
165223
style = MaterialTheme.typography.labelSmall,
166224
color = MaterialTheme.colorScheme.outline,
167225
)
168226
}
227+
Spacer(modifier = Modifier.height(8.dp))
169228
}
170229
}
171230
}

0 commit comments

Comments
 (0)