<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: KMP Bits</title>
    <description>The latest articles on DEV Community by KMP Bits (@kmpbits).</description>
    <link>https://dev.to/kmpbits</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3942501%2Fd307e39c-824c-479b-867c-25b4b97358a6.png</url>
      <title>DEV Community: KMP Bits</title>
      <link>https://dev.to/kmpbits</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kmpbits"/>
    <language>en</language>
    <item>
      <title>Clean Lap: UI Testing in Compose Multiplatform</title>
      <dc:creator>KMP Bits</dc:creator>
      <pubDate>Fri, 22 May 2026 18:33:50 +0000</pubDate>
      <link>https://dev.to/kmpbits/clean-lap-ui-testing-in-compose-multiplatform-1e6g</link>
      <guid>https://dev.to/kmpbits/clean-lap-ui-testing-in-compose-multiplatform-1e6g</guid>
      <description>&lt;p&gt;In qualifying, the data engineer doesn't wait for Sunday to find out something was wrong. By the time the driver comes back to the garage, every sector time is already on the screen, every corner apex mapped against the reference lap. If the front left is locking under braking at Turn 3, it shows up immediately, not three hours later when the car is fighting for position and has no margin to fix anything.&lt;/p&gt;

&lt;p&gt;I spent a long time treating UI tests the same way I treated the race result: something I'd look at after the damage was done. If a screen broke, someone filed a bug, I fixed it. But the first time I caught a regression in a CMP project because a test failed before the PR merged, I understood what the data engineer already knew. You want the telemetry before race day, not after.&lt;/p&gt;

&lt;p&gt;Compose Multiplatform 1.11 (currently in beta) brings &lt;code&gt;runComposeUiTest&lt;/code&gt; v2 support for non-Android targets. Writing a test in &lt;code&gt;commonTest&lt;/code&gt; and running it on Android, desktop, and iOS is no longer an experimental workaround. This article walks through the setup, the API, and the one coroutine change in 1.11 that will break your existing tests if you upgrade without knowing about it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A quick note on versions:&lt;/strong&gt; Everything in this article uses &lt;code&gt;1.11.0-beta02&lt;/code&gt;. The APIs work as described, but beta means things can still shift before the final release. If you're on a stable version, stick with what you have — the stable &lt;code&gt;runComposeUiTest&lt;/code&gt; API is available from 1.6.x onwards, but the v2 surface and the dispatcher change covered here are 1.11-specific.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note on Navigation&lt;/strong&gt;: To keep the focus on testing, this sample uses a manual state-switching approach. While simplified, this mirrors the state-driven philosophy of Navigation 3. For production apps—especially when implementing Shared Element Transitions as discussed in this &lt;a href="https://dev.to/posts/nav3-shared-elements"&gt;Navigation 3 CMP&lt;/a&gt; article, the formal Navigation 3 library is recommended to handle the transition orchestration and back-stack management effectively.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What you're actually testing here
&lt;/h2&gt;

&lt;p&gt;Before touching Gradle, it's worth being precise about what &lt;code&gt;compose.uiTest&lt;/code&gt; is for. It tests composable UI: the layout, the interaction flow, the visual states. It is not a replacement for unit tests on your ViewModel or business logic.&lt;/p&gt;

&lt;p&gt;In a clean CMP project, your ViewModel lives in &lt;code&gt;commonMain&lt;/code&gt;, your Compose UI lives in &lt;code&gt;commonMain&lt;/code&gt;, and your tests live in &lt;code&gt;commonTest&lt;/code&gt;. The tests invoke the composable directly, interact with it through semantic queries, and assert what the user sees. No emulator needed for desktop and iOS targets. No platform ceremony.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(If you're testing ViewModels and Flows in isolation without a UI, those tests can also live in &lt;code&gt;commonTest&lt;/code&gt; without compose. I covered how Flows behave across platforms in the &lt;a href="https://dev.to/posts/stateflow-kmp"&gt;StateFlow and SharedFlow article&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That context set, here is what the setup actually looks like.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting up the dependencies
&lt;/h2&gt;

&lt;p&gt;Start with &lt;code&gt;libs.versions.toml&lt;/code&gt;. Every new library goes here first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# gradle/libs.versions.toml&lt;/span&gt;
&lt;span class="nn"&gt;[versions]&lt;/span&gt;
&lt;span class="py"&gt;compose-multiplatform&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.11.0-beta02"&lt;/span&gt;
&lt;span class="py"&gt;kotlin&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2.1.20"&lt;/span&gt;
&lt;span class="py"&gt;androidx-compose-ui-test&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.8.0"&lt;/span&gt;

&lt;span class="nn"&gt;[libraries]&lt;/span&gt;
&lt;span class="c"&gt;# Android instrumented test support — not needed for desktop or iOS&lt;/span&gt;
&lt;span class="py"&gt;androidx-compose-ui-test-junit4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"androidx.compose.ui:ui-test-junit4-android"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;version.ref&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"androidx-compose-ui-test"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;androidx-compose-ui-test-manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"androidx.compose.ui:ui-test-manifest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;version.ref&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"androidx-compose-ui-test"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in &lt;code&gt;build.gradle.kts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// composeApp/build.gradle.kts&lt;/span&gt;
&lt;span class="nf"&gt;kotlin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;androidTarget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;@OptIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExperimentalKotlinGradlePluginApi&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// Without this, commonTest won't link to the Android instrumented variant&lt;/span&gt;
        &lt;span class="n"&gt;instrumentedTestVariant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sourceSetTree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KotlinSourceSetTree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;sourceSets&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;commonTest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;kotlin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="nd"&gt;@OptIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jetbrains&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ExperimentalComposeLibrary&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uiTest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Android instrumented tests need this additional dependency&lt;/span&gt;
        &lt;span class="n"&gt;androidInstrumentedTest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;androidx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;junit4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Manifest injection for Android debug builds&lt;/span&gt;
    &lt;span class="nf"&gt;debugImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;androidx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test)&lt;/code&gt; line is the one that trips people up. Without it, your &lt;code&gt;commonTest&lt;/code&gt; source set won't link to the Android instrumented test variant, and you'll get missing class errors on device. I spent two hours staring at that error before I found it buried in a JetBrains issue tracker thread.&lt;/p&gt;

&lt;p&gt;With that in place, the pit lane work is done. Everything else happens in &lt;code&gt;commonTest&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Writing your first common test
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;runComposeUiTest&lt;/code&gt; is a top-level function. You call it, get a &lt;code&gt;ComposeUiTest&lt;/code&gt; receiver, set your content, and query the semantic tree. No &lt;code&gt;TestRule&lt;/code&gt;, no JUnit class-level annotation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonTest/kotlin/ui/HomeScreenTest.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.ExperimentalTestApi&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.assertIsDisplayed&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.onNodeWithText&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.v2.runComposeUiTest&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlin.test.Test&lt;/span&gt;

&lt;span class="nd"&gt;@OptIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExperimentalTestApi&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HomeScreenTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;homeScreen_titleIsVisible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runComposeUiTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;HomeScreen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nf"&gt;onNodeWithText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Home"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;assertIsDisplayed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Import correct:&lt;/strong&gt; Use &lt;code&gt;androidx.compose.ui.test.v2.runComposeUiTest&lt;/code&gt;, not &lt;code&gt;androidx.compose.ui.test.runComposeUiTest&lt;/code&gt;. The package without &lt;code&gt;.v2&lt;/code&gt; is the old version (and it's deprecated on 1.11-beta02).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;code&gt;@OptIn(ExperimentalTestApi::class)&lt;/code&gt; is required for every file until the API fully graduates. In &lt;code&gt;1.11.0-beta02&lt;/code&gt;, the v2 APIs are available for non-Android targets but the annotation is still needed on the common surface — expect it to drop when 1.11 goes stable.&lt;/p&gt;

&lt;p&gt;For interaction flows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonTest/kotlin/ui/LoginScreenTest.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.ExperimentalTestApi&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.assertIsDisplayed&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.onNodeWithTag&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.onNodeWithText&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.performClick&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.performTextInput&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.v2.runComposeUiTest&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlin.test.Test&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlin.test.assertTrue&lt;/span&gt;

&lt;span class="nd"&gt;@OptIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExperimentalTestApi&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LoginScreenTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;loginScreen_showsErrorWhenEmailIsEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runComposeUiTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;LoginScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onLoginSuccess&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// User taps submit without filling anything in&lt;/span&gt;
        &lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"submit_button"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;performClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="nf"&gt;onNodeWithText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Email cannot be empty"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;assertIsDisplayed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;loginScreen_navigatesOnValidInput&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runComposeUiTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;navigated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;

        &lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;LoginScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onLoginSuccess&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;navigated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"email_field"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;performTextInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"test@example.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"password_field"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;performTextInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"hunter2"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"submit_button"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;performClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;navigated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test annotation is &lt;code&gt;kotlin.test.Test&lt;/code&gt;, not &lt;code&gt;org.junit.Test&lt;/code&gt;. That's what makes the test run across all targets, including iOS via the Kotlin/Native runner. If you accidentally import the JUnit annotation in a common file, the iOS and desktop targets will not pick it up at all — the test silently won't exist on those platforms.&lt;/p&gt;




&lt;h2&gt;
  
  
  The coroutine dispatcher change in CMP 1.11
&lt;/h2&gt;

&lt;p&gt;This is the part you need to know before you upgrade.&lt;/p&gt;

&lt;p&gt;Before v2, &lt;code&gt;runComposeUiTest&lt;/code&gt; used &lt;code&gt;UnconfinedTestDispatcher&lt;/code&gt; internally. Coroutines ran eagerly — side effects triggered immediately, states updated without you doing anything. Tests passed without any manual clock advancement. It felt convenient.&lt;/p&gt;

&lt;p&gt;In CMP 1.11, the default switches to &lt;code&gt;StandardTestDispatcher&lt;/code&gt;. Coroutines no longer run automatically. If your composable launches a coroutine on composition, such as a &lt;code&gt;LaunchedEffect&lt;/code&gt; triggering a data fetch, you need to advance the test scheduler to see the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonTest/kotlin/ui/FeedScreenTest.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.ExperimentalTestApi&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.assertIsDisplayed&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.onNodeWithTag&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.compose.ui.test.v2.runComposeUiTest&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlin.test.Test&lt;/span&gt;

&lt;span class="nd"&gt;@OptIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExperimentalTestApi&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FeedScreenTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;feedScreen_showsLoadingIndicator&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runComposeUiTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;FeedScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FeedUiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"loading_indicator"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;assertIsDisplayed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;feedScreen_showsContentItems&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runComposeUiTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;FeedScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FeedUiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Article 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Article 2"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feed_list"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;assertIsDisplayed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;feedScreen_showsLoadingThenContent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runComposeUiTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;state&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;mutableStateOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FeedUiState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="nc"&gt;FeedUiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;LaunchedEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FeedUiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Article 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Article 2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Article 3"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nc"&gt;FeedScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Check initial state&lt;/span&gt;
        &lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"loading_indicator"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;assertIsDisplayed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;// Wait until the list appears (this handles the clock advancement internally)&lt;/span&gt;
        &lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeoutMillis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Returns true when the node exists&lt;/span&gt;
            &lt;span class="nf"&gt;onAllNodesWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feed_list"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchSemanticsNodes&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feed_list"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;assertIsDisplayed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While &lt;code&gt;waitForIdle()&lt;/code&gt; pumps the event queue until the current UI state is stable, &lt;code&gt;waitUntil&lt;/code&gt; actively advances the virtual clock to bridge gaps created by &lt;code&gt;delay()&lt;/code&gt; or asynchronous tasks, ensuring the test stays paused until your specific UI condition is met.&lt;/p&gt;

&lt;p&gt;When I upgraded a CMP project from 1.9 to 1.11-beta02, five tests that were passing started failing. Every single one was relying on the eager dispatcher to hide an async gap in the UI. The tests were wrong before. The new dispatcher just finally showed it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running across platforms
&lt;/h2&gt;

&lt;p&gt;Three targets, three commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Android instrumented (emulator or connected device required)&lt;/span&gt;
./gradlew :composeApp:connectedAndroidTest

&lt;span class="c"&gt;# Desktop (JVM, no emulator needed — fast)&lt;/span&gt;
./gradlew :composeApp:desktopTest

&lt;span class="c"&gt;# iOS (Kotlin/Native, runs via XCTest wrapper)&lt;/span&gt;
./gradlew :composeApp:iosSimulatorArm64Test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The desktop run is the fastest feedback loop. I use it constantly during development — the round trip is under 30 seconds on a warm build. If the desktop test passes, Android and iOS almost always do too, unless there's a platform-specific composable in the mix.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A quick note:&lt;/strong&gt; iOS tests require a Mac with an iOS Simulator available. On CI, that means a macOS runner for the iOS command. Linux runners will handle desktop and Android.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The goal on CI is to run all three in parallel. You want the qualifying data from every target before the PR merges, not just one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;A few things that cost me time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Semantic tags must be added explicitly.&lt;/strong&gt; &lt;code&gt;onNodeWithTag()&lt;/code&gt; only works if you've added &lt;code&gt;Modifier.testTag("your_tag")&lt;/code&gt; to the composable. Don't rely on text content for interactive elements — button labels change, tags don't. Add tags from the start, not after the test breaks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The &lt;code&gt;@OptIn&lt;/code&gt; annotation is per file.&lt;/strong&gt; You can't declare it once at module level and have it propagate. Every test file using &lt;code&gt;runComposeUiTest&lt;/code&gt; needs its own &lt;code&gt;@OptIn(ExperimentalTestApi::class)&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;System dialogs are invisible to tests.&lt;/strong&gt; If your composable triggers a permission dialog or a system-level sheet, the test runner won't see it. Mock the permission state at the ViewModel level and test the resulting composable state, not the dialog.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;@Preview&lt;/code&gt; composables are not tests.&lt;/strong&gt; They don't run in &lt;code&gt;commonTest&lt;/code&gt;. If you want to test a state-dependent screen, you're testing the real composable with injected state — that's a different thing, and it's the right thing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Where this fits with everything else
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;commonTest&lt;/code&gt; UI tests are fast and platform-agnostic. They cover layout, interactions, and state transitions. They don't cover the full integration with real network, real databases, or real OS behaviour.&lt;/p&gt;

&lt;p&gt;The way I think about it: common UI tests confirm the qualifying lap. The composable is doing the right thing in isolation. Integration tests on a real device confirm the race pace: does the real data layer, real network, and real platform behave together as expected?&lt;/p&gt;

&lt;p&gt;Both matter. The qualifying lap is just considerably cheaper to run before the team heads out to the grid.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(For Android-specific UI testing on the JVM without a CMP setup, I covered Robolectric in an earlier article: &lt;a href="https://dev.to/posts/robolectric-compose"&gt;Testing Jetpack Compose UI on the JVM&lt;/a&gt;. It's a different approach and worth knowing about if you're working on an Android-only module alongside your KMP code.)&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Compose Multiplatform &lt;code&gt;1.11.0-beta02&lt;/code&gt; makes &lt;code&gt;runComposeUiTest&lt;/code&gt; a first-class citizen on non-Android targets. The setup is a handful of Gradle lines, the API keeps itself minimal, and the desktop feedback loop is fast enough to use on every save. The one thing to account for before upgrading is the dispatcher change: if your tests depended on eager coroutine execution, add &lt;code&gt;waitForIdle()&lt;/code&gt; or &lt;code&gt;waitUntil()&lt;/code&gt; (Depending on what you need) and treat the failures as the API finally telling you the truth.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The telemetry was always available. The only question is whether you check it before the race starts or after something goes wrong on track. 🏁&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The full demo for this article is available on &lt;a href="https://github.com/kmpbits/uiTestsComposeMultiplatform" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The KMP Bits app is available on &lt;a href="https://apps.apple.com/pt/app/bitsreader/id6755438235" rel="noopener noreferrer"&gt;App Store&lt;/a&gt; and &lt;a href="https://play.google.com/store/apps/details?id=com.joel.bitsreader" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt; — built entirely with KMP.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kmp</category>
      <category>cmp</category>
      <category>testing</category>
    </item>
    <item>
      <title>KMP Splash: How I Stopped Opening Xcode for Splash Screens</title>
      <dc:creator>KMP Bits</dc:creator>
      <pubDate>Thu, 21 May 2026 16:02:03 +0000</pubDate>
      <link>https://dev.to/kmpbits/kmp-splash-how-i-stopped-opening-xcode-for-splash-screens-mh4</link>
      <guid>https://dev.to/kmpbits/kmp-splash-how-i-stopped-opening-xcode-for-splash-screens-mh4</guid>
      <description>&lt;p&gt;Every Formula 1 driver knows. You don't change your own tyres. You don't adjust the front wing yourself. You pull in, the crew handles it in under three seconds, and you're back at racing speed before you've had time to think about it. The driver's job is to drive.&lt;/p&gt;

&lt;p&gt;I kept changing my own tyres.&lt;/p&gt;

&lt;p&gt;Every new Compose Multiplatform project came with the same ritual. Open Xcode, find the LaunchScreen storyboard, set the background color add the logo to the asset catalog, patch Info.plist, go back to Android Studio, generate themes.xml and add the theme to the manifest. I always ended up copying the setup from the last project because I never remembered all the steps. Sometimes I forgot one file and only found out on device. The race was already running and I was still in the garage.&lt;/p&gt;

&lt;p&gt;So I built the pit crew. KMP Splash is a Gradle plugin that handles the entire splash screen setup for both Android and iOS from a single config block. This is about why I built it and how it evolved. The README has the full setup. This is the story behind it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The storyboard era
&lt;/h2&gt;

&lt;p&gt;The standard iOS splash screen approach for years was &lt;code&gt;LaunchScreen.storyboard&lt;/code&gt;. You created a storyboard, set a background color, added an image view, connected it to the asset catalog, and referenced it in Info.plist. Xcode's tooling made it manageable, if you were working in Xcode.&lt;/p&gt;

&lt;p&gt;In a Compose Multiplatform project, you are not in Xcode, you're in Android Studio, and Xcode is a separate application you open only when something native needs touching. Any change, a color tweak, a new logo, means switching contexts entirely. Then switching back.&lt;/p&gt;

&lt;p&gt;What finally got to me was the copy-paste cycle. Every new project, I'd go back to the last one, try to remember which files I needed, copy them over, adjust the names, miss something, and only discover the miss when the app launched wrong on a real device. It wasn't a hard problem. It was a repetitive one. And repetition compounds.&lt;/p&gt;

&lt;p&gt;Apple introduced &lt;code&gt;UILaunchScreen&lt;/code&gt;, on iOS 14, as a plist-based alternative. Instead of a storyboard, you define a dictionary in Info.plist with your background color name and optional logo. No storyboard, no Interface Builder, no Xcode required for changes. The OS reads it directly.&lt;/p&gt;

&lt;p&gt;That was the door. The pit lane was always there. I just needed to build a crew.&lt;/p&gt;




&lt;h2&gt;
  
  
  One config block
&lt;/h2&gt;

&lt;p&gt;The idea was simple: describe the splash screen once, in Kotlin, in the build file you already have open. Let the plugin generate whatever the platforms need.&lt;/p&gt;

&lt;p&gt;The first version was rough. Background color was a plain hex string. Logo was a full file path. It worked, but it was fragile. A typo in the color string generated wrong assets with no error until the app ran. A wrong path gave you a cryptic Gradle message that pointed nowhere useful.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// composeApp/build.gradle.kts — first version, don't do this&lt;/span&gt;
&lt;span class="nf"&gt;splashScreen&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;backgroundColor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"#FFFFFF"&lt;/span&gt;
    &lt;span class="n"&gt;logoFile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"src/commonMain/composeResources/drawable/logo.png"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I shipped the alpha that way because it was functional. But I knew it was wrong. Strings give you no help. The crew hands you a tyre and you have to figure out if it's the right compound yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  The API learned to talk back
&lt;/h2&gt;

&lt;p&gt;The second iteration introduced typed wrappers. &lt;code&gt;SplashColor&lt;/code&gt; replaced the hex string and validates the format immediately, telling you exactly what's wrong if the input is malformed. &lt;code&gt;SplashLogo&lt;/code&gt; replaced the full path and resolves the location from &lt;code&gt;composeResources/drawable/&lt;/code&gt; automatically. You just name the file.&lt;/p&gt;

&lt;p&gt;The config went from something you had to get right to something that tells you when you haven't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// composeApp/build.gradle.kts&lt;/span&gt;
&lt;span class="nf"&gt;splashScreen&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;backgroundColor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SplashColor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"#FFFFFF"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;backgroundColorNight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SplashColor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"#1A1A2E"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;logo&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SplashLogo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"logo.png"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;logoDark&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SplashLogo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"logo_dark.png"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// optional&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SplashColor&lt;/code&gt; also accepts RGB values with &lt;code&gt;SplashColor.rgb(26, 26, 46)&lt;/code&gt; and named constants like &lt;code&gt;SplashColor.white&lt;/code&gt;. Both &lt;code&gt;backgroundColorNight&lt;/code&gt; and &lt;code&gt;logoDark&lt;/code&gt; are optional. If you don't set them, the plugin uses the light values for both modes.&lt;/p&gt;

&lt;p&gt;The difference feels small in a snippet. In practice, the config stopped being a source of bugs. I stopped thinking about it. That's the goal. The driver manages the tyres, but he doesn't fit them himself. That part stays in the pit lane.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gap nobody talks about
&lt;/h2&gt;

&lt;p&gt;Splash screens on mobile have two distinct phases. First, the OS renders something while your app loads. Then your Compose UI takes over.&lt;/p&gt;

&lt;p&gt;The problem is the transition between them. On iOS especially, there's a moment after the native launch screen disappears but before Compose has initialized where the screen flashes its default background. On a cold start on a slower device, that flash is visible. It feels rough even when everything else about the app is polished.&lt;/p&gt;

&lt;p&gt;KMP Splash bridges it. On Android, &lt;code&gt;SplashActivity&lt;/code&gt; extends the native splash by holding the screen until your initialization is done. On iOS, &lt;code&gt;SplashConfig&lt;/code&gt; renders a Compose screen that's visually identical to the native launch, using the same colors and logo, and holds it until the app is ready.&lt;/p&gt;

&lt;p&gt;The Compose side reads the config from your Gradle block automatically. You don't pass colors or logos at the call site. You configured them once, and everything is already wired.&lt;/p&gt;




&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;

&lt;p&gt;On Android, you extend &lt;code&gt;SplashActivity&lt;/code&gt; in your &lt;code&gt;MainActivity&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// androidMain/MainActivity.kt&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MainActivity&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SplashActivity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;isReady&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// load data, check auth, etc.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onFinished&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On iOS, you call &lt;code&gt;SplashConfig&lt;/code&gt; from your &lt;code&gt;MainViewController&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// iosMain/MainViewController.kt&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;MainViewController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ComposeUIViewController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;isAppReady&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;mutableStateOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;isAppReady&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;SplashConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;isReady&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;true&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;onFinished&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;isAppReady&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both tasks run automatically before compilation. You never call them manually.&lt;/p&gt;

&lt;p&gt;The first time I used it on a new project and didn't open Xcode once, I sat back for a second. The right color, the right logo, no flash. The app looked exactly right from the first pixel. And I hadn't touched a storyboard.&lt;/p&gt;




&lt;h2&gt;
  
  
  One limitation worth knowing
&lt;/h2&gt;

&lt;p&gt;There's one thing no library can fix. The native splash screen reads the system dark mode setting, not your app's dark mode setting. If your app has its own appearance toggle and the user has set it to dark while the phone is in light mode, the native launch screen will still show the light version.&lt;/p&gt;

&lt;p&gt;This isn't a bug in the library. The OS renders the native splash before any of your code runs. There's no way to communicate an app-level preference at that point. The Compose layer can respond to it, but the brief native phase cannot.&lt;/p&gt;

&lt;p&gt;The options are: use a background color that works in both modes, or accept that the native splash matches the system and the Compose layer corrects to the app preference immediately after. For most apps, neither is a real problem. But it's worth knowing before you spend time debugging it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The frustration that started this was trivial on any individual project. It's the repetition that wears you down. The same ritual, the same files to remember, the same context switch to Xcode. Automating it didn't require anything exotic. It just required sitting down and building the crew.&lt;/p&gt;

&lt;p&gt;KMP Splash is published on Maven Central at &lt;code&gt;io.github.kmpbits:splash-runtime&lt;/code&gt;. The README has the full setup guide. Source is on GitHub.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pull into the pits, let the crew handle it, and get back on track. 🏁&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The library is available on &lt;a href="https://github.com/kmpbits/KMP-Splash" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The KMP Bits app is available on &lt;a href="https://apps.apple.com/pt/app/bitsreader/id6755438235" rel="noopener noreferrer"&gt;App Store&lt;/a&gt; and &lt;a href="https://play.google.com/store/apps/details?id=com.joel.bitsreader" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;, built entirely with KMP.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>kmp</category>
      <category>gradle</category>
      <category>splash</category>
    </item>
    <item>
      <title>Drop the Clutch: Three Metro DI Patterns Every KMP Developer Should Know</title>
      <dc:creator>KMP Bits</dc:creator>
      <pubDate>Wed, 20 May 2026 14:32:46 +0000</pubDate>
      <link>https://dev.to/kmpbits/drop-the-clutch-three-metro-di-patterns-every-kmp-developer-should-know-58l2</link>
      <guid>https://dev.to/kmpbits/drop-the-clutch-three-metro-di-patterns-every-kmp-developer-should-know-58l2</guid>
      <description>&lt;p&gt;In a GT3 car, you have a choice. You can drive with a traditional manual gearbox: clutch pedal, H-pattern shifter, full control over every gear change. Or you can switch to the sequential paddle shifters that most modern GT3 cars run. The car is still yours to drive. The racing line, the braking points, the tyre management. All of that stays with you. The only thing that changes is that you stop managing something that was never really the point.&lt;/p&gt;

&lt;p&gt;I've been running Koin in a multi-module Compose Multiplatform project for a while, and for a long time it felt like the manual gearbox. Not painful exactly, just present. Every new dependency meant another &lt;code&gt;module { }&lt;/code&gt; block somewhere, another &lt;code&gt;get()&lt;/code&gt; in the factory lambda, another place where the compiler had nothing to say if I forgot to wire something. The error came at runtime, and usually in a context that made it harder to trace than it should have been.&lt;/p&gt;

&lt;p&gt;Metro is the paddle shifters. You annotate your classes, define a graph interface, and the Kotlin compiler plugin generates the wiring at build time. If something is missing from the graph, the build fails. Not the app launch. The build.&lt;/p&gt;

&lt;p&gt;I already covered Koin Annotations in a &lt;a href="https://dev.to/posts/koin-annotations"&gt;previous article&lt;/a&gt;. What follows isn't a migration guide. It's a look at three specific patterns that come up in every real KMP project and where Metro's behaviour changed how I think about dependency injection.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Metro is
&lt;/h2&gt;

&lt;p&gt;Metro is a compile-time dependency injection framework for Kotlin Multiplatform. It ships as a Kotlin compiler plugin, not an annotation processor or a KSP plugin. Code generation happens in the compiler's FIR/IR pipeline directly.&lt;/p&gt;

&lt;p&gt;The mental model draws from three places: &lt;code&gt;@Inject&lt;/code&gt;, &lt;code&gt;@Provides&lt;/code&gt;, and the scope system come from Dagger; the interface-based graph definition comes from kotlin-inject; and the contribution aggregation system comes from Anvil. If you've worked with any of those three, the ideas transfer quickly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Metro, Koin Annotations, and Dagger
&lt;/h2&gt;

&lt;p&gt;Before getting into the patterns, it's worth placing Metro relative to the tools you've probably already used.&lt;/p&gt;

&lt;h3&gt;
  
  
  Koin Annotations
&lt;/h3&gt;

&lt;p&gt;The comparison people reach for first is Koin Annotations, because both claim compile-time safety. I want to be clear about something: these are not competing answers to the same question. They solve the same problem, both do it well, and the choice comes down to what your project actually needs.&lt;/p&gt;

&lt;p&gt;Koin Annotations uses KSP to generate source files during the build. It catches graph errors in the KSP phase, which is earlier than runtime, but the Koin service locator container still exists underneath. If something slips through, the app can still throw on startup. The generated &lt;code&gt;.kt&lt;/code&gt; files land in &lt;code&gt;build/generated/ksp/&lt;/code&gt;, and you need KSP configured and on the right classpath. For most projects, that's no trouble at all. Koin is mature, widely used, and well documented.&lt;/p&gt;

&lt;p&gt;Metro works at a different level. It's a compiler plugin operating on the IR representation of your code, the same layer where kotlinx.serialization and the Compose compiler plugin live. There are no generated source files, no &lt;code&gt;build/generated/&lt;/code&gt; directory, no KSP classpath to manage. The output is direct constructor calls baked into bytecode. Metro has no runtime container. If the graph is invalid, the build doesn't produce a binary.&lt;/p&gt;

&lt;p&gt;The trade-offs are real on both sides. Metro is strictly static, so dynamic or conditional bindings that Koin handles without friction are outside Metro's scope. And Metro is a newer library, primarily maintained by one person. Koin has years of production use behind it.&lt;/p&gt;

&lt;p&gt;My take: if your team already knows Koin, or you need dynamic binding flexibility, stick with Koin Annotations. If you want the strictest compile-time guarantee with no runtime container underneath, Metro is the right call.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you know Dagger
&lt;/h3&gt;

&lt;p&gt;If you've used Dagger before, Metro will feel familiar in the right places. &lt;code&gt;@Inject&lt;/code&gt;, &lt;code&gt;@Provides&lt;/code&gt;, scope annotations, and compile-time graph validation are all there. The graph interface replaces Dagger's &lt;code&gt;@Component&lt;/code&gt;, &lt;code&gt;@DependencyGraph.Factory&lt;/code&gt; replaces &lt;code&gt;@Component.Builder&lt;/code&gt;, and &lt;code&gt;@ContributesBinding&lt;/code&gt; replaces the manual &lt;code&gt;@Binds&lt;/code&gt; plus module wiring you'd write by hand in Dagger.&lt;/p&gt;

&lt;p&gt;Metro is a compiler plugin rather than an annotation processor, so no KAPT, no KSP, no generated source files to track. The interface-based graph definition is cleaner than Dagger's abstract class components. And Metro supports Kotlin Multiplatform natively. Dagger never did. If you've used Hilt, the &lt;code&gt;@ContributesBinding&lt;/code&gt; aggregation pattern will look familiar: Metro borrows it from Anvil, which is what Hilt builds on.&lt;/p&gt;

&lt;p&gt;Where Dagger still has an edge: it's been used in large Android codebases for over a decade, the tooling is more mature, and the error messages are better when something goes wrong. If you're on a large Android-only project with an existing Dagger graph, there's rarely a reason to migrate.&lt;/p&gt;




&lt;h2&gt;
  
  
  The demo
&lt;/h2&gt;

&lt;p&gt;The three patterns I want to show are easier to understand in context than in isolation. To make them concrete, I built a fake real-time chat app: a conversations list, a notification permission screen, and a chat room with a scoped WebSocket. No server, no real backend. Just hardcoded data and coroutine timers. Each screen demonstrates one Metro concept, and together they cover the situations you'll hit in almost any real KMP project.&lt;/p&gt;

&lt;p&gt;The article focuses entirely on the DI wiring. The UI is minimal by design.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# gradle/libs.versions.toml&lt;/span&gt;
&lt;span class="nn"&gt;[versions]&lt;/span&gt;
&lt;span class="py"&gt;metro&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.0"&lt;/span&gt;

&lt;span class="nn"&gt;[plugins]&lt;/span&gt;
&lt;span class="py"&gt;metro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dev.zacsweers.metro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;version.ref&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"metro"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nn"&gt;[libraries]&lt;/span&gt;
&lt;span class="py"&gt;metro-viewmodel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dev.zacsweers.metro:metrox-viewmodel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;version.ref&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"metro"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;metro-viewmodel-compose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dev.zacsweers.metro:metrox-viewmodel-compose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;version.ref&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"metro"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// build.gradle.kts (root)&lt;/span&gt;
&lt;span class="nf"&gt;plugins&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metro&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;apply&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the plugin to every module that contains Metro annotations and add the ViewModel extensions to the modules that need them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// composeApp/build.gradle.kts&lt;/span&gt;
&lt;span class="nf"&gt;plugins&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metro&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;kotlin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;sourceSets&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;commonMain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;viewmodel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;viewmodel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;metrox-viewmodel&lt;/code&gt; and &lt;code&gt;metrox-viewmodel-compose&lt;/code&gt; are what give you &lt;code&gt;ViewModelGraph&lt;/code&gt;, &lt;code&gt;LocalMetroViewModelFactory&lt;/code&gt;, and &lt;code&gt;@ContributesIntoMap&lt;/code&gt; for lifecycle-aware ViewModel injection. The demo uses all three.&lt;/p&gt;

&lt;p&gt;One Gradle plugin, two extra libraries. No annotation processor configuration, no generated source directories to wire into your source sets.&lt;/p&gt;

&lt;p&gt;The project organises code by package rather than separate Gradle modules. Metro's contribution system works at the annotation level, not the module boundary, so packages are enough. A scope marker is all you need to get started:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/di/AppScope.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.Scope&lt;/span&gt;

&lt;span class="nd"&gt;@Scope&lt;/span&gt;
&lt;span class="k"&gt;annotation&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppScope&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Feature 1: Conversations list
&lt;/h2&gt;

&lt;p&gt;This is the baseline. The pattern you'll use for most features in any KMP project.&lt;/p&gt;

&lt;p&gt;The interface lives in the domain layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/feature/conversations/domain/ConversationRepository.kt&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ConversationRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getConversations&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation attaches itself to the graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/feature/conversations/data/FakeConversationRepository.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.ContributesBinding&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.Inject&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.SingleIn&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.delay&lt;/span&gt;

&lt;span class="nd"&gt;@Inject&lt;/span&gt;
&lt;span class="nd"&gt;@ContributesBinding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AppScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@SingleIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AppScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FakeConversationRepository&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ConversationRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getConversations&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// fake network latency&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Garage channel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lastMessage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Tyres are warm."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Pit wall"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lastMessage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Box this lap."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@ContributesBinding(AppScope::class)&lt;/code&gt; tells Metro: this class implements &lt;code&gt;ConversationRepository&lt;/code&gt; and belongs to &lt;code&gt;AppScope&lt;/code&gt;. The root graph never needs to declare it. Metro aggregates everything contributed to a scope automatically at compile time. &lt;code&gt;@SingleIn(AppScope::class)&lt;/code&gt; keeps one instance alive for the app's lifetime. Remove it and Metro creates a new repository on every injection, which is almost never what you want for a repository.&lt;/p&gt;

&lt;p&gt;This is the pattern that makes multi-feature wiring low-ceremony. Each feature declares its own contributions and the root graph stays small.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lifecycle-aware ViewModel injection
&lt;/h3&gt;

&lt;p&gt;The ViewModel follows the same constructor injection pattern, but contributing it uses a map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/feature/conversations/presentation/ConversationsViewModel.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.lifecycle.ViewModel&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.lifecycle.viewModelScope&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.ContributesIntoMap&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.Inject&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.ViewModelKey&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.binding&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.MutableStateFlow&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.StateFlow&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.launch&lt;/span&gt;

&lt;span class="nd"&gt;@Inject&lt;/span&gt;
&lt;span class="nd"&gt;@ContributesIntoMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AppScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;binding&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;binding&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())&lt;/span&gt;
&lt;span class="nd"&gt;@ViewModelKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConversationsViewModel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConversationsViewModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ConversationRepository&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;_conversations&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MutableStateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(&lt;/span&gt;&lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;conversations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_conversations&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;viewModelScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;_conversations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getConversations&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@ContributesIntoMap&lt;/code&gt; puts this ViewModel into a map that Metro generates for the scope. The &lt;code&gt;@ViewModelKey&lt;/code&gt; annotation sets the key. That map feeds into &lt;code&gt;MetroViewModelFactory&lt;/code&gt;, an abstract class from Metro's ViewModel extensions that you subclass once per scope.&lt;/p&gt;

&lt;p&gt;Metro won't provide &lt;code&gt;MetroViewModelFactory&lt;/code&gt; automatically. It's abstract and has no &lt;code&gt;@Inject&lt;/code&gt; constructor. You bridge the gap with a small file that subclasses it for each scope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/di/ViewModelFactory.kt&lt;/span&gt;
&lt;span class="nd"&gt;@Inject&lt;/span&gt;
&lt;span class="nd"&gt;@ContributesBinding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AppScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@SingleIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AppScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppViewModelFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;viewModelProviders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KClass&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="err"&gt;()&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;assistedFactoryProviders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KClass&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="err"&gt;()&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ViewModelAssistedFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;manualAssistedFactoryProviders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KClass&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="nc"&gt;ManualViewModelAssistedFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="err"&gt;()&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ManualViewModelAssistedFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;MetroViewModelFactory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@Inject&lt;/span&gt;
&lt;span class="nd"&gt;@ContributesBinding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@SingleIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatViewModelFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;viewModelProviders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KClass&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="err"&gt;()&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;assistedFactoryProviders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KClass&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="err"&gt;()&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ViewModelAssistedFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;manualAssistedFactoryProviders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KClass&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="nc"&gt;ManualViewModelAssistedFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="err"&gt;()&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ManualViewModelAssistedFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;MetroViewModelFactory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both classes are &lt;code&gt;@Inject&lt;/code&gt;, so Metro can construct them. The three maps are populated from &lt;code&gt;@ContributesIntoMap&lt;/code&gt; contributions in the matching scope. &lt;code&gt;@ContributesBinding&lt;/code&gt; wires &lt;code&gt;AppViewModelFactory&lt;/code&gt; to the &lt;code&gt;MetroViewModelFactory&lt;/code&gt; binding in &lt;code&gt;AppScope&lt;/code&gt;, and the same pattern applies for &lt;code&gt;ChatViewModelFactory&lt;/code&gt; in &lt;code&gt;ChatScope&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ViewModelGraph&lt;/code&gt; then exposes &lt;code&gt;metroViewModelFactory&lt;/code&gt;, which you provide through a &lt;code&gt;CompositionLocal&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/App.kt&lt;/span&gt;
&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;App&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RootGraph&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;CompositionLocalProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalMetroViewModelFactory&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metroViewModelFactory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AppTheme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;AppEntry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside any screen below that provider, &lt;code&gt;metroViewModel&amp;lt;ConversationsViewModel&amp;gt;()&lt;/code&gt; resolves from the map, scoped to the back stack entry, cleaned up when the screen pops. The Metro wiring doesn't touch any of that: it just provides the factory.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A quick note on &lt;code&gt;metroViewModelFactory&lt;/code&gt;:&lt;/strong&gt; Compose's &lt;code&gt;viewModel()&lt;/code&gt; function uses reflection by default, which doesn't work with Metro since there's no runtime container. &lt;code&gt;metroViewModel&amp;lt;T&amp;gt;()&lt;/code&gt; from &lt;code&gt;metrox-viewmodel-compose&lt;/code&gt; reads &lt;code&gt;LocalMetroViewModelFactory&lt;/code&gt; and constructs ViewModels from the contributed map instead. The &lt;code&gt;MetroViewModelFactory&lt;/code&gt; subclass you write in &lt;code&gt;ViewModelFactory.kt&lt;/code&gt; is the bridge: it takes the generated provider maps as constructor arguments and wires them into the factory. Everything else works exactly as you'd expect from the Jetpack ViewModel API.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The thing that clicked for me: &lt;code&gt;@ContributesBinding&lt;/code&gt; contributes a single binding, &lt;code&gt;@ContributesIntoMap&lt;/code&gt; contributes an entry to a map. Both are aggregated automatically. Neither requires touching the root graph.&lt;/p&gt;




&lt;h2&gt;
  
  
  Feature 2: Notification permission
&lt;/h2&gt;

&lt;p&gt;This is where Metro in a multiplatform project gets genuinely tricky, and where the constraint that makes it correct becomes obvious.&lt;/p&gt;

&lt;p&gt;On Android, requesting notification permission requires &lt;code&gt;Context&lt;/code&gt;, which goes to &lt;code&gt;ActivityCompat.requestPermissions&lt;/code&gt;. On iOS, you use &lt;code&gt;UNUserNotificationCenter&lt;/code&gt;. Neither type exists in &lt;code&gt;commonMain&lt;/code&gt;. The interface for the provider lives there, but the implementations are entirely platform-specific. If you want a deep dive into cross-platform notifications with KMP, I covered the full setup in a &lt;a href="https://dev.to/posts/notifications-kmp"&gt;previous article&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The instinct is to put &lt;code&gt;@DependencyGraph&lt;/code&gt; on the common interface and let platform graphs extend it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WRONG: fails to compile on the iOS target&lt;/span&gt;
&lt;span class="c1"&gt;// commonMain/di/AppGraph.kt&lt;/span&gt;
&lt;span class="nd"&gt;@DependencyGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AppScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;AppGraph&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@DependencyGraph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Factory&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Factory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Provides&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;AppGraph&lt;/span&gt; &lt;span class="c1"&gt;// Context is an Android type&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The iOS compiler doesn't know what &lt;code&gt;Context&lt;/code&gt; is. The build fails with an unresolved reference. After that error, we can try &lt;code&gt;@GraphExtension&lt;/code&gt;. Metro provides it for extending an existing graph, but &lt;code&gt;@GraphExtension.Factory&lt;/code&gt; is a different annotation from &lt;code&gt;@DependencyGraph.Factory&lt;/code&gt;, and &lt;code&gt;createGraphFactory&lt;/code&gt; only accepts the second one. You end up with a graph you can't instantiate.&lt;/p&gt;

&lt;p&gt;I went through both of those dead ends before the correct pattern became clear: the common interface is just a plain Kotlin interface with no Metro annotations. The &lt;code&gt;@DependencyGraph&lt;/code&gt; goes only on the platform-specific implementations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/di/RootGraph.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metrox.viewmodel.ViewModelGraph&lt;/span&gt;

&lt;span class="c1"&gt;// Plain Kotlin interface, no @DependencyGraph&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;RootGraph&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ViewModelGraph&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;platformNotificationProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlatformNotificationProvider&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// androidMain/di/AndroidRootGraph.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.DependencyGraph&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.Provides&lt;/span&gt;

&lt;span class="nd"&gt;@DependencyGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AppScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;AndroidRootGraph&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RootGraph&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@DependencyGraph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Factory&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Factory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Provides&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;AndroidRootGraph&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// iosMain/di/IosRootGraph.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.DependencyGraph&lt;/span&gt;

&lt;span class="nd"&gt;@DependencyGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AppScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IosRootGraph&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RootGraph&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating the graph on each platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// androidMain/App.kt&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Application&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RootGraph&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;createGraphFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AndroidRootGraph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Factory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;applicationContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// iosMain/MainViewController.kt&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;MainViewController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ComposeUIViewController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;graph&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;createGraph&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;IosRootGraph&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createGraph&amp;lt;T&amp;gt;()&lt;/code&gt; expects &lt;code&gt;T&lt;/code&gt; to be a &lt;code&gt;@DependencyGraph&lt;/code&gt; with no required external parameters. &lt;code&gt;createGraphFactory&amp;lt;F&amp;gt;()&lt;/code&gt; expects &lt;code&gt;F&lt;/code&gt; to be a &lt;code&gt;@DependencyGraph.Factory&lt;/code&gt; inside a graph that needs those parameters. Both return a &lt;code&gt;RootGraph&lt;/code&gt;, which is what the shared &lt;code&gt;App()&lt;/code&gt; composable receives. The composable never needs to know which platform it's running on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Platform implementations
&lt;/h3&gt;

&lt;p&gt;The notification provider expect class lives in common:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/core/domain/PlatformNotificationProvider.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.flow.StateFlow&lt;/span&gt;

&lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PlatformNotificationProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;permissionGranted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;requestPermission&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;observeNotificationServicePermission&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Android implementation uses &lt;code&gt;Context&lt;/code&gt; and an &lt;code&gt;ActivityHolder&lt;/code&gt;, a wrapper that &lt;code&gt;MainActivity&lt;/code&gt; updates on &lt;code&gt;onCreate&lt;/code&gt;/&lt;code&gt;onDestroy&lt;/code&gt; so DI-managed classes can reach the current &lt;code&gt;Activity&lt;/code&gt; without holding a direct reference to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// androidMain/feature/notifications/data/PlatformNotificationProviderImpl.kt&lt;/span&gt;
&lt;span class="nd"&gt;@Inject&lt;/span&gt;
&lt;span class="nd"&gt;@SingleIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AppScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PlatformNotificationProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;activityHolder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ActivityHolder&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;REQUEST_CODE_NOTIFICATIONS&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1001&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;_permissionGranted&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MutableStateFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;permissionGranted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_permissionGranted&lt;/span&gt;

    &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;requestPermission&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;activityHolder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="nc"&gt;ActivityCompat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nf"&gt;arrayOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Manifest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;POST_NOTIFICATIONS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;REQUEST_CODE_NOTIFICATIONS&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;observeNotificationServicePermission&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_permissionGranted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ContextCompat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkSelfPermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Manifest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;POST_NOTIFICATIONS&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;PackageManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PERMISSION_GRANTED&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Android&lt;/th&gt;
&lt;th&gt;iOS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src="/assets/drop-the-clutch-notifications-android.gif" alt="Notification permission dialog being requested and granted"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="/assets/drop-the-clutch-notifications-ios.gif" alt="Notification permission dialog being requested and granted"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Android doesn't deliver permission results to DI-managed classes. They come back to &lt;code&gt;Activity.onRequestPermissionsResult&lt;/code&gt;. The pattern that works: &lt;code&gt;MainActivity&lt;/code&gt; calls &lt;code&gt;observeNotificationServicePermission()&lt;/code&gt; in the callback, the provider re-reads the system state, and the &lt;code&gt;MutableStateFlow&lt;/code&gt; updates. Everything else observes the flow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// androidMain/MainActivity.kt&lt;/span&gt;
&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onRequestPermissionsResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;requestCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;,&lt;/span&gt;
    &lt;span class="n"&gt;grantResults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;IntArray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onRequestPermissionsResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grantResults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;PlatformNotificationProviderImpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;REQUEST_CODE_NOTIFICATIONS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;platformNotificationProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observeNotificationServicePermission&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The iOS implementation uses &lt;code&gt;UNUserNotificationCenter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The error you get if you annotate the common graph isn't Metro being fussy. It's the compiler catching a design that would break the iOS build entirely. The plain interface isn't a workaround. It's the correct model.&lt;/p&gt;




&lt;h2&gt;
  
  
  Feature 3: Chat room
&lt;/h2&gt;

&lt;p&gt;This is the pattern I wanted to understand before committing to Metro seriously. The first two features use &lt;code&gt;AppScope&lt;/code&gt; for everything, which makes sense: repositories and providers live for the app's lifetime. But not everything does.&lt;/p&gt;

&lt;p&gt;A WebSocket isn't a data object. It connects on creation and needs to be explicitly closed. Keeping it in &lt;code&gt;AppScope&lt;/code&gt; means the socket stays open for the entire app lifetime, even when the user is on a completely different screen. That's almost never what you want for a real-time connection.&lt;/p&gt;

&lt;p&gt;A scope is a lifetime, not a namespace. The &lt;code&gt;ChatScope&lt;/code&gt; exists because the socket needs to opened when the chat screen opens and closed when it leaves. And because every chat room is a different conversation, there's a second problem to solve: the conversation ID is only known at runtime, when the user taps a row. Metro needs to receive it and make it available to anything in the scope.&lt;/p&gt;

&lt;p&gt;Here's how the two scopes relate in the demo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AppScope  (AndroidRootGraph / IosRootGraph)
├── ConversationRepository       singleton, lives for the app's lifetime
├── PlatformNotificationProvider singleton, lives for the app's lifetime
├── AppViewModelFactory
│
└── ChatScope  (ChatGraph) ──── created via ChatGraph.Factory at navigation time
    ├── ConversationId           runtime value, injected through the factory parameter
    ├── ChatSocket               singleton within this scope
    └── ChatViewModelFactory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ChatScope&lt;/code&gt; is nested inside &lt;code&gt;AppScope&lt;/code&gt; and inherits all of its bindings. &lt;code&gt;ChatGraph&lt;/code&gt; uses &lt;code&gt;@GraphExtension&lt;/code&gt; for exactly that reason. Anything in &lt;code&gt;ChatScope&lt;/code&gt; can ask for &lt;code&gt;ConversationRepository&lt;/code&gt; or &lt;code&gt;PlatformNotificationProvider&lt;/code&gt; and Metro will resolve them from the parent graph. The reverse isn't true: &lt;code&gt;AppScope&lt;/code&gt; knows nothing about &lt;code&gt;ChatSocket&lt;/code&gt; or &lt;code&gt;ConversationId&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The scope and the socket
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/di/ChatScope.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.Scope&lt;/span&gt;

&lt;span class="nd"&gt;@Scope&lt;/span&gt;
&lt;span class="k"&gt;annotation&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatScope&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The conversation ID is a runtime value, so it gets a proper type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/feature/chat/domain/ConversationId.kt&lt;/span&gt;
&lt;span class="nd"&gt;@JvmInline&lt;/span&gt;
&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConversationId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A value class, different from a raw &lt;code&gt;String&lt;/code&gt;, keeps the binding unambiguous. Metro identifies bindings by type, so &lt;code&gt;ConversationId&lt;/code&gt; and &lt;code&gt;String&lt;/code&gt; are different things in the graph.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/feature/chat/data/ChatSocket.kt&lt;/span&gt;
&lt;span class="nd"&gt;@Inject&lt;/span&gt;
&lt;span class="nd"&gt;@SingleIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ConversationId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;_messages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MutableStateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(&lt;/span&gt;&lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_messages&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;_isTyping&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MutableStateFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isTyping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_isTyping&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CoroutineScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;lap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;_isTyping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
                &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;_isTyping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
                &lt;span class="n"&gt;_messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sender&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Engineer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Lap $lap: pace looks good."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;lap&lt;/span&gt;&lt;span class="p"&gt;++&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ChatSocket&lt;/code&gt; declares &lt;code&gt;ConversationId&lt;/code&gt; as a constructor parameter. Metro will inject it, no special annotation needed on the parameter itself. The binding just has to exist somewhere in the scope, and the factory is where it comes from.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@SingleIn(ChatScope::class)&lt;/code&gt; means one &lt;code&gt;ChatSocket&lt;/code&gt; per &lt;code&gt;ChatGraph&lt;/code&gt;. Not one per app, one per chat session. Navigate away and come back, and you get a fresh socket with its own conversation ID.&lt;/p&gt;

&lt;h3&gt;
  
  
  The graph extension
&lt;/h3&gt;

&lt;p&gt;The chat graph uses &lt;code&gt;@GraphExtension&lt;/code&gt; rather than &lt;code&gt;@DependencyGraph&lt;/code&gt;. The difference is that &lt;code&gt;@GraphExtension&lt;/code&gt; extends an existing parent graph and inherits all of its bindings. &lt;code&gt;@DependencyGraph&lt;/code&gt; creates a standalone graph that knows nothing about its caller.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/di/ChatGraph.kt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.GraphExtension&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metro.Provides&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dev.zacsweers.metrox.viewmodel.ViewModelGraph&lt;/span&gt;

&lt;span class="nd"&gt;@GraphExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ChatGraph&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ViewModelGraph&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;chatSocket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ChatSocket&lt;/span&gt;

    &lt;span class="nd"&gt;@ContributesTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AppScope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@GraphExtension&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Factory&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Factory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Provides&lt;/span&gt; &lt;span class="n"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ConversationId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;ChatGraph&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@Provides&lt;/code&gt; on the factory parameter is Metro's answer to runtime injection. When &lt;code&gt;create(conversationId)&lt;/code&gt; is called, Metro takes that value and makes it available as a binding for the entire &lt;code&gt;ChatScope&lt;/code&gt;. &lt;code&gt;ChatSocket&lt;/code&gt; asks for &lt;code&gt;ConversationId&lt;/code&gt; in its constructor; Metro delivers the one passed into the factory. No extra wiring, no manual passing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ConversationId&lt;/code&gt; isn't being threaded through a call chain. It's a graph-level binding. Any class in &lt;code&gt;ChatScope&lt;/code&gt; can list it as a constructor parameter and get the right one automatically. The &lt;code&gt;ChatGraph&lt;/code&gt; instance is this specific conversation. Add a read-receipt tracker or a typing service later and they get the ID for free, for as long as the socket is alive.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@ContributesTo(AppScope::class)&lt;/code&gt; on the factory tells Metro to aggregate it into the root graph automatically, so the root graph exposes &lt;code&gt;chatGraphFactory&lt;/code&gt; without needing to declare it explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The entry composable
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/feature/chat/presentation/ChatEntry.kt&lt;/span&gt;
&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;ChatEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ChatGraph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Factory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ConversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;onBackClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;graph&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;DisposableEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;onDispose&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chatSocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;CompositionLocalProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalMetroViewModelFactory&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metroViewModelFactory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ChatScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onBackClick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onBackClick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;remember { factory.create(conversationId) }&lt;/code&gt; creates the graph once for the lifetime of this composable. The &lt;code&gt;conversationId&lt;/code&gt; comes from the navigation back-stack entry and is fixed for the session, which is why &lt;code&gt;remember&lt;/code&gt; with no key is correct here. &lt;code&gt;DisposableEffect&lt;/code&gt; with &lt;code&gt;onDispose&lt;/code&gt; calls &lt;code&gt;close()&lt;/code&gt; when the composable leaves the composition. &lt;code&gt;CompositionLocalProvider&lt;/code&gt; swaps the ViewModel factory to the one backed by &lt;code&gt;ChatScope&lt;/code&gt;, so &lt;code&gt;metroViewModel()&lt;/code&gt; calls inside &lt;code&gt;ChatScreen&lt;/code&gt; resolve from &lt;code&gt;ChatGraph&lt;/code&gt;, not the root &lt;code&gt;AppScope&lt;/code&gt; factory.&lt;/p&gt;

&lt;p&gt;&lt;a href="/assets/drop-the-clutch-chat.gif" class="article-body-image-wrapper"&gt;&lt;img src="/assets/drop-the-clutch-chat.gif" alt="Chat screen: typing indicator followed by incoming message"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The socket starts emitting as soon as &lt;code&gt;ChatEntry&lt;/code&gt; enters the composition. While the screen is open, a new message arrives every three seconds, preceded by a one-second typing indicator. The moment you navigate away, &lt;code&gt;onDispose&lt;/code&gt; fires, &lt;code&gt;close()&lt;/code&gt; cancels the coroutine, and it stops. Navigate back to a different conversation and you get a fresh &lt;code&gt;ChatGraph&lt;/code&gt;, a fresh &lt;code&gt;ChatSocket&lt;/code&gt;, and a new &lt;code&gt;ConversationId&lt;/code&gt;, all from a single &lt;code&gt;factory.create(newId)&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AppScope&lt;/code&gt; would mean the socket survives every screen transition for the app's entire lifetime. &lt;code&gt;ChatScope&lt;/code&gt; means it lives exactly as long as the chat screen. The &lt;code&gt;@Provides&lt;/code&gt; on factory parameter means runtime values flow into the scope cleanly, without leaking Android lifecycle details into the graph definition.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;Most of these I hit personally while building the demo. Metro's error messages are generally good, but a few failure modes produce errors that point at the wrong place. You see the interface in the message, not the implementation that caused the problem. That's the pattern to watch for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;internal&lt;/code&gt; on &lt;code&gt;@ContributesBinding&lt;/code&gt; classes.&lt;/strong&gt;&lt;br&gt;
If a contributed class is &lt;code&gt;internal&lt;/code&gt; (Usually on a multi module project), Metro can't aggregate it across module boundaries. The reason: Metro's compiler plugin generates glue code that references your class by name. If that class is &lt;code&gt;internal&lt;/code&gt;, the generated code sits outside the declaring module's visibility boundary and can't see it. The build succeeds, but you'll get a &lt;code&gt;MissingBinding&lt;/code&gt; error naming the interface, not the implementation. This one is hard to trace the first time. Keep contributed classes &lt;code&gt;public&lt;/code&gt; (the default) or at least within the same Gradle module as every class that needs them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@DependencyGraph&lt;/code&gt; cannot extend &lt;code&gt;@DependencyGraph&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
Metro rejects this at compile time: "Graph class may not directly extend graph class." The plain-interface pattern from Feature 2 is the correct solution: annotate only the platform-specific leaf implementations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;createGraph&lt;/code&gt; vs &lt;code&gt;createGraphFactory&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;createGraph&amp;lt;T&amp;gt;()&lt;/code&gt; expects &lt;code&gt;T&lt;/code&gt; to be a &lt;code&gt;@DependencyGraph&lt;/code&gt; with no required external parameters. &lt;code&gt;createGraphFactory&amp;lt;F&amp;gt;()&lt;/code&gt; expects &lt;code&gt;F&lt;/code&gt; to be a &lt;code&gt;@DependencyGraph.Factory&lt;/code&gt;. These are different annotations; mixing them produces a compiler error that points at the right place, but the fix isn't obvious until you understand the split.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@GraphExtension&lt;/code&gt; vs &lt;code&gt;@DependencyGraph&lt;/code&gt; for child graphs.&lt;/strong&gt;&lt;br&gt;
Use &lt;code&gt;@GraphExtension&lt;/code&gt; when the child graph should inherit parent bindings, which is the right choice for a scoped feature graph like &lt;code&gt;ChatGraph&lt;/code&gt;. Use &lt;code&gt;@DependencyGraph&lt;/code&gt; only for the root platform graphs. Mixing them up produces a graph that either can't see parent bindings or can't be instantiated correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope mismatch.&lt;/strong&gt;&lt;br&gt;
A class scoped to &lt;code&gt;ChatScope&lt;/code&gt; that gets injected into something in &lt;code&gt;AppScope&lt;/code&gt; produces a &lt;code&gt;MissingBinding&lt;/code&gt; error at compile time. The error names the interface, not the implementation. When you see it, check whether the implementation is scoped to a graph the requester can't reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manifest permissions are a separate concern.&lt;/strong&gt;&lt;br&gt;
This is more an Android issue. Metro wires up a class that calls &lt;code&gt;requestPermissions&lt;/code&gt; correctly. But if &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt; isn't declared in &lt;code&gt;AndroidManifest.xml&lt;/code&gt;, the dialog won't appear and there's no error anywhere. Always check the manifest first if a permission flow does nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Metro moves dependency graph validation from runtime to compile time, and that shift changes how you think about DI mistakes. The patterns here cover most of the ground: binding contributions for everyday features, the platform graph split for anything that needs Android or iOS types, and child scopes for dependencies with a real lifecycle, including runtime values like a conversation ID flowing in through a factory parameter. Get those three things in place and the rest of the wiring tends to solve itself.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The paddle shifters don't make you a faster driver. But they do free up your hands for the things that actually determine where you finish.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The full demo for this article is available on &lt;a href="https://github.com/kmpbits/metroDIDemo" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The KMP Bits app is available on &lt;a href="https://apps.apple.com/pt/app/bitsreader/id6755438235" rel="noopener noreferrer"&gt;App Store&lt;/a&gt; and &lt;a href="https://play.google.com/store/apps/details?id=com.joel.bitsreader" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;, built entirely with KMP.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kmp</category>
      <category>cmp</category>
      <category>dependencyinversion</category>
      <category>android</category>
    </item>
  </channel>
</rss>
