<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Android Expert]]></title><description><![CDATA[Android Engineer & Kotlin Advocate. 🚀 Deep dives into Jetpack Compose, Coroutines, and Clean Architecture. Turning complex code into scalable solutions. 🛠️✨]]></description><link>https://android-expert.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:24:50 GMT</lastBuildDate><atom:link href="https://android-expert.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Part 5: Mirror With Intent: Building Right-to-Left Ready Screens in Jetpack Compose]]></title><description><![CDATA[Build RTL-ready Jetpack Compose apps: start/end layouts, auto-mirrored icons, bidi text, gestures, Canvas and RTL testing, plus a release checklist.]]></description><link>https://android-expert.hashnode.dev/part-5-mirror-with-intent-building-right-to-left-ready-screens-in-jetpack-compose</link><guid isPermaLink="true">https://android-expert.hashnode.dev/part-5-mirror-with-intent-building-right-to-left-ready-screens-in-jetpack-compose</guid><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[rtl]]></category><category><![CDATA[localization]]></category><category><![CDATA[internationalization]]></category><category><![CDATA[Android Accessibility]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Tue, 03 Nov 2026 01:46:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/ea7aa78e-093b-4bd4-ac3a-cf68dfcd5f4e.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Build for Everyone · Part 5 of 5</em></p>
<p><em>Previously: Parts 1 to 4 made screens readable, operable, testable, and alive for assistive technology. This final part is about a different kind of inclusion: the hundreds of millions of people who read Arabic, Hebrew, Persian, or Urdu, right to left.</em></p>
<blockquote>
<p><em><strong>TL;DR</strong></em> <em>Compose mirrors most layouts for free,</em> <em>as long as you say start and end, never</em> left <em>and</em> right*.*** <em>The rest of RTL support is judgement. Directional icons must flip; play buttons, checkmarks, and clocks must not. Phone numbers and English product names inside Arabic sentences need isolating. Raw gesture math, slide animations, and custom</em> <code>Canvas</code> <em>drawing don't mirror on their own. Test all of it in minutes with a forced-RTL preview and the</em> <code>ar-XB</code> <em>pseudolocale</em>, <em>then lock the result in with a layout assertion and reviewed LTR/RTL screenshot baselines.</em></p>
</blockquote>
<h2>The back arrow that points the wrong way</h2>
<p>An Arabic launch goes out. The layouts mirror nicely: titles sit on the right, lists align to the right, and the navigation icon moves to the top-right corner. Then a reviewer posts a screenshot. The back arrow, now on the right edge, still points <strong>left</strong>, deeper into the screen, which in an RTL layout means <em>forward</em>.</p>
<p>The cause is one line from years ago:</p>
<pre><code class="language-kotlin">// ❌ A fixed, left-pointing arrow. It moves with the layout but never turns around.
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_navigate_up))
</code></pre>
<img src="https://cdn-images-1.medium.com/max/800/1*jhEXCNdLtjP1VNAfHuyetQ.png" alt="back-arrow-bug" style="display:block;margin:0 auto" />

<p>It’s a small bug that tells every RTL user the app was never designed with them in mind. It also confuses screen reader users: TalkBack says <em>“Navigate up”</em> about an arrow that visually points forward. The fix is one word:</p>
<pre><code class="language-kotlin">// ✅ Flips automatically in RTL layouts
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_navigate_up))
</code></pre>
<p>The <code>AutoMirrored</code> icon set has existed since material-icons 1.6, and the old directional icons are deprecated in favour of it. If your build shows deprecation warnings for <code>Icons.Filled.ArrowBack</code>, that's your RTL backlog announcing itself.</p>
<h2>How Compose handles layout direction</h2>
<p>Layout direction is part of the composition, exposed as <code>LocalLayoutDirection</code>. Compose derives it from the device configuration: an Arabic or Hebrew locale means <code>LayoutDirection.Rtl</code>. Every direction-aware API reads it, so <code>start</code> resolves to the left edge in English and to the right edge in Arabic, with no code on your part.</p>
<p>There’s one switch upstream of all of this. Your manifest must opt in:</p>
<pre><code class="language-xml">&lt;application
    android:supportsRtl="true"
    ... &gt;
</code></pre>
<p>Without it, the configuration reports left-to-right for every locale, and nothing in this post applies. New projects set it by default. Older apps, and some white-label templates, quietly don’t.</p>
<p>The rule that follows is simple: <strong>use the start/end API, and treat every left/right API as a decision you need to justify.</strong></p>
<img src="https://cdn-images-1.medium.com/max/800/1*JmcjxN3f8AswTQu-pJMo6g.png" alt="start-end-vs-absolute" style="display:block;margin:0 auto" />

<pre><code class="language-kotlin">@Composable
fun IngredientRow(ingredient: Ingredient, onRemove: () -&gt; Unit, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(start = 16.dp, end = 8.dp),       // ✅ not absolutePadding(left = 16.dp)
        horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.Start),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(
            text = ingredient.quantityLabel,
            textAlign = TextAlign.End,                   // ✅ not TextAlign.Right
            modifier = Modifier.width(56.dp),
        )
        Text(ingredient.name, modifier = Modifier.weight(1f))
        IconButton(onClick = onRemove) {
            Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.cd_remove_ingredient, ingredient.name))
        }
    }
}
</code></pre>
<p>Two details catch even experienced teams:</p>
<ul>
<li><code>Modifier.offset(x = …)</code> <strong>mirrors.</strong> Its documentation states that a positive <code>x</code> moves content right in LTR and left in RTL, toward the <em>end</em>. That's usually what you want, for example nudging a badge toward the edge of an icon. When an offset is genuinely physical, such as aligning with a fixed piece of artwork, use <code>absoluteOffset</code> and leave a comment explaining why.</li>
<li><strong>Reading order follows direction too.</strong> Part 1 explained that TalkBack orders siblings top to bottom, then start to end. Use start/end APIs and TalkBack reads Arabic screens right to left automatically. Hard-code absolute positions and you break visual order <em>and</em> reading order in one go.</li>
</ul>
<h2>Icons: flip the ones that point</h2>
<p>An icon should mirror when it shows <strong>direction through the reading flow</strong>: back, forward, next, previous, send, list indentation, or a chevron that opens something “after” the current item. An icon should <em>not</em> mirror when its direction comes from the physical world or a universal convention.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*-aEzjBrXmjgWGIpFEH2loQ.png" alt="icon-mirroring" style="display:block;margin:0 auto" />

<ol>
<li><strong>Mirror:</strong> back and forward arrows, chevrons for navigation, send, undo and redo, “next step” indicators, text-indent icons.</li>
<li><strong>Don’t mirror:</strong></li>
</ol>
<ul>
<li><strong>Media playback controls.</strong> Play, fast-forward, and rewind describe the direction of the media’s timeline, not of reading.</li>
<li><strong>Checkmarks.</strong> A checkmark is a universal symbol, not a direction.</li>
<li><strong>Clocks and timers.</strong> Clock hands turn the same way in every country.</li>
<li><strong>Logos, and any icon containing Latin letters or numbers.</strong></li>
</ul>
<p>For your own vector drawables, the flag lives in the XML:</p>
<pre><code class="language-xml">&lt;!-- res/drawable/ic_reply.xml --&gt;
&lt;vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:autoMirrored="true"
    android:width="24dp" android:height="24dp"
    android:viewportWidth="24" android:viewportHeight="24"&gt;
    &lt;!-- paths --&gt;
&lt;/vector&gt;
</code></pre>
<p>The common failure is the reverse of the opening bug. A well-meaning bulk change adds <code>autoMirrored="true"</code> to <em>every</em> drawable, and suddenly the play button in your Arabic build points backwards. Review mirroring per icon, not per folder.</p>
<h2>Edge cases: where direction hides</h2>
<h2>Mixed-direction text</h2>
<p>Arabic sentences regularly contain English: product names, usernames, brand names. Unicode’s bidirectional algorithm usually places them correctly. It goes wrong at the <strong>edges</strong>, when an inserted LTR fragment starts or ends with numbers or punctuation. Then the surrounding RTL text can grab those characters and reorder them. The fix is to <strong>isolate</strong> the inserted value so it’s laid out as one unit:</p>
<pre><code class="language-kotlin">import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.os.ConfigurationCompat
import androidx.core.text.BidiFormatter
import java.util.Locale

@Composable
fun rememberBidiFormatter(): BidiFormatter {
    val locale = ConfigurationCompat.getLocales(LocalConfiguration.current)[0] ?: Locale.getDefault()
    return remember(locale) { BidiFormatter.getInstance(locale) }
}

@Composable
fun OrderConfirmation(productName: String) {
    val bidi = rememberBidiFormatter()
    // Without unicodeWrap, a name like "Pixel 9 Pro" can have its trailing
    // number pulled into the Arabic text around it.
    Text(stringResource(R.string.order_confirmed, bidi.unicodeWrap(productName)))
}
</code></pre>
<p>Wrap <strong>every runtime value</strong> you insert into a translated string: names, titles, search queries. The cost is negligible, and it removes an entire class of bug reports that are almost impossible to reproduce if you don’t read the language.</p>
<h2>Numbers, phone numbers, and charts</h2>
<p>Digits always read left to right, even inside RTL text. What breaks is <strong>sequences with separators</strong>. A phone number like <code>+91 98765 43210</code>, placed in an RTL paragraph, can be displayed with its groups in reverse order. To a reader it's a different number entirely. The same applies to ranges ("10–20"), IBANs, card numbers, and version strings.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*Nmyubp184CnMShkWNWRgbg.png" alt="bidi-numbers" style="display:block;margin:0 auto" />

<p>Force these values to left-to-right explicitly:</p>
<pre><code class="language-kotlin">// Display: isolate as LTR inside any surrounding text
Text(bidi.unicodeWrap(phoneNumber, TextDirectionHeuristicsCompat.LTR))

// Input: keep the field's text LTR while the field itself stays in the RTL layout
OutlinedTextField(
    value = phone,
    onValueChange = onPhoneChange,
    label = { Text(stringResource(R.string.label_phone)) },
    textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Ltr),
    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
)
</code></pre>
<p>Two more number traps:</p>
<ul>
<li><strong>Locale digits.</strong> Formatting a number with the user’s locale may produce Eastern Arabic digits (٠١٢٣…), depending on the locale. That’s correct for display. Never parse displayed text back into numbers, and never build IDs or API parameters from locale-formatted strings.</li>
<li><strong>Charts.</strong> Should a time axis run right to left? There is no universal answer. Many bidirectional design guidelines flip timelines so “later” sits toward the reading end, while many finance apps keep charts LTR because that’s what their users see everywhere else. Decide deliberately, apply it consistently, and ask people who read the language every day.</li>
</ul>
<h2>Gestures, animations, and custom drawing</h2>
<p>This is where layouts that mirror perfectly still feel <em>wrong</em>. Anything built on direction-aware APIs is handled for you. Anything built on raw coordinates is not.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*vJxh3XLWyiVBpwU2T7Jq-w.png" alt="motion-and-drawing" style="display:block;margin:0 auto" />

<p><strong>Handled for you:</strong> <code>HorizontalPager</code>, <code>LazyRow</code>, <code>horizontalScroll</code>, <code>SwipeToDismissBox</code> (its values are <code>StartToEnd</code> and <code>EndToStart</code>), and <code>AnimatedContent</code> transitions built with <code>SlideDirection.Start</code> and <code>SlideDirection.End</code>.</p>
<p><strong>You must flip yourself:</strong></p>
<pre><code class="language-kotlin">// 1. Slide animations with raw pixel offsets
AnimatedContent(
    targetState = step,
    transitionSpec = {
        // ❌ slideInHorizontally { it } always enters from the physical right.
        // ✅ Start/End resolve against the current layout direction.
        val direction = if (targetState &gt; initialState) {
            AnimatedContentTransitionScope.SlideDirection.Start
        } else {
            AnimatedContentTransitionScope.SlideDirection.End
        }
        slideIntoContainer(direction) togetherWith slideOutOfContainer(direction)
    },
    label = "wizard-step",
) { current -&gt; WizardStep(current) }
</code></pre>
<pre><code class="language-kotlin">// 2. Raw drag gestures: dragAmount is in screen coordinates, not reading direction
@Composable
fun Modifier.swipeBetweenPhotos(onNext: () -&gt; Unit, onPrevious: () -&gt; Unit): Modifier {
    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
    val currentOnNext by rememberUpdatedState(onNext)
    val currentOnPrevious by rememberUpdatedState(onPrevious)

    return pointerInput(isRtl) {
        val threshold = 56.dp.toPx()
        var total = 0f
        detectHorizontalDragGestures(
            onDragStart = { total = 0f },
            onDragEnd = {
                // Normalise so that positive always means "toward the end".
                val towardEnd = if (isRtl) -total else total
                when {
                    towardEnd &lt; -threshold -&gt; currentOnNext()      // swiped toward start: next
                    towardEnd &gt; threshold -&gt; currentOnPrevious()
                }
            },
        ) { _, dragAmount -&gt; total += dragAmount }
    }
}
</code></pre>
<p>The <code>rememberUpdatedState</code> calls are there for a reason too. <code>pointerInput</code> is keyed only on direction, so without them the gesture would keep calling whichever lambdas existed on first composition. And per Part 2: any swipe-only interaction also needs a custom accessibility action, such as "Next photo," because swipes aren't available to everyone.</p>
<pre><code class="language-kotlin">// 3. Custom Canvas drawing: coordinates are physical
@Composable
fun ReadingProgressBar(progress: Float, modifier: Modifier = Modifier) {
    val track = MaterialTheme.colorScheme.surfaceVariant
    val fill = MaterialTheme.colorScheme.primary
    Canvas(modifier.fillMaxWidth().height(6.dp)) {
        val mirror = layoutDirection == LayoutDirection.Rtl   // DrawScope knows the direction
        scale(scaleX = if (mirror) -1f else 1f, scaleY = 1f) { // mirror the shapes, not the text
            drawRect(track)
            drawRect(fill, size = Size(size.width * progress.coerceIn(0f, 1f), size.height))
        }
    }
}
</code></pre>
<p>Mirror <strong>shapes</strong> this way, never <strong>text</strong>. A <code>drawText</code> call inside a mirrored <code>scale</code> block renders backwards, letters and all. Draw labels outside the mirrored block, at positions you calculate from <code>layoutDirection</code>.</p>
<h2>Testing RTL in minutes, not sprints</h2>
<p>You don’t need to read Arabic to catch 90% of RTL bugs. You need five habits, from fastest to most thorough:</p>
<img src="https://cdn-images-1.medium.com/max/800/1*ypapGclZUNvLmd9Koge4yg.png" alt="rtl-testing" style="display:block;margin:0 auto" />

<p><strong>1. Previews in both directions.</strong> One extra annotation per component:</p>
<pre><code class="language-kotlin">@Preview(name = "LTR · English", locale = "en")
@Preview(name = "RTL · Arabic", locale = "ar")
@Composable
private fun IngredientRowPreview() {
    AppTheme { IngredientRow(ingredient = sampleIngredient, onRemove = {}) }
}
</code></pre>
<p>For a reusable wrapper that works in tests and screenshot tooling too:</p>
<pre><code class="language-kotlin">@Composable
fun ForceRtl(content: @Composable () -&gt; Unit) {
    CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl, content = content)
}
</code></pre>
<p><strong>2. Pseudolocales.</strong> Enable them for debug builds:</p>
<pre><code class="language-kotlin">android {
    buildTypes.getByName("debug") {
        isPseudoLocalesEnabled = true
    }
}
</code></pre>
<p>Then make <strong>AR (XB)</strong> the active language in your device’s language settings. It renders your <em>English</em> strings right to left, with the characters reversed, so you can review RTL layouts without any translations. Its sibling, <strong>en-XA</strong>, adds accents and expands text length to expose clipping, and it flags hard-coded strings that never went through resources.</p>
<p><strong>3. Force RTL on a device.</strong> In Developer options, enable <strong>Force RTL layout direction</strong>. It mirrors every app without changing the language, which makes it handy for checking third-party SDK screens your previews never see.</p>
<p><strong>4. A layout assertion in CI.</strong> Part 3’s tooling extends naturally:</p>
<pre><code class="language-kotlin">@Test
fun topBar_backButtonSitsAtStartInRtl() {
    rule.setContent { ForceRtl { AppTheme { RecipeTopBar(title = "Carbonara", onBack = {}) } } }

    val root = rule.onRoot().getBoundsInRoot()
    val back = rule.onNodeWithTag(TestTags.BACK).getBoundsInRoot()

    assertTrue(back.left &gt; root.width / 2, "Back button should sit on the right edge in RTL")
}
</code></pre>
<p>This test proves <em>placement</em>, not <em>orientation</em>. The opening bug would pass it: the icon reaches the right edge while the arrow keeps pointing left. Bounds can’t see the pixels inside an icon, so pair this with the next habit.</p>
<p><strong>5. <em>Reviewed LTR/RTL screenshot baselines.</em></strong> <em>Keep one small fixture holding an icon that must mirror and one that must not, and record it in both directions.</em></p>
<pre><code class="language-kotlin">@Composable
fun DirectionalIconFixture() {
    Row(
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        // Must mirror
        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
        // Must NOT mirror
        Icon(Icons.Filled.PlayArrow, contentDescription = null)
    }
}

@Preview(name = "LTR", locale = "en")
@Preview(name = "RTL", locale = "ar")
@Composable
private fun DirectionalIconFixturePreview() {
    AppTheme { DirectionalIconFixture() }
}
</code></pre>
<blockquote>
<p>Note: Paparazzi and Roborazzi don’t read the <code>@Preview</code> annotations, so with those tools set the locale or layout direction in the test configuration. Compose Preview Screenshot Testing does use them.</p>
</blockquote>
<blockquote>
<p><em>Expected result: the arrow points left in LTR and right in RTL, while the play triangle points right in both. A missing flip shows up as a wrong arrow, and an overbroad mirroring change (the bulk</em> <code>autoMirrored="true"</code> <em>mistake) shows up as a backwards play button. Because both icons sit in one fixture, anyone updating a shared component can see the intended difference at a glance. Record with Paparazzi, Roborazzi, or Compose Preview Screenshot Testing, whichever you already use.</em></p>
</blockquote>
<blockquote>
<p><em>Baselines only help if diffs are reviewed. Check the first recording by eye, ideally with someone who reads an RTL language, and treat “update snapshots” commits with suspicion.</em></p>
</blockquote>
<p><strong>A screen-by-screen audit</strong> in RTL takes about two minutes per screen. Check each of these:</p>
<ul>
<li>Alignment and padding mirror.</li>
<li>Directional icons flip; media icons, checkmarks, and logos don’t.</li>
<li>The screenshot fixture still shows the arrow flipped and the play button unchanged.</li>
<li>Inserted names and numbers read correctly.</li>
<li>Swipes and slide animations move with the reading direction.</li>
<li>Custom drawing such as progress bars and charts fills from the start.</li>
<li>TalkBack reads right to left.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong>Hard-coded left/right in custom drawing.</strong> <code>Canvas</code> code, <code>drawBehind</code>, and custom <code>Layout</code> placement all use physical coordinates. Read <code>layoutDirection</code> explicitly or mirror with <code>scale</code>, and keep text out of the mirrored block.</li>
<li><strong>Mirroring icons that shouldn’t mirror.</strong> A bulk <code>autoMirrored="true"</code> across the drawable folder flips play buttons, checkmarks, and logos. Decide per icon.</li>
<li><strong>Forgetting</strong> <code>supportsRtl</code><strong>.</strong> A single missing manifest attribute silently disables every direction-aware API in the app.</li>
<li><strong>Concatenating strings around user content.</strong> Build sentences from resources with placeholders, and wrap the inserted values. Concatenation can’t be reordered by translators and can’t be isolated.</li>
<li><strong>Trusting bounds tests for icon direction.</strong> A layout assertion confirms where an icon sits, not which way it points. Add a reviewed LTR/RTL screenshot baseline for directional and non-directional icons.</li>
</ul>
<h2>Series wrap-up: an app that works for far more people</h2>
<p>Five parts, one idea: <strong>the pixels are only one of the interfaces you ship.</strong> The semantics tree, the reading order, the announcements, and the layout direction are interfaces too, and they’re used by real people every day.</p>
<ul>
<li><a href="https://medium.com/p/119e2de393ad"><strong>Part 1, <em>Raising the Floor</em></strong></a><strong>,</strong> set the baseline: meaningful labels, 48dp touch targets, readable contrast, meaning that survives without color, and intentional reading order.</li>
<li><a href="https://medium.com/p/0f6360500ae8"><strong>Part 2, <em>From Five Stars to One Sentence</em></strong></a><strong>,</strong> turned a custom rating widget into one operable node with a state, a range, and custom actions.</li>
<li><a href="https://medium.com/p/99a7d8898804"><strong>Part 3, <em>Green Builds, Blind Spots</em></strong></a><strong>,</strong> made all of it testable in five layers, from semantics assertions to a ten-minute TalkBack routine.</li>
<li><a href="https://medium.com/p/9173562da48c"><strong>Part 4, <em>When the Screen Changes Silently</em></strong></a><strong>,</strong> covered live regions, headings, and focus for screens that change after they load.</li>
<li><strong>Part 5</strong>, this post, made every screen direction-aware.</li>
</ul>
<p>Here is everything condensed into one release checklist. Print it, or paste it into your pull request template.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*jN--yhg3hJPW2KspJdykNg.png" alt="series-release-checklist" style="display:block;margin:0 auto" />

<h2>What to do next week</h2>
<ol>
<li><strong>Add one layer of tests.</strong> Pick your most valuable flow and add Part 3’s targeted and bulk assertions to CI. It’s an afternoon’s work that protects everything else.</li>
<li><strong>Run both manual passes on that flow:</strong> the ten-minute TalkBack routine, then two minutes per screen in <code>ar-XB</code>. Write down everything you find. That list is your roadmap.</li>
<li>Add the icon fixture from Part 5 to your screenshot suite while you’re there. It takes minutes and guards your shared components.</li>
<li><strong>Fix it in the design system, not the screen.</strong> Required <code>contentDescription</code> parameters, a <code>SectionHeader</code> with <code>heading()</code>, auto-mirrored icons by default. Every fix in a shared component pays out on every screen.</li>
<li><strong>Talk to users.</strong> Five sessions with people who use TalkBack, Switch Access, magnification, or an RTL language every day will reprioritise your backlog better than any checklist, this one included.</li>
</ol>
<h2>Further reading</h2>
<ul>
<li><a href="https://developer.android.com/develop/ui/compose/accessibility">Accessibility in Jetpack Compose</a>: the official guide to semantics, traversal, merging, and testing.</li>
<li><a href="https://developer.android.com/guide/topics/ui/accessibility">Make apps more accessible</a>: platform-level Android guidance.</li>
<li><a href="https://developer.android.com/guide/topics/resources/pseudolocales">Test your app with pseudolocales</a>: setup for <code>en-XA</code> and <code>ar-XB</code>.</li>
<li><a href="https://www.w3.org/TR/WCAG22/">Web Content Accessibility Guidelines (WCAG) 2.2</a>: the standard behind contrast ratios and target sizes.</li>
<li><a href="https://github.com/google/Accessibility-Test-Framework-for-Android">Accessibility Test Framework for Android</a>: the engine behind ATF checks and Accessibility Scanner.</li>
<li><a href="https://m2.material.io/design/usability/bidirectionality.html">Material Design: Bidirectionality</a>: visual guidance on mirroring layouts and icons.</li>
</ul>
<p>Thanks for reading the series. If one screen in your app works for one more person next month than it did last month, it did its job.</p>
<h2>📱 Level Up Your Android System Design Game</h2>
<p>If you’re prepping for a senior Android role and system design rounds are the part that makes you sweat, I highly recommend <strong>“Mastering Android System Design”</strong> — a 37-chapter playbook that treats Android like the constrained runtime it actually is, not a small server. It covers Binder IPC, ART’s GC, process death, and background execution limits, then walks through the CRDDS interview framework and 13 full worked case studies (chat, ride-hailing, video streaming, payments, and more) carried end-to-end through API design, offline sync, and the Android-specific edge cases that trip candidates up mid-interview.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=O6IAEgAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0HDS8R7NM"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249089"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover</strong>.</li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/part-5-mirror-with-intent-building-right-to-left-ready-screens-in-jetpack-compose-507cc0bc3ae0"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Part 4: When the Screen Changes Silently: Live Regions, Headings and Focus in Compose]]></title><description><![CDATA[Make dynamic Jetpack Compose screens accessible: polite vs assertive live regions, heading semantics and focus management for TalkBack, with Kotlin.]]></description><link>https://android-expert.hashnode.dev/part-4-when-the-screen-changes-silently-live-regions-headings-and-focus-in-compose</link><guid isPermaLink="true">https://android-expert.hashnode.dev/part-4-when-the-screen-changes-silently-live-regions-headings-and-focus-in-compose</guid><category><![CDATA[Android Accessibility]]></category><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[Talkback]]></category><category><![CDATA[focus management]]></category><category><![CDATA[Accessibility]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 17:07:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/b3370c02-21a9-4b61-9a25-affd9ee34692.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Build for Everyone · Part 4 of 5</em></p>
<p><em>Previously: Parts 1 to 3 made static screens accessible and kept them that way with tests. This part is about everything that happens after the screen first appears.</em></p>
<blockquote>
<p><em><strong>TL;DR</strong></em> <em>Sighted users notice change for free: a badge ticks up, an error turns red, results slide in. TalkBack users only notice what is</em> <em><strong>spoken</strong></em> <em>or what</em> <em><strong>receives focus</strong></em>*. Three tools close that gap:*</p>
</blockquote>
<ul>
<li><strong>Live regions</strong> announce changes without moving focus.</li>
<li><strong>Headings</strong> let people jump between sections instead of swiping through everything.</li>
<li><strong>Deliberate focus management</strong> puts the user where the next action is, but only at moments they’ll expect.</li>
</ul>
<blockquote>
<p><em>Each one is easy to add and just as easy to overdo, so this post spends as much time on restraint as on code.</em></p>
</blockquote>
<h2>The silent cart counter</h2>
<p>A user is on a product page with TalkBack running. They double-tap <strong>Add to cart</strong>. On screen, the badge on the cart icon in the top bar ticks from 3 to 4. That’s the confirmation.</p>
<p>TalkBack says nothing. Its focus is still on the button, and the change happened somewhere else. The user doesn’t know whether the tap worked, so they tap again. Now there are five items in the cart, and they only wanted one more.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*6mXRC5d19R8-eyW7Ew4xog.png" alt="silent-cart" style="display:block;margin:0 auto" />

<p>No crash, no visual bug, and the code from Part 1 passes every check in Part 3. The screen <em>changed</em>, and only some users were told.</p>
<h2>Live regions: announce without moving</h2>
<p>A <strong>live region</strong> is a node that asks accessibility services to announce it whenever its content changes, wherever focus happens to be. In Compose it’s one semantics property:</p>
<pre><code class="language-kotlin">@Composable
fun CartAction(itemCount: Int, onOpenCart: () -&gt; Unit) {
    val description = if (itemCount == 0) {
        stringResource(R.string.cd_cart_empty)
    } else {
        pluralStringResource(R.plurals.cd_cart_items, itemCount, itemCount)
    }

    IconButton(
        onClick = onOpenCart,
        modifier = Modifier.semantics {
            contentDescription = description
            liveRegion = LiveRegionMode.Polite
        },
    ) {
        BadgedBox(
            badge = {
                if (itemCount &gt; 0) {
                    Badge(Modifier.clearAndSetSemantics { }) { Text(itemCount.toString()) }
                }
            },
        ) {
            Icon(Icons.Outlined.ShoppingCart, contentDescription = null)
        }
    }
}
</code></pre>
<p>This is Part 1’s cart button with one structural change: the description moved from the <code>Icon</code> up to the <code>IconButton</code>, onto the <strong>same node</strong> as the live region. That's deliberate. Keep the live region on the node that owns the changing text, rather than relying on changes inside merged children to bubble up to a live parent. It's the most predictable arrangement across Compose and TalkBack versions. Now adding an item produces <em>"Shopping cart, 4 items"</em> after TalkBack finishes whatever it was saying.</p>
<h2>Polite vs assertive</h2>
<p>The two modes differ in <strong>when</strong> TalkBack speaks:</p>
<ul>
<li><code>Polite</code> waits until current speech finishes. That's the right choice for nearly everything: counters, "Saved," "3 results," "Back online."</li>
<li><code>Assertive</code> interrupts current speech. Use it only when the user must react <em>now</em> and waiting would cost them something.</li>
</ul>
<img src="https://cdn-images-1.medium.com/max/800/1*nCPwMGyVR-wuUBq39m4YEw.png" alt="polite-vs-assertive" style="display:block;margin:0 auto" />

<p>An assertive alternative for the cart is justified for exactly one case, a <strong>failure</strong> that changes what the user should do next:</p>
<pre><code class="language-kotlin">@Composable
fun AddToCartError(message: String?, modifier: Modifier = Modifier) {
    // The node stays composed; the live region is only switched on while there is
    // something to say, so clearing the error can't fire an empty interruption.
    Text(
        text = message.orEmpty(),
        color = MaterialTheme.colorScheme.error,
        modifier = modifier.semantics {
            if (!message.isNullOrEmpty()) liveRegion = LiveRegionMode.Assertive
        },
    )
}
</code></pre>
<p><em>“Couldn’t add to cart: only 2 left in stock”</em> deserves to cut in. <em>“Added to cart”</em> never does. If you find yourself reaching for <code>Assertive</code> more than once or twice per app, step back. Each interruption trains users to stop trusting what TalkBack is about to say.</p>
<p>Notice two details. First, the <code>Text</code> is always in the composition and only its content changes, which is more predictable than a node that appears and disappears with its message. An empty <code>Text</code> has nothing to speak, so TalkBack skips it during normal navigation. Second, the assertive flag is switched on only while there's a message. Otherwise clearing the error would itself count as a change, and on some TalkBack versions an <em>empty</em> assertive update can interrupt current speech with nothing. Polite regions, like <code>FormStatus</code> below, don't need this guard, since an empty polite update interrupts nothing.</p>
<h2>Where live regions fit</h2>
<ul>
<li><strong>Counters that change because of something the user did elsewhere:</strong> cart, notification count, “2 items selected.”</li>
<li><strong>Form submission results:</strong> “Order placed” (polite) or “Payment declined” (a case for assertive).</li>
<li><strong>Network status:</strong> “You’re offline. Changes will sync later” and “Back online.” Polite, and only on transitions, never on a timer.</li>
<li><strong>Async results:</strong> “12 recipes found” after a search finishes.</li>
</ul>
<p>You <em>don’t</em> need one when the change happens to the node that already has focus. Part 2’s rating control, for example, announces its own new state description.</p>
<h2>The announcement spam trap</h2>
<p>Live regions fire on <strong>every</strong> change. Put one on a search result count, and a user typing “carbonara” hears <em>“120 results… 48 results… 12 results… 3 results”</em> while trying to listen to their own typing echo. The same happens with countdown timers, progress percentages, and live prices.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*-MOopOmTaRT9137vFiZfLg.png" alt="announcement-spam" style="display:block;margin:0 auto" />

<p>The fix is to announce the value only once it <strong>settles</strong>:</p>
<pre><code class="language-kotlin">import androidx.compose.runtime.*
import kotlinx.coroutines.delay

/** Returns [value] only after it has stopped changing for [settleMillis]. */
@Composable
fun &lt;T&gt; rememberSettled(value: T, settleMillis: Long = 750L): T {
    var settled by remember { mutableStateOf(value) }
    LaunchedEffect(value) {
        delay(settleMillis)   // cancelled and restarted on every change
        settled = value
    }
    return settled
}
</code></pre>
<pre><code class="language-kotlin">@Composable
fun ResultCount(count: Int, modifier: Modifier = Modifier) {
    val settledCount = rememberSettled(count)
    val spoken = pluralStringResource(R.plurals.search_results, settledCount, settledCount)

    Text(
        // Sighted users see the live value immediately...
        text = pluralStringResource(R.plurals.search_results, count, count),
        // ...TalkBack hears only the settled one.
        modifier = modifier.clearAndSetSemantics {
            contentDescription = spoken
            liveRegion = LiveRegionMode.Polite
        },
    )
}
</code></pre>
<p>Because <code>LaunchedEffect(value)</code> restarts on every change, the delay only completes once typing pauses. A screen reader user hears one sentence instead of four. For countdowns and progress, announce <strong>milestones</strong> ("1 minute left," "Upload complete") rather than every tick.</p>
<p>A note on <code>View.announceForAccessibility()</code>. It still shows up in older code, but it's deprecated as of Android 16 (API 36). Prefer live regions and pane titles, which describe <em>state</em> rather than firing one-off speech that services can't revisit.</p>
<h2>Headings: a table of contents for the ear</h2>
<p>Sighted users skim. They glance at bold section titles and jump straight to “Reviews.” TalkBack users without headings have one option: swipe through every element in order. On a long recipe screen, that can mean dozens of swipes past ingredients and steps just to reach the reviews.</p>
<p>Marking section titles as <strong>headings</strong> gives them the equivalent of skimming. TalkBack lets users switch navigation to “Headings” and jump section to section. The exact gesture or menu depends on the TalkBack version, but every recent version supports it.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*6R7UewKKuDqC3bbu6p5Ddw.png" alt="heading-navigation" style="display:block;margin:0 auto" />

<pre><code class="language-kotlin">@Composable
fun SectionHeader(text: String, modifier: Modifier = Modifier) {
    Text(
        text = text,
        style = MaterialTheme.typography.titleLarge,
        modifier = modifier
            .padding(top = 24.dp, bottom = 8.dp)
            .semantics { heading() },
    )
}
</code></pre>
<p>Build heading semantics into your design system’s section header component, so nobody has to remember it at each call site. Just as important, <strong>don’t</strong> build it into a general-purpose typography component like <code>TitleText</code>. If you do, every card title in every list becomes a heading.</p>
<p><strong>What deserves heading status:</strong></p>
<ul>
<li>The screen’s title. Check whether your top app bar already exposes it as a heading; if not, add it.</li>
<li>Section titles that divide a long screen: <em>Ingredients, Steps, Nutrition, Reviews.</em></li>
<li>Group titles in settings: <em>Notifications, Privacy, Account.</em></li>
</ul>
<p><strong>What doesn’t:</strong></p>
<ul>
<li>Every card or list-item title. Forty “headings” in a product grid make heading navigation as slow as swiping.</li>
<li>Bold labels, buttons, and decorative large text.</li>
<li>A word that’s styled big for visual emphasis but doesn’t start a section.</li>
</ul>
<p>A useful test: read only the headings out loud. If they sound like a table of contents for the screen, you’ve got it right.</p>
<h2>Focus management: move people only when they’d expect it</h2>
<p>Live regions <em>tell</em> users about change. Focus management <em>moves</em> them to it. That’s more powerful, and much more disruptive when it’s wrong.</p>
<p>One distinction first. <code>FocusRequester</code> moves <strong>input focus</strong>: the element that receives keyboard input and shows the soft keyboard. TalkBack keeps its own <strong>accessibility focus</strong>, the element it's reading. TalkBack usually follows input focus into text fields, but the two aren't the same thing. Design so that moving input focus helps, and <em>verify on a device</em> that TalkBack goes along with it.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*tVw3GUn69Z-bJcVaDUIy9w.png" alt="focus-decisions" style="display:block;margin:0 auto" />

<h2>On screen entry: when it helps and when it hurts</h2>
<p>Auto-focus helps when a screen exists <strong>for</strong> typing: search, a one-time-code entry, “rename playlist.” The user arrives wanting to type.</p>
<pre><code class="language-kotlin">@Composable
fun SearchField(query: String, onQueryChange: (String) -&gt; Unit, modifier: Modifier = Modifier) {
    val focusRequester = remember { FocusRequester() }
    // Saveable: rotating the device or coming back from a result must not
    // refocus the field and pop the keyboard up again.
    var hasAutoFocused by rememberSaveable { mutableStateOf(false) }

    LaunchedEffect(Unit) {
        if (!hasAutoFocused) {
            focusRequester.requestFocus()
            hasAutoFocused = true
        }
    }

    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        label = { Text(stringResource(R.string.label_search_recipes)) },
        singleLine = true,
        modifier = modifier.fillMaxWidth().focusRequester(focusRequester),
    )
}
</code></pre>
<p>Two failure modes ship with this pattern all the time:</p>
<ul>
<li><strong>Refocusing on return.</strong> With a plain <code>LaunchedEffect(Unit)</code> and no saved flag, the keyboard pops up every time the user navigates back to search or rotates the phone. That's annoying for everyone and disorienting for TalkBack users, whose focus is yanked away from the result they just came back to.</li>
<li><strong>Requesting focus before the target exists.</strong> If the field is composed conditionally, say after a loading state, calling <code>requestFocus()</code> before its <code>focusRequester</code> modifier is attached throws <code>IllegalStateException</code>. Only request focus for a node you know is in the composition.</li>
</ul>
<p>Auto-focus <strong>hurts</strong> on long forms and content screens. A TalkBack user dropped straight into field three of a checkout form skips the page title, the instructions, and the order summary above it. They have to swipe backwards to find out where they are. On those screens, let focus start at the top as usual.</p>
<h2>After failed validation: go to the problem</h2>
<p>When a submit fails, moving focus to the first invalid field is the single most helpful thing you can do. Keyboard users land where they need to type, sighted users see the field scroll into view, and TalkBack usually reads the field together with its error. Part 1’s <code>error()</code> semantics pay off here.</p>
<pre><code class="language-kotlin">enum class CheckoutField { Name, Email, Phone }

data class CheckoutFormState(
    val values: Map&lt;CheckoutField, String&gt; = emptyMap(),
    val errors: Map&lt;CheckoutField, String&gt; = emptyMap(),
    val submitAttempt: Int = 0,
)

@Composable
fun CheckoutForm(state: CheckoutFormState, onEvent: (CheckoutEvent) -&gt; Unit) {
    val requesters = remember { CheckoutField.entries.associateWith { FocusRequester() } }

    // Keyed on the attempt counter, not the field: a second failed submit
    // on the same field must still move focus.
    LaunchedEffect(state.submitAttempt) {
        if (state.submitAttempt == 0) return@LaunchedEffect
        val firstInvalid = CheckoutField.entries.firstOrNull { it in state.errors } ?: return@LaunchedEffect
        requesters.getValue(firstInvalid).requestFocus()
    }

    Column {
        CheckoutField.entries.forEach { field -&gt;
            ValidatedTextField( // Part 1's EmailField, generalized: icon + "Error:" text + error()
                field = field,
                value = state.values[field].orEmpty(),
                errorMessage = state.errors[field],
                onValueChange = { onEvent(CheckoutEvent.Changed(field, it)) },
                modifier = Modifier
                    .focusRequester(requesters.getValue(field))
                    .testTag(TestTags.field(field)),
            )
        }
        FormStatus(state) // see below
        Button(onClick = { onEvent(CheckoutEvent.Submit) }) {
            Text(stringResource(R.string.action_place_order))
        }
    }
}
</code></pre>
<p>The <code>submitAttempt</code> counter matters. If you key the effect on <code>firstInvalid</code>, the second failed submit on the <em>same</em> field produces the same key. The effect doesn't re-run, focus doesn't move, and the user hears nothing.</p>
<h2>Pair the focus move with an announcement</h2>
<p>Moving focus is not a guaranteed announcement. If input focus is <em>already</em> on the invalid field, which happens when the user typed in it and then double-tapped Submit, requesting focus changes nothing, and some TalkBack versions say nothing either. So combine the focus move with a short polite summary near the submit button:</p>
<pre><code class="language-kotlin">@Composable
fun FormStatus(state: CheckoutFormState, modifier: Modifier = Modifier) {
    val message = if (state.submitAttempt &gt; 0 &amp;&amp; state.errors.isNotEmpty()) {
        pluralStringResource(R.plurals.form_errors_summary, state.errors.size, state.errors.size)
        // "Couldn't place order. %d field needs attention." / "%d fields need attention."
    } else {
        ""
    }
    Text(message, modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite })
}
</code></pre>
<p>Keep the summary short and <strong>counted</strong> rather than repeating every error. The field-level errors are read when the user reaches each field. The summary’s only job is to make sure nobody is left wondering whether the button did anything.</p>
<h2>Focus traps and stolen focus</h2>
<ul>
<li><strong>Fake dialogs.</strong> An overlay drawn with a <code>Box</code> on top of the screen isn't a dialog to TalkBack. Users can swipe straight past it into the content behind. Use <code>Dialog</code>, <code>AlertDialog</code>, or <code>ModalBottomSheet</code>. They open their own window, so accessibility focus stays inside until they're dismissed.</li>
<li><strong>Moving focus on background updates.</strong> A <code>LaunchedEffect(items)</code> that requests focus whenever the list refreshes pulls the user away from whatever they were reading, every time new data arrives. Focus should only move in response to <strong>the user's own action</strong>.</li>
<li><strong>Unstable list keys.</strong> When a <code>LazyColumn</code> reloads without stable <code>key</code>s, items can be recreated as new nodes, and TalkBack can lose its place and jump to the top. Give list items <code>key = { it.id }</code>.</li>
<li><strong>Forgetting where to return.</strong> When a dialog or sheet closes, check on a device that focus goes back to the control that opened it, not to the top of the screen.</li>
</ul>
<h2>A design checklist for dynamic UI</h2>
<p>Most dynamic moments in an app fall into four patterns. Each has a small set of checks:</p>
<img src="https://cdn-images-1.medium.com/max/800/1*O7UWaqrSwj3AeI2Tq05jcw.png" alt="dynamic-ui-checklist" style="display:block;margin:0 auto" />

<ul>
<li><strong>Dialogs:</strong> Use Material’s dialog components. The title should be the first thing spoken. Every action needs a clear label: “Delete recipe,” not just “OK.” Focus should return to the trigger when the dialog closes.</li>
<li><strong>Bottom sheets:</strong> Use <code>ModalBottomSheet</code> for anything modal. Give a custom sheet a <code>paneTitle</code> so its appearance is announced. Make sure dismissing doesn't depend <em>only</em> on dragging: offer a labelled close action too.</li>
<li><strong>Snackbars:</strong> Material’s <code>SnackbarHost</code> already announces politely and stretches its timeout based on the user's accessibility timeout setting. Your job is never to put the <em>only</em> path to something important in a snackbar action that disappears. If "Undo" matters, offer it somewhere persistent as well.</li>
<li><strong>Loading states:</strong> Give progress indicators a label (“Loading recipes”). Announce the <strong>outcome</strong> (“12 recipes found,” “Couldn’t load recipes”), not every percentage. Don’t announce the start of a load the user didn’t trigger.</li>
</ul>
<h2>Testing what you just built</h2>
<p>Everything in this post is visible in the semantics tree, so Part 3’s tools apply directly:</p>
<pre><code class="language-kotlin">@Test
fun cartAction_isPoliteLiveRegion() {
    rule.setContent { CartAction(itemCount = 4, onOpenCart = {}) }
    rule.onNodeWithContentDescription(context.resources.getQuantityString(R.plurals.cd_cart_items, 4, 4))
        .assert(SemanticsMatcher.expectValue(SemanticsProperties.LiveRegion, LiveRegionMode.Polite))
}

@Test
fun recipeScreen_exposesExactlyItsSectionHeadings() {
    rule.setContent { AppTheme { RecipeScreen(recipe = sampleRecipe) } }
    // Catches both forgotten headings and heading inflation from a shared component.
    rule.onAllNodes(isHeading()).assertCountEquals(5) // title + 4 sections
}

@Test
fun failedSubmit_movesFocusToFirstInvalidField() {
    var state by mutableStateOf(CheckoutFormState())
    rule.setContent { CheckoutForm(state = state, onEvent = {}) }

    state = state.copy(errors = mapOf(CheckoutField.Email to "Enter a complete email address"), submitAttempt = 1)

    rule.onNodeWithTag(TestTags.field(CheckoutField.Email)).assertIsFocused()
}
</code></pre>
<p>Testing the timing of a live region announcement belongs in the manual routine from Part 3. Automated tests can prove the property is <em>set</em>. Only a person with TalkBack running can tell you whether the announcement arrives at a sensible moment.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Assertive for everything.</strong> “Saved,” “Added,” and “Loaded” are not emergencies. Interruptions should be rare enough that users never learn to ignore them.</li>
<li><strong>Heading inflation.</strong> Building <code>heading()</code> into a shared title style turns every card into a heading, and heading navigation becomes as slow as swiping. Test with <code>assertCountEquals</code>.</li>
<li><strong>Stealing focus after the user has started.</strong> Auto-focus on return, focus moves on data refresh, and dialogs that pop up mid-task all yank TalkBack users away from what they were doing. Move focus only in response to the user’s own action, or once on first entry to a screen built for typing.</li>
<li><strong>Announcing every tick.</strong> Live regions on raw, fast-changing values (search counts, timers, progress) turn TalkBack into a stream of numbers. Settle first, then announce.</li>
</ul>
<h2>Up next</h2>
<p>There’s one more dimension of inclusion left, and it has nothing to do with screen readers: <strong>direction</strong>. Hundreds of millions of people read right to left. <a href="https://medium.com/p/507cc0bc3ae0"><strong>Part 5, <em>Going Global</em></strong></a><strong>,</strong> covers:</p>
<ul>
<li>what Compose mirrors for you automatically, and what it can’t</li>
<li>why some icons must flip and others must never flip</li>
<li>how to audit every screen in a single pass</li>
</ul>
<p>It closes the series with one combined release checklist covering accessible, testable, and RTL-ready.</p>
<h2>📱 Crack Your Next Android Interview</h2>
<p>If you’re targeting a senior Android role at a FAANG or top-tier MNC, syntax knowledge alone won’t get you through the loop. I highly recommend <strong>“Cracking Top MNC Android Interviews”</strong> — a senior-level playbook covering framework internals, Jetpack Compose, coroutines, Android-specific system design, and DSA reframed around real mobile constraints, to help you walk into bar-raiser rounds ready.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=h7P4EQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0H6C8JDGC"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249119"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover.</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/part-4-when-the-screen-changes-silently-live-regions-headings-and-focus-in-compose-9173562da48c"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Part 3: Green Builds, Blind Spots: Testing Compose Accessibility Layer by Layer]]></title><description><![CDATA[Test Jetpack Compose accessibility in layers: semantics assertions, bulk checks, 48dp touch targets, ATF audits, manual TalkBack review and CI setup.]]></description><link>https://android-expert.hashnode.dev/part-3-green-builds-blind-spots-testing-compose-accessibility-layer-by-layer</link><guid isPermaLink="true">https://android-expert.hashnode.dev/part-3-green-builds-blind-spots-testing-compose-accessibility-layer-by-layer</guid><category><![CDATA[Android Accessibility]]></category><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[android testing]]></category><category><![CDATA[ui testing]]></category><category><![CDATA[Talkback]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 17:04:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/6b987f3d-e24f-4b9d-aec5-fa2f230f028d.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Build for Everyone · Part 3 of 5</em></p>
<p><em>Previously:</em> <a href="https://medium.com/p"><em>Part 1 (“Raising the Floor”)</em></a> <em>set the baseline rules, and</em> <a href="https://medium.com/p/0f6360500ae8"><em>Part 2 (“From Five Stars to One Sentence”)</em></a> <em>built a custom rating control with real semantics. This part makes sure none of that quietly breaks.</em></p>
<blockquote>
<p><em><strong>TL;DR</strong></em> <em>Accessibility regressions don’t crash, don’t change a single pixel, and don’t fail a single existing test. You catch them by testing the</em> <em><strong>semantics tree</strong></em>*, in five layers:*</p>
</blockquote>
<ol>
<li><strong>Targeted assertions</strong> on the nodes that matter.</li>
<li><strong>Bulk assertions</strong> across groups of nodes.</li>
<li><strong>Touch-size checks.</strong></li>
<li><strong>Automated audits</strong> with Google’s Accessibility Test Framework (ATF).</li>
<li><strong>A short manual TalkBack routine</strong> for everything machines can’t judge.</li>
</ol>
<blockquote>
<p><em>Each layer catches what the one below it can’t.</em></p>
</blockquote>
<h2>The regression nobody noticed</h2>
<p>Here’s a migration that happens in real codebases. The design system team ships a new <code>AppIconButton</code> to replace scattered <code>IconButton</code> usages. It looks like this:</p>
<pre><code class="language-kotlin">@Composable
fun AppIconButton(icon: ImageVector, onClick: () -&gt; Unit, modifier: Modifier = Modifier) {
    IconButton(onClick = onClick, modifier = modifier) {
        Icon(icon, contentDescription = null) // "the caller can add semantics if needed"
    }
}
</code></pre>
<p>Forty call sites are migrated in one pull request, and the review approves it. Everything checks out:</p>
<ul>
<li>The compiler is happy: no call site passes a description, because there’s no parameter for one.</li>
<li>Screenshot tests pass, because the pixels are identical.</li>
<li>Unit tests pass, because none of them ever looked at labels.</li>
</ul>
<p>Three weeks later, a screen reader user writes in: the share, save, and delete buttons all say <em>“Unlabeled, button.”</em></p>
<p>That’s why accessibility needs its own tests. Every check you already run looks at pixels or behavior. <strong>TalkBack reads neither. It reads the semantics tree.</strong> So that’s what the tests have to read too.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*C72HvVezxAvFaHzBb6qFlg.png" alt="silent-regression" style="display:block;margin:0 auto" />

<p>The fix for <code>AppIconButton</code> is a required <code>contentDescription: String?</code> parameter, with no default, so every caller has to decide. The <em>protection</em> is the test layers below.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*ukVCdEIXOujLmtKaX8HIdQ.png" alt="testing-layers" style="display:block;margin:0 auto" />

<h2>Layer 1: Targeted semantics assertions</h2>
<p>Start with the nodes that carry the most risk: primary actions, custom controls, and anything a redesign is likely to touch. Compose UI tests find nodes by semantics, so the test reads the same tree TalkBack does.</p>
<p>One rule before any code: <strong>never hard-code visible strings in tests.</strong> <code>"Save recipe"</code> breaks the day someone edits the copy, and it means you can never run the suite in another locale. Resolve strings from resources the same way the app does:</p>
<pre><code class="language-kotlin">private val context: Context = ApplicationProvider.getApplicationContext()
private fun str(@StringRes id: Int, vararg args: Any): String = context.getString(id, *args)
</code></pre>
<p>Now assert what TalkBack depends on:</p>
<pre><code class="language-kotlin">class RecipeHeaderA11yTest {

    @get:Rule val rule = createComposeRule()

    @Test
    fun header_exposesHeadingAndLabelledActions() {
        rule.setContent { AppTheme { RecipeHeader(recipe = sampleRecipe, onSave = {}, onShare = {}) } }

        rule.onNode(hasText(sampleRecipe.title) and isHeading())
            .assertIsDisplayed()

        rule.onNodeWithContentDescription(str(R.string.cd_save_recipe, sampleRecipe.title))
            .assertIsDisplayed()
            .assertHasClickAction()

        rule.onNodeWithContentDescription(str(R.string.cd_share_recipe, sampleRecipe.title))
            .assertHasClickAction()
    }
}
</code></pre>
<p><em>(Heading semantics are the subject of Part 4. Here we only check they exist.)</em></p>
<p>The real payoff comes with custom controls. Part 2’s <code>RatingStars</code> made promises in its semantics: one node, a state description, a range, and actions that keep the spoken value in sync. Each promise becomes an assertion:</p>
<pre><code class="language-kotlin">class RatingStarsA11yTest {

    @get:Rule val rule = createComposeRule()

    private val label get() = str(R.string.label_recipe_rating)

    @Test
    fun rating_isOneAdjustableNodeWithSpokenValue() {
        rule.setContent { RatingStars(rating = 3, onRatingChange = {}, label = label) }

        rule.onNodeWithContentDescription(label)
            .assert(hasStateDescription(str(R.string.rating_state, 3, 5)))
            .assert(hasProgressBarRangeInfo(ProgressBarRangeInfo(3f, 0f..5f, steps = 4)))
            .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress))
    }

    @Test
    fun increaseAction_updatesStateAndSpokenValue() {
        var rating by mutableIntStateOf(3)
        rule.setContent { RatingStars(rating = rating, onRatingChange = { rating = it }, label = label) }

        rule.onNodeWithContentDescription(label)
            .performCustomAccessibilityActionWithLabel(str(R.string.rating_action_increase))

        assertEquals(4, rating)
        rule.onNodeWithContentDescription(label)
            .assert(hasStateDescription(str(R.string.rating_state, 4, 5)))
    }

    @Test
    fun increaseAction_isNotOfferedAtMaximum() {
        rule.setContent { RatingStars(rating = 5, onRatingChange = {}, label = label) }

        rule.onNodeWithContentDescription(label)
            .assert(!hasCustomAction(str(R.string.rating_action_increase)))
    }
}

fun hasCustomAction(label: String) = SemanticsMatcher("has custom action \"$label\"") { node -&gt;
    node.config.getOrNull(SemanticsActions.CustomActions).orEmpty().any { it.label == label }
}
</code></pre>
<p>The second test is the one that pays for itself. It invokes the action <strong>the way an accessibility service would</strong>, then checks two things:</p>
<ul>
<li><strong>The state changed.</strong> This catches callbacks that were never hoisted.</li>
<li><strong>The <em>spoken</em> state changed.</strong> This catches Part 2’s stale <code>remember { }</code> trap.</li>
</ul>
<p>A tap-based test catches neither.</p>
<h2>Layer 2: Bulk assertions</h2>
<p>Targeted tests protect the nodes you thought about. Bulk tests protect the ones you didn’t, like every product image in a grid or every icon button in a toolbar. Use test tags to select the group:</p>
<pre><code class="language-kotlin">AsyncImage(
    model = product.imageUrl,
    contentDescription = product.altText ?: product.name,
    modifier = Modifier.testTag(TestTags.PRODUCT_IMAGE),
)
</code></pre>
<p>Then make the check reusable. There are two traps in this helper, and both cause <strong>false greens</strong>, meaning the test passes when it shouldn’t:</p>
<pre><code class="language-kotlin">fun SemanticsNodeInteractionsProvider.assertAllHaveDescriptions(tag: String, minimumCount: Int = 1) {
    // Trap 1: tagged images inside a clickable card are merged into the card.
    // In the default (merged) tree, the tagged node doesn't exist on its own.
    val nodes = onAllNodesWithTag(tag, useUnmergedTree = true)

    // Trap 2: assertAll on an empty collection passes. Prove the nodes exist first.
    val found = nodes.fetchSemanticsNodes().size
    assertTrue(found &gt;= minimumCount, "Expected at least $minimumCount nodes tagged '$tag', found $found")

    nodes.assertAll(hasMeaningfulContentDescription())
}

fun hasMeaningfulContentDescription() = SemanticsMatcher("has a meaningful contentDescription") { node -&gt;
    val placeholders = setOf("image", "icon", "photo", "picture")
    node.config.getOrNull(SemanticsProperties.ContentDescription).orEmpty()
        .any { it.isNotBlank() &amp;&amp; it.trim().lowercase() !in placeholders }
}
</code></pre>
<p><code>SemanticsMatcher.keyIsDefined(ContentDescription)</code> alone isn't enough: an empty string <code>""</code> or a lazy <code>"image"</code> passes it. The custom matcher rejects both.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*TXMuexrHwR2S9G1u8K7W-w.png" alt="bulk-assertion-traps" style="display:block;margin:0 auto" />

<p>Usage is one line per group:</p>
<pre><code class="language-kotlin">@Test
fun productGrid_everyImageIsDescribed() {
    rule.setContent { AppTheme { ProductGrid(products = sampleProducts, onOpen = {}) } }
    rule.assertAllHaveDescriptions(TestTags.PRODUCT_IMAGE, minimumCount = sampleProducts.size)
}
</code></pre>
<p>One more caution about lazy layouts. <code>LazyVerticalGrid</code> only composes the items that are visible. With twenty products, the test above checks perhaps six of them. Either feed the test a small dataset that fits on screen, or scroll with <code>performScrollToIndex</code> and assert in batches.</p>
<h2>Layer 3: Touch target checks</h2>
<p>Part 1’s 48dp rule becomes an assertion:</p>
<pre><code class="language-kotlin">@Test
fun toolbarActions_meetMinimumTouchSize() {
    rule.setContent { AppTheme { RecipeToolbar(onBack = {}, onSave = {}, onShare = {}) } }

    val clickables = rule.onAllNodes(hasClickAction())
    val count = clickables.fetchSemanticsNodes().size
    assertTrue(count &gt; 0, "No clickable nodes found")

    repeat(count) { index -&gt;
        clickables[index]
            .assertTouchWidthIsAtLeast(48.dp)
            .assertTouchHeightIsAtLeast(48.dp)
    }
}
</code></pre>
<p>Both assertions are part of <code>androidx.compose.ui.test</code> and run against a <code>SemanticsNodeInteraction</code> inside a Compose UI test. They are not runtime modifiers. They measure a node's <strong>touch</strong> bounds, which aren't always the same as its layout bounds. ATF (Layer 4) measures the bounds Android reports to accessibility services. If the two layers ever disagree about a control, trust the stricter one and fix the layout, as Part 1 did with <code>sizeIn(minWidth = 48.dp, minHeight = 48.dp)</code>. That version satisfies both.</p>
<h2>Layer 4: Automated framework checks</h2>
<p>Layers 1 to 3 check what <em>you</em> thought to assert. The <strong>Accessibility Test Framework</strong> (ATF), the same engine behind Accessibility Scanner, checks what you didn’t. It runs a catalogue of rules against the live accessibility tree.</p>
<p>On Compose 1.8 and later, ATF is wired into Compose tests through a separate artifact:</p>
<pre><code class="language-kotlin">// build.gradle.kts
androidTestImplementation("androidx.compose.ui:ui-test-junit4-accessibility:&lt;compose-version&gt;")
</code></pre>
<pre><code class="language-kotlin">class RecipeScreenAuditTest {

    // Accessibility checks need a real Activity behind the composition.
    @get:Rule val rule = createAndroidComposeRule&lt;ComponentActivity&gt;()

    @Before
    fun enableChecks() {
        rule.enableAccessibilityChecks()
    }

    @Test
    fun recipeScreen_passesAutomatedAudit() {
        rule.setContent { AppTheme { RecipeScreen(recipe = sampleRecipe) } }
        rule.onRoot().tryPerformAccessibilityChecks()
    }
}
</code></pre>
<p>Once enabled, checks also run when your tests perform actions like <code>performClick()</code>. That means every existing interaction test becomes an accessibility audit at no extra cost.</p>
<p>For View-based or hybrid screens tested with Espresso, the equivalent switch is set once per test class. <code>setRunChecksFromRootView(true)</code> audits the whole window, not just the view being acted on:</p>
<pre><code class="language-kotlin">companion object {
    @BeforeClass @JvmStatic
    fun enableAccessibilityChecks() {
        AccessibilityChecks.enable()
            .setRunChecksFromRootView(true)
            .setSuppressingResultMatcher(
                // Narrow, documented, temporary: one check, one view, one ticket.
                allOf(
                    matchesCheck(TextContrastCheck::class.java),
                    matchesViews(withId(R.id.legacy_promo_banner)), // A11Y-412, remove by Q1
                ),
            )
    }
}
</code></pre>
<p><strong>What ATF catches well:</strong></p>
<ul>
<li>missing labels on things people can act on</li>
<li>touch targets that are too small</li>
<li>duplicate or redundant descriptions (a description that says “button” on a button)</li>
<li>low contrast</li>
<li>clickable elements stacked on top of each other</li>
</ul>
<p>Contrast results depend on ATF capturing a screenshot, so keep Part 1’s theme contrast test as your main contrast guard.</p>
<p><strong>What ATF can’t catch:</strong> whether a label makes <em>sense</em>, whether the reading order matches the <em>intent</em>, whether focus lands somewhere sensible after an error, and whether a custom control can actually be <em>operated</em>. A label of <code>"btn_2"</code> passes every automated check ever written.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*uc6ZFcnI0Le760ruu5QDDw.png" alt="atf-coverage" style="display:block;margin:0 auto" />

<h2>Layer 5: Manual testing</h2>
<p>Automated layers protect the floor. Only people can judge the experience. Keep the manual part small enough that it actually happens on every release: <strong>ten minutes, one critical flow, every release candidate.</strong></p>
<img src="https://cdn-images-1.medium.com/max/800/1*OyUiN-9iJ5C23PxPLiN4-A.png" alt="talkback-routine" style="display:block;margin:0 auto" />

<ol>
<li><strong>Linear swipe (minutes 0–2).</strong> Swipe through the flow from start to finish with TalkBack’s speech output shown on screen. Listen for “Unlabeled,” duplicated phrases, and moments where you lose track of where you are.</li>
<li><strong>Operate, don’t just listen (2–4).</strong> Use every custom control with adjust gestures and the Actions menu only. No sighted shortcuts.</li>
<li><strong>Break something (4–6).</strong> Submit an invalid form and trigger a loading state. Is the error spoken? Does the result get announced? (That’s Part 4.)</li>
<li><strong>Scale up (6–8).</strong> Set the largest font size and display size. Look for clipped text, overlapping controls, and buttons pushed off screen.</li>
<li><strong>Scan (8–10).</strong> Run Accessibility Scanner on each screen in the flow. It’s ATF with a UI, and it catches things on real data that your test fixtures never contain.</li>
</ol>
<p><strong>Involve real users</strong> at least once per major feature. Five sessions with people who use TalkBack, Switch Access, or magnification every day will teach you more than months of internal testing. Pay them for their time, give them tasks (“reorder last week’s groceries”) rather than asking “is this accessible?”, and watch quietly.</p>
<h2>Putting tests in CI</h2>
<p>Split the layers by cost so the fast ones run on every change:</p>
<img src="https://cdn-images-1.medium.com/max/800/1*vaRoZz0MNCfjE3u3Tl3h9g.png" alt="ci-pipeline" style="display:block;margin:0 auto" />

<ul>
<li><strong>On every pull request:</strong> Layers 1 to 3. Semantics and touch-size assertions run on the JVM with Robolectric, so they take seconds and need no device. This is where the <code>AppIconButton</code> regression gets stopped.</li>
<li><strong>On merge or nightly:</strong> Layer 4 on an emulator. Gradle Managed Devices give you the same emulator image every run, without anyone looking after a device farm.</li>
<li><strong>Before release:</strong> Layer 5, the manual routine, run by a person and signed off in the release checklist.</li>
</ul>
<p><strong>Keeping them stable:</strong></p>
<ul>
<li>Disable animations in <code>testOptions</code>.</li>
<li>Pin font scale, locale, and display size per run, so ATF results don’t drift between machines.</li>
<li>Use fixed fixtures instead of live network data.</li>
</ul>
<p><strong>Triage every failure into one of three buckets:</strong></p>
<ul>
<li><strong>Regression:</strong> blocks the merge. No exceptions.</li>
<li><strong>Known issue:</strong> suppress it narrowly. One check, one element, a ticket number, and a date to remove it, as in the Espresso snippet above. A broad suppression like “ignore all contrast checks” is how a suite slowly stops protecting anything.</li>
<li><strong>False positive:</strong> suppress it with a comment explaining <em>why</em> it’s wrong, so the next person doesn’t re-litigate it.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong>Treating a passing audit as proof of accessibility.</strong> ATF proves that labels <em>exist</em>, not that they’re <em>right</em>. A screen can pass every check and still be unusable with TalkBack. Layer 5 isn’t optional.</li>
<li><strong>Finding nodes by visible text.</strong> <code>onNodeWithText("Add to cart")</code> breaks when the copy changes, and it ties the suite to one locale forever. Use string resources, test tags, and semantics matchers.</li>
<li><strong>False greens.</strong> <code>assertAll</code> over an empty collection, tagged nodes searched in the merged tree, and lazy lists that compose six of twenty items all <em>pass</em>. Assert counts before asserting properties.</li>
<li><strong>Testing taps instead of actions.</strong> <code>performClick()</code> on a star proves the star works for sighted users. <code>performCustomAccessibilityActionWithLabel</code> proves it works for everyone else.</li>
</ul>
<h2>Up next</h2>
<p>Everything so far has been about screens that sit still. Real apps change constantly: a badge updates, a form fails validation, a result loads three seconds later. <a href="https://medium.com/p/9173562da48c"><strong>Part 4</strong></a><strong>, <em>Announcing, Navigating, and Focusing</em>,</strong> covers three things:</p>
<ul>
<li>live regions, which announce changes without stealing focus</li>
<li>heading semantics, which let users jump between sections</li>
<li>deliberate focus management, which puts focus where the user needs it after an error</li>
</ul>
<p>It also shows how to test all three with what you learned here.</p>
<h2>📱 Go Beyond Using AI — Learn How to Actually Build It Into Your Android Apps</h2>
<p>If you’re building Android apps in 2026, “adding AI” can’t just mean bolting on a chatbot API — it means understanding on-device inference, model trade-offs, and production realities well enough to ship a feature users actually trust. I highly recommend <strong>“Mastering Android AI: App to Intelligence”</strong> It’s a practical, code-first journey through the entire Android AI stack — from ML Kit and LiteRT to Gemma, Gemini, and MediaPipe — covering everything from your first “hello inference” screen to thermal throttling, model drift monitoring, and full applied projects like an AI-powered camera app and a health companion, with hands-on Kotlin builds at every step.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=ROjzEQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0H8LJWY14"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249159"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/part-3-green-builds-blind-spots-testing-compose-accessibility-layer-by-layer-99a7d8898804"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Part 2: From Five Stars to One Sentence: Semantics for Custom Compose Controls]]></title><description><![CDATA[Make custom Jetpack Compose controls work with TalkBack: merge vs clear semantics, state descriptions, adjustable ranges and custom actions in Kotlin.]]></description><link>https://android-expert.hashnode.dev/part-2-from-five-stars-to-one-sentence-semantics-for-custom-compose-controls</link><guid isPermaLink="true">https://android-expert.hashnode.dev/part-2-from-five-stars-to-one-sentence-semantics-for-custom-compose-controls</guid><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[Android Accessibility]]></category><category><![CDATA[Talkback]]></category><category><![CDATA[Accessibility]]></category><category><![CDATA[android development]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 16:59:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/91cda093-8a85-4b00-8273-6d1c6f6b69ab.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Build for Everyone · Part 2 of 5</em></p>
<p><em>Previously:</em> <a href="https://medium.com/p/119e2de393ad"><em>Part 1 (“Raising the Floor”)</em></a> <em>covered the five baseline rules every screen must pass. Those rules assume standard components. This part is about the ones you build yourself.</em></p>
<blockquote>
<p><em><strong>TL;DR</strong></em> <em>Material components ship with semantics. Your custom components ship with none until you add them. The semantics tree, not the pixels, is what TalkBack reads. Making a custom control accessible comes down to four moves:</em></p>
</blockquote>
<ul>
<li>Collapse it into one node: merge descendants for things people read, and clear and replace them for things people operate.</li>
<li>Say what it is (<code>contentDescription</code>) separately from what it currently holds (<code>stateDescription</code>).</li>
<li>Pick an interaction model that matches how the control behaves.</li>
<li>Expose actions so nobody has to hit a 20dp star precisely.</li>
</ul>
<h2>The five-stars problem</h2>
<p>Picture a recipe app with a “Rate this recipe” row: five stars, tap one to set your score. It looks great and works perfectly with a finger. Here’s the code, which you’ll find in some form in a lot of codebases:</p>
<pre><code class="language-kotlin">// ❌ To TalkBack, this is five unrelated buttons
@Composable
fun NaiveRating(rating: Int, onRatingChange: (Int) -&gt; Unit) {
    Row {
        for (star in 1..5) {
            Icon(
                painter = painterResource(
                    if (star &lt;= rating) R.drawable.ic_star_filled else R.drawable.ic_star_outline,
                ),
                contentDescription = "Star",
                modifier = Modifier
                    .size(32.dp)
                    .clickable { onRatingChange(star) },
            )
        }
    }
}
</code></pre>
<p>Now turn on TalkBack and swipe through it. You hear <em>“Star, button”</em> five times. You aren’t told which stars are filled, what the control is for, or what the current score is. If someone “fixed” the hard-coded string by passing <code>null</code>, it gets worse: five <em>"Unlabeled, button"</em> stops in a row.</p>
<p>What a screen reader user needs is one stop and one sentence: <em>“Recipe rating, 3 of 5 stars, slider,”</em> plus a way to change the value without hunting for a 32dp target.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*81WQhFRO3AGym5-rhiDFoQ.png" alt="five-stars-problem" style="display:block;margin:0 auto" />

<p>Getting from the first version to the second takes three steps. First, a short look at the data structure all three steps edit.</p>
<h2>Semantics 101: the tree TalkBack actually reads</h2>
<p>As Compose builds your UI tree, it also builds a parallel <strong>semantics tree</strong>. That tree only has nodes where something carries meaning: text, a description, a role, a state, or an action someone can perform. TalkBack, Switch Access, Voice Access, autofill, and your UI tests all read this tree. None of them see your pixels.</p>
<p>The tree comes in two views:</p>
<ul>
<li><strong>Unmerged</strong> is every semantics node exactly as each composable declared it.</li>
<li><strong>Merged</strong> is what accessibility services work with. A node marked <code>mergeDescendants = true</code> absorbs its descendants' properties and becomes a single focus stop.</li>
</ul>
<p>Many stock components merge for you. <code>Button</code>, <code>IconButton</code>, <code>ListItem</code>, and any element using <code>Modifier.clickable</code> or <code>Modifier.toggleable</code> do this, which is why a Material button containing an icon and a label reads as one thing.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*C2rc4c5eiqJbLlOGB8PAfg.png" alt="semantics-trees" style="display:block;margin:0 auto" />

<p>You can inspect both views in two ways:</p>
<ul>
<li><strong>Layout Inspector</strong> in Android Studio. Select a composable and its attributes panel lists the semantics it declares and the semantics it ends up with after merging.</li>
<li><strong>A test or debug harness</strong>, which dumps the whole tree to Logcat:</li>
</ul>
<pre><code class="language-kotlin">composeTestRule.onRoot().printToLog("A11Y")                       // merged: what TalkBack sees
composeTestRule.onRoot(useUnmergedTree = true).printToLog("A11Y") // as each composable declared it
</code></pre>
<p>A useful rule of thumb: if a node in the merged tree doesn’t read like something you’d say out loud, TalkBack won’t say it well either.</p>
<h2>Step 1: Collapse the control into one node</h2>
<h2>Merge things people read</h2>
<p>Start with the read-only case: a review header with an avatar, the reviewer’s name, a star strip, and a timestamp. It should be one stop, not four.</p>
<pre><code class="language-xml">&lt;string name="rating_display"&gt;Rated %1$d out of %2$d&lt;/string&gt;
</code></pre>
<pre><code class="language-kotlin">private const val MAX_STARS = 5

@Composable
fun ReviewHeader(review: Review, modifier: Modifier = Modifier) {
    // semantics { } is not a composable scope, so resolve strings before entering it.
    val starsLabel = stringResource(R.string.rating_display, review.stars, MAX_STARS)

    Row(
        modifier = modifier.semantics(mergeDescendants = true) { },
        verticalAlignment = Alignment.CenterVertically,
    ) {
        AsyncImage( // Coil
            model = review.authorPhotoUrl,
            contentDescription = null, // decorative: the name sits right next to it
            modifier = Modifier.size(40.dp).clip(CircleShape),
        )
        Column(Modifier.padding(start = 12.dp)) {
            Text(review.authorName, style = MaterialTheme.typography.titleSmall)
            StarStrip(
                filled = review.stars,
                modifier = Modifier.semantics { contentDescription = starsLabel },
            )
            Text(review.postedLabel, style = MaterialTheme.typography.bodySmall)
        }
    }
}
</code></pre>
<p>The star strip itself draws icons and nothing else:</p>
<pre><code class="language-kotlin">@Composable
private fun StarStrip(filled: Int, modifier: Modifier = Modifier) {
    Row(modifier) {
        repeat(MAX_STARS) { index -&gt;
            Icon(
                painter = painterResource(
                    if (index &lt; filled) R.drawable.ic_star_filled else R.drawable.ic_star_outline,
                ),
                contentDescription = null, // the strip speaks for all five
                modifier = Modifier.size(16.dp),
            )
        }
    }
}
</code></pre>
<p><strong>Why do the child icons get</strong> <code>null</code><strong>?</strong> Because merging <em>collects</em>. It doesn't summarize or remove duplicates. If each star kept a <code>"Star"</code> description, the merged node would carry the strip's label <em>and</em> five more stars, and TalkBack would read all of them. After merging, the parent is the only one who should speak.</p>
<p>The merged node ends up with the two <code>Text</code> values and the strip's content description, so TalkBack reaches the whole header in one stop. Merged nodes keep text and content descriptions as separate lists, and the way TalkBack joins them can differ slightly between versions. Listen to the final phrasing once on a device rather than assuming it.</p>
<h2>Clear and replace for things people operate</h2>
<p>Merging has a limit, and it shows up the moment the children are interactive. <strong>A child that is itself clickable, toggleable, or otherwise merging stays its own focus stop.</strong> Wrap <code>NaiveRating</code> in <code>semantics(mergeDescendants = true)</code> and nothing changes: TalkBack still finds five buttons.</p>
<p>When the visual parts of an <em>input</em> control mean nothing on their own, replace them. <code>clearAndSetSemantics</code> throws away every descendant's semantics and lets you declare the node's entire accessible identity. Touch input on the children keeps working exactly as before. Only what assistive technology sees changes.</p>
<pre><code class="language-kotlin">Row(
    modifier = Modifier.clearAndSetSemantics {
        // Everything TalkBack knows about this control is declared here, and only here.
    },
) {
    // five tappable stars, invisible to accessibility services
}
</code></pre>
<p>The trade-off: you now own everything. Anything you don’t declare doesn’t exist. That includes the label, the value, the role, and every way to operate the control. Steps 2 and 3 fill those in.</p>
<h2>Step 2: Say what it is, then what it holds</h2>
<p>Two properties do two different jobs:</p>
<ul>
<li><code>contentDescription</code> <strong>is identity.</strong> It's stable for the life of the screen: <em>"Recipe rating."</em></li>
<li><code>stateDescription</code> <strong>is the current value.</strong> It changes as the user acts: <em>"Not rated," "3 of 5 stars."</em></li>
</ul>
<p>It’s tempting to cram both into one string, <code>"Recipe rating, 3 of 5 stars"</code>. Keep them apart instead, for three reasons:</p>
<ol>
<li>When the focused node’s state description changes, TalkBack can announce the new value without re-reading the label.</li>
<li>Tests (Part 3) can assert on the value directly.</li>
<li>Translators get two clean strings instead of one concatenation.</li>
</ol>
<img src="https://cdn-images-1.medium.com/max/800/1*7g1J_sEzwtkg59T7fxDweg.png" alt="announcement-anatomy" style="display:block;margin:0 auto" />

<h2>Choosing the interaction model</h2>
<p>The role question is really a question about <strong>how the control behaves</strong>. For star ratings there are three honest options.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*v3BiVUi9EwDisuu21ctvaw.png" alt="interaction-models" style="display:block;margin:0 auto" />

<ul>
<li><strong>Read-only display.</strong> One node with a description and no actions. Use this for averages on product cards. Don’t make it <em>look</em> tappable if it isn’t.</li>
<li><strong>Radio group.</strong> Put <code>Modifier.selectableGroup()</code> on the row and <code>Modifier.selectable(selected = ..., role = Role.RadioButton)</code> on each star. This allows direct selection, but it costs five focus stops. It also creates a quiet mismatch: three stars <em>look</em> filled while only one is <em>selected</em>. Radio groups suit small sets of choices with no order, like S/M/L, much better than ordered scales.</li>
<li><strong>Adjustable value.</strong> One node that declares a range and a <code>setProgress</code> action. This is exactly how Material's own <code>Slider</code> presents itself: it sets no role at all, and Compose exposes it to Android as a seek bar. It gives users one stop, and it matches what a rating actually <em>is</em>, a point on an ordered scale.</li>
</ul>
<p>For rating <em>input</em>, the adjustable model wins. Its one weakness is that adjust gestures are invisible to people who don’t already know them. Step 3 fixes that.</p>
<pre><code class="language-kotlin">Modifier.clearAndSetSemantics {
    contentDescription = label
    stateDescription = stateText
    progressBarRangeInfo = ProgressBarRangeInfo(
        current = rating.toFloat(),
        range = 0f..maxRating.toFloat(),
        steps = maxRating - 1, // whole stars only: 1, 2, 3, 4 sit between the ends
    )
    setProgress { target -&gt;
        val next = target.roundToInt().coerceIn(0, maxRating)
        if (next != rating) onRatingChange(next)
        true
    }
}
</code></pre>
<p>Three details here matter in production:</p>
<ul>
<li><strong>Without</strong> <code>stateDescription</code><strong>, the value is spoken as a percentage.</strong> Compose falls back to describing a range by its percentage, so a 3-star rating would come out as <em>"60 percent."</em> Your state description replaces that with the words people actually use.</li>
<li><code>steps</code> <strong>controls how far one adjustment moves the value.</strong> When a node exposes both a range and <code>setProgress</code>, Compose also offers the platform's scroll-forward and scroll-backward actions. TalkBack's adjust gestures use those, moving by one step. With <code>steps = maxRating - 1</code>, one step is exactly one star. Leave <code>steps</code> at <code>0</code> and the range is treated as continuous, so each adjustment moves by a small fraction of a star.</li>
<li><code>setProgress</code> <strong>must snap.</strong> Assistive technology can request any float value. Round it and clamp it to the range yourself rather than trusting the caller.</li>
</ul>
<h2>Step 3: Custom actions, for people who can’t aim</h2>
<p>Adjust gestures exist, but plenty of TalkBack users never learn them. Switch Access and Voice Access users depend on <em>named</em> actions. <code>CustomAccessibilityAction</code> adds named commands to the node, which TalkBack lists in its Actions menu and other services expose in their own way.</p>
<pre><code class="language-xml">&lt;string name="rating_action_increase"&gt;Increase rating&lt;/string&gt;
&lt;string name="rating_action_decrease"&gt;Decrease rating&lt;/string&gt;
&lt;string name="rating_action_clear"&gt;Clear rating&lt;/string&gt;
</code></pre>
<pre><code class="language-kotlin">customActions = buildList {
    if (rating &lt; maxRating) {
        add(CustomAccessibilityAction(increaseLabel) { onRatingChange(rating + 1); true })
    }
    if (rating &gt; 0) {
        add(CustomAccessibilityAction(decreaseLabel) { onRatingChange(rating - 1); true })
        add(CustomAccessibilityAction(clearLabel) { onRatingChange(0); true })
    }
}
</code></pre>
<p>The lambda returns a <code>Boolean</code>. <code>true</code> means "handled." <code>false</code> tells the accessibility service the action didn't happen, and the service may give the user failure feedback. The better pattern, shown above, is to <strong>offer only the actions that can succeed right now.</strong> There's no "Increase rating" at five stars, so a user can never pick an action that silently does nothing.</p>
<p>This step isn’t polish. Once you’ve called <code>clearAndSetSemantics</code>, the individual stars are <em>invisible</em> to TalkBack. Exploring by touch lands on the whole control, not on star four. For anyone using assistive technology, adjust gestures and named actions are now the <em>only</em> ways to change the value. That's exactly why both must exist.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*nIFOAzMR4R8CMHtf0esR0g.png" alt="action-roundtrip" style="display:block;margin:0 auto" />

<h2>The stale-state trap</h2>
<p>Here’s a bug that survives code review because the screen looks correct:</p>
<pre><code class="language-kotlin">// ❌ Computed once on first composition, never again
val context = LocalContext.current
val stateText = remember { context.getString(R.string.rating_state, rating, maxRating) }
</code></pre>
<p>The user picks “Increase rating.” The parent updates its state, the stars repaint, and TalkBack still says <em>“3 of 5 stars.”</em> What the screen shows and what TalkBack says have drifted apart, and only screen reader users notice. Derive the description from current state on every composition (<code>stringResource</code> is cheap). If you really must cache it, key the cache: <code>remember(rating, maxRating) { ... }</code>.</p>
<p>A related version of the same bug: a caller passes <code>onRatingChange = {}</code> while wiring things up, the stars appear to work in a preview because the preview owns its own state, and the action does nothing in the real screen. Hoist the state, and test the action, not just the tap. Part 3 shows how.</p>
<h2>Putting it together</h2>
<p>Here’s the complete control. Every line maps to one of the steps above.</p>
<pre><code class="language-xml">&lt;string name="rating_state"&gt;%1$d of %2$d stars&lt;/string&gt;
&lt;string name="rating_state_none"&gt;Not rated&lt;/string&gt;
&lt;string name="label_recipe_rating"&gt;Recipe rating&lt;/string&gt;
</code></pre>
<pre><code class="language-kotlin">@Composable
fun RatingStars(
    rating: Int,
    onRatingChange: (Int) -&gt; Unit,
    label: String,
    modifier: Modifier = Modifier,
    maxRating: Int = 5,
    enabled: Boolean = true,
) {
    // Step 2: state is derived on every composition, never cached without a key.
    val stateText = if (rating == 0) {
        stringResource(R.string.rating_state_none)
    } else {
        stringResource(R.string.rating_state, rating, maxRating)
    }
    val increaseLabel = stringResource(R.string.rating_action_increase)
    val decreaseLabel = stringResource(R.string.rating_action_decrease)
    val clearLabel = stringResource(R.string.rating_action_clear)

    Row(
        // Step 1: one node; the stars below contribute nothing to semantics.
        modifier = modifier.clearAndSetSemantics {
            contentDescription = label
            stateDescription = stateText
            progressBarRangeInfo = ProgressBarRangeInfo(
                current = rating.toFloat(),
                range = 0f..maxRating.toFloat(),
                steps = (maxRating - 1).coerceAtLeast(0),
            )
            if (enabled) {
                setProgress { target -&gt;
                    val next = target.roundToInt().coerceIn(0, maxRating)
                    if (next != rating) onRatingChange(next)
                    true
                }
                // Step 3: only actions that can succeed right now.
                customActions = buildList {
                    if (rating &lt; maxRating) {
                        add(CustomAccessibilityAction(increaseLabel) { onRatingChange(rating + 1); true })
                    }
                    if (rating &gt; 0) {
                        add(CustomAccessibilityAction(decreaseLabel) { onRatingChange(rating - 1); true })
                        add(CustomAccessibilityAction(clearLabel) { onRatingChange(0); true })
                    }
                }
            } else {
                disabled()
            }
        },
    ) {
        for (star in 1..maxRating) {
            val filled = star &lt;= rating
            Box(
                modifier = Modifier
                    .size(48.dp)  // Part 1, Rule 2: 48dp touch target per star
                    .clip(CircleShape)
                    .clickable(enabled = enabled) {
                        // Tapping the current top star again clears the rating.
                        onRatingChange(if (star == rating) 0 else star)
                    },
                contentAlignment = Alignment.Center,
            ) {
                Icon(
                    // Part 1, Rule 4: filled vs outline differ in shape, not only color.
                    painter = painterResource(
                        if (filled) R.drawable.ic_star_filled else R.drawable.ic_star_outline,
                    ),
                    contentDescription = null,
                    tint = if (filled) {
                        MaterialTheme.colorScheme.primary
                    } else {
                        MaterialTheme.colorScheme.onSurfaceVariant
                    },
                    modifier = Modifier.size(28.dp),
                )
            }
        }
    }
}
</code></pre>
<p>Using it looks like any other state-hoisted control:</p>
<pre><code class="language-kotlin">var rating by rememberSaveable { mutableIntStateOf(0) }

RatingStars(
    rating = rating,
    onRatingChange = { rating = it },
    label = stringResource(R.string.label_recipe_rating),
)
</code></pre>
<p><strong>Before and after</strong>, as a TalkBack user experiences it:</p>
<ul>
<li><strong>Before:</strong> five stops, each <em>“Star, button.”</em> The score is never spoken, and the control’s purpose is never stated.</li>
<li><strong>After, on focus:</strong> one stop, <em>“Recipe rating, Not rated, slider,”</em> followed by TalkBack’s usage hint.</li>
<li><strong>After an adjust gesture or “Increase rating”:</strong> <em>“1 of 5 stars.”</em></li>
<li><strong>After “Clear rating” from the Actions menu:</strong> <em>“Not rated.”</em></li>
</ul>
<p>Exact wording, like whether the role is spoken as “slider” or “seek control,” depends on the TalkBack version. The structure is what matters.</p>
<blockquote>
<p><em><strong>Keyboard note.</strong></em> <em>The stars are hidden from TalkBack, but their</em> <code>clickable</code> <em>modifiers still accept keyboard focus. On tablets and Chromebooks with a hardware keyboard, that means five Tab stops and no arrow-key adjustment. If keyboard users matter to your app, give the stars</em> <code>focusProperties { canFocus = false }</code><em>, make the row focusable, and handle left and right arrow keys there. Part 4 covers focus in depth.</em></p>
</blockquote>
<h2>A reusable pattern for any custom control</h2>
<p>The rating control is just one example. The same five questions work for any custom component you build:</p>
<ol>
<li><strong>What’s the unit?</strong> Merge things people read. Clear and replace things people operate.</li>
<li><strong>What is it?</strong> A stable <code>contentDescription</code>, or the label from a role-bearing modifier.</li>
<li><strong>What does it hold right now?</strong> A <code>stateDescription</code>, plus the platform's own notion of state where one exists: <code>toggleableState</code>, <code>selected</code>, or a range.</li>
<li><strong>How is it operated without precision?</strong> Use built-in actions first (<code>onClick</code>, <code>setProgress</code>, scrolling), then custom actions for everything else.</li>
<li><strong>Does the spoken state change when the pixels do?</strong> If you can’t answer that with a test, you don’t know.</li>
</ol>
<img src="https://cdn-images-1.medium.com/max/800/1*EmUFXJl58u9h63qYg3y4FA.png" alt="custom-control-checklist" style="display:block;margin:0 auto" />

<p>One more rule: <strong>reach for the semantic modifier before hand-rolling semantics.</strong> <code>Modifier.toggleable(value, role = Role.Switch, onValueChange = ...)</code> gives you the role, the on/off state, and the click action in one line. Write custom semantics only for what the stock modifiers can't express.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Merging without silencing children.</strong> The parent gets a good label, but each child icon keeps its own description, so TalkBack reads the summary and then <em>“Star, Star, Star, Star, Star.”</em> After merging, children that are only visual get <code>contentDescription = null</code>.</li>
<li><strong>Actions that change state but not the spoken state.</strong> This usually comes from a description cached with <code>remember { }</code> and no key, or a callback that was never hoisted. The screen updates and the speech doesn't.</li>
<li><strong>A role that contradicts behavior.</strong> <code>Role.Button</code> on an adjustable control makes TalkBack promise <em>"double-tap to activate,"</em> and then nothing happens. Radio buttons on an ordered scale confuse which stars are "selected." If the role tells the user what gesture to use, that gesture must work.</li>
<li><strong>Clearing semantics and forgetting to put anything back.</strong> After <code>clearAndSetSemantics</code>, a control with no <code>setProgress</code>, no <code>onClick</code>, and no custom actions can be focused but never operated.</li>
</ul>
<h2>Up next</h2>
<p>Every property we declared here (the content description, the state, the range, the actions) can be queried, which means it can be tested. <a href="https://medium.com/p/99a7d8898804"><strong>Part 3, <em>Testing Accessibility</em></strong></a><strong>,</strong> turns this post into CI checks:</p>
<ul>
<li>asserting state descriptions</li>
<li>invoking custom actions from a test</li>
<li>enforcing 48dp targets</li>
<li>letting Google’s Accessibility Test Framework catch what you forgot</li>
</ul>
<p>It also covers what no automated check can catch, and why the manual TalkBack routine from Part 1 still earns its ten minutes per release.</p>
<h2>🔐 Go Beyond Using Android — Master How It’s Actually Secured</h2>
<p>If you’re building, testing, or breaking Android apps, understanding the security model beneath the SDK separates developers who follow best practices from those who truly understand <em>why</em> those practices exist. I highly recommend <strong>“Mastering Android Security: From Fundamentals to Advanced Practice.”</strong> It’s a comprehensive, architecture-first journey through app sandboxing, cryptography, network defense, reverse engineering, and exploitation — spanning everything from <code>AndroidManifest.xml</code> internals to TrustZone and the Verified Boot chain, with hands-on labs at every step.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=1LLyEQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0H7XXM933"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249109"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/part-2-from-five-stars-to-one-sentence-semantics-for-custom-compose-controls-0f6360500ae8"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Part 1: Raising the Floor: Five Accessibility Rules Every Compose Screen Must Pass]]></title><description><![CDATA[Make every Jetpack Compose screen accessible: TalkBack labels, 48dp touch targets, WCAG contrast, color-free meaning and reading order in Kotlin.]]></description><link>https://android-expert.hashnode.dev/part-1-raising-the-floor-five-accessibility-rules-every-compose-screen-must-pass</link><guid isPermaLink="true">https://android-expert.hashnode.dev/part-1-raising-the-floor-five-accessibility-rules-every-compose-screen-must-pass</guid><category><![CDATA[Android Accessibility]]></category><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[Talkback]]></category><category><![CDATA[Accessibility]]></category><category><![CDATA[Android UI]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 16:36:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/9d061862-3b13-48b0-b9ed-a0720fbc9c2f.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Build for Everyone · Part 1 of 5</em></p>
<blockquote>
<p><em><strong>TL;DR</strong></em> <em>Accessibility doesn’t start with exotic APIs. It starts with five properties every screen must have: meaningful labels, 48dp touch targets, readable contrast, meaning that survives without color, and a reading order that matches intent. Each one is cheap to get right on day one and expensive to retrofit later. This post shows the idiomatic Compose way to do each, plus the specific ways each one breaks in production.</em></p>
</blockquote>
<h2>The bug report that wasn’t a crash</h2>
<p>Imagine a release that looks perfect on every dashboard. Crash-free sessions above 99.9%, ANRs flat, conversion steady. Two weeks later a support ticket arrives: <em>“I can’t find how to pay.”</em> The user relies on TalkBack. During a redesign, the checkout entry point became an <code>Icon</code> with <code>Modifier.clickable</code> and no description. TalkBack announced it as <em>"Unlabeled, double-tap to activate."</em> Nothing threw. No metric moved. A paying customer simply could not pay.</p>
<p>That’s the defining property of accessibility bugs: <strong>they fail silently, and only for the people least able to work around them.</strong></p>
<img src="https://cdn-images-1.medium.com/max/800/1*wbBKRy5vUfY4Xrc-LUjSDA.png" alt="who-benefits" style="display:block;margin:0 auto" />

<p>The audience is wider than “screen reader users.” It includes people with low vision who run at 200% font scale, the roughly one in twelve men with some form of color-vision deficiency, people with tremors, and every user who has ever squinted at a phone in direct sunlight or used it one-handed on a crowded train.</p>
<p>There’s a business and legal side too. The European Accessibility Act has applied to many consumer-facing digital products and services in the EU since June 2025, accessibility claims against mobile apps are routine in the US, and Google Play’s pre-launch report already flags accessibility issues on your builds. But the strongest argument is the simplest: an app that some people cannot use is, for them, a broken app.</p>
<p>Here are the five rules that form the floor.</p>
<h2>Rule 1: Every interactive element has a meaningful label</h2>
<p>When a clickable node has no text, no <code>contentDescription</code>, and nothing merged in from its children, TalkBack has nothing meaningful to say. It falls back to the role and "Unlabeled," as in "Unlabeled, button." A label should describe <strong>what the thing is and its current state</strong>, not what it looks like.</p>
<p>Generic: “Cart.” Useful: “Shopping cart, 3 items.”</p>
<pre><code class="language-xml">&lt;!-- res/values/strings.xml --&gt;
&lt;string name="cd_cart_empty"&gt;Shopping cart, empty&lt;/string&gt;
&lt;plurals name="cd_cart_items"&gt;
    &lt;item quantity="one"&gt;Shopping cart, %d item&lt;/item&gt;
    &lt;item quantity="other"&gt;Shopping cart, %d items&lt;/item&gt;
&lt;/plurals&gt;
</code></pre>
<pre><code class="language-kotlin">@Composable
fun CartAction(itemCount: Int, onOpenCart: () -&gt; Unit) {
    // English ignores quantity="zero", so the empty state needs its own string.
    val description = if (itemCount == 0) {
        stringResource(R.string.cd_cart_empty)
    } else {
        pluralStringResource(R.plurals.cd_cart_items, itemCount, itemCount)
    }

    IconButton(onClick = onOpenCart) {
        BadgedBox(
            badge = {
                if (itemCount &gt; 0) {
                    // The icon label already carries the count. Without this, IconButton
                    // merges the badge text too: "Shopping cart, 3 items, 3".
                    Badge(modifier = Modifier.clearAndSetSemantics { }) {
                        Text(itemCount.toString())
                    }
                }
            },
        ) {
            Icon(Icons.Outlined.ShoppingCart, contentDescription = description)
        }
    }
}
</code></pre>
<p>Three production failure modes worth knowing:</p>
<ol>
<li><strong>String concatenation.</strong> <code>"Cart, " + count + " items"</code> ships English to your Arabic users and breaks pluralization in languages with more than two plural forms. Always go through resources.</li>
<li><strong>The silent</strong> <code>quantity="zero"</code><strong>.</strong> In English, Android selects plural forms by grammatical category, not literal value, so a <code>zero</code> item is never chosen. Teams discover this when TalkBack reads "Shopping cart, 0 items" in a locale they never tested.</li>
<li><strong>Leaky merges.</strong> Clickable containers merge their children’s semantics. Anything visible inside, like badges, prices, or “NEW” chips, gets appended to the announcement unless you deliberately clear it.</li>
</ol>
<h2>Decorative vs informative images</h2>
<p>If removing an image loses no information, it’s decorative. Pass <code>null</code> so TalkBack skips it entirely:</p>
<pre><code class="language-kotlin">Image(
    painter = painterResource(R.drawable.header_wave),
    contentDescription = null, // pure ornament: skip it
    modifier = Modifier.fillMaxWidth(),
)
</code></pre>
<p>If the image carries information, like a product photo, describe it. Prefer alt text from your backend, and fall back to something human rather than a filename:</p>
<pre><code class="language-kotlin">AsyncImage( // Coil
    model = product.imageUrl,
    contentDescription = product.altText ?: product.name,
)
</code></pre>
<p>A related rule: an <code>Icon</code> sitting next to a <code>Text</code> inside a <code>Button</code> should have <code>contentDescription = null</code>. The text is already the label; describing the icon too produces "Download, Download."</p>
<h2>Rule 2: Touch targets are at least 48dp × 48dp</h2>
<p>Small targets punish users with tremors, limited dexterity, or large fingers, and anyone tapping while walking. The key insight is that <strong>the visual size and the touch size are independent</strong>. Your designer’s 18dp info glyph can stay 18dp; its hit area cannot.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*ucHlZ1UsROCdU56HYXzSCQ.png" alt="touch-target" style="display:block;margin:0 auto" />

<p>Here’s the bug as it usually appears in a code review:</p>
<pre><code class="language-kotlin">// ❌ 18dp visual AND 18dp touch area
Icon(
    imageVector = Icons.Outlined.Info,
    contentDescription = stringResource(R.string.cd_more_info),
    modifier = Modifier
        .size(18.dp)
        .clickable(onClick = onClick),
)
</code></pre>
<p>And the fix, which separates the two sizes explicitly:</p>
<pre><code class="language-kotlin">@Composable
fun InlineInfoButton(onClick: () -&gt; Unit, modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .sizeIn(minWidth = 48.dp, minHeight = 48.dp) // touch area
            .clip(CircleShape)                           // 48dp circular ripple, like IconButton
            .clickable(role = Role.Button, onClick = onClick),
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            imageVector = Icons.Outlined.Info,
            contentDescription = stringResource(R.string.cd_more_info),
            modifier = Modifier.size(18.dp), // visual size
        )
    }
}
</code></pre>
<p>If you’re on Material 3, <code>Modifier.minimumInteractiveComponentSize()</code> does the same job and is what <code>IconButton</code>, <code>Checkbox</code>, and friends use internally. Prefer the stock components where they fit; reach for the modifier when you build your own.</p>
<p>Two subtler failure modes:</p>
<ul>
<li><strong>Modifier order shrinks hit areas.</strong> <code>Modifier.padding(12.dp).clickable { }</code> makes the padding <em>untappable</em>, because <code>clickable</code> only sees the space left after padding. If the padding should be tappable, put <code>clickable</code> first.</li>
<li><strong>“Dense mode” overrides.</strong> Teams squeezing toolbars sometimes provide a zero or unspecified value for <code>LocalMinimumInteractiveComponentSize</code> high in the tree. That silently shrinks every Material control below it. If you see that override, treat it as a bug unless there's a documented reason.</li>
</ul>
<p>Also watch spacing: two 48dp targets that touch edge to edge are technically compliant and still easy to mis-tap. Leave at least 8dp between adjacent actions.</p>
<h2>Rule 3: Text has enough contrast to read</h2>
<p>The WCAG thresholds are the de facto standard: <strong>4.5:1</strong> for normal text, <strong>3:1</strong> for large text (WCAG defines this as 18pt regular or 14pt bold, which is roughly 24sp and 18.7sp on Android), and <strong>3:1</strong> for meaningful non-text UI like input borders and icons.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*-GgYnjpDHqf3NAObeU4i5A.png" alt="contrast-ratios" style="display:block;margin:0 auto" />

<p>The most useful thing a senior engineer can do here is turn contrast into a test against your theme tokens instead of a one-off check in a design tool. The math lives in <code>ui-graphics</code> already:</p>
<pre><code class="language-kotlin">/** WCAG 2.x contrast ratio. Translucent foregrounds are flattened onto the background first. */
fun contrastRatio(foreground: Color, background: Color): Float {
    val fg = foreground.compositeOver(background).luminance()
    val bg = background.luminance()
    val lighter = maxOf(fg, bg)
    val darker = minOf(fg, bg)
    return (lighter + 0.05f) / (darker + 0.05f)
}
</code></pre>
<pre><code class="language-kotlin">import kotlin.test.Test
import kotlin.test.assertTrue

class ThemeContrastTest {

    private fun ColorScheme.textPairs() = mapOf(
        "onSurface/surface" to (onSurface to surface),
        "onSurfaceVariant/surface" to (onSurfaceVariant to surface),
        "onPrimary/primary" to (onPrimary to primary),
        "error/surface" to (error to surface),
        "secondaryText/surface" to (onSurface.copy(alpha = 0.7f) to surface),
    )

    @Test
    fun textRolesMeetWcagAa() {
        val failures = mapOf("light" to AppLightColors, "dark" to AppDarkColors)
            .flatMap { (themeName, scheme) -&gt;
                scheme.textPairs().mapNotNull { (role, pair) -&gt;
                    val ratio = contrastRatio(pair.first, pair.second)
                    if (ratio &lt; 4.5f) "$themeName/$role = ${"%.2f".format(ratio)}:1" else null
                }
            }
        assertTrue(failures.isEmpty(), "Contrast failures:\n${failures.joinToString("\n")}")
    }
}
</code></pre>
<p>Note the <code>compositeOver</code> call. The most common real-world contrast regression isn't a bad palette; it's <code>color.copy(alpha = 0.6f)</code> used for "secondary" text. On a light surface it may scrape by. On a dark surface, the same alpha can drop well below 4.5:1. Measuring the raw color without compositing hides the problem completely.</p>
<p><strong>Dark theme pitfalls</strong>, briefly:</p>
<ul>
<li>Brand colors tuned for white backgrounds often fail on dark surfaces. Give dark theme its own tonal values rather than reusing the light ones.</li>
<li>Elevated surfaces in Material 3 dark theme are lighter than the base surface. Text that passes on <code>surface</code> may fail on <code>surfaceContainerHighest</code>, so test the containers you actually use.</li>
<li>Disabled controls are exempt from WCAG contrast, but disabled is not the same as “secondary.” Don’t use disabled styling for content users need to read.</li>
</ul>
<p>For quick manual checks, Google’s Accessibility Scanner flags low-contrast text on a running screen, and Part 3 of this series automates the same check in instrumented tests.</p>
<h2>Rule 4: Never let color carry meaning alone</h2>
<p>If you convert your screen to grayscale and something stops making sense, you’re relying on color alone. Error states are the classic case: a red border that means “this field is invalid” is invisible to many color-blind users and meaningless to TalkBack.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*U8lnVG8u7vddqK4QUT8FrA.png" alt="color-alone" style="display:block;margin:0 auto" />

<p>Redundancy is the fix: <strong>color plus an icon plus text</strong> on screen, and a single, specific error for assistive tech.</p>
<pre><code class="language-xml">&lt;string name="error_with_prefix"&gt;Error: %1$s&lt;/string&gt;
</code></pre>
<pre><code class="language-kotlin">@Composable
fun EmailField(
    value: String,
    onValueChange: (String) -&gt; Unit,
    errorMessage: String?,
    modifier: Modifier = Modifier,
) {
    OutlinedTextField(
        value = value,
        onValueChange = onValueChange,
        label = { Text(stringResource(R.string.label_email)) },
        isError = errorMessage != null,
        trailingIcon = errorMessage?.let {
            { Icon(Icons.Filled.Error, contentDescription = null) } // the text explains it
        },
        supportingText = errorMessage?.let { message -&gt;
            {
                Text(
                    text = stringResource(R.string.error_with_prefix, message),
                    // Visual only: the reason reaches TalkBack once, via error() below.
                    modifier = Modifier.clearAndSetSemantics { },
                )
            }
        },
        singleLine = true,
        modifier = modifier.semantics {
            // With isError = true, Material 3 attaches a generic, localized error
            // description ("Invalid input" in English). Replace it with the real reason.
            if (errorMessage != null) error(errorMessage)
        },
    )
}
</code></pre>
<p>Why clear the supporting text’s semantics? The field merges its decorations into one node, so if both the supporting text and <code>error()</code> carry the message, TalkBack reads it twice in a row. Pick one channel for speech. The <code>error()</code> property is the better one: TalkBack announces it as an error, and Part 3's tests can assert on it directly. Behavior varies across Material 3 and TalkBack versions, so confirm on a device that you hear the message exactly once.</p>
<p>The same principle applies well beyond forms:</p>
<ul>
<li><strong>Status chips</strong> (“Shipped,” “Delayed,” “Cancelled”) need a text label, not just a green, amber, or red dot.</li>
<li><strong>Charts</strong> need direct labels or patterns, not a legend that maps five similar hues.</li>
<li><strong>Required fields</strong> need an asterisk <em>and</em> a “required” label in semantics, not only a colored label.</li>
<li><strong>Links in body text</strong> need an underline or weight change, not just a different color.</li>
</ul>
<h2>Rule 5: The reading order matches the visual intent</h2>
<p>TalkBack users move through a screen element by element with swipes. Compose builds that order from the semantics tree and then sorts siblings geometrically: roughly top to bottom, then start to end, grouping elements that share a row. For simple <code>Column</code> and <code>Row</code> layouts, that's exactly what you want.</p>
<p>It goes wrong when your layout is organized in <strong>columns that should be read one at a time</strong>. A side-by-side plan comparison is the textbook case:</p>
<img src="https://cdn-images-1.medium.com/max/800/1*1ugSCpiiBIgY7oA5vIOC3w.png" alt="reading-order" style="display:block;margin:0 auto" />

<p>Out of the box, TalkBack reads row by row across both columns: “Basic, Pro, ₹149 a month, ₹399 a month…” The user hears two products shuffled together. The fix is to tell Compose each column is a unit:</p>
<pre><code class="language-kotlin">@Composable
fun PlanComparison(basic: Plan, pro: Plan, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        PlanColumn(basic, Modifier.weight(1f).semantics { isTraversalGroup = true })
        PlanColumn(pro, Modifier.weight(1f).semantics { isTraversalGroup = true })
    }
}
</code></pre>
<p>For finer control within a group, <code>traversalIndex</code> reorders siblings (lower values come first; the default is <code>0f</code>). A common case is an overlay action pinned to the top corner of a card. Geometry says read it first; intent says read the content first:</p>
<pre><code class="language-kotlin">@Composable
fun DealCard(deal: Deal, onSave: () -&gt; Unit) {
    Box(Modifier.semantics { isTraversalGroup = true }) {
        Image(
            painter = painterResource(deal.imageRes),
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
        )
        IconButton(
            onClick = onSave,
            modifier = Modifier
                .align(Alignment.TopEnd)
                .semantics { traversalIndex = 1f }, // after title and price
        ) {
            Icon(
                Icons.Outlined.FavoriteBorder,
                contentDescription = stringResource(R.string.cd_save_deal, deal.title),
            )
        }
        Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
            Text(deal.title, style = MaterialTheme.typography.titleMedium)
            Text(deal.priceLabel)
        }
    }
}
</code></pre>
<p><strong>When not to override.</strong> Traversal overrides are a patch, not a design tool. If you find yourself setting <code>traversalIndex</code> on half a screen, the layout structure itself is probably wrong, and restructuring will fix TalkBack, keyboard navigation, and future maintainers all at once. Overrides also don't survive refactors well: a stray index left behind after someone moves a composable creates order bugs nobody can explain.</p>
<h2>A five-minute exercise</h2>
<p>Do this today, on your own app:</p>
<ol>
<li>Enable TalkBack (<em>Settings → Accessibility → TalkBack</em>), or toggle it with the volume-key shortcut.</li>
<li>In TalkBack’s developer settings, turn on <strong>Display speech output</strong> so you can read what it says.</li>
<li>Open your busiest screen and swipe right through every element without looking at the layout.</li>
<li>Write down every “Unlabeled,” every duplicated phrase, and every moment you lose track of where you are.</li>
</ol>
<p>Most teams find at least one issue per screen the first time they do this. That list is your backlog.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Repeating the role in the label.</strong> Setting <code>contentDescription = "Submit button"</code> on a <code>Button</code> that already shows "Submit" produces "Submit button, Submit, Button." TalkBack announces the role itself.</li>
<li><strong>Describing decoration.</strong> Labelling dividers, background shapes, and avatar placeholders forces users to swipe past noise on every visit.</li>
<li><strong>Trusting Material to cover everything.</strong> Stock components handle roles, states, and touch size, but they can’t invent a meaningful label for your icon, fix your theme’s contrast, or know that your columns should be read separately.</li>
</ul>
<h2>The baseline checklist</h2>
<p>Print this, pin it next to your PR template, or turn each line into a review question.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*CmYamerwFIvA6ZNTlQ84LA.png" alt="baseline-checklist" style="display:block;margin:0 auto" />

<h2>Up next</h2>
<p>These five rules cover screens built from standard components. But the moment you build your own, like a star rating, a custom slider, or a swipe carousel, TalkBack sees a pile of unrelated shapes. <a href="https://medium.com/p/0f6360500ae8"><strong>Part 2, <em>Making Custom Composables Speak</em></strong></a><strong>,</strong> walks through the Compose semantics tree and turns a five-icon rating widget into a single, operable control.</p>
<h2>📱 Go Beyond Using Jetpack Compose</h2>
<p>If you’re building on Android, understanding what happens under the hood separates developers who use Compose from those who master it. I highly recommend <strong>“Mastering Jetpack Compose Internals”</strong>. It’s a deep, architecture-first walkthrough of the composition tree, the slot table, snapshot state, and the runtime that powers modern Android UI — capped off with a full case study building a real app called Mosaic.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=M2zxEQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0H7N45T8Q"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249073"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/part-1-raising-the-floor-five-accessibility-rules-every-compose-screen-must-pass-119e2de393ad"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Keep the Expensive Stuff Alive: A Field Guide to retain in Jetpack Compose]]></title><description><![CDATA[Learn how Jetpack Compose retain() keeps heavy objects alive across rotation, when to pick it over ViewModel, and four production pitfalls to avoid.]]></description><link>https://android-expert.hashnode.dev/keep-the-expensive-stuff-alive-a-field-guide-to-retain-in-jetpack-compose</link><guid isPermaLink="true">https://android-expert.hashnode.dev/keep-the-expensive-stuff-alive-a-field-guide-to-retain-in-jetpack-compose</guid><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[Compose State Management]]></category><category><![CDATA[Retain Api]]></category><category><![CDATA[Compose Best Practices]]></category><category><![CDATA[Android Viewmodel]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 16:27:34 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/9dcde9f5-59ec-48eb-bb9b-b1ed86f4aa6e.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every Compose codebase eventually grows the same awkward corner: a composable that needs one heavyweight object, a search session, a player wrapper, a debounced event pipeline, and that object has to survive a rotation. The usual answers are all slightly wrong for the job.</p>
<ul>
<li><code>remember</code> forgets everything on a configuration change.</li>
<li><code>rememberSaveable</code> demands something a <code>Bundle</code> can carry.</li>
<li>A <code>ViewModel</code> works, but for a single screen-scoped helper it is a lot of ceremony.</li>
</ul>
<p><code>retain</code> fills that gap. It shipped stable in Compose 1.10 (BOM <code>2025.12.00</code>), and this post covers what it does, where it fits, and the failure modes you will hit in production. (APIs this young move, so check signatures against the current release notes before copying code verbatim.)</p>
<h2>The mental model</h2>
<p><code>remember</code> stores a value in the composition. When the Activity is recreated, the composition is thrown away and the value goes with it.</p>
<p><code>rememberSaveable</code> stores a <em>serialized copy</em> in saved instance state. The value is rebuilt from that copy, so it must be <code>Bundle</code>-friendly and small, since the Bundle is shared with a size-limited cross-process transaction.</p>
<p><code>retain</code> does neither. It keeps the <strong>actual instance</strong> alive in memory while the Activity is torn down and rebuilt, then hands the same instance back to the new composition. Nothing is serialized, so there is no type restriction and no Bundle budget.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*iKYwxQQhXq4APbZroicvOw.png" alt="retain-comparison-card" style="display:block;margin:0 auto" />

<p>Two API facts to know up front. <code>retain</code> lives in the <code>androidx.compose.runtime.retain</code> package of Compose Runtime 1.10+. And it retains only as far as the surrounding <code>RetainedValuesStore</code> allows: the default store is forgetful, which makes <code>retain</code> behave exactly like <code>remember</code>. Compose UI's Android host supplies a store that survives Activity recreation, but if you build custom hosts or tests, check which store is in play. Also, retaining an object that implements <code>RememberObserver</code> without also implementing <code>RetainObserver</code> throws, and you can mark a class with <code>@DoNotRetain</code> to forbid retaining it.</p>
<p>The trade-off is in the last column of that card: retained values live on the process heap. They have no answer for process death.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*BXDEO6gnHhJ6o-NmEgtLqA.png" alt="retain-lifecycle-matrix" style="display:block;margin:0 auto" />

<h2>A first example</h2>
<p>Here is a search screen that owns a small pipeline: a query flow, a debounce, and a result stream. Rebuilding it on rotation would cancel in-flight requests and flash the UI.</p>
<pre><code class="language-kotlin">import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.retain.RetainObserver
import androidx.compose.runtime.retain.retain
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class CatalogSearch(private val api: CatalogApi) : RetainObserver {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    private val queries = MutableStateFlow("")

    val results: StateFlow&lt;List&lt;Item&gt;&gt; = queries
        .debounce(250)
        .distinctUntilChanged()
        .mapLatest { q -&gt; if (q.isBlank()) emptyList() else api.search(q) }
        .stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun onQueryChanged(q: String) { queries.value = q }

    override fun onRetained() = Unit
    override fun onEnteredComposition() = Unit
    override fun onExitedComposition() = Unit
    override fun onUnused() = scope.cancel()   // created but never applied
    override fun onRetired() = scope.cancel()  // screen is gone for good
}

@Composable
fun CatalogScreen(api: CatalogApi) {
    val search = retain(api) { CatalogSearch(api) }
    val results by search.results.collectAsStateWithLifecycle()

    SearchField(onChange = search::onQueryChanged)
    ResultList(results)
}
</code></pre>
<p>Three details matter here. The <code>api</code> key rebuilds the object if the dependency changes. Collection uses <code>collectAsStateWithLifecycle()</code> (from <code>lifecycle-runtime-compose</code>) so it pauses in the background. And the class implements <code>RetainObserver</code>, so its scope is cancelled when the value is retired rather than leaking until process death.</p>
<p>Rotate the device mid-typing: the same <code>CatalogSearch</code> instance, same scope, same debounce window. No <code>Parcelable</code>, no <code>ViewModelProvider</code>.</p>
<h2>Retained state that drives recomposition</h2>
<p><code>retain</code> stores whatever you hand it. If you want a value that is both retained <em>and</em> observable, retain a <code>MutableState</code>:</p>
<pre><code class="language-kotlin">@Composable
fun TapCounter() {
    val count = retain { mutableIntStateOf(0) }

    Button(onClick = { count.intValue++ }) {
        Text("Taps: ${count.intValue}")
    }
}
</code></pre>
<p><code>mutableIntStateOf</code> avoids boxing for a primitive counter, and the retained state holder is the same instance after rotation. Writes to it still trigger recomposition exactly as they would with <code>remember</code>.</p>
<h2>Choosing between the options</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*ummKkaFvl11nXEDLs_coWw.png" alt="retain-decision-flow" style="display:block;margin:0 auto" />

<p>A rule of thumb that has held up well: if losing the value on a rotation would hurt, but losing it after process death is acceptable, <code>retain</code> is a candidate. If the value must also survive process death, you need <code>rememberSaveable</code> or a <code>ViewModel</code> backed by <code>SavedStateHandle</code>.</p>
<p><code>retain</code> is also screen-level by design. Per-item state inside <code>LazyColumn</code> items is a poor fit, because items enter and leave composition constantly while scrolling. Hoist that state into a <code>ViewModel</code>, or use <code>rememberSaveable</code> keyed by a stable item ID.</p>
<h2>Teardown: the part people forget</h2>
<p>A retained object outlives the composition that created it. That is the point, and also the hazard. Something has to release it when the screen is <em>actually</em> done, not merely rotated.</p>
<p>Compose provides <code>RetainObserver</code> for this. An object is <em>retained</em> when <code>retain</code> first returns it, may enter and exit composition any number of times (rotation is exactly that), and is <em>retired</em> once it is removed from composition and no store is keeping it. The interface has five callbacks: <code>onRetained</code>, <code>onEnteredComposition</code>, <code>onExitedComposition</code>, <code>onRetired</code>, and <code>onUnused</code>, which fires when the value was created but the composition was abandoned before applying it. The opening example already shows the pattern; cancel in both <code>onRetired</code> and <code>onUnused</code>.</p>
<p>For one-off side effects that don’t belong inside a class, <code>RetainedEffect</code> gives you a <code>DisposableEffect</code>-style block whose cleanup runs on retirement rather than on every rotation.</p>
<p>Tie cancellation to <em>retirement</em>, never to <code>onDispose</code> of a <code>DisposableEffect</code>. <code>onDispose</code> fires on every rotation, which would cancel the very scope you just worked to preserve.</p>
<h2>Four failure modes from production</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*mWxPzZCqfi3wH1strA42jA.png" alt="retain-failure-modes" style="display:block;margin:0 auto" />

<h2>1. The leaked Activity</h2>
<p>The classic. A retained object captures a <code>Context</code> obtained from <code>LocalContext.current</code>, which is the Activity. After rotation the old Activity cannot be collected, because your retained object still points at it.</p>
<pre><code class="language-kotlin">// Leaks the Activity across every rotation
val player = retain { VideoSession(LocalContext.current) }

// Safer: pass only what the object genuinely needs
val appContext = LocalContext.current.applicationContext
val player = retain { VideoSession(appContext) }
</code></pre>
<p>Better still, inject the collaborators an object needs (an <code>ExoPlayerFactory</code>, a repository) instead of any <code>Context</code>. Audit every constructor argument of a retained object for UI references: views, Activities, <code>Resources</code> tied to a themed context, listeners that close over composables.</p>
<h2>2. Nothing ever shuts it down</h2>
<p>Retained coroutine scopes keep collecting. Retained players keep their codecs. Retained socket wrappers keep sockets. If the screen is dismissed and nothing cancels the work, it runs until the process dies. Use <code>onRetired</code>/<code>onUnused</code> as shown above, and verify it with a leak-canary style test that pops the screen and asserts the scope is cancelled.</p>
<h2>3. Assuming it survives process death</h2>
<p>Retained values are plain heap objects. When the system reclaims your process in the background, they are gone, and the restored screen starts from scratch. Anything the user would be upset to lose, a draft, a selected ID, a scroll anchor, must also go through <code>rememberSaveable</code> or <code>SavedStateHandle</code>.</p>
<p>A pattern that works well is to save the <em>identity</em> and retain the <em>machinery</em>:</p>
<pre><code class="language-kotlin">class ArticleLoader(private val repo: ArticleRepository) : RetainObserver {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    private var loadedId: String? = null
    val state = MutableStateFlow&lt;ArticleUiState&gt;(ArticleUiState.Idle)

    fun load(id: String) {
        if (id == loadedId) return        // idempotent: a rotation must not reload
        loadedId = id
        scope.launch { state.value = ArticleUiState.Ready(repo.fetch(id)) }
    }

    override fun onRetained() = Unit
    override fun onEnteredComposition() = Unit
    override fun onExitedComposition() = Unit
    override fun onUnused() = scope.cancel()
    override fun onRetired() = scope.cancel()
}

@Composable
fun ArticleScreen(repo: ArticleRepository) {
    var articleId by rememberSaveable { mutableStateOf&lt;String?&gt;(null) }
    val loader = retain(repo) { ArticleLoader(repo) }
    val ui by loader.state.collectAsStateWithLifecycle()

    LaunchedEffect(articleId) { articleId?.let(loader::load) }
    // render ui ...
}
</code></pre>
<p><code>LaunchedEffect</code> re-runs after every recreation, so the retained loader makes <code>load</code> idempotent. After a rotation it is a no-op; after process death the loader is new, <code>loadedId</code> is null, and the saved ID triggers a fresh load.</p>
<h2>4. Stale inputs</h2>
<p>A retained lambda runs once. If the object was built from a parameter that later changes, it keeps using the old one. Pass the changing inputs as keys so the value is rebuilt when they change:</p>
<pre><code class="language-kotlin">val search = retain(api, userId) { CatalogSearch(api, userId) }
</code></pre>
<p>When the keys change, the previous instance is retired (and your teardown runs) before the new one takes over. That is exactly why teardown belongs in the retirement callback.</p>
<h2>Testing notes</h2>
<p>Retained values tie directly to Activity recreation, so unit tests on composables will not catch most of these bugs. Use an instrumented test that calls <code>ActivityScenario.recreate()</code> and asserts two things: the object identity is unchanged after recreation, and the teardown fires only after the screen is removed. Also run your leak detector across a rotation; a leaked Activity from failure mode one shows up immediately.</p>
<h2>When I would still reach for a ViewModel</h2>
<ul>
<li>The state must survive process death through <code>SavedStateHandle</code>.</li>
<li>Several screens or destinations share the same object.</li>
<li>Your team already has conventions, tooling, and test fixtures built around <code>ViewModel</code>.</li>
<li>The object participates in per-item logic inside lazy layouts.</li>
</ul>
<p><code>retain</code> is not a <code>ViewModel</code> replacement. It is a smaller tool for a narrower, very common job: one screen, one heavy helper, rotation-safe, no ceremony. Compose-first architectures such as Circuit, where presenters are themselves composable, are a natural home for it.</p>
<h2>🔚 Takeaways</h2>
<ol>
<li><code>retain</code> keeps the real instance alive across configuration changes, with no serialization and no Bundle limit.</li>
<li>It retains only as far as the host’s <code>RetainedValuesStore</code> allows, and it does not survive process death. Save identity with <code>rememberSaveable</code>, retain machinery.</li>
<li>Wrap <code>mutableStateOf</code> (or its primitive variants) inside <code>retain</code> when you need retained state that drives recomposition.</li>
<li>Never capture an Activity-scoped <code>Context</code> in a retained object.</li>
<li>Release resources on retirement, not on <code>onDispose</code>.</li>
<li>Keep it screen-level; hoist per-item state out of lazy layouts.</li>
</ol>
<p>Are you already using <code>retain</code> in a real project? I would like to hear which failure mode bit you first.</p>
<h2>📱 New to Jetpack Compose? Start Here</h2>
<p>If you’re coming from the classic Android View system — XML layouts, <code>findViewById</code>, RecyclerView adapters — and want a clear, no-jargon path into Jetpack Compose, I recommend <strong>"Learning Jetpack Compose: Your Practical Guide from the Android View System to Modern Declarative UI."</strong> Every chapter starts from something you already know and shows its Compose equivalent side by side, building up to a full capstone: a real Notes app. Once you're comfortable and want to go deeper — the Composition tree, recomposition internals, the runtime itself — <strong>"</strong><a href="https://play.google.com/store/books/details?id=M2zxEQAAQBAJ"><strong>Mastering Jetpack Compose Internals</strong></a><strong>"</strong> is the natural next step.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=nZb-EQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0HD2ZM4Z1"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249004"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/keep-the-expensive-stuff-alive-a-field-guide-to-retain-in-jetpack-compose-3f6d9b71ab90"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Seed Decides Everything: fold() vs reduce() in Production Kotlin on Android]]></title><description><![CDATA[Learn when to use Kotlin fold() vs reduce() on Android. Avoid empty-list crashes, mutation bugs, and overflow with examples and a decision guide.]]></description><link>https://android-expert.hashnode.dev/the-seed-decides-everything-fold-vs-reduce-in-production-kotlin-on-android</link><guid isPermaLink="true">https://android-expert.hashnode.dev/the-seed-decides-everything-fold-vs-reduce-in-production-kotlin-on-android</guid><category><![CDATA[kotlin collections]]></category><category><![CDATA[Functional Programming]]></category><category><![CDATA[Kotlin Fold]]></category><category><![CDATA[Kotlin Reduce]]></category><category><![CDATA[clean code]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 14:55:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/420e8d7a-916d-4c75-9d54-81221c7e2505.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><code>fold()</code> and <code>reduce()</code> look like twins. Both walk a collection, both hand you an <code>acc</code> and the next element, and both collapse everything into one value. Most developers pick whichever name comes to mind first, and for a long time nothing bad happens.</p>
<p>Then a Room query returns zero rows, a <code>Flow</code> emits its first empty list, and a screen crashes in production with <code>UnsupportedOperationException</code>. Or a "harmless" <code>reduce</code> quietly rewrites the first item in your UI state. Or a total of cached bytes wraps around to a negative number on a device with a big cache.</p>
<p>All of these trace back to one question: <strong>where does the accumulator start?</strong></p>
<p>This post walks through the mechanics, the type rules that follow from them, and the failure modes I would flag in a code review. The examples use a download and sync domain, so none of it is tied to a particular app.</p>
<h2>The one difference that matters</h2>
<ul>
<li><code>fold(initial)</code> starts from a value <strong>you</strong> provide, then processes <strong>every</strong> element.</li>
<li><code>reduce</code> starts from the <strong>first element</strong> and processes the <strong>remaining</strong> ones.</li>
</ul>
<img src="https://cdn-images-1.medium.com/max/800/1*riAmWHaxlxnrhotPiL7dkQ.png" alt="accumulator-start" style="display:block;margin:0 auto" />

<p>Here is the same sum written both ways:</p>
<pre><code class="language-kotlin">data class DownloadPart(
    val index: Int,
    val bytes: Long,
    val error: String? = null,
)

val parts = listOf(
    DownloadPart(index = 0, bytes = 4_096),
    DownloadPart(index = 1, bytes = 8_192),
    DownloadPart(index = 2, bytes = 2_048),
)

// reduce needs elements that are already the accumulator's type, so we map first.
val viaReduce: Long = parts
    .map { it.bytes }
    .reduce { acc, bytes -&gt; acc + bytes }

// fold starts from 0L, so it can read the field directly and skips the intermediate list.
val viaFold: Long = parts.fold(0L) { acc, part -&gt; acc + part.bytes }
</code></pre>
<p>Both give <code>14_336</code>. Look at what each version had to do to get there, though. The <code>reduce</code> version had to turn <code>DownloadPart</code> into <code>Long</code> first, because the accumulator and the elements share a type. The <code>fold</code> version was free to bring its own seed type.</p>
<p>(For a plain sum you would normally write <code>parts.sumOf { it.bytes }</code>. I'm using <code>fold</code> here because it is the simplest case that shows the mechanics.)</p>
<h2>Type freedom: why fold builds state and reduce merges values</h2>
<p>Because <code>fold</code> takes an explicit seed, its result type <code>R</code> is independent of the element type <code>T</code>:</p>
<pre><code class="language-kotlin">inline fun &lt;T, R&gt; Iterable&lt;T&gt;.fold(
    initial: R,
    operation: (acc: R, T) -&gt; R,
): R
</code></pre>
<p><code>reduce</code> has no seed to infer anything from. Its signature ties the accumulator to the elements:</p>
<pre><code class="language-kotlin">inline fun &lt;S, T : S&gt; Iterable&lt;T&gt;.reduce(
    operation: (acc: S, T) -&gt; S,
): S
</code></pre>
<p><code>S</code> has to be a supertype of <code>T</code>, so in practice the result is the element type, or a common parent of it.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*HHosZfhwRB2KOeIGH8T1xg.png" alt="type-flexibility" style="display:block;margin:0 auto" />

<p>That is why <code>fold</code> is the natural tool when you are <em>building a different shape</em> out of a list. Here a list of parts becomes a transfer report:</p>
<pre><code class="language-kotlin">data class TransferReport(
    val completedBytes: Long = 0,
    val failedParts: List&lt;Int&gt; = emptyList(),
)

fun List&lt;DownloadPart&gt;.toReport(): TransferReport =
    fold(TransferReport()) { report, part -&gt;
        if (part.error == null) {
            report.copy(completedBytes = report.completedBytes + part.bytes)
        } else {
            report.copy(failedParts = report.failedParts + part.index)
        }
    }
</code></pre>
<p><code>reduce</code> could not express this, since <code>DownloadPart</code> and <code>TransferReport</code> have nothing in common.</p>
<p><code>reduce</code> is the better fit when the first element really is a valid starting point and you are merging same-shaped values. A good example is combining time windows into one covering window:</p>
<pre><code class="language-kotlin">data class SyncWindow(val startMs: Long, val endMs: Long)

fun List&lt;SyncWindow&gt;.coveringWindow(): SyncWindow? =
    reduceOrNull { a, b -&gt;
        SyncWindow(
            startMs = minOf(a.startMs, b.startMs),
            endMs = maxOf(a.endMs, b.endMs),
        )
    }
</code></pre>
<p>There is no meaningful “zero window” to seed with, which is exactly why starting from the first element feels natural here. The function returns <code>SyncWindow?</code> for a reason, and the next section explains it.</p>
<h2>Failure mode 1: the empty collection</h2>
<p><code>reduce</code> cannot produce a value when there is no first element, so it throws. <code>fold</code> already holds its seed, so it simply returns it.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*ru-UuAHCKi0yfVlsNSEgCQ.png" alt="empty-collection" style="display:block;margin:0 auto" />

<p>On Android, empty collections are normal runtime states. They show up on a fresh install, after “clear cache”, on the first emission of a <code>Flow</code>, and for an account with no data yet. Here is a bug I have seen in different forms in several codebases:</p>
<pre><code class="language-kotlin">class StorageViewModel(dao: CachedFileDao) : ViewModel() {

    val usedBytes: StateFlow&lt;Long&gt; = dao.observeAll()
        .map { files -&gt;
            files.map { it.sizeBytes }.reduce { a, b -&gt; a + b } // throws when the cache is empty
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L)
}
</code></pre>
<p>It works in every manual test, because the tester always has data. It crashes the first time <code>observeAll()</code> emits an empty list. A crash inside <code>map</code> on a flow collected by <code>stateIn</code> goes through the scope's exception handling, so depending on your setup it can take down the screen or the whole process.</p>
<p>You have three sound fixes, and the choice depends on what “empty” should mean:</p>
<pre><code class="language-kotlin">// 1. An empty total is a legitimate zero.
files.fold(0L) { acc, f -&gt; acc + f.sizeBytes }   // or simply files.sumOf { it.sizeBytes }

// 2. Absence is a legitimate answer and the caller must handle it.
files.map { it.lastAccessMs }.reduceOrNull { a, b -&gt; maxOf(a, b) }   // Long?

// 3. The input is guaranteed non-empty, and you document why.
require(files.isNotEmpty()) { "Cache snapshot must contain at least one entry" }
</code></pre>
<p>My rule is that <code>reduce</code> is acceptable only when I can point to the line that guarantees non-emptiness. If I can't, it is <code>fold</code> or <code>reduceOrNull</code>.</p>
<h2>Failure mode 2: reduce mutates your first element</h2>
<p>Because the first element becomes the accumulator, a lambda that mutates <code>acc</code> mutates <strong>your data</strong>:</p>
<pre><code class="language-kotlin">class ShardTally(var hits: Int, var misses: Int)

val combined = shardTallies.reduce { acc, t -&gt;
    acc.also {
        it.hits += t.hits
        it.misses += t.misses
    }
}
</code></pre>
<p>After this call, <code>shardTallies[0]</code> holds the combined totals. If that list backs a <code>StateFlow</code>, observers now hold an object that changed without any emission. Call the function a second time and the first shard is counted twice. This kind of bug is slow to reproduce, because it depends on call order.</p>
<p>The fix is to produce new values and keep the elements immutable:</p>
<pre><code class="language-kotlin">data class ShardTally(val hits: Int = 0, val misses: Int = 0)

val combined = shardTallies.fold(ShardTally()) { acc, t -&gt;
    ShardTally(acc.hits + t.hits, acc.misses + t.misses)
}
</code></pre>
<h2>Failure mode 3: the shared mutable seed</h2>
<p><code>fold</code> has a mirror-image trap. The seed is just an argument, so if you hand it something that outlives the call, every call mutates the same object:</p>
<pre><code class="language-kotlin">private val pending = mutableListOf&lt;String&gt;()   // lives as long as the class

fun collectNames(parts: List&lt;DownloadPart&gt;): List&lt;String&gt; =
    parts.fold(pending) { acc, p -&gt; acc.apply { add("part-${p.index}") } }
</code></pre>
<p>The first call returns three names. The second returns six. Always construct the seed <em>inside</em> the call, for example <code>fold(mutableListOf&lt;String&gt;())</code>.</p>
<h2>Failure mode 4: the O(n²) copy-on-every-step</h2>
<p>Immutable seeds and <code>copy()</code> read nicely, but <code>+</code> on a <code>List</code> or <code>Map</code> allocates a new collection each time. In the earlier <code>toReport()</code> I used <code>report.failedParts + part.index</code>, which is fine for a handful of parts and quietly quadratic for a large journal.</p>
<p>For large inputs, accumulate into a private builder and freeze it once at the end:</p>
<pre><code class="language-kotlin">fun List&lt;DownloadPart&gt;.toReportFast(): TransferReport {
    var completed = 0L
    val failed = ArrayList&lt;Int&gt;()
    for (part in this) {
        if (part.error == null) completed += part.bytes else failed += part.index
    }
    return TransferReport(completedBytes = completed, failedParts = failed)
}
</code></pre>
<p>This is still a fold in spirit, and the mutable state never escapes the function. If you prefer to keep the <code>fold</code> shape, seed it with a small private accumulator class and read it out at the end. Either way, measure first. Most lists on a phone screen are short enough that readability wins.</p>
<h2>Failure mode 5: integer overflow in the seed</h2>
<p>The seed’s type is chosen by you, and <code>0</code> is an <code>Int</code>:</p>
<pre><code class="language-kotlin">// sizeKb is an Int field on each cache entry
val totalBytes = entries.fold(0) { acc, e -&gt; acc + e.sizeKb * 1024 }
</code></pre>
<p>Once the cache passes 2 GiB, this wraps to a negative number. A “storage used” label that shows <code>-1.9 GB</code> is a good way to learn that <code>fold(0)</code> and <code>fold(0L)</code> are different programs. Pick the widest sensible type for the seed, and convert before multiplying (<code>e.sizeKb.toLong() * 1024</code>).</p>
<h2>Failure mode 6: swapping one for the other changes the answer</h2>
<p>Replacing <code>reduce</code> with <code>fold(0)</code> looks like a safe refactor. It is safe for addition, because the seed is the identity element. It silently changes results for operations where the first element is special:</p>
<pre><code class="language-kotlin">val steps = listOf(100, 30, 20)

steps.reduce { a, b -&gt; a - b }   // 50    (100 - 30 - 20)
steps.fold(0) { a, b -&gt; a - b }  // -150  (0 - 100 - 30 - 20)
</code></pre>
<p>The same applies to <code>min</code>/<code>max</code>-style merges, string joins with separators, and anything non-commutative. Before swapping, ask whether your seed is a true identity for the operation.</p>
<h2>Where fold really shines: replayable state</h2>
<p>Android developers see the clearest payoff of <code>fold</code> in unidirectional state. A reducer has the shape <code>(State, Event) -&gt; State</code>, which is exactly what <code>fold</code> expects, and <code>reduce</code> could never fit it because the two types differ.</p>
<pre><code class="language-kotlin">sealed interface SyncEvent {
    data class Started(val totalParts: Int) : SyncEvent
    data class PartDone(val bytes: Long) : SyncEvent
    data class Failed(val reason: String) : SyncEvent
}

data class SyncState(
    val totalParts: Int = 0,
    val doneBytes: Long = 0,
    val failure: String? = null,
) {
    fun transition(event: SyncEvent): SyncState = when (event) {
        is SyncEvent.Started -&gt; copy(totalParts = event.totalParts, doneBytes = 0, failure = null)
        is SyncEvent.PartDone -&gt; copy(doneBytes = doneBytes + event.bytes)
        is SyncEvent.Failed -&gt; copy(failure = event.reason)
    }
}

// Rebuild the state after process death from a persisted event journal.
val restored: SyncState = journal.fold(SyncState(), SyncState::transition)

// Same events, but keep every intermediate state, for debugging or time-travel tooling.
val timeline: List&lt;SyncState&gt; = journal.runningFold(SyncState(), SyncState::transition)
</code></pre>
<p>Two production notes:</p>
<ul>
<li><strong>The reducer must be deterministic.</strong> No clock reads, no random IDs, no I/O inside <code>transition</code>. If replay produces a different state than the original run, the journal is useless.</li>
<li><strong>Version your events.</strong> A journal written by last month’s app will contain shapes that today’s <code>when</code> has never seen. Decide on purpose whether unknown events are skipped, migrated, or fatal.</li>
</ul>
<p>If the journal can grow large, run the fold on <code>Dispatchers.Default</code> rather than the main thread, and use <code>asSequence()</code> first when you also filter or map to avoid intermediate lists.</p>
<h2>A quick decision guide</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*m0pAr-9Bq1yX6YM_ojcnNA.png" alt="decision-flow" style="display:block;margin:0 auto" />

<p>In words, the questions I ask are:</p>
<ol>
<li><strong>Do I need to define the starting state myself?</strong> If yes, use <code>fold</code>.</li>
<li><strong>Is the first element naturally the starting state, and is the result the same type as the elements?</strong> If yes, <code>reduce</code> can be the cleaner choice.</li>
<li><strong>Can the collection be empty at runtime?</strong> If so, use <code>fold</code> with a sensible seed, or <code>reduceOrNull</code> and handle the <code>null</code>.</li>
</ol>
<h2>🔚 Takeaways</h2>
<ul>
<li>The only real difference is the starting accumulator, but it drives everything else: type flexibility, empty-input behavior, and what your lambda is allowed to mutate.</li>
<li><code>fold</code> is the default for building state or a different type, and for any input that might be empty.</li>
<li><code>reduce</code> is for merging same-typed values when the first element is a valid start and non-emptiness is guaranteed, or when you use <code>reduceOrNull</code> and handle the <code>null</code>.</li>
<li>Never mutate the accumulator when it is also one of your elements, and never reuse a seed across calls.</li>
<li>Choose the seed’s numeric type deliberately, and check that the seed is an identity before swapping <code>reduce</code> for <code>fold</code>.</li>
</ul>
<p>Once the accumulator is the first thing you look at, the two functions stop being confusing and start reading as two different statements about where your data begins.</p>
<h2>📘 Master Your Next Technical Interview</h2>
<p>Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend <strong>“Mastering Data Structures &amp; Algorithms in Java”</strong>. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=biiSEQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0FXWL8XMB"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2248718"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover.</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/the-seed-decides-everything-fold-vs-reduce-in-production-kotlin-on-android-ade391d2b40b"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Release Build Autopsy: Following One Kotlin Line from Gradle to the First Frame]]></title><description><![CDATA[Follow one Kotlin line through Gradle, AAPT2, R8, signing, and ART. Learn real release-build failure modes and fixes, plus Baseline Profiles.]]></description><link>https://android-expert.hashnode.dev/release-build-autopsy-following-one-kotlin-line-from-gradle-to-the-first-frame</link><guid isPermaLink="true">https://android-expert.hashnode.dev/release-build-autopsy-following-one-kotlin-line-from-gradle-to-the-first-frame</guid><category><![CDATA[gradle]]></category><category><![CDATA[r8]]></category><category><![CDATA[Android Build System]]></category><category><![CDATA[Baseline Profiles]]></category><category><![CDATA[Android runtime]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 14:52:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/8b89b0e5-f270-446f-9da8-721f90419130.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Picture a Friday afternoon. The debug build of your app is flawless. You cut a release, and within an hour crash reports arrive: a <code>ClassNotFoundException</code> for a payment plugin that definitely exists in the repo. On Monday CI takes fourteen minutes for a one-line change, and nobody can say why. Meanwhile, product wants cold start under a second on a three-year-old phone.</p>
<p>Three different complaints, one root cause: the build-and-runtime pipeline is a <strong>black box</strong> to most of the team. This post opens it. We follow a single Kotlin line from <code>./gradlew assembleRelease</code> through Gradle, AAPT2, R8, signing, Play delivery, and finally ART on the device, and at each stage we look at how it <em>actually</em> fails in production.</p>
<p>Every code sample uses a fictional app, <strong>Harborlane</strong>, a ferry-ticketing client. Package names are <code>dev.harborlane.*</code>.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*JDT6t1jHqEqQfQrLBh_GXg.png" alt="assemble-release-journey" style="display:block;margin:0 auto" />

<p>The rest of the article walks that picture left to right, top to bottom.</p>
<h2>1. Gradle: the planner that runs before anything compiles</h2>
<h2>The three phases</h2>
<p>Gradle never “just builds”. It works in three distinct phases:</p>
<ol>
<li><strong>Initialization.</strong> Gradle reads <code>settings.gradle.kts</code> and decides which projects take part.</li>
<li><strong>Configuration.</strong> Every participating project’s build script is evaluated, tasks are registered, and the <strong>task graph</strong> (a directed acyclic graph) is assembled.</li>
<li><strong>Execution.</strong> Only the tasks required by the requested target run, and each one first asks, “Do I even need to?”</li>
</ol>
<img src="https://cdn-images-1.medium.com/max/800/1*8WVtqbr17kVJuYgd2OvxlQ.png" alt="gradle-lifecycle" style="display:block;margin:0 auto" />

<p>The key asymmetry: execution has long been cache-friendly (up-to-date checks, the build cache), while configuration historically was not. The <strong>configuration cache</strong> (stable since Gradle 8.1, opt in with <code>org.gradle.configuration-cache=true</code>) changes that by serializing the result of the configuration phase and skipping script evaluation entirely on a hit. But a hit requires unchanged inputs and plugins and scripts that are compatible with it, which is exactly where configuration-time work bites. On a miss, whatever your scripts do while being evaluated is paid for on every command, including <code>./gradlew help</code>, IDE syncs, and CI shards.</p>
<h2>Failure mode: the configuration-time tax</h2>
<p>This is a real pattern that has quietly added minutes to builds:</p>
<pre><code class="language-kotlin">// app/build.gradle.kts: evaluated on EVERY Gradle invocation
val gitSha: String = Runtime.getRuntime()
    .exec(arrayOf("git", "rev-parse", "--short", "HEAD"))
    .inputStream.bufferedReader().readText().trim()

android {
    defaultConfig {
        buildConfigField("String", "GIT_SHA", "\"$gitSha\"")
        // Worse: a value that is different on every run
        buildConfigField("long", "BUILT_AT", "${System.currentTimeMillis()}L")
    }
}
</code></pre>
<p>There are two separate sins here:</p>
<ul>
<li>The process spawn happens at configuration time, in every project that applies the script, even when you are only asking Gradle for its help text.</li>
<li><code>BUILT_AT</code> changes every run, so the generated <code>BuildConfig</code> is never up to date. Everything downstream (compilation, dexing, packaging) is invalidated, and the build cache can never produce a hit for it.</li>
</ul>
<p>The fix is to model the value as a <strong>lazy provider</strong> and feed it to a task as a declared input:</p>
<pre><code class="language-kotlin">// build-logic or app/build.gradle.kts
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*

@CacheableTask
abstract class GenerateBuildInfo : DefaultTask() {

    @get:Input
    abstract val gitSha: Property&lt;String&gt;

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty

    @TaskAction
    fun generate() {
        val file = outputDir.get().asFile.resolve("dev/harborlane/BuildInfo.kt")
        file.parentFile.mkdirs()
        file.writeText(
            """
            package dev.harborlane

            internal object BuildInfo {
                const val GIT_SHA = "${gitSha.get()}"
            }
            """.trimIndent()
        )
    }
}

val gitShaProvider: Provider&lt;String&gt; = providers
    .exec { commandLine("git", "rev-parse", "--short", "HEAD") }
    .standardOutput.asText
    .map { it.trim() }

androidComponents.onVariants { variant -&gt;
    val taskName = "generate${variant.name.replaceFirstChar(Char::uppercase)}BuildInfo"
    val generate = tasks.register&lt;GenerateBuildInfo&gt;(taskName) {
        gitSha.set(gitShaProvider)
    }
    variant.sources.java?.addGeneratedSourceDirectory(generate, GenerateBuildInfo::outputDir)
}
</code></pre>
<p>Three idioms matter here: <code>tasks.register</code> instead of <code>create</code> (the task is only configured if it is actually needed), <code>Property</code>/<code>Provider</code> instead of eager values, and annotated inputs and outputs so Gradle can fingerprint the task. <code>providers.exec</code> is the configuration-cache-compatible replacement for <code>Project.exec</code>: it returns a lazy provider, and the process only runs when something resolves that provider (here, when Gradle fingerprints the task's <code>@Input</code>). The resolved value is then recorded as a configuration cache input, so a new commit correctly invalidates the cache entry. If you ever need the value <em>during</em> configuration (for example for <code>buildConfigField</code>), wrap the command in a custom <code>ValueSource</code> so the cache can track it, and accept that this value is read at configuration time. One honest caveat: a commit hash as an input means the generated file changes on every commit. Keep it tiny and isolated in its own module so only a small slice recompiles.</p>
<h2>Up-to-date checks, incremental builds, and the build cache</h2>
<p>Three mechanisms often get lumped together; they are different:</p>
<ul>
<li><strong>UP-TO-DATE:</strong> inputs and outputs of this task are unchanged <em>in this working directory</em>, so skip it.</li>
<li><strong>Incremental task:</strong> the task <em>does</em> run, but processes only the changed inputs (Kotlin compilation works this way).</li>
<li><strong>FROM-CACHE:</strong> a task with identical input fingerprints ran <em>somewhere else</em> (another branch, another CI agent), so its outputs are restored from the build cache.</li>
</ul>
<p>Cache hits depend on <strong>fingerprints being deterministic and relocatable</strong>. The usual saboteurs are absolute paths baked into inputs, timestamps, random UUIDs, and un-normalized line endings. If your remote cache hit rate is poor, run a build twice on different checkouts with <code>-Dorg.gradle.caching.debug=true</code>, or compare Build Scans, to see which input fingerprint differs.</p>
<h2>Build variants</h2>
<p>Build types and product flavors form a matrix. Each cell is a complete, buildable variant with its own tasks and its own source set.</p>
<pre><code class="language-kotlin">android {
    namespace = "dev.harborlane.app"
    compileSdk = 36

    defaultConfig { minSdk = 23 }

    flavorDimensions += "env"
    productFlavors {
        create("dev") {
            dimension = "env"
            applicationIdSuffix = ".dev"
            resValue("string", "app_name", "Harborlane Dev")
        }
        create("prod") { dimension = "env" }
    }

    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}
</code></pre>
<img src="https://cdn-images-1.medium.com/max/800/1*OYA8pRyFDN2Eq3FlPsl-6w.png" alt="variant-matrix" style="display:block;margin:0 auto" />

<p>When the same file exists in several source sets, the <strong>most specific one wins</strong>: <code>src/devDebug</code> beats <code>src/debug</code> or <code>src/dev</code>, which beat <code>src/main</code>. That is a feature (swap an API endpoint per environment) and a trap (a stray file in <code>src/dev</code> silently shadows production behavior in only one variant).</p>
<h2>Debugging a slow build, in order</h2>
<ol>
<li><code>./gradlew assembleRelease --dry-run</code> shows which tasks would run and in what order, without running them.</li>
<li><code>./gradlew assembleRelease --scan</code> gives a timeline and a per-task cache verdict.</li>
<li>Android Studio’s <strong>Build Analyzer</strong> points at plugins and tasks that always rerun.</li>
<li>Compare <em>configuration</em> time against <em>execution</em> time first. If <code>help</code> itself is slow, the problem is in your scripts, not your compiler.</li>
</ol>
<h2>2. Resources and manifests: two merges before a single class compiles</h2>
<h2>AAPT2 in two steps</h2>
<p>AAPT2 is split into <strong>compile</strong> and <strong>link</strong> so that it can be incremental:</p>
<ul>
<li><strong>Compile</strong> converts each resource file into an intermediate <code>.flat</code> unit. Edit one layout and only that unit is redone.</li>
<li><strong>Link</strong> takes all <code>.flat</code> files plus the <strong>merged manifest</strong> and produces the final <code>resources.arsc</code>, the resource-ID table, and the generated <code>R</code> class (for AAB, a protobuf-format variant).</li>
</ul>
<p>A resource ID is a 32-bit integer in the form <code>0xPPTTEEEE</code>: package (<code>0x7f</code> for your app), resource type, and entry. IDs are assigned at <strong>link time</strong>, which is why they must never be persisted or hard-coded, and why library modules cannot treat their <code>R</code> fields as true constants.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*sI0raZZFGSMVe7aVmKE0Gg.png" alt="resources-and-manifest" style="display:block;margin:0 auto" />

<h2>Failure mode: the resource shrinker and names it cannot see</h2>
<p><code>isShrinkResources = true</code> removes resources that nothing references. By default the shrinker runs in a forgiving <em>safe</em> mode: if it finds string constants that look like the prefix of a resource name (for example <code>"banner_"</code> passed to <code>getIdentifier</code>), it conservatively keeps every resource matching that pattern. That heuristic is why many apps never notice the problem, and also why the failure below shows up only when the pattern is invisible:</p>
<pre><code class="language-kotlin">// routeAsset arrives from remote config, e.g. "banner_summer_sale"
fun bannerFor(context: Context, routeAsset: String): Int =
    context.resources.getIdentifier(routeAsset, "drawable", context.packageName)
</code></pre>
<p>The full name is built outside the app, so the dex contains no recognizable constant prefix and the shrinker has nothing to match. Banners vanish in release only, and <code>getIdentifier</code> returns <code>0</code>. The same happens if you opt into <code>tools:shrinkMode="strict"</code>, which disables the heuristics on purpose. Declare the dynamic family explicitly:</p>
<pre><code class="language-xml">&lt;!-- res/raw/keep.xml --&gt;
&lt;resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@drawable/banner_*" /&gt;
</code></pre>
<p>Better still, map the allowed values through an explicit <code>when</code> over real <code>R.drawable</code> references; the compiler and the shrinker then both see the dependency, and an unknown value becomes a handled case instead of a silent <code>0</code>.</p>
<h2>Manifest merging and conflict resolution</h2>
<p>The merger combines the manifests of your build variant, your main source set, and every library, with priority in that order. When two sources disagree on the same attribute and the higher-priority one does not say it wins, the build fails with a “Manifest merger failed” error. Resolve it deliberately, using the <code>tools</code> namespace:</p>
<pre><code class="language-xml">&lt;manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"&gt;

    &lt;!-- A transitive analytics SDK declares this; we don't want it --&gt;
    &lt;uses-permission
        android:name="android.permission.READ_PHONE_STATE"
        tools:node="remove" /&gt;

    &lt;application
        android:allowBackup="false"
        android:label="@string/app_name"
        tools:replace="android:allowBackup,android:label" /&gt;
&lt;/manifest&gt;
</code></pre>
<p>Use <code>tools:replace</code> for individual attributes and <code>tools:node="remove"</code> or <code>"replace"</code> for whole elements. Treat every marker as documentation of a decision, and check the <strong>Merged Manifest</strong> tab in Studio after adding a dependency, because a library can silently contribute permissions, exported components, or <code>usesCleartextTraffic</code>.</p>
<h2>3. Compilation, desugaring, and shrinking: D8 versus R8</h2>
<h2>The path from text to dex</h2>
<p>Kotlin and Java sources are compiled by <code>kotlinc</code> and <code>javac</code> into JVM <code>.class</code> files. Android does not run those. A <strong>dex</strong> tool translates them into register-based DEX bytecode for the runtime:</p>
<ul>
<li><strong>D8</strong> is the dexer. It converts classes to <code>.dex</code> and also <strong>desugars</strong> newer language constructs for older API levels.</li>
<li><strong>R8</strong> is a superset used on minified builds. It does whole-program <strong>shrinking</strong> (removes unreachable code), <strong>optimization</strong> (inlining, merging, devirtualization), and <strong>obfuscation</strong> (renaming), and performs the dexing and desugaring itself.</li>
</ul>
<img src="https://cdn-images-1.medium.com/max/800/1*_ZUPbVaY_ZwToGFj07PoRg.png" alt="source-to-dex" style="display:block;margin:0 auto" />

<h2>Failure mode: modern <code>java.time</code> on an older device</h2>
<p>Desugaring covers language features cheaply, but <strong>library APIs</strong> need the runtime to contain them. Code that compiles against <code>java.time</code> can still crash on API 23 unless you opt into library desugaring:</p>
<pre><code class="language-kotlin">android {
    compileOptions {
        isCoreLibraryDesugaringEnabled = true
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}
</code></pre>
<p>Without it, the symptom is <code>NoClassDefFoundError</code> or <code>NoSuchMethodError</code> on specific API levels, and nothing in your emulator on API 35 will ever reproduce it. Keep one low-API device or emulator in your release checklist.</p>
<h2>Failure mode: R8 removes or renames something you reach by name</h2>
<p>R8 can only reason about what code references <strong>statically</strong>. Reflection, string-based class lookup, JNI, and serialization frameworks reach code in ways it cannot see. Here is a plugin loader driven by remote config:</p>
<pre><code class="language-kotlin">interface CheckoutPlugin {
    val id: String
    fun launch(context: Context)
}

object PluginLoader {
    // className arrives from remote config, e.g. "dev.harborlane.pay.SepaPlugin"
    fun load(className: String): Result&lt;CheckoutPlugin&gt; = runCatching {
        val plugin = Class.forName(className)
            .getDeclaredConstructor()
            .newInstance()
        plugin as? CheckoutPlugin
            ?: error("$className does not implement CheckoutPlugin")
    }
}
</code></pre>
<p>In the debug build this works. In release, no call site mentions <code>SepaPlugin</code>, so R8 either drops it or renames it, and the lookup fails with <code>ClassNotFoundException</code> (or <code>NoSuchMethodException</code> if the class survived without its constructor). Returning a <code>Result</code> keeps that failure observable and recoverable instead of crashing checkout.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*Z3Pj--ha9FzKg4sH_DWztg.png" alt="r8-keep-rules" style="display:block;margin:0 auto" />

<p>The fix is a rule that describes the <em>relationship</em>, not one class, so new plugins are covered automatically:</p>
<pre><code class="language-kotlin"># proguard-rules.pro
-keep class * implements dev.harborlane.checkout.CheckoutPlugin {
    &lt;init&gt;();
}
</code></pre>
<p>Note the explicit <code>&lt;init&gt;()</code>. Since AGP 8, R8's <strong>full mode</strong> is the default, and in full mode R8 no longer assumes a default constructor must be kept just because a class is kept. Rules written against older behavior can break after an upgrade.</p>
<p>The same logic applies to JSON models parsed by reflection:</p>
<pre><code class="language-kotlin">data class OrderDto(
    val orderId: String,
    val totalMinor: Long,
    val lines: List&lt;LineDto&gt;,
)
</code></pre>
<p>If Gson maps by field name and R8 renames <code>orderId</code> to <code>a</code>, the JSON key no longer matches. The result is not a crash at the parse site but a <strong>silent</strong> <code>null</code> <strong>or</strong> <code>0</code>, which is worse:</p>
<pre><code class="language-kotlin">-keepattributes Signature, *Annotation*
-keepclassmembers class dev.harborlane.data.remote.dto.** { &lt;fields&gt;; }
</code></pre>
<p>A senior-level observation: reaching for more keep rules is the <em>second-best</em> fix. Prefer libraries whose generated code is statically visible (Moshi codegen, <code>kotlinx.serialization</code>, Retrofit with a converter that does not need reflection on your models), because then R8 can do its job without your help and shrinks more of your app. Also, check whether a library ships its own <strong>consumer rules</strong>; if you author a library, publish them with <code>consumerProguardFiles</code>.</p>
<h2>A repeatable R8 triage routine</h2>
<ol>
<li>Reproduce on a <strong>minified</strong> build. Add a <code>staging</code> build type that sets <code>isMinifyEnabled = true</code> and signs with the debug key, and run your instrumented tests against it in CI.</li>
<li>De-obfuscate the stack trace with the <code>mapping.txt</code> produced by that exact build: <code>retrace mapping.txt crash.txt</code>. Upload the mapping file to Play Console or your crash tool for every release.</li>
<li>Ask R8 why something exists with <code>-whyareyoukeeping class dev.harborlane.pay.SepaPlugin</code>, and inspect <code>-printusage</code> and <code>-printseeds</code> output to see what was removed and what your rules matched.</li>
<li>Fix the rule at the narrowest scope that survives refactoring, and add a test that loads the class by name.</li>
</ol>
<h2>4. Packaging, signing, and distribution</h2>
<h2>APK versus AAB</h2>
<p>An <strong>APK</strong> is an installable artifact. An <strong>Android App Bundle</strong> is a <em>publishing</em> format: you upload it, and Google Play generates APKs tailored per device from it: a base APK plus configuration splits for screen density, CPU ABI, and language.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*--L73NHfNRvvKgYnishjNA.png" alt="aab-delivery" style="display:block;margin:0 auto" />

<p>The practical outcomes are smaller downloads and the ability to ship optional <strong>dynamic feature</strong> modules later.</p>
<h2>Failure mode: the in-app language switcher that does nothing</h2>
<p>Because language splits are delivered only for the device’s configured languages, an app that lets users pick a language <em>inside</em> the app may find the strings for the chosen language were never installed. Either rely on the platform per-app language support or opt out of language splitting:</p>
<pre><code class="language-kotlin">android {
    bundle {
        language { enableSplit = false }
    }
}
</code></pre>
<h2>Signature schemes, V1 through V4</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*KCxiIpUOpKQ0vrT9d-5YkQ.png" alt="signing-timeline" style="display:block;margin:0 auto" />

<p>Practical consequences:</p>
<ul>
<li>AGP signs with V1 only when <code>minSdk</code> is below 24, and always with the newer schemes for the device generation they support.</li>
<li>V4 is stored in a separate <code>.idsig</code> file and exists for incremental installation workflows.</li>
<li>With Play App Signing, you sign uploads with an <strong>upload key</strong> and Google re-signs the delivered APKs with the <strong>app signing key</strong>. Losing the upload key is recoverable. Losing a self-managed signing key without rotation is not.</li>
</ul>
<h2>zipalign and ordering</h2>
<p><code>zipalign</code> aligns uncompressed entries inside the archive so they can be memory-mapped efficiently. Because V2 and later sign the archive as a whole, the sequence is <strong>align first, sign second</strong>. If you script packaging manually and reverse the order, signature verification fails. AGP does it correctly for you, which is one more reason to avoid hand-rolled packaging.</p>
<h2>5. ART: what happens after install</h2>
<h2>From Dalvik to a hybrid runtime</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*3fidcvqNW9wdq3i9C1m2LA.png" alt="art-evolution" style="display:block;margin:0 auto" />

<p>Early Android used Dalvik with a JIT. Android 5.0 made ART the runtime and compiled <strong>everything ahead of time</strong> during install, which gave fast execution but slow installs and system updates and noticeable storage cost. From Android 7.0, ART became a hybrid:</p>
<ol>
<li>The app starts in the <strong>interpreter</strong>, so install is quick.</li>
<li>Hot methods are <strong>JIT-compiled</strong> and the runtime records a <strong>profile</strong> of what was hot.</li>
<li>When the device is <strong>idle and charging</strong>, a background job (<code>dex2oat</code>) <strong>AOT-compiles</strong> the profiled methods.</li>
<li>Subsequent launches execute native code for exactly the code that mattered.</li>
</ol>
<p>The weakness is obvious: the first launches, which are the ones that shape a user’s first impression and your retention numbers, happen <em>before</em> any profile exists.</p>
<h2>Baseline Profiles close the cold gap</h2>
<p>A <strong>Baseline Profile</strong> is a list of classes and methods, shipped inside your APK or bundle, that tells ART which code is critical for startup and common journeys. At install time ART can compile those entries ahead of time, so the very first launch already runs the hot path as native code.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*ZQzlN7Z8eb3BHVExauiArw.png" alt="baseline-profile-effect" style="display:block;margin:0 auto" />

<p>Google’s documentation cites improvements on the order of 30 percent in code execution speed from first launch; treat that as a ceiling-style claim and <strong>measure your own app</strong>, because the gain depends on how much of your startup path is interpreted code.</p>
<p>Generating the profile is a Macrobenchmark-style test that exercises your critical user journeys. (<code>includeInStartupProfile</code> needs a reasonably recent <code>benchmark-macro-junit4</code>, 1.2 or later. Run the generator against the non-minified release-like variant the Baseline Profile plugin creates, so view IDs and class names are intact. Note the explicit <code>checkNotNull</code>: without it a missing view fails silently and you ship a profile that never saw your real screen.)</p>
<pre><code class="language-kotlin">@RunWith(AndroidJUnit4::class)
class StartupProfileGenerator {

    @get:Rule
    val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect(
        packageName = "dev.harborlane.app",
        includeInStartupProfile = true,
    ) {
        pressHome()
        startActivityAndWait()

        // Exercise the first real screen, not just the splash
        val list = device.wait(
            Until.findObject(By.res(packageName, "departures_list")), 5_000
        )
        checkNotNull(list) { "departures_list never appeared; the profile would miss the real startup path" }
        list.fling(Direction.DOWN)
    }
}
</code></pre>
<p>And you verify the benefit with a benchmark that compares compilation modes:</p>
<pre><code class="language-kotlin">@RunWith(AndroidJUnit4::class)
class ColdStartBenchmark {

    @get:Rule
    val rule = MacrobenchmarkRule()

    @Test fun noCompilation() = coldStart(CompilationMode.None())

    @Test fun withProfile() =
        coldStart(CompilationMode.Partial(BaselineProfileMode.Require))

    private fun coldStart(mode: CompilationMode) = rule.measureRepeated(
        packageName = "dev.harborlane.app",
        metrics = listOf(StartupTimingMetric()),
        iterations = 10,
        startupMode = StartupMode.COLD,
        compilationMode = mode,
    ) {
        pressHome()
        startActivityAndWait()
    }
}
</code></pre>
<h2>Failure modes worth knowing</h2>
<ul>
<li><strong>A profile that covers the splash screen only.</strong> Your journeys decide the value of the profile. Cover the first screen users really see, scrolling, and one or two common navigations.</li>
<li><strong>Benchmarking a debuggable build or an emulator.</strong> Numbers from either are misleading. Use a release-like, non-debuggable variant on a real device.</li>
<li><strong>Stale profiles after big refactors.</strong> Entries match class and method identities, so major renames or module moves reduce coverage; regenerate as part of releases, through the Baseline Profile Gradle plugin, rather than committing a file once and forgetting it.</li>
<li><strong>Sideloaded builds.</strong> Outside Play, the <code>profileinstaller</code> library is what lets the profile be installed from the APK; keep that dependency in.</li>
</ul>
<h2>6. The interview answers, in plain language</h2>
<p><strong>“What happens between</strong><code>./gradlew assembleRelease</code> <strong>and the final artifact?"</strong> Gradle initializes the build, configures all projects, and constructs the task graph. Then tasks run in dependency order: resources are compiled and manifests merged, AAPT2 links resources and assigns IDs, Kotlin and Java compile to <code>.class</code> files, R8 shrinks, optimizes, obfuscates, and dexes them, the packaging task zips everything, aligns it, and signs it. Each task is skipped when up to date or restored from cache when its inputs match a previous run. For Play delivery, <code>bundleRelease</code> produces an AAB instead, which Play turns into per-device APKs.</p>
<p><strong>“Why did R8 remove an essential class or method?”</strong> R8 keeps only what is reachable by static analysis from entry points such as manifest components and your keep rules. If the class is reached only through reflection, a string, JNI, or a serializer, it looks unreachable, so it is deleted or renamed. A keep rule declares the dynamic entry point to R8: <code>-keep</code> for classes and members that must survive intact, <code>-keepclassmembers</code> when members matter only if the class survives, and <code>-keepnames</code> when only the name is the problem. The better long-term answer is to remove the reflection.</p>
<p><strong>“How do Baseline Profiles improve startup?”</strong> ART normally learns what is hot only after the app has run for a while. A Baseline Profile ships that knowledge with the app, so critical classes and methods can be compiled ahead of time at install, and the first launches skip most interpretation and JIT warm-up.</p>
<h2>A pre-release checklist you can steal</h2>
<ul>
<li>Run <code>help</code> and a no-op build; if either is slow, hunt configuration-time work.</li>
<li>Confirm no timestamps, absolute paths, or random values leak into task inputs.</li>
<li>Build and test the <strong>minified</strong> variant on CI, not only debug.</li>
<li>Archive <code>mapping.txt</code> for every shipped build.</li>
<li>Open the merged manifest after every dependency bump.</li>
<li>Test on your lowest supported API level with desugaring enabled.</li>
<li>Verify language, density, and ABI splits with a real bundle install (<code>bundletool</code> or an internal-testing track).</li>
<li>Keep a Baseline Profile in the release pipeline and benchmark it against <code>CompilationMode.None()</code>.</li>
</ul>
<p>The pipeline is not magic; it is a chain of narrow, inspectable transformations. Once you can say which link failed, “the release build is weird” becomes a ten-minute investigation instead of a lost weekend.</p>
<h2>🍎 Share Your Kotlin Code with iOS — Without Rewriting Your App</h2>
<p>If “we’ll just build it twice, once per platform” describes how your team ships mobile, I’d recommend “<strong>Kotlin Multiplatform for Android Developers</strong>” — a 22-chapter, hands-on guide that goes past the sample-project demos and follows the shared module all the way to two app store submissions. No iOS or Swift background required.</p>
<p>It walks through the expect/actual mechanism and source set hierarchy, shared architecture, networking with Ktor, persistence with Room KMP and SQLDelight, Koin dependency injection, Flow and StateFlow with Swift interop (SKIE and Swift Export), Compose Multiplatform versus native SwiftUI, navigation, testing across two runtimes, dual-toolchain CI/CD with Linux and macOS runners, publishing a KMP library, and migrating an existing Android app module by module. Everything builds toward one capstone: an offline-first Expense/Budget Tracker, complete with a pending-sync queue, retry/backoff, and a deployment checklist for both stores.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=8NgPEgAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0HKR1SKDN"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2312568"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover.</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/release-build-autopsy-following-one-kotlin-line-from-gradle-to-the-first-frame-b0d5a2fe740d"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Why Your Flow Refuses to Hop Threads: Context Preservation, flowOn, and channelFlow in Kotlin]]></title><description><![CDATA[Why withContext breaks flow { } in Kotlin. Learn context preservation, when to use flowOn or channelFlow, and the Android failures that crash apps.]]></description><link>https://android-expert.hashnode.dev/why-your-flow-refuses-to-hop-threads-context-preservation-flowon-and-channelflow-in-kotlin</link><guid isPermaLink="true">https://android-expert.hashnode.dev/why-your-flow-refuses-to-hop-threads-context-preservation-flowon-and-channelflow-in-kotlin</guid><category><![CDATA[kotlin-flow]]></category><category><![CDATA[kotlin coroutines]]></category><category><![CDATA[coroutines]]></category><category><![CDATA[concurrency]]></category><category><![CDATA[Kotlin]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 14:47:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/d22e2ab5-24e5-4835-a638-6b5d8e4907ce.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You wrap a network call in <code>withContext(Dispatchers.IO)</code> inside a <code>flow { }</code>, run the screen, and the app dies with <code>IllegalStateException: Flow invariant is violated</code>. Nothing in the stack trace points at your repository. It points at <code>SafeCollector</code>, a class you have never opened.</p>
<p>This post explains the rule behind that crash, why Kotlin enforces it at runtime, the three legitimate ways to get work onto another dispatcher, and the production failure modes that still bite experienced teams.</p>
<p><strong>What you will take away</strong></p>
<ul>
<li>What <em>context preservation</em> means, stated precisely rather than as folklore.</li>
<li>The one-line fix that covers most cases, and why it beats <code>flowOn</code>.</li>
<li>How <code>flowOn</code> and <code>channelFlow</code> actually work, including the hidden buffer.</li>
<li>A test that locks the invariant in so a refactor can’t quietly break it.</li>
</ul>
<h2>The invariant, stated precisely</h2>
<p>A cold <code>flow { }</code> is a suspend lambda that the collector runs <em>inside its own coroutine</em>. Every call to <code>emit</code> is a direct, suspending call into the collector's downstream code. The rule is:</p>
<blockquote>
<p><code>emit</code> <em>must be called from the same coroutine context in which</em> <code>collect</code> <em>is running.</em></p>
</blockquote>
<p>“Same context” means the relevant elements match: the dispatcher, plus a compatible <code>Job</code> hierarchy. It is not only about threads, which is why a nested <code>withContext</code> that swaps in a different dispatcher is rejected, while a <code>withContext</code> that doesn't change the effective context passes.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*uNASZTJhuGXTsOO0Gh2mEw.png" alt="context-invariant" style="display:block;margin:0 auto" />

<p>The runtime check lives in the collector wrapper, and it fires on every <code>emit</code>. It is not a debug-only assertion.</p>
<h2>Why the rule exists</h2>
<p>Downstream operators and your own <code>collect</code> lambda are written as ordinary sequential code. That design depends on three guarantees:</p>
<ol>
<li><strong>Sequential delivery.</strong> One value is fully processed before the next arrives.</li>
<li><strong>A predictable execution context.</strong> A <code>collect { }</code> on <code>Dispatchers.Main</code> can touch views without wrapping every line in <code>withContext(Dispatchers.Main)</code>.</li>
<li><strong>Safe unsynchronised state.</strong> An operator such as <code>scan</code> or <code>onEach</code> can keep a plain <code>var</code> because it is never entered concurrently.</li>
</ol>
<p>If <code>emit</code> could be called from arbitrary dispatchers, all three break at once. Allowing it would push synchronisation costs onto every operator author and every collector. Kotlin chose the opposite trade: make the producer side responsible, and fail fast when it cheats.</p>
<p>A small clarification of a claim you will see repeated: the invariant is not “all emissions happen on one <em>thread</em>.” Dispatchers can resume a coroutine on different threads between suspension points. The guarantee is about the coroutine <em>context</em>, and about emissions being sequential.</p>
<h2>Reproducing the crash</h2>
<p>Here is a deliberately ordinary repository for a parcel-tracking app. The broken version is the one many of us write on the first attempt:</p>
<pre><code class="language-kotlin">class ParcelRepository(
    private val api: ParcelApi,
    private val io: CoroutineDispatcher = Dispatchers.IO,
) {
    // Violates the invariant: emit() is called while the context is `io`.
    fun trackBroken(id: String): Flow&lt;ParcelStatus&gt; = flow {
        withContext(io) {
            emit(api.fetchStatus(id))
        }
    }
}
</code></pre>
<p>Collect it from a <code>viewModelScope</code> coroutine (which runs on <code>Dispatchers.Main.immediate</code>) and the first emission throws. The message begins with <code>Flow invariant is violated</code> and then lists the collector's context and the emitter's context side by side, which is the quickest way to see what differed.</p>
<p>Pin the behaviour down with a test. Injecting the dispatcher is what makes this testable:</p>
<pre><code class="language-kotlin">@Test
fun `emitting from a different dispatcher is rejected`() = runTest {
    val otherDispatcher = StandardTestDispatcher(testScheduler)

    val broken = flow {
        withContext(otherDispatcher) { emit("late") }
    }

    assertFailsWith&lt;IllegalStateException&gt; { broken.first() }
}
</code></pre>
<h2>Fix 1 (preferred): make the suspend function main-safe</h2>
<p>Most of the time you don’t need a different flow builder at all. You need to separate <em>where the work runs</em> from <em>where the value is emitted</em>:</p>
<pre><code class="language-kotlin">class ParcelRepository(
    private val api: ParcelApi,
    private val io: CoroutineDispatcher = Dispatchers.IO,
) {
    fun track(id: String): Flow&lt;ParcelStatus&gt; = flow {
        val status = withContext(io) { api.fetchStatus(id) }
        emit(status) // back in the collector's context
    }
}
</code></pre>
<p><code>withContext</code> returns before <code>emit</code> runs, so the emission happens in the collector's context. The context switch is a detail of one suspend call, not a property of the whole stream.</p>
<p>Better still, push the dispatcher switch into the data source so every caller benefits:</p>
<pre><code class="language-kotlin">class ParcelRemoteSource(
    private val service: ParcelService,
    private val io: CoroutineDispatcher = Dispatchers.IO,
) : ParcelApi {
    override suspend fun fetchStatus(id: String): ParcelStatus =
        withContext(io) { service.status(id).toDomain() }
}

// The repository no longer needs a dispatcher, because `api` is main-safe.
class ParcelRepository(private val api: ParcelApi) {
    fun track(id: String): Flow&lt;ParcelStatus&gt; = flow { emit(api.fetchStatus(id)) }
}
</code></pre>
<p>This is the convention behind “suspend functions should be main-safe”: callers never have to know which dispatcher a function wants.</p>
<h2>Fix 2: <code>flowOn</code> for a stream of blocking or CPU-heavy work</h2>
<p>When the producer is a loop, such as polling, reading a file line by line, or decoding frames, wrapping every iteration in <code>withContext</code> is noisy. <code>flowOn</code> moves the entire upstream instead:</p>
<pre><code class="language-kotlin">class ParcelRepository(
    private val api: ParcelApi,
    private val io: CoroutineDispatcher = Dispatchers.IO,
) {
    // normalised() stands for any pure, CPU-bound extension on ParcelStatus.
    fun poll(id: String, every: Duration = 5.seconds): Flow&lt;ParcelStatus&gt; = flow {
        while (currentCoroutineContext().isActive) {
            emit(api.fetchStatus(id))
            delay(every)
        }
    }
        .map { it.normalised() }      // runs on io
        .flowOn(io)                   // boundary
        .distinctUntilChanged()       // runs in the collector's context
}
</code></pre>
<img src="https://cdn-images-1.medium.com/max/800/1*-9DDjMQQDPYC7ck59Figmg.png" alt="flowon-boundary" style="display:block;margin:0 auto" />

<p>Two details deserve attention.</p>
<p><strong>It only affects what is above it.</strong> Operators placed after <code>flowOn</code> keep running in the collector's context. A <code>flowOn(io)</code> added at the end of a chain does not rescue a heavy <code>map</code> that was placed below an earlier boundary.</p>
<p><strong>It is not free.</strong> To cross the boundary, <code>flowOn</code> runs the upstream in a separate coroutine and hands values over through a channel. With default settings that channel uses <code>Channel.BUFFERED</code>, which is 64 items on the JVM and therefore on Android (tunable through the <code>kotlinx.coroutines.channels.defaultBuffer</code> system property). The producer can therefore run ahead of the collector, which changes your back-pressure story compared with a plain <code>flow</code>. See failure mode 4 below.</p>
<p>If the upstream is already running on the dispatcher you request, the operator can avoid the extra hop, but you should not rely on that when reasoning about buffering.</p>
<h2>Fix 3: <code>channelFlow</code> for concurrent or multi-context producers</h2>
<p>Sometimes the problem genuinely is “several things produce values and they live on different dispatchers.” That is the job of <code>channelFlow</code>. Here <code>send</code> goes through a <code>Channel</code>, which is safe to call from any coroutine in the producer scope:</p>
<pre><code class="language-kotlin">class ParcelRepository(
    private val api: ParcelApi,
    private val io: CoroutineDispatcher = Dispatchers.IO,
) {
    fun trackMany(ids: List&lt;String&gt;): Flow&lt;ParcelStatus&gt; = channelFlow {
        val gate = Semaphore(permits = 4) // bound the fan-out
        for (id in ids) {
            launch {
                gate.withPermit {
                    withContext(io) { send(api.fetchStatus(id)) }
                }
            }
        }
    }
}
</code></pre>
<p>The semaphore caps how many requests are in flight, but this still creates one coroutine per id. Coroutines are cheap, so that is fine for hundreds of items. For very large lists, prefer <code>flatMapMerge</code> (shown below), which keeps only <code>concurrency</code> collectors active at a time.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*Bob87vs2GGDrmf9oO4dVSA.png" alt="channelflow-anatomy" style="display:block;margin:0 auto" />

<p>The legality of <code>send</code> inside <code>withContext(io)</code> is the key contrast with <code>emit</code>. The collector is not being called by the producer; it is draining a channel from its own coroutine. The invariant still holds. It simply holds on the receiving side.</p>
<p>Notes that matter in review:</p>
<ul>
<li>The block returns only after all of its children finish, and then the channel closes. That is why launching children <em>inside</em> the <code>channelFlow</code> scope matters.</li>
<li>Results arrive in completion order, not list order. If you need the original order, tag each result with its index and sort downstream, or use a different structure.</li>
<li>For a plain “map each element concurrently” requirement, <code>ids.asFlow().flatMapMerge(concurrency = 4) { id -&gt; flow { emit(api.fetchStatus(id)) } }</code> (inside the same repository) says the same thing with less ceremony.</li>
</ul>
<h2><code>callbackFlow</code> is <code>channelFlow</code> with an obligation</h2>
<p>Bridging a listener API follows the same channel mechanics, plus a rule you must satisfy:</p>
<pre><code class="language-kotlin">fun connectivity(manager: ConnectivityManager): Flow&lt;Boolean&gt; = callbackFlow {
    val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) { trySend(true) }
        override fun onLost(network: Network) { trySend(false) }
    }
    manager.registerDefaultNetworkCallback(callback)

    awaitClose { manager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
</code></pre>
<p>Two platform notes: <code>registerDefaultNetworkCallback</code> requires API 24 (Android 7.0), so on a lower <code>minSdk</code> register a <code>NetworkRequest</code> with <code>registerNetworkCallback</code> instead or guard the call with a <code>Build.VERSION.SDK_INT</code> check, and the manifest needs the <code>ACCESS_NETWORK_STATE</code> permission.</p>
<p>Without <code>awaitClose</code>, the block would finish immediately, the channel would close, and Kotlin throws to tell you the listener would leak.</p>
<h2>Choosing between them</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*Ay2arVbdPHcB2vqFDSoLFw.png" alt="builder-decision-matrix" style="display:block;margin:0 auto" />

<p>A short heuristic to go with the matrix:</p>
<ol>
<li>Can the suspend call itself switch dispatchers? Do that, and keep a plain <code>flow</code>.</li>
<li>Is it a long-running sequential producer? Use <code>flowOn</code>.</li>
<li>Do multiple coroutines produce, or must values come from several contexts? Use <code>channelFlow</code>.</li>
<li>Is a non-coroutine callback the producer? Use <code>callbackFlow</code>.</li>
</ol>
<h2>Failure modes you will actually meet</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*yPTTi-ZUoPCZdIxiRkh3Gg.png" alt="failure-modes" style="display:block;margin:0 auto" />

<h2>1. <code>emit</code> inside <code>withContext</code></h2>
<p>Covered above. The tell-tale detail in code review is <code>emit</code> appearing <em>inside</em> the braces of a <code>withContext</code>, <code>launch</code>, or <code>async</code> block in a <code>flow { }</code>.</p>
<h2>2. <code>emit</code> from a launched child</h2>
<p>This is the same violation, but it hides behind a fork, so it passes your happy-path test:</p>
<pre><code class="language-kotlin">// Inside ParcelRepository, so `api` resolves.
// Compiles. Throws the moment the child reaches emit().
fun trackManyBroken(ids: List&lt;String&gt;): Flow&lt;ParcelStatus&gt; = flow {
    coroutineScope {
        ids.forEach { id -&gt; launch { emit(api.fetchStatus(id)) } }
    }
}
</code></pre>
<p>The child coroutine has a different <code>Job</code>, so the collector rejects the call. The fix is <code>channelFlow</code> with <code>send</code>, or <code>flatMapMerge</code>.</p>
<h2>3. <code>flowOn</code> in the wrong place</h2>
<pre><code class="language-kotlin">// The heavy parse still runs on the collector's context.
repo.rawLines()
    .flowOn(Dispatchers.IO)
    .map { parseHeavy(it) }   // below the boundary
</code></pre>
<p>Move <code>flowOn</code> so it sits directly after the expensive operators. When in doubt, log <code>Thread.currentThread().name</code> from inside each stage once, and read the output.</p>
<h2>4. The hidden buffer</h2>
<p>Because <code>flowOn</code> introduces a channel, a fast producer and a slow collector no longer run in lock-step. For "latest state wins" streams such as sensor readings or UI state, you rarely want a queue of stale values:</p>
<pre><code class="language-kotlin">sensor.readings()
    .flowOn(Dispatchers.Default)
    .conflate()  // keep only the newest value when the collector is busy
</code></pre>
<p>For streams where every item matters, set the capacity deliberately instead of inheriting the default: <code>.buffer(capacity = 16, onBufferOverflow = BufferOverflow.SUSPEND)</code>.</p>
<h2>5. A <code>callbackFlow</code> that never closes properly</h2>
<p>Two variants. Forgetting <code>awaitClose</code> causes the exception described earlier. Putting <code>awaitClose { }</code> in place but never unregistering inside it leaks the listener for the life of the process, with no crash to warn you. The unregister call belongs in that trailing block.</p>
<h2>A related trap: swallowing exceptions around <code>emit</code></h2>
<pre><code class="language-kotlin">flow {
    try {
        emit(compute())
    } catch (e: Exception) {   // also catches downstream failures
        emit(fallback())       // now emitting after a downstream error
    }
}
</code></pre>
<p>A <code>try/catch</code> around <code>emit</code> also intercepts exceptions thrown by the collector. The flow contract (<em>exception transparency</em>) says the producer must not handle them. Catch around the upstream work, or use the <code>catch</code> operator, which only sees upstream exceptions.</p>
<h2>A review checklist you can paste into your PR template</h2>
<ul>
<li><input disabled type="checkbox" /> No <code>emit</code> call sits lexically inside <code>withContext</code>, <code>launch</code>, or <code>async</code> in a <code>flow { }</code>.</li>
<li><input disabled type="checkbox" /> Suspend data-source functions switch dispatchers themselves, with the dispatcher injected.</li>
<li><input disabled type="checkbox" /> Every <code>flowOn</code> is placed directly after the operators it is meant to move.</li>
<li><input disabled type="checkbox" /> Each <code>flowOn</code> has an intentional buffering policy (<code>conflate</code>, <code>buffer</code>, or a documented default).</li>
<li><input disabled type="checkbox" /> Each <code>channelFlow</code> bounds its concurrency and does not launch outside its own scope.</li>
<li><input disabled type="checkbox" /> Each <code>callbackFlow</code> ends with <code>awaitClose</code> and unregisters the listener there.</li>
<li><input disabled type="checkbox" /> A test asserts the happy path with an injected test dispatcher.</li>
</ul>
<h2>🔚 Closing thought</h2>
<p>The restriction on <code>flow { }</code> is not an arbitrary limitation. It is what lets <code>map</code>, <code>filter</code>, <code>collect</code>, and your own operators be written as plain sequential Kotlin. Most of the time the right answer is to move the dispatcher switch <em>down</em> into the suspend function. When the shape of the problem is truly concurrent, <code>channelFlow</code> exists because a channel gives you the thread safety that the plain builder deliberately refuses to provide.</p>
<h2>📱 Stop Guessing Whether Your Android Code Actually Works</h2>
<p>If “we’ll add tests later” is a sentence your team says a little too often, I’d recommend <strong>“Mastering Android Testing”</strong> — a 35-chapter deep dive into why Android testing is uniquely hard (Activity/Fragment coupling, async complexity, tooling churn) and exactly how to fix it, layer by layer. It walks through architecting for testability, JUnit/Truth/MockK fundamentals, ViewModel and coroutine/Flow testing without flakiness, Compose UI testing and the semantics tree, Espresso and instrumented tests, Room/Retrofit data-layer testing with a full sync-conflict-resolution case study, Robolectric’s real limits, CI pipelines, flaky test triage, and how to build a testing culture that actually survives contact with a deadline.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=d48HEgAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0HGXB4LH7"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2275839"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover.</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/why-your-flow-refuses-to-hop-threads-context-preservation-flowon-and-channelflow-in-kotlin-884c95f7a774"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Dropped on the Floor: Conflation in Kotlin Flows and the UI Events You Never Saw]]></title><description><![CDATA[Learn how Kotlin Flow conflation drops events in StateFlow, SharedFlow, and collectLatest, and fix it with Channels, buffers, and tested patterns.]]></description><link>https://android-expert.hashnode.dev/dropped-on-the-floor-conflation-in-kotlin-flows-and-the-ui-events-you-never-saw</link><guid isPermaLink="true">https://android-expert.hashnode.dev/dropped-on-the-floor-conflation-in-kotlin-flows-and-the-ui-events-you-never-saw</guid><category><![CDATA[kotlin-flow]]></category><category><![CDATA[kotlin coroutines]]></category><category><![CDATA[StateFlow]]></category><category><![CDATA[SharedFlow]]></category><category><![CDATA[Jetpack Viewmodel]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 13:17:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/a2d8d77a-c368-46f1-b449-c685d3bba12f.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every few hundred checkouts, a support ticket arrived with the same story: <em>“I paid, the money left my account, and the receipt screen never showed up.”</em> The backend logs were spotless. The ViewModel logs were spotless. The <code>ShowReceipt</code> effect had been sent, and nobody had received it.</p>
<p>That bug lives in a gap between two ideas Kotlin Flow lets you mix freely: <strong>state</strong>, where only the latest value matters, and <strong>events</strong>, where every occurrence matters. The mechanism that blurs the line is <em>conflation</em>, and it is not the only way values vanish. This post walks through what conflation is, when it quietly becomes a data-loss bug, and how to pick a primitive that matches the meaning of what you’re sending.</p>
<h2>What conflation is</h2>
<p>Conflation means that when a producer outpaces its collector, intermediate values are discarded and the collector receives the most recent one. The collector never asks for this; it simply never gets a chance to look at the values it skipped.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*2-YZ84t2EnHgT54woUGaeA.png" alt="" style="display:block;margin:0 auto" />

<p><em>conflation-timeline</em></p>
<p><code>StateFlow</code> conflates by design. Here is a download screen that updates its progress in a tight loop:</p>
<pre><code class="language-kotlin">class DownloadViewModel : ViewModel() {

    private val _progress = MutableStateFlow(0)
    val progress: StateFlow&lt;Int&gt; = _progress.asStateFlow()

    fun start() {
        viewModelScope.launch(Dispatchers.Default) {
            for (percent in 1..100) {
                _progress.value = percent   // no suspension point between writes
            }
        }
    }
}
</code></pre>
<p>A UI that takes a few milliseconds per frame to render will see only a handful of values (the exact ones depend on dispatcher scheduling and frame timing, so the sequence is nondeterministic), not a hundred distinct values. The only guarantee is that the last value written is eventually delivered. For a progress bar this is the correct behavior: rendering stale numbers faster than the user can read them would only waste CPU. Conflation is the reason <code>StateFlow</code> is cheap, and it is why you can write to it from anywhere without worrying about a slow UI.</p>
<p>Two properties of <code>StateFlow</code> are worth keeping in mind, because they are conflation's quieter cousins:</p>
<pre><code class="language-kotlin">data class Cart(val items: MutableList&lt;String&gt; = mutableListOf())

val cart = MutableStateFlow(Cart())

cart.value.items.add("green tea")   // same instance, nothing assigned: no emission
cart.value = cart.value              // equal to the current value: ignored
</code></pre>
<p><code>StateFlow</code> compares each new value to the current one with <code>equals</code> and drops it when they match. Mutating an object in place never emits at all. Prefer immutable state and <code>update { it.copy(...) }</code>, which also gives you atomic read-modify-write.</p>
<h2>When conflation is a feature</h2>
<p>Conflation is exactly right whenever <em>the latest value is the whole truth</em>: a screen’s UI state, a search query, a connectivity flag, a playback position. Skipping the middle changes nothing the user can perceive, and you get natural backpressure handling for free.</p>
<p>The failure begins when something that is semantically an <strong>event</strong> (navigate, show a dialog, play a sound, confirm a purchase) gets modeled as state.</p>
<h2>When conflation is a bug</h2>
<h2>Failure mode 1: the snackbar that ate itself</h2>
<p>A very common shortcut is a nullable field in UI state:</p>
<pre><code class="language-kotlin">data class CheckoutUiState(val snackbar: String? = null)

// Two things go wrong in the same frame:
_state.update { it.copy(snackbar = "Card declined") }
_state.update { it.copy(snackbar = "Retry limit reached") }
</code></pre>
<p>The collector may only ever see the second message, so the user never learns the card was declined. Worse, if the user taps Retry and the same error occurs, <code>snackbar = "Card declined"</code> equals the previous value (assuming it was never cleared), so <code>StateFlow</code> swallows it. The screen looks frozen.</p>
<h2>Failure mode 2: a wrong belief about <code>SharedFlow</code></h2>
<p>The usual advice is “use <code>SharedFlow</code> for events", and the usual explanation is that it doesn't conflate. Both halves are only partly true. A default <code>MutableSharedFlow()</code> has <code>replay = 0</code>, <code>extraBufferCapacity = 0</code> and <code>BufferOverflow.SUSPEND</code>:</p>
<pre><code class="language-kotlin">private val _effects = MutableSharedFlow&lt;CheckoutEffect&gt;()   // all defaults

suspend fun confirm(orderId: String) {
    _effects.emit(CheckoutEffect.ShowReceipt(orderId))
    _effects.emit(CheckoutEffect.NavigateToOrders)
}
</code></pre>
<p>With that configuration, <code>emit</code> suspends until every current subscriber has received the value, so nothing is conflated. Two other things happen instead:</p>
<ol>
<li><strong>A slow subscriber suspends the emitter.</strong> A collector that does heavy work in its handler stalls whatever coroutine called <code>emit</code>.</li>
<li><strong>With no subscribers, the value is gone.</strong> <code>extraBufferCapacity</code> only buffers values for <em>existing</em> subscribers that are lagging. When nobody is collecting and <code>replay = 0</code>, <code>emit</code> returns immediately and the event evaporates, no matter how large the extra buffer is. Only a <code>replay &gt; 0</code> cache retains values for future collectors, and it does so by replaying the last <em>n</em> values to every new collector, which is the wrong semantics for one-shot effects (a rotated screen would show the receipt again). That is the checkout bug: the user had backgrounded the app during the payment call, the lifecycle-aware collector was stopped, and <code>ShowReceipt</code> went nowhere.</li>
</ol>
<p><code>tryEmit</code> makes it worse: on a flow with no buffer and active subscribers it returns <code>false</code> when it cannot hand the value over, and most call sites ignore that result.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*m5Xoiy5v6cBDiQEwfYQhaA.png" alt="lifecycle-event-loss" style="display:block;margin:0 auto" />

<h2>Failure mode 3: <code>collectLatest</code> is conflation with a cancel button</h2>
<p><code>collectLatest</code> cancels the handler for the previous value when a new one arrives. That is perfect for "search as you type" and dangerous for effects:</p>
<pre><code class="language-kotlin">effects.collectLatest { effect -&gt;
    when (effect) {
        is CheckoutEffect.ShowReceipt -&gt; {
            delay(300)                         // let the transition finish
            receiptSheet.show(effect.orderId)  // never reached if another effect lands first
        }
        CheckoutEffect.NavigateToOrders -&gt; navigator.toOrders()
    }
}
</code></pre>
<p>If <code>NavigateToOrders</code> arrives within 300 ms, the receipt handler is cancelled mid-flight. The same happens with <code>.conflate()</code> and <code>.buffer(Channel.CONFLATED)</code>, as well as with <code>SharedFlow</code> configured with <code>DROP_OLDEST</code> or <code>DROP_LATEST</code>. Each of these is a deliberate "drop it" policy. None of them belongs on an effect stream.</p>
<h2>Comparing your options</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*swpBntgx5RYUi2pkM598AA.png" alt="flow-primitives-compared" style="display:block;margin:0 auto" />

<p>Notice what the matrix says about the two <code>SharedFlow</code> rows in the middle. Neither conflates, and both still lose an event emitted into an empty room. If you need delivery guarantees across a lifecycle gap, a buffer on the <code>SharedFlow</code> isn't enough: you need something that <em>holds</em> events for a collector that isn't there yet.</p>
<h2>Fixes that actually hold</h2>
<h2>Option A: a buffered <code>SharedFlow</code> when a collector is always present</h2>
<p>If your collector is long-lived (a repository, a coordinator, a service-scoped bus), you can make a <code>SharedFlow</code> lossless under bursts:</p>
<pre><code class="language-kotlin">private val _effects = MutableSharedFlow&lt;CheckoutEffect&gt;(
    extraBufferCapacity = 64,                     // absorb bursts
    onBufferOverflow = BufferOverflow.SUSPEND,    // back-pressure, never drop
)
val effects: SharedFlow&lt;CheckoutEffect&gt; = _effects.asSharedFlow()
</code></pre>
<p>This removes conflation and protects against slow collectors, but it does <em>not</em> help when nobody is subscribed. For UI work, that disqualifies it.</p>
<h2>Option B: a <code>Channel</code> for single-consumer UI effects</h2>
<p>A <code>Channel</code> keeps elements until someone receives them, so effects emitted while the screen is stopped are waiting when it comes back:</p>
<pre><code class="language-kotlin">class CheckoutViewModel(
    private val payments: PaymentsRepository,
) : ViewModel() {

    private val _effects = Channel&lt;CheckoutEffect&gt;(capacity = Channel.BUFFERED)
    val effects: Flow&lt;CheckoutEffect&gt; = _effects.receiveAsFlow()

    fun confirm(orderId: String) {
        viewModelScope.launch {
            payments.confirm(orderId)
            _effects.send(CheckoutEffect.ShowReceipt(orderId))
            _effects.send(CheckoutEffect.NavigateToOrders)
        }
    }
}
</code></pre>
<p>And on the UI side, collect only while the screen is visible:</p>
<pre><code class="language-kotlin">viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.effects.collect { effect -&gt; render(effect) }
    }
}
</code></pre>
<p>A few details separate a working version from a subtly broken one:</p>
<ul>
<li><strong>Use</strong> <code>receiveAsFlow()</code><strong>, not</strong> <code>consumeAsFlow()</code><strong>.</strong> <code>consumeAsFlow()</code> cancels the channel when its collector is cancelled, and <code>repeatOnLifecycle</code> cancels its collector every time the screen stops. After the first rotation or backgrounding, your channel is closed and later sends throw or are lost.</li>
<li><strong>Prefer</strong> <code>BUFFERED</code> <strong>over</strong> <code>UNLIMITED</code><strong>.</strong> An unlimited channel never suspends, so <code>trySend</code> always succeeds. If the UI is gone for a long time or an effect source is chatty, memory grows without bound. A bounded buffer makes the producer's behavior explicit.</li>
<li><strong>A bounded buffer shifts the failure mode; it doesn’t remove it.</strong> <code>Channel.BUFFERED</code> holds 64 elements by default. If the screen stays gone while more than that are queued, further <code>send</code> calls suspend, and each suspended coroutine stays alive in <code>viewModelScope</code> until the UI returns. For effects triggered by user taps this is rarely reachable, but if a producer can outrun the UI for long periods, decide on purpose: use <code>trySend</code> and handle the failure, coalesce at the source, or move that data to state.</li>
<li><strong>A</strong> <code>Channel</code> <strong>has fan-out semantics.</strong> Two collectors will split the elements between them instead of each seeing all of them. If more than one consumer needs every effect, a <code>Channel</code> is the wrong tool.</li>
<li><strong>Handlers should be short and non-suspending.</strong> An element is considered delivered once it’s received. If the screen stops while a suspending handler is half-way done, that effect is not redelivered.</li>
</ul>
<h2>Option C: make the effect part of the state</h2>
<p>The Android architecture guidance leans toward representing one-off UI events as state that the UI acknowledges. This sidesteps the question of “who is collecting” entirely, but you must design it so conflation can’t hurt you. A single nullable field loses messages. A queue with identities does not:</p>
<pre><code class="language-kotlin">import java.util.concurrent.atomic.AtomicLong

data class PendingEffect(val id: Long, val effect: CheckoutEffect)

data class CheckoutUiState(
    val total: Money = Money.ZERO,
    val pending: List&lt;PendingEffect&gt; = emptyList(),
)

class CheckoutViewModel : ViewModel() {

    private val _state = MutableStateFlow(CheckoutUiState())
    val state: StateFlow&lt;CheckoutUiState&gt; = _state.asStateFlow()

    private val nextId = AtomicLong(0)   // safe even if enqueue() is called off the main thread

    private fun enqueue(effect: CheckoutEffect) {
        val id = nextId.getAndIncrement()   // outside update{}: its lambda can be re-run on CAS retry
        _state.update { it.copy(pending = it.pending + PendingEffect(id, effect)) }
    }

    fun onEffectHandled(id: Long) {
        _state.update { s -&gt; s.copy(pending = s.pending.filterNot { it.id == id }) }
    }
}
</code></pre>
<p>Conflation is harmless here because each emission carries the whole queue. The UI renders <code>pending.firstOrNull()</code> and calls <code>onEffectHandled(id)</code> when done. The cost is more boilerplate and the need to make handlers idempotent: after a configuration change, the same pending effect may be rendered again until it is acknowledged.</p>
<h2>A decision path you can apply in code review</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*5F9tiDVQ5ctrm_VWhKKGMw.png" alt="primitive-decision-flow" style="display:block;margin:0 auto" />

<h2>Testing for the loss, not around it</h2>
<p>The reason this bug ships is that the natural unit test collects first and emits second, which is the one ordering where a plain <code>SharedFlow</code> works. Flip it: send while nobody is listening.</p>
<pre><code class="language-kotlin">import app.cash.turbine.test
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import kotlin.test.assertEquals

@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
    val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
    override fun finished(description: Description) = Dispatchers.resetMain()
}

class CheckoutViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun `receipt effect survives a stopped UI`() = runTest(mainDispatcherRule.dispatcher) {
        val vm = CheckoutViewModel(FakePayments())

        vm.confirm("A-17")        // launches on viewModelScope (Dispatchers.Main)
        advanceUntilIdle()        // let both sends complete before anyone collects

        vm.effects.test {         // Turbine: collector arrives late, on purpose
            assertEquals(CheckoutEffect.ShowReceipt("A-17"), awaitItem())
            assertEquals(CheckoutEffect.NavigateToOrders, awaitItem())
            cancelAndIgnoreRemainingEvents()
        }
    }
}
</code></pre>
<p>Passing the rule’s dispatcher into <code>runTest</code> makes the test body and <code>viewModelScope</code> share one scheduler, so <code>advanceUntilIdle()</code> really does run the ViewModel's coroutine. Against a <code>MutableSharedFlow&lt;CheckoutEffect&gt;()</code> the same test fails with a Turbine timeout on the first <code>awaitItem()</code>, which reproduces the production symptom.</p>
<p>For <code>StateFlow</code> assertions, remember the opposite: assert on the <em>final</em> value or on the properties you really need, because intermediate emissions can legitimately be skipped.</p>
<h2>Checklist</h2>
<ul>
<li>Ask of every flow: <em>is this a value or an occurrence?</em> Values go in <code>StateFlow</code>; occurrences need delivery semantics.</li>
<li>Never ship an effect through a nullable state field. Use a queue with ids if you want effects in state.</li>
<li>A <code>SharedFlow</code> is a broadcast to whoever is listening now. It is not a mailbox.</li>
<li>Treat <code>conflate()</code>, <code>collectLatest</code>, <code>DROP_OLDEST</code> and <code>DROP_LATEST</code> as explicit data-loss opt-ins.</li>
<li>For single-consumer UI effects, use <code>Channel(BUFFERED)</code> with <code>receiveAsFlow()</code> and <code>repeatOnLifecycle</code>.</li>
<li>Write the test that emits before anyone collects.</li>
</ul>
<p>Conflation is not the villain. It makes UI state cheap and robust. The problem is applying it to things that were never meant to be summarized by their latest value.</p>
<h2>🧵 Stop Treating Coroutines Like Magic Keywords</h2>
<p>If “just add launch and hope” describes how your team handles concurrency, I’d recommend <strong>“Kotlin Coroutines Under the Hood”</strong> — a 25-chapter deep dive that goes past the syntax and down to the bytecode: the Continuation-Passing Style transform, the compiler-generated state machine, and exactly why a suspended coroutine costs a few dozen bytes while a blocked thread costs a megabyte.</p>
<p>It walks through structured concurrency and Job hierarchies, dispatcher internals (Default vs IO vs Unconfined), cooperative cancellation and exception aggregation, Flow’s cold-stream semantics and the full operator vocabulary, StateFlow/SharedFlow’s hot-broadcast model, Channels and fan-in/fan-out patterns, Mutex/Semaphore/actors for shared state, and a complete Android case study retrofitting a legacy callback-driven app onto coroutines and Flow — including a 100,000-coroutines-vs-100,000-threads benchmark lab you can run yourself.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=CMEIEgAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0HHK4MHMS"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2281589"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover.</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/dropped-on-the-floor-conflation-in-kotlin-flows-and-the-ui-events-you-never-saw-70283320853a"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Three Moments in a Composable’s Life: Composition, Recomposition, and Measure-Time Subcomposition]]></title><description><![CDATA[Understand composition, recomposition, and deferred composition in Jetpack Compose with Kotlin examples, SubcomposeLayout code and production fixes.]]></description><link>https://android-expert.hashnode.dev/three-moments-in-a-composable-s-life-composition-recomposition-and-measure-time-subcomposition</link><guid isPermaLink="true">https://android-expert.hashnode.dev/three-moments-in-a-composable-s-life-composition-recomposition-and-measure-time-subcomposition</guid><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[Recomposition]]></category><category><![CDATA[Subcomposelayout]]></category><category><![CDATA[Compose Performance]]></category><category><![CDATA[lazycolumn]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 11:46:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/4cc1404d-75f9-44a4-a8d6-3f153501ea5e.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most Compose bugs I get pulled into are not syntax bugs. They are timing bugs. Someone reads state in the wrong place, builds a layout that needs a size before it exists, or assumes a <code>remember</code> block lives longer than it does. Each of those traces back to one question: <em>when</em> does my composable body run, and <em>what triggered it</em>?</p>
<p>This post separates three ideas that tend to get blurred together:</p>
<ul>
<li><strong>Composition</strong>: the first execution of a composable when it enters the tree.</li>
<li><strong>Recomposition</strong>: a later, targeted re-execution after state it read has changed.</li>
<li><strong>Deferred composition</strong>: composing children during the layout phase, because their content depends on constraints or sizes that only exist at that point.</li>
</ul>
<p>We will go through each one, look at the code that makes the distinction visible, and finish with six failure modes I have seen in production apps.</p>
<h2>Where these ideas sit in a frame</h2>
<p>Compose renders a frame in three phases: composition, layout, drawing. Composition and recomposition live in the first phase. Deferred composition is the odd one out, because it reaches forward and runs inside the second.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*vnDMBPuGrbDedaEEmQwFKA.png" alt="frame-phases" style="display:block;margin:0 auto" />

<p>Keep that picture in mind. Every section below is a closer look at one of its arrows.</p>
<h2>Composition: the first run</h2>
<p>When a composable enters the tree for the first time, the runtime executes its body, executes every composable it calls, and records what happened in a structure called the <strong>slot table</strong>. The slot table stores groups, remembered values, and the information needed to diff against on the next run.</p>
<pre><code class="language-kotlin">@Composable
fun ProfileHeader(user: User) {
    // Runs during initial composition, and again only if something invalidates it.
    val formatter = remember { DateFormat.getDateInstance(DateFormat.MEDIUM) }

    Column {
        Avatar(url = user.avatarUrl)
        Text(user.displayName)
        Text(formatter.format(user.joinedAt))
    }
}
</code></pre>
<p>Two clarifications are worth making, because the simplified version (“composition happens once”) causes real mistakes.</p>
<p><strong>Once per entry, not once per screen.</strong> If a composable leaves the tree and comes back, it is a fresh composition and <code>remember</code> starts empty. An <code>if (expanded) { Details() }</code> branch, a navigation destination being popped, or a lazy item scrolling far enough away can all trigger this. State that must survive belongs in <code>rememberSaveable</code>, a <code>ViewModel</code>, or a hoisted holder.</p>
<p><strong>The body is not a place for side effects.</strong> The runtime is allowed to run, abandon, and re-run a composition. Writing to a database or firing analytics from the body is a bug waiting for a retry to expose it. Use <code>LaunchedEffect</code>, <code>DisposableEffect</code>, or <code>SideEffect</code>, which have defined timing.</p>
<pre><code class="language-kotlin">// Fragile: may run more than once, or run for a composition that is thrown away.
@Composable
fun ScreenViewed(name: String) {
    analytics.track("screen_viewed", name)
}

// Correct: tied to the composition actually being applied and to the key.
@Composable
fun ScreenViewed(name: String) {
    LaunchedEffect(name) { analytics.track("screen_viewed", name) }
}
</code></pre>
<h2>Recomposition: the targeted re-run</h2>
<p>Recomposition is not “redraw the screen.” It is the runtime re-executing only the <strong>scopes</strong> that were invalidated, then reusing everything else.</p>
<p>A scope becomes invalid when a snapshot <code>State</code> it <em>read during composition</em> changes. The runtime finds the nearest enclosing restartable scope and queues it. When that scope re-runs, each child call is compared to its previous inputs. If the inputs are equal and the child is skippable, the call is skipped without executing the body.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*iNlw3uazh2AbhCneTdj7yg.png" alt="recomposition-skipping" style="display:block;margin:0 auto" />

<p>A small instrument makes this visible. Drop it into any suspect composable while debugging:</p>
<pre><code class="language-kotlin">import android.util.Log

@Composable
fun RecompositionLog(tag: String) {
    val count = remember { intArrayOf(0) }
    SideEffect {
        count[0]++
        Log.d("Recomposition", "$tag ran ${count[0]} time(s)")
    }
}
</code></pre>
<p><code>SideEffect</code> fires only for compositions that are actually applied, so recompositions the runtime abandons are not counted. That is what you want when measuring user-visible work, but it means the number can be lower than how many times the body executed.</p>
<h2>What decides whether a child is skipped</h2>
<p>Skipping depends on the inputs being <strong>stable</strong> and <strong>equal</strong>. A parameter type the compiler cannot prove stable (a plain <code>List&lt;T&gt;</code>, a class from a module without the Compose compiler, a class with <code>var</code> properties) used to make the whole call non-skippable. Recent Compose compiler versions enable <em>strong skipping</em> by default (it became the default with Kotlin 2.0.20, via the Compose compiler Gradle plugin; on older setups it is an opt-in flag). With it on, every restartable composable becomes skippable: stable parameters are compared with <code>equals()</code>, and unstable ones are compared by instance identity (<code>===</code>). Lambdas that capture unstable values are also memoized. That softens the problem but does not remove it: a new <code>List</code> instance on every parent pass is a different instance, so it still defeats skipping.</p>
<pre><code class="language-kotlin">// Allocates a new list each time the parent runs. Equal contents, different instance.
@Composable
fun Inbox(messages: List&lt;Message&gt;) {
    MessageList(items = messages.filter { !it.archived })
}

// Cache the derived value against the thing it depends on.
@Composable
fun Inbox(messages: List&lt;Message&gt;) {
    val visible = remember(messages) { messages.filter { !it.archived } }
    MessageList(items = visible)
}
</code></pre>
<h2>Where you read state matters more than how often it changes</h2>
<p>This is the single highest-leverage habit in Compose performance. The scope that <em>reads</em> the value is the scope that recomposes.</p>
<pre><code class="language-kotlin">// Reads the offset in the body: every scroll pixel recomposes ScrollTitle.
@Composable
fun ScrollTitle(listState: LazyListState) {
    val offset = listState.firstVisibleItemScrollOffset
    Text(
        text = "Library",
        modifier = Modifier.padding(top = (offset / 4).dp)
    )
}

// Defer the read into the layout phase. Composition is untouched while scrolling.
@Composable
fun ScrollTitle(listState: LazyListState) {
    Text(
        text = "Library",
        modifier = Modifier.offset {
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 4)
        }
    )
}
</code></pre>
<p>When you only need a threshold, not the raw value, <code>derivedStateOf</code> collapses thousands of changes into a handful of invalidations:</p>
<pre><code class="language-kotlin">val showJumpToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex &gt; 3 }
}
</code></pre>
<p>Note the vocabulary here. Moving a read into a lambda modifier is a <strong>deferred state read</strong>. It is related to, but not the same as, deferred <em>composition</em>, which is the subject of the next section.</p>
<h2>Deferred composition: composing during layout</h2>
<p>Normally composition finishes before layout starts. That ordering breaks down when the <em>content</em> of a child depends on information layout produces: the available width, the viewport height, or the measured size of a sibling. Composing first and measuring later cannot express that.</p>
<p>Compose solves this with <strong>subcomposition</strong>: a layout can call <code>subcompose</code> while it is measuring, which creates (or updates) an independent child composition on demand.</p>
<p>Two things you already use do this:</p>
<ul>
<li><code>LazyColumn</code> and friends only compose the items that fit the viewport. They can only know which items fit once layout knows the viewport size, so item composition is driven from the measure pass, with some prefetching to hide the latency.</li>
<li><code>BoxWithConstraints</code> and <code>Scaffold</code> use subcomposition so their slot content can see constraints or sibling sizes.</li>
</ul>
<p><code>SubcomposeLayout</code> is the explicit, general form.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*WhGTvAgrHiP_yCJDRrVOIg.png" alt="subcompose-flow" style="display:block;margin:0 auto" />

<h2>A header-and-body layout that survives real constraints</h2>
<p>The textbook version of this layout works in a demo and fails in an app. This one handles loose constraints, unbounded parents, and windows too small for the header:</p>
<pre><code class="language-kotlin">private enum class Slot { Header, Body }

@Composable
fun HeaderBodyLayout(
    modifier: Modifier = Modifier,
    header: @Composable () -&gt; Unit,
    body: @Composable () -&gt; Unit,
) {
    SubcomposeLayout(modifier) { incoming -&gt;
        // Children may be smaller than the slot we were offered.
        val loose = incoming.copy(minWidth = 0, minHeight = 0)

        val headerPlaceables = subcompose(Slot.Header, header).map { it.measure(loose) }
        val headerHeight = headerPlaceables.maxOfOrNull { it.height } ?: 0

        // Remaining space for the body: clamp at zero, and respect an unbounded parent.
        val bodyMaxHeight = if (incoming.hasBoundedHeight) {
            (incoming.maxHeight - headerHeight).coerceAtLeast(0)
        } else {
            Constraints.Infinity
        }
        val bodyPlaceables = subcompose(Slot.Body, body)
            .map { it.measure(loose.copy(maxHeight = bodyMaxHeight)) }
        val bodyHeight = bodyPlaceables.maxOfOrNull { it.height } ?: 0

        val width = (maxOf(
            headerPlaceables.maxOfOrNull { it.width } ?: 0,
            bodyPlaceables.maxOfOrNull { it.width } ?: 0,
        )).coerceIn(incoming.minWidth, incoming.maxWidth)
        val height = (headerHeight + bodyHeight)
            .coerceIn(incoming.minHeight, incoming.maxHeight)

        layout(width, height) {
            headerPlaceables.forEach { it.placeRelative(0, 0) }
            bodyPlaceables.forEach { it.placeRelative(0, headerHeight) }
        }
    }
}
</code></pre>
<p>One caveat about the unbounded branch. When the parent offers infinite height, the body is measured with <code>Constraints.Infinity</code>. That keeps the layout itself from crashing, but it does not make the body's content safe: a <code>LazyColumn</code> placed in <code>body</code> under an unbounded parent (for example inside <code>verticalScroll</code>) will still throw, for the same reason as failure mode 2 below. The layout is defensive about its own math, not about what you put in the slots. Place it under a bounded parent, or keep scrollable content out of <code>body</code> in that situation.</p>
<p>Usage reads like any other layout, and the body can be a lazy list that sizes itself to whatever the header left behind (here <code>fillMaxSize</code> gives it a bounded height):</p>
<pre><code class="language-kotlin">HeaderBodyLayout(
    modifier = Modifier.fillMaxSize(),
    header = { SearchBar(query, onQueryChange) },
    body = { ResultsList(results) },
)
</code></pre>
<h2>Why not just measure the header with <code>onSizeChanged</code>?</h2>
<p>You can, and many apps do:</p>
<pre><code class="language-kotlin">var headerHeight by remember { mutableIntStateOf(0) }
Column {
    Header(Modifier.onSizeChanged { headerHeight = it.height })
    Body(maxHeight = with(LocalDensity.current) { headerHeight.toDp() })
}
</code></pre>
<p>The cost: the size is only known after the first layout, so the body renders once with a wrong value, then a state write triggers recomposition, then it renders again. You get an extra pass and often a visible one-frame jump. Subcomposition removes the round trip because the dependency is resolved inside a single measure pass.</p>
<h2>The three at a glance</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*eIgwmD7zpiKrwTzLkSJRmg.png" alt="concepts-at-a-glance" style="display:block;margin:0 auto" />

<h2>Six failure modes worth memorizing</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*Qxbr6lu3-uNsRF_8WTRb9A.png" alt="failure-modes" style="display:block;margin:0 auto" />

<p>Four of these deserve a closer look.</p>
<h2>1. Negative or unbounded remaining space</h2>
<p><code>Constraints</code> validates its arguments. A <code>maxHeight</code> below zero throws, and so does a <code>maxHeight</code> smaller than <code>minHeight</code>. The naive <code>constraints.maxHeight - header.height</code> hits this on split-screen windows, landscape phones, or when the IME is open. The fix is in the layout above: reset <code>minHeight</code>, clamp with <code>coerceAtLeast(0)</code>, and branch on <code>hasBoundedHeight</code>.</p>
<h2>2. Lazy content under a scroller</h2>
<pre><code class="language-kotlin">// Crashes at measure time: the lazy list is offered infinite height.
Column(Modifier.verticalScroll(rememberScrollState())) {
    Header()
    LazyColumn { items(rows) { RowItem(it) } }
}
</code></pre>
<p>Compose throws an <code>IllegalStateException</code> about a vertically scrollable component measured with infinite maximum height. Either give the inner list a bounded height, or make the whole screen one <code>LazyColumn</code> with a header item.</p>
<h2>3. Unkeyed lazy items</h2>
<p>Without keys, item identity is positional. Insert at the top and every remembered value shifts one row. Provide a stable key, and a content type if rows differ in structure so the runtime can reuse compatible compositions:</p>
<pre><code class="language-kotlin">LazyColumn {
    items(
        items = messages,
        key = { it.id },
        contentType = { it.kind },
    ) { message -&gt;
        MessageRow(message)
    }
}
</code></pre>
<h2>4. Reaching for <code>SubcomposeLayout</code> by default</h2>
<p>Subcomposition is more expensive than a plain <code>Layout</code>, and <code>SubcomposeLayout</code> does not support intrinsic measurements by default. Putting <code>BoxWithConstraints</code> in every row of a long list is a common way to turn smooth scrolling into dropped frames. Use it when the <em>composition</em> genuinely depends on constraints, not when you only need to <em>measure</em> something. If a modifier or a plain <code>Layout</code> can do the job, prefer that.</p>
<h2>A debugging checklist</h2>
<p>When something feels slow or wrong, ask in this order:</p>
<ol>
<li><strong>Which phase is this happening in?</strong> The Layout Inspector’s recomposition counts tell you whether composition is the problem or layout is.</li>
<li><strong>Which scope reads the changing value?</strong> If a fast-changing <code>State</code> is read high in the tree, move the read down or into a lambda.</li>
<li><strong>Are the inputs equal?</strong> Log with the <code>RecompositionLog</code> helper, then look for fresh <code>List</code>, lambda, or object instances created on every pass.</li>
<li><strong>Is subcomposition justified?</strong> If a child’s content does not depend on constraints, a plain layout is cheaper.</li>
<li><strong>What happens at the edges?</strong> Zero height, unbounded height, a window half the size you tested, and a reordered list.</li>
</ol>
<h2>🔚 Takeaways</h2>
<ul>
<li>Composition is when the tree is built. Treat it as a pure function that may run more than once.</li>
<li>Recomposition is a scoped re-run keyed on what was read. Where you read state decides what recomposes.</li>
<li>Deferred composition exists for exactly one reason: some children cannot be composed until layout knows something. Use it for that, and write its constraint math defensively.</li>
</ul>
<p>If you internalize the “which phase, which scope, which trigger” triple, most Compose performance and correctness bugs become short investigations rather than long ones.</p>
<h2>📱 Level Up Your Android System Design Game</h2>
<p>If you’re prepping for a senior Android role and system design rounds are the part that makes you sweat, I highly recommend <strong>“Mastering Android System Design”</strong> — a 37-chapter playbook that treats Android like the constrained runtime it actually is, not a small server. It covers Binder IPC, ART’s GC, process death, and background execution limits, then walks through the CRDDS interview framework and 13 full worked case studies (chat, ride-hailing, video streaming, payments, and more) carried end-to-end through API design, offline sync, and the Android-specific edge cases that trip candidates up mid-interview.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=O6IAEgAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0HDS8R7NM"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249089"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover</strong>.</li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/three-moments-in-a-composables-life-composition-recomposition-and-measure-time-subcomposition-e920d5ab3386"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Slot Tax: What Compose’s remember Really Costs, and When to Leave It Out]]></title><description><![CDATA[Learn what Compose's remember costs on every recomposition, when to skip it, and six production failure modes, with Kotlin examples and fixes.]]></description><link>https://android-expert.hashnode.dev/the-slot-tax-what-compose-s-remember-really-costs-and-when-to-leave-it-out</link><guid isPermaLink="true">https://android-expert.hashnode.dev/the-slot-tax-what-compose-s-remember-really-costs-and-when-to-leave-it-out</guid><category><![CDATA[Android]]></category><category><![CDATA[ #JetpackCompose ]]></category><category><![CDATA[performance]]></category><category><![CDATA[composeperformance]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 11:37:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/3b7519d5-15c2-4969-88a5-b7bfa3422f1a.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most Compose developers learn <code>remember</code> in their first hour, and many keep reaching for it long after it stops helping. The habit hardens into a reflex: if a value is computed in a composable, wrap it. Review a few production codebases and you will find <code>remember</code> around boolean checks, string concatenations, and sums of two integers.</p>
<p>This post lays out what a <code>remember</code> call does at runtime, which workloads justify it, which don't, and the bugs that show up when it is misused. In my experience, most of those bugs come from over-using <code>remember</code>, not from forgetting it.</p>
<h2>What happens at a <code>remember</code> call site</h2>
<p><code>remember</code> is an inline function, so your trailing block is not allocated as a function object. At each call site the compiler produces logic that behaves roughly like this:</p>
<pre><code class="language-kotlin">// What you write
val sections = remember(transactions) {
    transactions.groupBy { it.date }.toSortedMap(reverseOrder())
}

// Roughly what executes (simplified; mirrors Composer.cache in the runtime)
val dirty = composer.changed(transactions)              // compares against the key kept in the slot table, updates it if different
val stored = composer.rememberedValue()                 // then reads the value slot
val sections = if (dirty || stored === Composer.Empty) {
    transactions.groupBy { it.date }.toSortedMap(reverseOrder())
        .also { composer.updateRememberedValue(it) }
} else {
    stored as SortedMap&lt;LocalDate, List&lt;Transaction&gt;&gt;
}
</code></pre>
<img src="https://cdn-images-1.medium.com/max/800/1*ZUYuE0_5Pc5cxIyK1px5Gw.png" alt="slot-anatomy" style="display:block;margin:0 auto" />

<p>The key comparison is the argument to <code>cache</code>, so it is evaluated <em>before</em> the value slot is read. The comparison itself goes through <code>changed</code>, which relies on <code>equals()</code>.</p>
<p>Three separate costs fall out of that:</p>
<ol>
<li><strong>Reuse cost.</strong> One <code>equals()</code>-based comparison per key against the key stored in the slot table, plus one read of the value slot. You pay this on every composition pass that reaches the call site, even when nothing changed. (Most <code>equals()</code> implementations return quickly for the same instance, but that is a property of the key type, not something <code>remember</code> guarantees.)</li>
<li><strong>Recompute cost.</strong> The reuse cost, plus your block, plus a slot write.</li>
<li><strong>Standing cost.</strong> The stored value and its keys stay referenced for as long as the call site remains in the composition. This is a memory cost, and it is the one people overlook.</li>
</ol>
<p>The reuse cost is small: roughly tens of nanoseconds on current hardware. I am deliberately stating an order of magnitude and nothing tighter. The real figure depends on how many keys you pass, how expensive their <code>equals()</code> is, and the device, so benchmark your own case instead of trusting any number in an article.</p>
<h2>Cost spectrum</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*RAAiKWxNO5LGa6cmL2QvAg.png" alt="cost-spectrum" style="display:block;margin:0 auto" />

<p>The question to ask is not “is this slow in absolute terms?” It is “is this slow compared with what I pay on every pass, and does anything downstream depend on the result’s identity?”</p>
<h2>Cases where it earns its place</h2>
<h2>Grouping, sorting, and reshaping collections</h2>
<pre><code class="language-kotlin">@Composable
fun TransactionFeed(transactions: List&lt;Transaction&gt;, currency: Currency) {
    // Tracks per-app language switching; the compat call also covers minSdk &lt; 24
    val locale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0) ?: Locale.getDefault()
    // A miss costs a pass over every row plus map allocation.
    // A hit costs a slot read and one key comparison.
    val sections = remember(transactions) {
        transactions
            .groupBy { it.date }
            .toSortedMap(compareByDescending { it })
    }
    // Locale is a key, otherwise the formatter would keep the old language after a switch
    val money = remember(currency, locale) {
        NumberFormat.getCurrencyInstance(locale).apply { this.currency = currency }
    }

    LazyColumn {
        sections.forEach { (date, rows) -&gt;
            stickyHeader(key = date) { DayHeader(date) }
            items(rows, key = { it.id }) { TransactionRow(it, money) }
        }
    }
}
</code></pre>
<p>Grouping, sorting, mapping to display models, and building formatted text from collections are all comfortably above the cutoff.</p>
<p>Three notes on this sample. First, the locale lookup is a cheap read, so it is deliberately <em>not</em> wrapped in <code>remember</code>. Second, anything derived from the environment (locale, density, layout direction) belongs in the key list, as the formatter shows. Third, on older foundation releases <code>stickyHeader</code> is experimental and needs <code>@OptIn(ExperimentalFoundationApi::class)</code>; recent releases no longer require it, so check the version you ship.</p>
<h2>Objects that are costly to build but cheap to use</h2>
<p>Formatters, <code>Regex</code>, <code>Paint</code>, <code>Path</code>, and gradient <code>Brush</code> objects fit this pattern.</p>
<pre><code class="language-kotlin">@Composable
fun RingMeter(progress: Float, colors: List&lt;Color&gt;) {
    val sweep = remember(colors) { Brush.sweepGradient(colors) }
    Canvas(Modifier.size(96.dp)) {
        drawArc(
            brush = sweep,
            startAngle = -90f,
            sweepAngle = 360f * progress,
            useCenter = false,
            style = Stroke(width = 10.dp.toPx(), cap = StrokeCap.Round),
        )
    }
}
</code></pre>
<p>One refinement: if the object has <em>no inputs at all</em> and is immutable, thread-safe, and independent of context, configuration, and density, skip <code>remember</code> and hoist it to a <code>private val</code> at file level. It is built once per process instead of once per call site.</p>
<pre><code class="language-kotlin">private val HexColor = Regex("^#[0-9a-fA-F]{6}$")   // beats remember { Regex(...) }
</code></pre>
<p>That condition is strict, and it rules out several objects from the list above. <code>Path</code> and <code>Paint</code> are mutable, so a shared instance invites cross-composable interference (a <code>reset()</code> in one place corrupts another). <code>NumberFormat</code> and <code>SimpleDateFormat</code> are not thread-safe and follow the locale. Anything sized by density or layout belongs in <code>remember</code> with the right keys. Keep those at the call site, and hoist only the plain immutable constants.</p>
<h2>Allocations whose identity matters downstream</h2>
<p>A primitive sum allocates nothing. A freshly built <code>Brush</code>, <code>List</code>, or <code>PaddingValues</code> on every pass allocates, and it also gives each child a new instance to compare. If the child takes that instance as a parameter of an unstable type, or you are not running with Strong Skipping, the child recomposes each time.</p>
<p>This is why a pure “under 1 µs, skip it” rule is too blunt. The better test has two halves: <em>is the work heavy, or does a new instance change what downstream code sees?</em></p>
<h2>Cases where it is dead weight</h2>
<h2>Trivial expressions</h2>
<pre><code class="language-kotlin">// ❌ Each of these costs less than the slot machinery wrapped around it
val isEmpty   = remember(items) { items.isEmpty() }
val total     = remember(a, b) { a + b }
val isLoading = remember(state) { state is UiState.Loading }

// ✅
val isEmpty   = items.isEmpty()
val total     = a + b
val isLoading = state is UiState.Loading
</code></pre>
<p>Property reads, type checks, equality checks, and simple arithmetic all belong here. The wrapped version is longer to read and adds a slot to the table to save nothing.</p>
<h2>Callbacks, once Strong Skipping is on</h2>
<pre><code class="language-kotlin">// ❌ Redundant with Strong Skipping
val onRetry = remember { { presenter.reload() } }

// ✅
val onRetry = { presenter.reload() }
</code></pre>
<p>Strong Skipping is the default from Kotlin 2.0.20 with the Compose compiler plugin. It lets composables with unstable parameters skip, and it auto-remembers lambdas created inside composable functions. Captures that are stable are compared with <code>equals()</code>; unstable ones are compared by reference (<code>===</code>), which the compiler does through an identity-based variant of the key check (<code>changedInstance</code>) rather than plain <code>changed</code>.</p>
<p>Two limits to keep in mind:</p>
<ul>
<li>The automatic memoization applies to lambdas <em>created within composable functions</em>. A lambda built in a plain class or produced by a top-level factory gets no such treatment.</li>
<li><code>@DontMemoize</code> turns it off for a given lambda, and <code>@NonSkippableComposable</code> turns off skipping for a composable. If you have used either, the "write it plainly" advice no longer applies at that site.</li>
</ul>
<p>The hand-rolled <code>remember</code> here is also <em>riskier</em> than the redundant one, as the first failure mode below shows.</p>
<h2>Decision flow</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*w9bFSrQIATq9ghcGZliHmg.png" alt="decision-flow" style="display:block;margin:0 auto" />

<h2>Failure modes</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*RM3Mrp2JD3zz_UBDP4_IzQ.png" alt="failure-modes" style="display:block;margin:0 auto" />

<h2>1. Stale capture from an unkeyed block</h2>
<p>This is the bug I see most often, and it is the dark side of “memoize my callback”.</p>
<pre><code class="language-kotlin">@Composable
fun TaskRow(task: Task, onToggle: (TaskId) -&gt; Unit) {
    // ❌ Pins the first task's id and the first onToggle for as long as this call site lives
    val toggle = remember { { onToggle(task.id) } }
    Checkbox(checked = task.done, onCheckedChange = { toggle() })
}
</code></pre>
<p>After a reorder or a data refresh, the checkbox toggles the wrong task. Strong Skipping cannot save you, because an explicit unkeyed <code>remember</code> asks for a value that never invalidates. Delete it, or key it on every captured value. For effects that run for a long time and must see the newest callback, reach for <code>rememberUpdatedState</code>.</p>
<h2>2. A key that is mutated in place</h2>
<p><code>remember</code> invalidates when <code>equals()</code> says the key changed, and an instance always equals itself.</p>
<pre><code class="language-kotlin">val picked = remember { mutableListOf&lt;TagId&gt;() }
// ❌ Same instance forever, so this summary is computed once
val summary = remember(picked) { picked.sorted().joinToString() }

// ✅ Snapshot-aware list, with a derived value that tracks reads
val picked = remember { mutableStateListOf&lt;TagId&gt;() }
val summary by remember { derivedStateOf { picked.sorted().joinToString() } }
</code></pre>
<p>A plain <code>MutableList</code> also gives Compose no signal to recompose, so you end up with stale UI and a stale cache together.</p>
<h2>3. A key that costs as much as the work</h2>
<p><code>List.equals</code> compares element by element. If the caller rebuilds an equal-but-new list on every pass, your key check traverses all of it.</p>
<pre><code class="language-kotlin">// ❌ Equal-but-new list each pass: an O(n) comparison to avoid an O(n) map
val cards = remember(articles) { articles.map(::toCardModel) }
</code></pre>
<p>On a miss you can pay the comparison up to the first difference and then the full recompute, so the cache is a wash at best. Two fixes beat a clever key:</p>
<ul>
<li>Produce <code>cards</code> upstream (a ViewModel, or a <code>StateFlow</code> with <code>map</code>) so the composable receives the finished value.</li>
<li>Or key on something cheap that changes whenever the data does: a revision counter, a version id, or a stable content hash computed once when the data is created.</li>
</ul>
<h2>4. Heavy work still blocks the main thread</h2>
<p><code>remember</code> makes <em>repeats</em> cheap. It does nothing about the first run, which happens synchronously during composition.</p>
<pre><code class="language-kotlin">// ❌ Highlighting a large file during the first composition delays the frame
val highlighted = remember(source) { SyntaxHighlighter.highlight(source) }

// ✅ Move it off the main thread, with an explicit loading state
val highlighted by produceState&lt;AnnotatedString?&gt;(initialValue = null, source) {
    value = withContext(Dispatchers.Default) { SyntaxHighlighter.highlight(source) }
}
</code></pre>
<p><code>produceState</code> runs its coroutine on the composition's dispatcher, so the <code>withContext</code> hop is what moves the work off the main thread. When <code>source</code> changes, the previous coroutine is cancelled, but cancellation only takes effect if the work cooperates, so call <code>ensureActive()</code> inside long loops in the highlighter.</p>
<p>If several screens need the same result, a ViewModel is usually a better home than any composable.</p>
<h2>5. State vanishes when the call site leaves composition</h2>
<p><code>remember</code> survives recomposition. It does not survive an item scrolling out of a <code>LazyColumn</code>, a destination popping off the back stack, or an activity being recreated.</p>
<pre><code class="language-kotlin">// Cleared on rotation, and when the item scrolls away and returns
var draft by remember { mutableStateOf("") }

// Survives configuration change and process death (for Saver-compatible types)
var draft by rememberSaveable { mutableStateOf("") }
</code></pre>
<p>State that has to outlive the UI element entirely should be hoisted into a state holder or ViewModel.</p>
<h2>6. Lingering references</h2>
<p>The slot retains the value <em>and the keys</em>. Remember a decoded image, a large collection, or anything that captures an <code>Activity</code>, and it stays reachable while the call site lives.</p>
<pre><code class="language-kotlin">// ❌ Holds a full-size bitmap for as long as the call site lives
val thumbnail = remember(uri) { ThumbnailLoader.decode(context, uri) }
</code></pre>
<p>The remembered <em>value</em> is what stays alive, so make sure the decoded result does not itself hold the <code>Activity</code>. Pass <code>LocalContext.current.applicationContext</code> into loaders that keep a reference.</p>
<p>Narrow the scope, prefer an image-loading library built for this job, and if the object owns a resource, implement <code>RememberObserver</code> (or use <code>DisposableEffect</code>) so cleanup runs in <code>onForgotten</code> or <code>onDispose</code>.</p>
<h2><code>remember</code> and <code>derivedStateOf</code> solve different problems</h2>
<pre><code class="language-kotlin">val listState = rememberLazyListState()

// ❌ Reads the index in composition, so every index change recomposes this scope,
//    and remember(key) cannot help because the key changes each time too.
val showJumpToTop = listState.firstVisibleItemIndex &gt; 3

// ✅ Recomposes only when the Boolean actually flips
val showJumpToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex &gt; 3 }
}
</code></pre>
<p>Use <code>remember(keys)</code> when inputs change rarely and the work is heavy. Use <code>derivedStateOf</code> when inputs change frequently but the output changes rarely. A <code>derivedStateOf</code> itself is normally wrapped in <code>remember</code>, and that particular <code>remember</code> is almost always justified.</p>
<h2>Verify instead of guessing</h2>
<p>A rule of thumb ends arguments quickly and settles edge cases poorly. For the edge cases:</p>
<ul>
<li><strong>Layout Inspector recomposition counts</strong> reveal children that recompose more than you expect, which is the typical symptom of an identity problem.</li>
<li><strong>Macrobenchmark with</strong> <code>FrameTimingMetric</code> tells you whether work inside composition causes jank on a cold first frame.</li>
<li><strong>Microbenchmark</strong> the block on a mid-range phone, not your daily driver, to learn whether it takes nanoseconds or microseconds.</li>
<li><strong>Compose compiler reports</strong> (enabled through <code>reportsDestination</code>) show what the compiler considers stable and skippable, which settles most callback and parameter questions.</li>
</ul>
<h2>A working checklist</h2>
<ul>
<li><strong>Cache</strong> heavy transformations (group, sort, map, parse, format), objects that are costly to construct, and allocations whose identity affects children.</li>
<li><strong>Skip</strong> <code>remember</code> for arithmetic, property reads, type and equality checks, and callbacks created inside composables under Strong Skipping.</li>
<li><strong>Hoist to a file-level</strong> <code>val</code> anything with no inputs.</li>
<li><strong>List every value the block reads as a key</strong>, and keep those keys immutable and cheap to compare.</li>
<li><strong>Reach for</strong> <code>derivedStateOf</code> when the input changes more often than the output.</li>
<li><strong>Move heavy work off the main thread</strong>, because <code>remember</code> only caches it.</li>
<li><strong>Use</strong> <code>rememberSaveable</code> <strong>or hoist state</strong> when it must outlive the call site.</li>
</ul>
<p><code>remember</code> is inexpensive, not free, and a cached value that is wrong costs far more than a recomputed one. If you cannot name what a particular <code>remember</code> saves, remove it and see whether anything actually gets slower.</p>
<h2>📱 Crack Your Next Android Interview</h2>
<p>If you’re targeting a senior Android role at a FAANG or top-tier MNC, syntax knowledge alone won’t get you through the loop. I highly recommend <strong>“Cracking Top MNC Android Interviews”</strong> — a senior-level playbook covering framework internals, Jetpack Compose, coroutines, Android-specific system design, and DSA reframed around real mobile constraints, to help you walk into bar-raiser rounds ready.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=h7P4EQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0H6C8JDGC"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249119"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover.</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/the-slot-tax-what-composes-remember-really-costs-and-when-to-leave-it-out-071af626291c"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[State, MutableState, and SnapshotStateList: What Actually Differs (and How Each One Fails in Production)]]></title><description><![CDATA[Learn how State, MutableState, and SnapshotStateList differ in Jetpack Compose, avoid boxing with mutableIntStateOf, and fix six real-world bugs.]]></description><link>https://android-expert.hashnode.dev/state-mutablestate-and-snapshotstatelist-what-actually-differs-and-how-each-one-fails-in-production</link><guid isPermaLink="true">https://android-expert.hashnode.dev/state-mutablestate-and-snapshotstatelist-what-actually-differs-and-how-each-one-fails-in-production</guid><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[Jetpack Compose State]]></category><category><![CDATA[State Management ]]></category><category><![CDATA[Recomposition]]></category><category><![CDATA[Snapshotstatelist]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 01:16:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/54a954f2-23fe-4ea3-b938-d552df3cb0da.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every Compose codebase eventually grows the same three bugs: a list that never updates, a counter that quietly allocates in a scroll handler, and a “read-only” state that someone mutated from the wrong layer. All three trace back to the same question: <strong>what are</strong> <code>State</code><strong>,</strong> <code>MutableState</code><strong>, and</strong> <code>SnapshotStateList</code><strong>, and what does Compose really observe?</strong></p>
<p>This post answers that with idiomatic Kotlin, and with the failure modes that only show up once the screen has real data, real rotation, and real users.</p>
<p>🧠 TL;DR (Too Long; Didn’t Read)</p>
<ul>
<li><code>State&lt;T&gt;</code> is a read-only, observable handle. Compose tracks <em>reads</em> of <code>.value</code>.</li>
<li><code>MutableState&lt;T&gt;</code> adds a setter. A write invalidates every scope that read it.</li>
<li><code>SnapshotStateList&lt;T&gt;</code> is an observable list: in-place mutation is tracked, so you don't copy the list on every change.</li>
<li>For primitives, prefer <code>mutableIntStateOf</code> (and the <code>Long</code>/<code>Float</code>/<code>Double</code> variants) <strong>and</strong> read through <code>.intValue</code> or the <code>by</code> delegate. Otherwise you still box.</li>
</ul>
<h2>1. The type family</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*lAxL95CssN36LL_xTH0WuA.png" alt="state-type-hierarchy" style="display:block;margin:0 auto" />

<p>Two things in that picture trip people up:</p>
<ol>
<li><code>IntState</code> is <code>State&lt;Int&gt;</code> specialized for primitives, not the generic <code>State&lt;T&gt;</code>. <code>MutableIntState</code> is <em>both</em> an <code>IntState</code> and a <code>MutableState&lt;Int&gt;</code>. It can be passed anywhere a <code>State&lt;Int&gt;</code> is expected, but the primitive-specialized accessors live on <code>IntState</code>.</li>
<li><code>SnapshotStateList&lt;T&gt;</code> implements <code>MutableList&lt;T&gt;</code>. It is not a <code>State&lt;List&lt;T&gt;&gt;</code>. Compose observes the list object itself, not a <code>.value</code> wrapper around it.</li>
</ol>
<h2><code>State&lt;T&gt;</code>: a read-only window</h2>
<pre><code class="language-kotlin">val count: State&lt;Int&gt; = mutableIntStateOf(0)
</code></pre>
<p><code>State&lt;T&gt;</code> exposes one property: <code>val value: T</code>. Reading it inside a composable (or inside a <code>derivedStateOf</code>, <code>snapshotFlow</code>, or <code>LaunchedEffect</code> block that runs under snapshot observation) registers a dependency.</p>
<p>The practical use is <strong>API design</strong>. Keep the mutable handle private and expose the read-only one:</p>
<pre><code class="language-kotlin">class CounterViewModel : ViewModel() {
    private val _count = mutableIntStateOf(0)
    val count: IntState get() = _count          // read-only to the UI

    fun increment() { _count.intValue++ }
}
</code></pre>
<p>Note the type is <code>IntState</code>, not <code>State&lt;Int&gt;</code>. That keeps the primitive read path available to callers.</p>
<p>One caveat worth stating plainly: <code>State&lt;T&gt;</code> is <em>read-only to you</em>, not <em>immutable</em>. If something else holds the <code>MutableState</code> and writes to it, your <code>State</code> reference changes underneath you. That's the point of the abstraction, but it's not a snapshot of a value.</p>
<h2><code>MutableState&lt;T&gt;</code>: reads and writes</h2>
<pre><code class="language-kotlin">val count: MutableState&lt;Int&gt; = mutableStateOf(0)
count.value = 5   // invalidates every scope that read count.value
</code></pre>
<p>Two behaviors matter here:</p>
<p><strong>Writes are filtered by an equality policy.</strong> <code>mutableStateOf</code> defaults to <code>structuralEqualityPolicy()</code>, so writing a value that is <code>equals()</code> to the current one does nothing. No invalidation, no recomposition. That's usually what you want, and it's exactly why mutating an object in place (section 5) is invisible.</p>
<p><strong>Writes are cheap; reads are the expensive part to get wrong.</strong> A write marks dirty scopes. The actual re-execution happens later, in the next recomposition pass. On a normal Android UI that is the next frame; in unit tests or with a custom frame clock it runs when that clock ticks.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*uApHdqt4WDsO8fZGaFD6JA.png" alt="read-write-recompose-flow" style="display:block;margin:0 auto" />

<p>The consequence is that <strong>where you read matters more than where you write.</strong> Reading <code>count.value</code> in a parent composable and passing the <code>Int</code> down makes the <em>parent</em> the reader, so the whole parent scope reruns. Passing the <code>State</code> (or a lambda <code>{ count.value }</code>) down defers the read to the child, so only the child reruns.</p>
<pre><code class="language-kotlin">@Composable
fun Screen(count: IntState) {
    // ❌ Parent reads on every change: Screen and everything under it reruns
    // Header(count.intValue)

    // ✅ Read is deferred into Header's own scope
    Header(countProvider = { count.intValue })
}

@Composable
private fun Header(countProvider: () -&gt; Int) {
    Text("Count: ${countProvider()}")
}
</code></pre>
<h2>2. <code>mutableIntStateOf</code> vs <code>mutableStateOf&lt;Int&gt;</code>: the boxing story, accurately</h2>
<p>The common one-liner is “<code>mutableStateOf(0)</code> boxes, <code>mutableIntStateOf(0)</code> doesn't." That's directionally right, but it hides a trap.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*8W8VN21-BvCdyCjMg28bzw.png" alt="boxing-lanes" style="display:block;margin:0 auto" />

<pre><code class="language-kotlin">val a = mutableStateOf(0)       // generic MutableState&lt;Int&gt;: value stored as java.lang.Integer
val b = mutableIntStateOf(0)    // MutableIntState: value stored as a primitive int
</code></pre>
<p>The generic version stores a <code>java.lang.Integer</code>. Small values (−128..127) come from the JVM's <code>Integer</code> cache, so you often won't see allocations in a toy demo. Push a scroll offset, an animation tick, or a timer through it and the cache stops helping.</p>
<p>Now the trap:</p>
<pre><code class="language-kotlin">val count = mutableIntStateOf(0)

val x = count.value      // ⚠️ goes through MutableState&lt;Int&gt;.value → boxes
val y = count.intValue   // ✅ primitive read
var z by count           // ✅ delegate reads/writes intValue (count is a MutableIntState; an IntState-typed handle only allows `val`)
</code></pre>
<p><code>MutableIntState</code> still <em>is</em> a <code>MutableState&lt;Int&gt;</code> for compatibility, so <code>.value</code> is available and does box. Creating the state with the right factory but reading it with <code>.value</code> gets you the worse of both: extra API surface and no benefit.</p>
<p>The same applies to <code>mutableLongStateOf</code>, <code>mutableFloatStateOf</code>, and <code>mutableDoubleStateOf</code>. Recent Compose versions also ship a lint check (<code>AutoboxingStateCreation</code>) that flags <code>mutableStateOf</code> with a primitive; turn it on rather than relying on code review.</p>
<p>Where it genuinely pays off: values written at frame rate (drag offsets, scroll position mirrors, progress). For a boolean toggled twice a minute, don’t bother.</p>
<h2>3. <code>SnapshotStateList</code>: observable in place</h2>
<pre><code class="language-kotlin">val items = mutableStateListOf("A", "B", "C")
items.add("D")        // composables reading `items` are invalidated
items.removeAt(0)     // same
items[1] = "X"        // same
</code></pre>
<p><code>mutableStateListOf</code> returns a <code>SnapshotStateList&lt;T&gt;</code>. Every structural change is recorded in the current snapshot, and readers (anything that iterated, indexed, or read <code>size</code>) are invalidated.</p>
<p>Compare with the alternative most people reach for first:</p>
<pre><code class="language-kotlin">val items = mutableStateOf(listOf("A", "B", "C"))

// items.value.add("D")   // won't compile: List&lt;T&gt; has no add()
items.value = items.value + "D"   // new list, O(n) copy, new reference
</code></pre>
<p>Both work. They fail differently.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*lnZK7DDxIQQz8YpeIzk_kg.png" alt="snapshotstatelist-vs-state-of-list" style="display:block;margin:0 auto" />

<h2>Which one should you use?</h2>
<p>My default rule:</p>
<ul>
<li><code>mutableStateListOf</code> when the list is <em>large-ish or updated often</em> (chat messages, form rows, selection sets) and you own the mutations in one place.</li>
<li><code>MutableState&lt;List&lt;T&gt;&gt;</code> (or, more commonly, a <code>StateFlow&lt;List&lt;T&gt;&gt;</code> in the ViewModel) when the list is produced wholesale from elsewhere, such as a repository emission, a diff result, or a sort. Replacing the reference is the natural operation.</li>
</ul>
<h2>Don’t leak mutability</h2>
<pre><code class="language-kotlin">class CartViewModel : ViewModel() {
    private val _items = mutableStateListOf&lt;CartItem&gt;()
    val items: List&lt;CartItem&gt; get() = _items     // callers can't add(); reads are still tracked

    fun add(item: CartItem) { _items.add(item) }
    fun remove(id: String) { _items.removeAll { it.id == id } }
}
</code></pre>
<p>Exposing the property as <code>List&lt;T&gt;</code> hides the mutators from callers while keeping observability, because the object behind the interface is still the <code>SnapshotStateList</code>. (Under strong skipping, now the default in the Kotlin 2.0.20+ Compose compiler, passing it as a <code>List&lt;T&gt;</code> parameter is fine: unstable parameters compare by instance, and the child recomposes because it <em>reads</em> the list.)</p>
<h2>4. Failure modes</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*Qj3sAmzmzRgWLys9WgQdHA.png" alt="failure-modes" style="display:block;margin:0 auto" />

<p>Here’s the code behind the four that cost the most debugging time.</p>
<h2>4.1 The list that never updates</h2>
<pre><code class="language-kotlin">// ❌ Same instance, mutated. equals() is trivially true, so no invalidation.
val items = mutableStateOf(mutableListOf("A"))
items.value.add("B")

// ✅ Option 1: observable list
val items = mutableStateListOf("A")
items.add("B")

// ✅ Option 2: replace the reference
val items = mutableStateOf(listOf("A"))
items.value = items.value + "B"
</code></pre>
<p>Typing <code>MutableList</code> inside <code>mutableStateOf</code> is the root cause. If you catch yourself writing that, switch to <code>mutableStateListOf</code>.</p>
<h2>4.2 The invisible element edit</h2>
<p><code>SnapshotStateList</code> tracks the <em>list</em>, not the objects inside it.</p>
<pre><code class="language-kotlin">data class Task(val id: String, var done: Boolean)   // ❌ var inside a data class

val tasks = mutableStateListOf(Task("1", false))
tasks[0].done = true      // list unchanged → no recomposition
</code></pre>
<p>Make items immutable and replace the element, which <em>is</em> a tracked list write:</p>
<pre><code class="language-kotlin">data class Task(val id: String, val done: Boolean)

tasks[index] = tasks[index].copy(done = true)
</code></pre>
<p>This also makes strong skipping and equality-based skipping behave the way you expect.</p>
<h2>4.3 Rotation eats your list</h2>
<p><code>rememberSaveable</code> has no default saver for <code>SnapshotStateList</code>. If you need the list to survive configuration changes or process death at the UI layer, provide one:</p>
<pre><code class="language-kotlin">@Composable
fun rememberSaveableStringList(initial: List&lt;String&gt; = emptyList()): SnapshotStateList&lt;String&gt; =
    rememberSaveable(
        saver = listSaver&lt;SnapshotStateList&lt;String&gt;, String&gt;(
            save = { it.toList() },
            restore = { it.toMutableStateList() }
        )
    ) { initial.toMutableStateList() }
</code></pre>
<p>In most apps, the better answer is to keep the state in a <code>ViewModel</code> (with <code>SavedStateHandle</code> for what must survive process death) and not in <code>remember</code> at all.</p>
<h2>4.4 Index-based removal in a <code>LazyColumn</code></h2>
<pre><code class="language-kotlin">// ❌ `index` was captured when this item composed. After other removals it can be stale.
LazyColumn {
    itemsIndexed(items) { index, item -&gt;
        Row(Modifier.clickable { items.removeAt(index) }) { Text(item.title) }
    }
}

// ✅ Stable key, remove by identity
LazyColumn {
    items(items, key = { it.id }) { item -&gt;
        Row(Modifier
            .animateItem()   // Foundation 1.7+; on older versions use animateItemPlacement() with @OptIn(ExperimentalFoundationApi::class)
            .clickable { items.removeAll { it.id == item.id } }
        ) { Text(item.title) }
    }
}
</code></pre>
<p>Without keys, Lazy layouts match items by position, so state like <code>remember</code>ed text fields can jump to the wrong row after a removal. With keys, state follows the item.</p>
<h2>4.5 Two more worth knowing</h2>
<p><strong>Read-modify-write races.</strong> <code>SnapshotStateList</code> is snapshot-safe, but this is still a lost-update bug if two coroutines run it:</p>
<pre><code class="language-kotlin">state.value = state.value + newItem   // read, then write: not atomic
</code></pre>
<p>Fix it by funneling updates through one dispatcher, using <code>Snapshot.withMutableSnapshot { ... }</code> to batch related writes, or moving the source of truth to <code>MutableStateFlow</code> and using <code>update { }</code>.</p>
<p><strong>Writing state during composition.</strong> Changing a state after it has already been read in the same composition is a “backwards write,” and it can cause a recomposition loop. Writes belong in event handlers and effects. If a value is a pure function of other state, derive it:</p>
<pre><code class="language-kotlin">val hasItems by remember { derivedStateOf { items.isNotEmpty() } }
</code></pre>
<h2>5. A decision guide</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*0a5g4Pm1ZUEyCtT8E8JZAQ.png" alt="decision-guide" style="display:block;margin:0 auto" />

<h2>🔚 Closing thoughts</h2>
<p>The mental model that prevents most of these bugs is short:</p>
<ol>
<li><strong>Compose observes reads and writes of state objects, not changes to arbitrary objects.</strong> Mutating something it can’t see changes nothing on screen.</li>
<li><strong>Equality decides whether a write counts.</strong> Same value, no invalidation.</li>
<li><strong>Where you read decides what reruns.</strong> Defer reads as deep as the layout allows.</li>
<li><strong>Specialize and read through the specialized accessor.</strong> <code>mutableIntStateOf</code> only helps if you use <code>.intValue</code>.</li>
</ol>
<p>If you only change one habit after reading this: stop putting <code>MutableList</code> inside <code>mutableStateOf</code>, and start exposing <code>List&lt;T&gt;</code> backed by a private <code>SnapshotStateList</code> or <code>StateFlow</code>.</p>
<h2>🔐 Go Beyond Using Android — Master How It’s Actually Secured</h2>
<p>If you’re building, testing, or breaking Android apps, understanding the security model beneath the SDK separates developers who follow best practices from those who truly understand <em>why</em> those practices exist. I highly recommend <strong>“Mastering Android Security: From Fundamentals to Advanced Practice.”</strong> It’s a comprehensive, architecture-first journey through app sandboxing, cryptography, network defense, reverse engineering, and exploitation — spanning everything from <code>AndroidManifest.xml</code> internals to TrustZone and the Verified Boot chain, with hands-on labs at every step.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=1LLyEQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0H7XXM933"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249109"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/state-mutablestate-and-snapshotstatelist-what-actually-differs-and-how-each-one-fails-in-8e2df5b1f0ed"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[LazyColumn Under the Hood: LazyLayout, Slot Reuse, and the Failure Modes That Ship to Production]]></title><description><![CDATA[Learn how Jetpack Compose LazyColumn and LazyLayout work: viewport composition, prefetching, slot reuse, key vs contentType, and real production bugs.]]></description><link>https://android-expert.hashnode.dev/lazycolumn-under-the-hood-lazylayout-slot-reuse-and-the-failure-modes-that-ship-to-production</link><guid isPermaLink="true">https://android-expert.hashnode.dev/lazycolumn-under-the-hood-lazylayout-slot-reuse-and-the-failure-modes-that-ship-to-production</guid><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[lazycolumn]]></category><category><![CDATA[android performance]]></category><category><![CDATA[Compose Performance]]></category><category><![CDATA[Android UI]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Mon, 02 Nov 2026 01:04:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/967c00d4-90a0-46d0-8a94-060f2e643b67.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><code>LazyColumn</code> looks like a simple function. You pass it a list, it scrolls. But if you have ever debugged a checkbox that jumped to the wrong row, a text field that cleared itself mid-scroll, or a feed that stutters only when ads appear, you already know there is a machine behind it.</p>
<p>This post walks through that machine: what <code>LazyColumn</code> composes, when, and what happens to your state when an item leaves the screen. Along the way we will pin down what <code>key</code> and <code>contentType</code> really do. A popular explanation of <code>contentType</code> is slightly off, and the difference matters for performance work.</p>
<blockquote>
<p><em><strong>Version note:</strong></em><code>LazyLayout</code> <em>internals (prefetch strategies, reuse policies, pausable composition) have changed between Compose releases and some remain experimental. Treat numbers such as pool sizes as implementation details and verify against the Compose version you ship.</em></p>
</blockquote>
<h2>1. The mental model: composition follows the viewport</h2>
<p><code>LazyColumn</code> does <strong>not</strong> compose every item. It composes what the current viewport needs, plus a little work done ahead of time.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*n5b4TXlF_g_ENFednSozXw.png" alt="lazy-viewport-window" style="display:block;margin:0 auto" />

<p>Four populations of items exist at any moment:</p>
<ol>
<li><strong>Visible items</strong> are composed, measured, and placed during the measure pass. How many there are depends on the viewport size, the actual (often variable) item sizes, content padding, spacing, and partially visible edge items. It does not depend on <code>list.size</code>.</li>
<li><strong>A prefetched item</strong> is composed ahead of time, in the scroll direction, using idle time in the frame. If it is not ready when the next frame needs it, the layout composes it synchronously during measure, and that is where jank comes from.</li>
<li><strong>Recently departed items</strong> are not necessarily destroyed. Their composition slots may be retained in a reuse pool, bucketed by <code>contentType</code>.</li>
<li><strong>Everything else does not exist.</strong> No composable runs, no <code>LayoutNode</code>s are allocated, nothing is remembered. A 10,000-item list costs roughly what a 20-item list costs.</li>
</ol>
<p>You will often see this described as “visible items plus <code>prefetchDistance</code>". There is no <code>prefetchDistance</code> parameter on <code>LazyColumn</code>. Prefetching is driven by <code>LazyListState</code> and its prefetch strategy: by default, the next item in the scroll direction and, for a <code>LazyRow</code> nested in a <code>LazyColumn</code>, the first few items of the upcoming row. Recent Compose versions expose the strategy as an experimental API, and pausable composition lets a prefetch span frames instead of blowing one frame's budget.</p>
<h2>2. The stack: from <code>LazyColumn</code> to <code>LayoutNode</code></h2>
<img src="https://cdn-images-1.medium.com/max/800/1*aqAwiPbpJ5N_2UM_LclVPw.png" alt="lazylayout-stack" style="display:block;margin:0 auto" />

<p>Each layer explains a behavior you will meet later:</p>
<ul>
<li><code>LazyColumn { items(...) }</code> is only a DSL. The lambda records <em>intervals</em> (index ranges, each with a <code>key</code>, a <code>contentType</code>, and an item lambda) into an item provider. It does not compose anything, so never put per-item side effects in it.</li>
<li><code>LazyListState</code> <strong>and the measure policy</strong> own scroll position and, on every measure pass, work out which indices the incoming constraints require.</li>
<li><code>LazyLayout</code> is the shared foundation. <code>LazyColumn</code>, <code>LazyRow</code>, <code>LazyVerticalGrid</code>, <code>LazyVerticalStaggeredGrid</code>, and pagers are policies on top of it. It connects an item provider, a measure policy, and prefetch state.</li>
<li><code>SubcomposeLayout</code> is what <code>LazyLayout</code> is built on, and it makes laziness possible. Normal composition happens before layout. <code>SubcomposeLayout</code> lets measure-time code call <code>subcompose(slotId, content)</code>, so composition can depend on layout results ("how many items fit?"). It also owns the slot pool used for reuse; <code>LazyLayout</code> plugs in its own reuse policy (a <code>SubcomposeSlotReusePolicy</code> that is aware of <code>contentType</code>) and wraps item content through an internal content factory.</li>
<li><strong>Composer and Applier</strong> do the real work, producing <code>LayoutNode</code>s that are measured and placed in the same pass.</li>
</ul>
<p>The practical consequence: <strong>item composition happens inside the measure phase.</strong> An expensive item composable is not “slow to scroll”. It is slow to <em>measure</em>, and it blocks the frame.</p>
<h2>3. Life of an item</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*2IL5EbZVbJ0s70fL_Wu-4A.png" alt="item-lifecycle" style="display:block;margin:0 auto" />

<p>As you scroll, every item follows the same path:</p>
<ol>
<li>The measure pass decides an index is needed, because it entered the viewport or the prefetch window.</li>
<li><code>LazyLayout</code> asks <code>SubcomposeLayout</code> for a slot. If a free slot of the same <code>contentType</code> exists it is <strong>reused</strong>; otherwise a new composition is created.</li>
<li>The item is measured, placed, and drawn.</li>
<li>It scrolls out of bounds.</li>
<li>Its slot goes to the reuse pool if there is room (roughly seven slots by default). If the pool is full, or no compatible item arrives, the slot is <strong>disposed</strong>.</li>
</ol>
<h2>What happens to your state</h2>
<p>Reuse changes the answer to “is my state still there?”, which is why this trips up so many teams:</p>
<ul>
<li><code>remember { }</code> is lost when the item is disposed, and also reset when its slot is reused for a different item. Never assume it survives a scroll.</li>
<li><code>rememberSaveable { }</code> is stored per <code>key</code> in a <code>SaveableStateHolder</code> and restored when the same key scrolls back in. The <code>LazyColumn</code> documentation asks that the key type be saveable via a <code>Bundle</code> on Android (<code>String</code>, <code>Long</code>, <code>Int</code>, a <code>Parcelable</code>, and so on) so the saved state can survive process death. Within a single process, the registry keeps it in memory.</li>
<li><strong>State hoisted by item id</strong> (a <code>ViewModel</code> or state holder) survives scrolling, reordering, and rotation. Anything a user would be upset to lose belongs here.</li>
</ul>
<h2>4. <code>key</code>: identity, not just state</h2>
<p>By default an item’s identity is its <strong>index</strong>. That is fine for append-only lists and wrong for anything that reorders, inserts, or deletes.</p>
<pre><code class="language-kotlin">@Immutable
sealed interface FeedItem {
    val id: Long
    val stableKey: String // unique across ALL types, saveable in a Bundle

    data class Post(override val id: Long, val title: String, val body: String) : FeedItem {
        override val stableKey: String = "post-$id" // computed once, not per read
    }
    data class Ad(override val id: Long, val sponsor: String) : FeedItem {
        override val stableKey: String = "ad-$id"
    }
    data class Story(override val id: Long, val frames: List&lt;String&gt;) : FeedItem {
        override val stableKey: String = "story-$id"
    }
}
</code></pre>
<p>A <code>key</code> buys you three things:</p>
<ul>
<li><strong>State follows the item.</strong> <code>rememberSaveable</code> state is attached to the key, not the position, so a checkbox stays with its row when the list changes.</li>
<li><strong>Scroll position is anchored.</strong> <code>LazyListState</code> tracks the first visible item by key, so inserting items above the viewport does not shove the user's content around.</li>
<li><code>Modifier.animateItem()</code> <strong>works.</strong> Item animations need identity to tell "moved" from "removed and added".</li>
</ul>
<p>Note the <code>"post-$id"</code> / <code>"ad-$id"</code> prefixes. Raw ids from different tables often collide, and a duplicate key is a hard crash, not a warning.</p>
<h2>5. <code>contentType</code>: what it really does</h2>
<p>Here is the version of the story you will find in many posts, and the correction:</p>
<ul>
<li><strong>Often claimed:</strong> without <code>contentType</code>, every item is unique, so nothing is recycled.</li>
<li><strong>Actually true:</strong> <code>contentType</code> defaults to <code>null</code>, and <code>null</code> is a valid type. Items without one are all considered <em>compatible with each other</em>. Reuse still happens, but it happens across items whose composition structure may have nothing in common.</li>
</ul>
<img src="https://cdn-images-1.medium.com/max/800/1*7ef6McGFGK5KuqpgxoVkyw.png" alt="contenttype-reuse" style="display:block;margin:0 auto" />

<p>Reuse is only cheap when the new item has the <strong>same shape</strong> as the old one. When a slot built for an <code>Ad</code> receives a <code>Story</code>, Compose has to diff the groups, dispose the ad's nodes, and create the story's nodes. You paid for the slot bookkeeping and still got something close to a fresh composition. During a fast fling through a mixed feed, that is exactly the pattern that produces dropped frames.</p>
<pre><code class="language-kotlin">import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.unit.dp

@Composable
fun FeedList(
    feed: List&lt;FeedItem&gt;,
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
) {
    LazyColumn(
        modifier = modifier,
        state = state,
        contentPadding = PaddingValues(vertical = 8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(
            items = feed,
            key = { it.stableKey },
            contentType = { item -&gt;
                when (item) {
                    is FeedItem.Post -&gt; "post"
                    is FeedItem.Ad -&gt; "ad"
                    is FeedItem.Story -&gt; "story"
                }
            },
        ) { item -&gt;
            when (item) {
                is FeedItem.Post -&gt; PostCard(item)
                is FeedItem.Ad -&gt; AdCard(item)
                is FeedItem.Story -&gt; StoryRow(item)
            }
        }
    }
}
</code></pre>
<p>With this in place, a <code>post</code> that leaves the viewport parks its slot in the <code>post</code> bucket. The next <code>post</code> to arrive takes that slot, the node structure already matches, and only parameters that actually changed recompose.</p>
<p>Two rules of thumb:</p>
<ul>
<li><strong>Use one</strong> <code>contentType</code> <strong>per distinct layout shape</strong>, not per data class. Two data classes that render the same composable can share a type, and one data class that renders two layouts (say, <code>Post</code> with and without media) needs two.</li>
<li><strong>Prefer cheap constants.</strong> <code>contentType</code> is evaluated frequently during measure. Return a string literal or enum, never something allocated per call.</li>
</ul>
<p>Because reuse resets <code>remember</code>, per-item UI state must be keyed, saved, or hoisted. Reuse is not a bug that "loses" your state. It is the designed behavior that your state model has to survive.</p>
<h2>6. Failure modes in production</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*n0L2R0YzSniWrH4lZBsFAg.png" alt="failure-modes" style="display:block;margin:0 auto" />

<p>Let’s expand the ones that cost the most debugging time.</p>
<h2>6.1 State lands on the wrong row</h2>
<p>Without a key, identity is the index. Insert one item at the top and the state that lived at index 3 now belongs to a different item.</p>
<pre><code class="language-kotlin">// Bug: identity is the index
items(feed) { PostCard(it) }

// Fix: identity is the domain id
items(feed, key = { it.stableKey }) { PostCard(it) }
</code></pre>
<h2>6.2 <code>IllegalArgumentException: Key "…" was already used</code></h2>
<p>Common with paging, where adjacent pages overlap, and with ids that are only unique per type. Deduplicate upstream and namespace keys by type, as in section 4. Do not “fix” this by appending the index to the key, since that silently reintroduces index identity.</p>
<h2>6.3 Nested vertical scrolling</h2>
<p>This one throws an <code>IllegalStateException</code> (not the <code>IllegalArgumentException</code> of duplicate keys):</p>
<pre><code class="language-kotlin">// Crash: "Vertically scrollable component was measured with an infinity maximum height"
Column(Modifier.verticalScroll(rememberScrollState())) {
    Header()
    LazyColumn { items(feed) { PostCard(it) } }
}
</code></pre>
<p>A lazy list needs a bounded height to know what fits. Put the header inside the list instead:</p>
<pre><code class="language-kotlin">LazyColumn {
    item(key = "header", contentType = "header") { Header() }
    items(feed, key = { it.stableKey }, contentType = { "post" }) { PostCard(it) }
    item(key = "footer", contentType = "footer") { Footer() }
}
</code></pre>
<h2>6.4 UI state that resets on scroll</h2>
<p>Expanded cards and half-typed text are classic victims. Scroll away, scroll back, and they are gone.</p>
<pre><code class="language-kotlin">// Resets whenever the slot is disposed or reused
var expanded by remember { mutableStateOf(false) }

// Survives scrolling and process death, given a stable key on the item
var expanded by rememberSaveable { mutableStateOf(false) }
</code></pre>
<p>For anything more valuable than a toggle, hoist by id:</p>
<pre><code class="language-kotlin">class FeedViewModel : ViewModel() {
    private val _expanded = MutableStateFlow&lt;Set&lt;String&gt;&gt;(emptySet())
    val expanded: StateFlow&lt;Set&lt;String&gt;&gt; = _expanded

    fun toggle(key: String) = _expanded.update { if (key in it) it - key else it + key }
}
</code></pre>
<h2>6.5 Reading scroll state in composition</h2>
<pre><code class="language-kotlin">// Bug: recomposes the whole parent each time the first visible index changes
val showButton = state.firstVisibleItemIndex &gt; 3

// Fix: only recompose when the boolean actually flips
val showButton by remember {
    derivedStateOf { state.firstVisibleItemIndex &gt; 3 }
}
</code></pre>
<p>To be precise, <code>firstVisibleItemIndex</code> changes only when the first visible item changes, so intra-item pixel scrolling (<code>firstVisibleItemScrollOffset</code>) does not trigger it. In a fast fling it still changes many times per second, and every change recomposes the parent for no visible effect.</p>
<p>The same applies to infinite scrolling. Do it as a side effect, not a composition read:</p>
<pre><code class="language-kotlin">LaunchedEffect(state) {
    snapshotFlow {
        val last = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
        last &gt;= state.layoutInfo.totalItemsCount - 5
    }
        .distinctUntilChanged()
        .filter { it }
        .collect { onLoadMore() }
}
</code></pre>
<p>The <code>snapshotFlow</code> block re-runs whenever <code>layoutInfo</code> changes, but <code>distinctUntilChanged()</code> ensures downstream code only sees the boolean flipping, and nothing recomposes.</p>
<h2>6.6 Unstable parameters defeating skipping</h2>
<p>Item composables can only skip recomposition when their inputs are stable and equal. A <code>List&lt;String&gt;</code> parameter on a non-strong-skipping setup, or a lambda recreated on every parent recomposition, forces every visible item to recompose. Prefer immutable models, <code>@Immutable</code>/<code>@Stable</code> where honest, <code>kotlinx.collections.immutable</code> for collections, and lambdas that capture stable values such as the item id.</p>
<h2>7. A checklist before you ship a feed</h2>
<ul>
<li>Every <code>items</code> block has a <strong>stable, unique, type-namespaced</strong> <code>key</code>.</li>
<li>Every <code>items</code> block has a <code>contentType</code> <strong>per distinct layout shape</strong>, as cheap constants.</li>
<li>No per-item UI state lives in plain <code>remember</code> unless losing it is acceptable.</li>
<li>Scroll-derived UI uses <code>derivedStateOf</code> or <code>snapshotFlow</code>, not direct reads in composition.</li>
<li>No lazy list sits inside an unbounded vertical scroll container.</li>
<li>You have profiled a <strong>fast fling on a mid-range device in a release build</strong>, with R8 and baseline profiles. Debug builds exaggerate lazy-list jank considerably.</li>
</ul>
<h2>🔚 Takeaways</h2>
<ul>
<li><code>LazyColumn</code> composes the viewport plus a prefetched item, and does so inside the measure pass.</li>
<li>Slots left behind are pooled by <code>contentType</code>, not destroyed immediately, and reuse resets <code>remember</code>.</li>
<li><code>key</code> gives items identity. <code>contentType</code> gives slots <em>compatibility</em>. Without <code>contentType</code>, reuse still happens, just between items that may not match, which costs you most of its benefit.</li>
<li>Most production bugs here are not Compose bugs. They are state models that quietly assumed items live forever.</li>
</ul>
<h2>📱 Go Beyond Using AI — Learn How to Actually Build It Into Your Android Apps</h2>
<p>If you’re building Android apps in 2026, “adding AI” can’t just mean bolting on a chatbot API — it means understanding on-device inference, model trade-offs, and production realities well enough to ship a feature users actually trust. I highly recommend <strong>“Mastering Android AI: App to Intelligence”</strong> It’s a practical, code-first journey through the entire Android AI stack — from ML Kit and LiteRT to Gemma, Gemini, and MediaPipe — covering everything from your first “hello inference” screen to thermal throttling, model drift monitoring, and full applied projects like an AI-powered camera app and a health companion, with hands-on Kotlin builds at every step.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=ROjzEQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0H8LJWY14"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249159"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/lazycolumn-under-the-hood-lazylayout-slot-reuse-and-the-failure-modes-that-ship-to-production-165d68e224ed"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Inside the Compose Slot Table: Positional Memoization, Gap Buffers, and the Bugs They Cause]]></title><description><![CDATA[Learn how the Jetpack Compose slot table works: gap buffers, group keys, recomposition skipping, and the state bugs that break production apps.]]></description><link>https://android-expert.hashnode.dev/inside-the-compose-slot-table-positional-memoization-gap-buffers-and-the-bugs-they-cause</link><guid isPermaLink="true">https://android-expert.hashnode.dev/inside-the-compose-slot-table-positional-memoization-gap-buffers-and-the-bugs-they-cause</guid><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[Compose Internals]]></category><category><![CDATA[Recomposition]]></category><category><![CDATA[Slot Table]]></category><category><![CDATA[android performance]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Sun, 01 Nov 2026 16:08:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/37a33109-26e4-4d97-9682-a4e685382190.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most Compose bugs I see in code review share one root cause: someone treated a <code>@Composable</code> function like a view factory instead of a function whose <em>call position</em> is its identity. Once you understand the slot table, state loss on reorder, <code>remember</code> that "doesn't remember", and mysterious skipping stop being mysterious.</p>
<p>This post covers what the slot table is, how recomposition walks it, and the failure modes you will hit in production.</p>
<h2>What the slot table is</h2>
<p>The slot table is Compose’s runtime record of your composition. Where the View system gives you a tree of heap objects, Compose gives you a <strong>linear, array-backed structure</strong> that is read and written in the same order your composable functions execute.</p>
<p>Take this code:</p>
<pre><code class="language-kotlin">@Composable
fun Screen() {
    Column {
        Text("Hello")
        val counter = remember { mutableStateOf(0) }
        Text("World")
    }
}
</code></pre>
<p>The mental model is a flat sequence of groups and data:</p>
<img src="https://cdn-images-1.medium.com/max/800/1*ToTBkRbNN1unIAIkFRipzw.png" alt="slot-table-linear-model" style="display:block;margin:0 auto" />

<p>Nesting is encoded by position and group size, not by parent pointers you chase at runtime. That is what makes the walk cheap and cache-friendly.</p>
<h2>The mental model is slightly wrong (on purpose)</h2>
<p>The single-array picture is the right way to <em>think</em>, but the real <code>SlotTable</code> in <code>androidx.compose.runtime</code> splits the data across two parallel arrays:</p>
<ul>
<li><code>groups: IntArray</code>: a fixed number of ints per group (key, flags and node count, parent anchor, size, data anchor).</li>
<li><code>slots: Array&lt;Any?&gt;</code>: the payload: remembered values, recompose scopes, previous values of non-constant parameters (for skipping checks), nodes, and so on.</li>
</ul>
<p>One correction to the simplified picture above: <code>"Hello"</code> and <code>"World"</code> are compile-time constants. The compiler marks such arguments as static in <code>$changed</code>, so they are <em>not</em> stored in a slot. A parameter that varies at runtime, such as <code>Text(name)</code>, is stored so the next recomposition can compare it. The linear diagram is a teaching model; group descriptors and slot payloads actually live in separate address spaces.</p>
<p>Both are <strong>gap buffers</strong>. Details change between runtime versions, so treat the field layout as illustrative rather than as an API.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*v_xdgACNdH8TLKRxfq__Vg.png" alt="slot-table-real-structure" style="display:block;margin:0 auto" />

<p>Why gap buffers? Composition is mostly “edit near where I last edited”. A gap buffer keeps a hole at the last edit point, so insertions and removals there are close to free. A distant edit costs one array copy to move the gap.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*4NGXSon-34Beorvi2jP_rA.png" alt="gap-buffer-insert" style="display:block;margin:0 auto" />

<h2>Where the keys come from</h2>
<p>You never write group keys. The Compose compiler plugin rewrites each composable to take an extra <code>Composer</code> and a <code>$changed</code> bitmask, and it wraps bodies in group calls with an <code>Int</code> key derived from the source location. Conceptually, <code>Screen</code> becomes:</p>
<pre><code class="language-kotlin">// Conceptual output. Do not rely on exact shape.
fun Screen(composer: Composer, changed: Int) {
    composer.startRestartGroup(/* key = 0x1A2B... */)
    // body, with startReplaceGroup/startMovableGroup around
    // conditionals, loops, and key { } blocks
    composer.endRestartGroup()?.updateScope { c, _ -&gt; Screen(c, changed or 1) }
}
</code></pre>
<p>Three group flavors matter in practice:</p>
<ul>
<li><strong>Restart groups</strong> wrap composables that return <code>Unit</code>. They are the unit of recomposition scope.</li>
<li><strong>Replaceable groups</strong> wrap branches (<code>if</code>/<code>when</code>). A different branch means a different key, so old slots are discarded.</li>
<li><strong>Movable groups</strong> come from <code>key(x) { }</code>. They carry an object key, so the runtime can find and <em>move</em> a group instead of destroying it.</li>
</ul>
<h2>How recomposition walks the table</h2>
<p>During recomposition the composer keeps a cursor into the table and does not rebuild the tree. At each group it asks:</p>
<img src="https://cdn-images-1.medium.com/max/800/1*YDrk9lXoVERAKTQAiaAXKQ.png" alt="recomposition-walk" style="display:block;margin:0 auto" />

<p>In words:</p>
<ol>
<li><strong>Same key, inputs unchanged</strong>: skip. The cursor jumps past the group using its recorded size, so none of its children run.</li>
<li><strong>Same key, inputs changed</strong>: re-run the body, reusing the slots already stored for children.</li>
<li><strong>Different key</strong>: look for a movable group with that key. If found, move it and keep its state. Otherwise, insert a new group, run the code, and remove the old slots.</li>
<li><code>remember</code> <strong>slot</strong>: compare the remember keys. Unchanged returns the cached value without running your lambda.</li>
</ol>
<p>Skipping depends on the compiler comparing each parameter to the value stored in the slot table from the last composition. Stable types are compared with <code>equals</code>. Since Kotlin 2.0.20, strong skipping mode is on by default and also skips with unstable parameters, comparing them by instance (<code>===</code>). Check your compiler version before reasoning about why something did or did not skip.</p>
<h2>Position is identity</h2>
<p>Here is the sentence that explains most state bugs: <strong>the position in the slot table is the identity.</strong> Two <code>remember {}</code> calls at different positions get different slots, because they are different <em>call sites</em> in execution order.</p>
<p>That is also the failure mode.</p>
<h2>Failure mode 1: Loops without keys</h2>
<pre><code class="language-kotlin">@Composable
fun ExpandableList(items: List&lt;Item&gt;) {
    Column {
        items.forEach { item -&gt;
            // BUG: identity is the loop iteration order, not item.id
            var expanded by remember { mutableStateOf(false) }
            ItemRow(item, expanded) { expanded = !expanded }
        }
    }
}
</code></pre>
<p>The user expands B, then deletes A. Slot 0 still holds <code>false</code>, slot 1 still holds <code>true</code>:</p>
<img src="https://cdn-images-1.medium.com/max/800/1*i1qvmyaFMM8cYmTh1Ii2hg.png" alt="positional-identity-bug" style="display:block;margin:0 auto" />

<p>Fix it by giving each iteration a stable identity:</p>
<pre><code class="language-kotlin">@Composable
fun ExpandableList(items: List&lt;Item&gt;) {
    Column {
        items.forEach { item -&gt;
            key(item.id) {
                var expanded by remember { mutableStateOf(false) }
                ItemRow(item, expanded) { expanded = !expanded }
            }
        }
    }
}
</code></pre>
<p>I kept plain <code>remember</code> so the only change is <code>key(item.id)</code>. If the expanded state must also survive recreation or process death, swap in <code>rememberSaveable</code>. Its default key already incorporates the enclosing <code>key(item.id)</code> group, so no extra key is needed, but the value must be <code>Bundle</code>-saveable.</p>
<p>In lazy lists, the equivalent is <code>items(items, key = { it.id })</code>. Make sure keys are unique and stable. Duplicate keys crash <code>LazyColumn</code> at runtime, and <code>key {}</code> with duplicates silently produces ambiguous matching.</p>
<h2>Failure mode 2: Keys that are not really keys</h2>
<p><code>key(index)</code> or <code>key(item.hashCode())</code> on mutable data defeats the purpose. Index is just position again, and a hash that changes when the item mutates will tear down state on every edit. Use a stable domain ID.</p>
<h2>Failure mode 3: Conditional branches silently reset state</h2>
<pre><code class="language-kotlin">@Composable
fun Profile(isEditing: Boolean, user: User) {
    if (isEditing) {
        var draft by remember { mutableStateOf(user.name) }
        EditForm(draft) { draft = it }
    } else {
        ReadOnlyForm(user)
    }
}
</code></pre>
<p>Each branch has its own group, so leaving the <code>isEditing</code> branch discards <code>draft</code>. Often that is what you want; the bug is <em>expecting</em> it to survive. If state must outlive the branch, hoist it above the <code>if</code>, or store it in a <code>ViewModel</code> or <code>rememberSaveable</code> higher up.</p>
<h2>Failure mode 4: <code>remember</code> with the wrong keys</h2>
<pre><code class="language-kotlin">@Composable
fun UserBadge(userId: String, repo: UserRepo) {
    // BUG: stale if userId changes; the slot already holds a value
    val user = remember { repo.cachedUser(userId) }

    // Correct: re-run when the input changes
    val fresh = remember(userId) { repo.cachedUser(userId) }
}
</code></pre>
<p>Without keys, the lambda runs once for the group’s lifetime. Pass every input the lambda captures as a key.</p>
<h2>Failure mode 5: Stale lambdas captured by long-lived effects</h2>
<pre><code class="language-kotlin">@Composable
fun Timer(onTimeout: () -&gt; Unit) {
    // BUG: effect captures the first onTimeout forever
    LaunchedEffect(Unit) {
        delay(5_000)
        onTimeout()
    }
}

@Composable
fun TimerFixed(onTimeout: () -&gt; Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)
    LaunchedEffect(Unit) {
        delay(5_000)
        currentOnTimeout()
    }
}
</code></pre>
<p><code>rememberUpdatedState</code> keeps one slot whose value is refreshed each recomposition, so the long-lived coroutine always reads the latest lambda.</p>
<h2>Failure mode 6: Remembered objects leak what they capture</h2>
<p>Slots hold strong references until their group leaves the composition. Remembering something that captures an <code>Activity</code> or a large <code>Bitmap</code> ties its lifetime to that group. For resources with cleanup, implement <code>RememberObserver</code> (or use <code>DisposableEffect</code>) so <code>onForgotten</code> runs when the slot is dropped.</p>
<pre><code class="language-kotlin">// Common pattern: fine for normal lifecycles
@Composable
fun rememberPlayer(context: Context): ExoPlayer {
    val appContext = context.applicationContext
    val player = remember { ExoPlayer.Builder(appContext).build() }
    DisposableEffect(player) { onDispose { player.release() } }
    return player
}
</code></pre>
<p>This has a gap. If composition is <em>abandoned</em> after <code>remember</code> runs but before the changes are applied (for example, a cancelled or interrupted composition), the <code>DisposableEffect</code> never enters, so <code>onDispose</code> never runs and the player leaks. Wrapping the resource in a <code>RememberObserver</code> covers forgotten <em>and</em> abandoned slots:</p>
<pre><code class="language-kotlin">private class PlayerHolder(context: Context) : RememberObserver {
    val player: ExoPlayer = ExoPlayer.Builder(context).build()

    override fun onRemembered() = Unit
    override fun onForgotten() = player.release()
    override fun onAbandoned() = player.release()
}

@Composable
fun rememberPlayer(context: Context): ExoPlayer {
    val appContext = context.applicationContext
    return remember { PlayerHolder(appContext) }.player
}
</code></pre>
<h2>Failure mode 7: Expecting <code>remember</code> to survive recreation</h2>
<p>The slot table belongs to the composition. When the Activity is recreated on a configuration change, the composition is rebuilt and every <code>remember</code> slot starts empty. Use <code>rememberSaveable</code> for small UI state, and a <code>ViewModel</code> for anything heavy or asynchronous.</p>
<h2>Failure mode 8: Side effects during composition</h2>
<p>Composition can run more than once, be abandoned, or run in a different order than you expect. Writing to state or doing I/O in the body is how you get infinite recomposition loops and flaky tests:</p>
<pre><code class="language-kotlin">@Composable
fun Bad(vm: VM) {
    vm.track("screen_shown")   // runs on every recomposition
}

@Composable
fun Good(vm: VM) {
    LaunchedEffect(Unit) { vm.track("screen_shown") }
}
</code></pre>
<h2>Debugging checklist</h2>
<ul>
<li><strong>Layout Inspector recomposition counts</strong> show which composables run and which are skipped.</li>
<li><strong>Suspect state loss?</strong> Look for a branch, a loop, or a <code>movableContentOf</code> boundary above the <code>remember</code>.</li>
<li><strong>Suspect missed skipping?</strong> Check parameter stability and the Compose compiler reports (<code>-P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=...</code>).</li>
<li><strong>Reordering a collection?</strong> Every item needs a stable key.</li>
</ul>
<h2>Takeaways</h2>
<ul>
<li>The slot table stores the composition as groups and data in a linear, gap-buffered structure; the real implementation uses parallel arrays, and constants are not stored in slots.</li>
<li>Recomposition is a cursor walk: matching keys are skipped or re-run, mismatched keys move or replace.</li>
<li><strong>Position is identity.</strong> <code>remember</code> is scoped to a call site in execution order, so keys, branches, and loops determine whether your state survives.</li>
<li>Most “Compose bugs” are identity bugs: add <code>key()</code>, hoist state, or key your <code>remember</code>.</li>
</ul>
<h2>📱 New to Jetpack Compose? Start Here</h2>
<p>If you’re coming from the classic Android View system — XML layouts, <code>findViewById</code>, RecyclerView adapters — and want a clear, no-jargon path into Jetpack Compose, I recommend <strong>"Learning Jetpack Compose: Your Practical Guide from the Android View System to Modern Declarative UI."</strong> Every chapter starts from something you already know and shows its Compose equivalent side by side, building up to a full capstone: a real Notes app. Once you're comfortable and want to go deeper — the Composition tree, recomposition internals, the runtime itself — <strong>"</strong><a href="https://play.google.com/store/books/details?id=M2zxEQAAQBAJ"><strong>Mastering Jetpack Compose Internals</strong></a><strong>"</strong> is the natural next step.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=nZb-EQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0HD2ZM4Z1"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249004"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/inside-the-compose-slot-table-positional-memoization-gap-buffers-and-the-bugs-they-cause-25d5999aae48"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[If/Else in Compose Is Not View.GONE: Where Your State Goes When a Branch Flips]]></title><description><![CDATA[Why Jetpack Compose destroys remember state when an if/else branch flips, why AnimatedVisibility won't preserve it, and five production-ready fixes.]]></description><link>https://android-expert.hashnode.dev/if-else-in-compose-is-not-view-gone-where-your-state-goes-when-a-branch-flips</link><guid isPermaLink="true">https://android-expert.hashnode.dev/if-else-in-compose-is-not-view-gone-where-your-state-goes-when-a-branch-flips</guid><category><![CDATA[Jetpack Compose]]></category><category><![CDATA[Compose State Management]]></category><category><![CDATA[remember]]></category><category><![CDATA[Compose Recomposition]]></category><category><![CDATA[Android Architecture]]></category><dc:creator><![CDATA[Android Expert]]></dc:creator><pubDate>Sun, 01 Nov 2026 12:24:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae6fd909af3dc0afb7a2ccf/897abf1b-b132-47ac-8203-f72bec3691d2.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You ship a filter screen. QA toggles between “List” and “Grid”, scrolls the list, types into a search box, switches back, and everything is reset. The code looks harmless:</p>
<pre><code class="language-kotlin">@Composable
fun Conditional(showA: Boolean) {
    Column {
        if (showA) {
            ComponentA()   // owns remember { } state
        } else {
            ComponentB()   // owns remember { } state
        }
    }
}
</code></pre>
<p>When <code>showA</code> flips from <code>true</code> to <code>false</code>, <code>ComponentA</code> is not hidden. It is <strong>disposed</strong>. Every <code>remember</code>, every <code>LaunchedEffect</code>, every <code>DisposableEffect</code> inside it is torn down, and <code>ComponentB</code> is composed from scratch with initial state.</p>
<p>This post covers why, the failure modes that bite in production, and the five tools you actually have for controlling it.</p>
<h2>The mental model: hidden means deleted</h2>
<p>In the View system, <code>visibility = GONE</code> keeps the node in the tree. The object, its fields, and its listeners all survive. Compose inverts that: the composition describes what <em>exists right now</em>, and anything not described is gone.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*rqPpxdeBDrIPCLasTxJ37g.png" alt="views-vs-compose" style="display:block;margin:0 auto" />

<h2>What actually happens at the slot table</h2>
<p>The Compose compiler wraps each branch of a conditional in its own <em>replace group</em>, keyed by call site. Recomposition after <code>showA</code> flips goes like this:</p>
<ol>
<li>The <code>Column</code> group is still there, so it is reused.</li>
<li>The group for the <code>if</code> branch no longer matches the group the runtime expects at that position. The runtime sees a different key and treats it as a different node, not as an update.</li>
<li>The old group (and everything nested under it) is removed from the slot table. Each <code>RememberObserver</code> receives <code>onForgotten()</code>, <code>DisposableEffect</code>s run <code>onDispose</code>, and <code>LaunchedEffect</code> coroutines are cancelled.</li>
<li>The <code>else</code> group is inserted and its <code>remember</code> blocks run their initializers.</li>
</ol>
<img src="https://cdn-images-1.medium.com/max/800/1*H2xZjdkPCovOI3D4wzXMng.png" alt="slot-table-swap" style="display:block;margin:0 auto" />

<p>One detail that surprises people: this is keyed by <strong>call site, not by composable function</strong>. Calling the <em>same</em> composable from both branches still resets state, because each branch is a distinct group:</p>
<pre><code class="language-kotlin">// Looks like "the same Player", but state resets on every flip.
if (isCompact) {
    Player(modifier = Modifier.fillMaxWidth())
} else {
    Player(modifier = Modifier.size(320.dp))
}
</code></pre>
<h2>Failure modes you will meet in production</h2>
<p><strong>1. Scroll position and text drafts vanish.</strong> <code>rememberLazyListState()</code> and <code>rememberTextFieldState()</code> are backed by <code>rememberSaveable</code>, but that only protects them from configuration change and process death. A branch swap disposes their entries in a live process, so the user is back at the top with an empty field. A plain <code>remember { TextFieldState() }</code> or <code>remember { mutableStateOf("") }</code> fails the same way, just with less protection.</p>
<p><strong>2. Effects re-fire and double your network traffic.</strong> Flipping back re-enters the branch, so <code>LaunchedEffect(Unit) { repository.load() }</code> runs again. Rapid toggling produces a burst of cancelled and restarted requests, plus loading spinners that flash on every flip.</p>
<p><strong>3. Heavy resources are rebuilt.</strong> An <code>ExoPlayer</code> created in <code>remember</code> and released in <code>onDispose</code> is released and recreated each time. Users hear audio restart; you see <code>MediaCodec</code> init cost in traces.</p>
<p><strong>4.</strong> <code>rememberSaveable</code> <strong>does not save you here.</strong> It survives configuration change and process death, but a branch swap within a live process disposes its entry. People assume "saveable" means "survives anything". It does not.</p>
<p><strong>5.</strong> <code>AnimatedVisibility</code> <strong>is not a state preserver.</strong> This is the most common wrong fix. <code>AnimatedVisibility</code> plays the exit transition and then <strong>removes the content from composition</strong>. After the exit finishes, <code>remember</code> state is gone, same as <code>if</code>. It is a visual transition, not a lifetime extension.</p>
<pre><code class="language-kotlin">// Does NOT preserve ComponentA's remember state after the exit animation ends.
AnimatedVisibility(visible = showA) {
    ComponentA()
}
</code></pre>
<p><strong>6. Passing a different</strong> <code>key()</code> <strong>resets by design.</strong> <code>key()</code> is often described as a way to preserve state. It does the opposite: a different key means a different identity, hence fresh state. It is a tool for <em>forcing</em> a reset, or for keeping identity stable when position changes (reordering list items).</p>
<h2>What survives what</h2>
<p>The pattern is clear: if the state lives <em>inside</em> the subtree, anything that removes the subtree removes the state. To keep it, either move the state out, or stop removing the subtree.</p>
<img src="https://cdn-images-1.medium.com/max/800/1*pEd2NkhJ7tQFUiurpvHmhQ.png" alt="state-survival" style="display:block;margin:0 auto" />

<h2>The toolbox</h2>
<img src="https://cdn-images-1.medium.com/max/800/1*6cdnaPEDcBpNRzHJ3aV4Tw.png" alt="decision-flow" style="display:block;margin:0 auto" />

<h2>1. Hoist the state (default choice)</h2>
<p>State that must outlive visibility does not belong inside the thing that gets hidden. Lift it to a state holder owned by a stable parent:</p>
<pre><code class="language-kotlin">@Stable
class ListPaneState(
    initialQuery: String = "",
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0,
) {
    var query by mutableStateOf(initialQuery)
    val listState = LazyListState(firstVisibleItemIndex, firstVisibleItemScrollOffset)

    companion object {
        // Constructing LazyListState directly is fine, but nothing persists it
        // unless you save index and offset yourself.
        val Saver: Saver&lt;ListPaneState, Any&gt; = listSaver(
            save = {
                listOf(
                    it.query,
                    it.listState.firstVisibleItemIndex,
                    it.listState.firstVisibleItemScrollOffset,
                )
            },
            restore = { ListPaneState(it[0] as String, it[1] as Int, it[2] as Int) },
        )
    }
}

@Composable
fun rememberListPaneState(): ListPaneState =
    rememberSaveable(saver = ListPaneState.Saver) { ListPaneState() }

@Composable
fun Conditional(showA: Boolean) {
    val paneState = rememberListPaneState() // lives in the parent, outlives the branch
    Column {
        if (showA) ComponentA(paneState) else ComponentB()
    }
}
</code></pre>
<p>For anything that must survive configuration change, put it in a <code>ViewModel</code> and expose it as <code>StateFlow</code> collected with <code>collectAsStateWithLifecycle()</code>. This is also what makes the UI trivially testable.</p>
<h2>2. <code>SaveableStateProvider</code> for tabs and switchers</h2>
<p>If the content is “one of N screens” and each screen owns its own <code>rememberSaveable</code> state, give each a keyed saveable registry. This is what Navigation Compose does per back-stack entry:</p>
<pre><code class="language-kotlin">@Composable
fun &lt;K : Any&gt; KeyedSwitcher(
    current: K,          // must be saveable: String, Int, enum name, Parcelable...
    liveKeys: Set&lt;K&gt;,    // every key that can still be navigated to; must contain `current`
    modifier: Modifier = Modifier,
    content: @Composable (K) -&gt; Unit,
) {
    val holder = rememberSaveableStateHolder()
    val seen = remember { mutableSetOf&lt;K&gt;() }

    // Without this, saved state for dead keys accumulates for the whole session.
    LaunchedEffect(current, liveKeys) {
        seen += current
        (seen - liveKeys).forEach { holder.removeState(it) }
        seen.retainAll(liveKeys)
    }

    Box(modifier) {
        holder.SaveableStateProvider(key = current) {
            content(current)
        }
    }
}
</code></pre>
<p>Each key’s <code>rememberSaveable</code> values are stashed when that content leaves composition and restored when it returns. Plain <code>remember</code> is still lost, so use <code>rememberSaveable</code> inside the content. Two operational notes:</p>
<ul>
<li><strong>Cleanup is your job.</strong> The holder never evicts anything on its own. For dynamic keys (a tab per open document, say), wire <code>removeState(key)</code> to the moment a key is truly gone, as above. Skipping this is a slow leak that only shows up in long sessions.</li>
<li><strong>The holder’s lifetime is the switcher’s lifetime.</strong> It is created with <code>rememberSaveableStateHolder()</code>, so if <code>KeyedSwitcher</code> itself leaves composition, the stashed entries go with it. Place the switcher above whatever boundary you need to survive, or hoist the holder.</li>
</ul>
<h2>3. Keep it composed, but not drawn</h2>
<p>When the subtree is expensive to rebuild and you must keep <em>everything</em> (animations, scroll, player), keep it in composition and skip placement:</p>
<pre><code class="language-kotlin">/**
 * Keeps the subtree composed (state and effects alive) but invisible and inert.
 * collapse = false behaves like View.INVISIBLE: the space stays reserved.
 * collapse = true behaves like View.GONE: the subtree takes 0x0.
 */
fun Modifier.hidden(hidden: Boolean, collapse: Boolean = false): Modifier =
    if (!hidden) this else this
        .layout { measurable, constraints -&gt;
            val placeable = measurable.measure(constraints)
            val w = if (collapse) 0 else placeable.width
            val h = if (collapse) 0 else placeable.height
            // Measured but never placed: nothing is drawn, nothing is hit-tested.
            layout(w, h) {}
        }
        .clearAndSetSemantics {} // defensive: keep TalkBack out of the hidden subtree

@Composable
fun Conditional(showA: Boolean) {
    Column {
        // collapse = true, otherwise the hidden one leaves a blank gap in the Column.
        ComponentA(Modifier.hidden(hidden = !showA, collapse = true))
        ComponentB(Modifier.hidden(hidden = showA, collapse = true))
    }
}
</code></pre>
<p>Pick the <code>collapse</code> mode deliberately. The default reserves space, which is right for toggling something in place, and wrong for a swap, where it leaves a blank hole.</p>
<p>The trade-off is real: a hidden subtree still composes, still measures, and its <code>LaunchedEffect</code>s keep running. A polling loop or a Lottie animation inside it keeps burning battery. Pair it with a <code>visible</code> parameter that your effects respect:</p>
<pre><code class="language-kotlin">LaunchedEffect(visible) {
    if (visible) feed.collect { /* ... */ }
}
</code></pre>
<p>Avoid <code>Modifier.alpha(0f)</code> as the whole solution: it still lays out and, depending on version and parent, can remain reachable by touch and by accessibility services. Verify with TalkBack before shipping.</p>
<h2>4. <code>movableContentOf</code> when the same content changes parent</h2>
<p>For the “same Player, different container” case, a branch swap loses state but moving content preserves it:</p>
<pre><code class="language-kotlin">@Composable
fun AdaptivePlayer(isWide: Boolean, url: String) {
    val player = remember(url) {
        movableContentOf { Player(url) }
    }
    if (isWide) {
        Row { player() }
    } else {
        Column { player() }
    }
}
</code></pre>
<p><code>remember</code> state inside <code>Player</code> follows it between the <code>Row</code> and the <code>Column</code>. This only applies when the content is <em>moved</em>; if it is absent from both parents for a frame, it is disposed.</p>
<h2>5. <code>key()</code> to reset on purpose</h2>
<p>When you want the opposite, such as clearing a form when the selected entity changes, use <code>key</code>:</p>
<pre><code class="language-kotlin">key(userId) {
    ProfileEditor(userId) // new userId = new identity = fresh state
}
</code></pre>
<p>This is the correct replacement for hand-written “reset on id change” <code>LaunchedEffect</code> logic.</p>
<h2>Proving it: a test that guards the behavior</h2>
<p>Do not trust your memory of this. Encode it:</p>
<pre><code class="language-kotlin">@Composable
fun Counter(tag: String) {
    var count by remember { mutableIntStateOf(0) }
    Text(
        text = count.toString(),
        modifier = Modifier.testTag(tag).clickable { count++ },
    )
}

class ConditionalStateTest {
    @get:Rule val rule = createComposeRule() // androidx.compose.ui.test.junit4

    @Test
    fun counterResetsWhenBranchSwaps() {
        val showA = mutableStateOf(true)
        rule.setContent {
            if (showA.value) Counter(tag = "a") else Counter(tag = "b")
        }

        rule.onNodeWithTag("a").performClick()
        rule.onNodeWithTag("a").assertTextEquals("1")

        rule.runOnIdle { showA.value = false }
        rule.onNodeWithTag("b").assertTextEquals("0")

        rule.runOnIdle { showA.value = true }
        rule.onNodeWithTag("a").assertTextEquals("0") // state was lost
    }
}
</code></pre>
<p>This needs <code>androidx.compose.ui:ui-test-junit4</code> as an <code>androidTestImplementation</code> (or <code>testImplementation</code> with Robolectric) and <code>ui-test-manifest</code> as <code>debugImplementation</code>.</p>
<p>For quick debugging, drop a lifecycle probe into the suspect composable. Seeing <code>onDispose</code> fire on every toggle ends the argument immediately:</p>
<pre><code class="language-kotlin">@Composable
fun LifecycleProbe(name: String) {
    DisposableEffect(Unit) {
        Log.d("Probe", "$name entered composition")
        onDispose { Log.d("Probe", "$name left composition") }
    }
}
</code></pre>
<h2>Rules of thumb</h2>
<ul>
<li>Treat every <code>if</code> that gates a composable as a lifetime boundary, not a visibility switch.</li>
<li>State that must outlive a boundary lives above it: a hoisted holder, a <code>ViewModel</code>, or a <code>SaveableStateProvider</code>.</li>
<li><code>AnimatedVisibility</code> animates the boundary; it does not remove it.</li>
<li>Keeping content composed is a performance decision, not a free lunch. Gate its effects.</li>
<li>Reach for <code>key()</code> to reset, <code>movableContentOf</code> to relocate, and neither to preserve across hiding.</li>
</ul>
<p>The View-era instinct says “hide it and the state will be fine”. In Compose, the question to ask is different: <strong>who owns this state, and does that owner outlive the branch?</strong></p>
<h2>📱 Go Beyond Using Jetpack Compose</h2>
<p>If you’re building on Android, understanding what happens under the hood separates developers who use Compose from those who master it. I highly recommend <strong>“Mastering Jetpack Compose Internals”</strong>. It’s a deep, architecture-first walkthrough of the composition tree, the slot table, snapshot state, and the runtime that powers modern Android UI — capped off with a full case study building a real app called Mosaic.</p>
<ul>
<li><strong>E-book:</strong> <a href="https://play.google.com/store/books/details?id=M2zxEQAAQBAJ"><strong>Available on Google Play</strong></a></li>
<li><strong>Kindle Edition:</strong> <a href="https://www.amazon.com/dp/B0H7N45T8Q"><strong>Available on Amazon</strong></a></li>
<li><strong>Topmate:</strong> <a href="https://topmate.io/sivavishnu_r/2249073"><strong>Available on Topmate</strong></a></li>
<li>Also available in <strong>Paperback &amp; Hardcover</strong></li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://medium.com/@sivavishnu0705/if-else-in-compose-is-not-view-gone-where-your-state-goes-when-a-branch-flips-8e2371d0a6ef"><em>Medium</em></a><em>.</em></p>
]]></content:encoded></item></channel></rss>