DEV Community

Stuart
Stuart

Posted on

Metro: The KMP DI Framework You Never Knew You Needed

TL;DR — If you want to skip ahead to the tutorial, feel free to jump to The Implementation!

To begin, this post is targeting a more intermediate audience. You may not gain anything from reading if concepts like creating a @Component in Dagger or instantiating a @HiltAndroidApp in Hilt fly over your head. If you have this basic knowledge, by the end, you should have a good understanding of how the concepts from traditional and popular DI frameworks transfer over to this library, and also how to understand a basic boilerplate app using Metro, with the source code.


The Story

Every great (or really any) Kotlin Multiplatform (KMP) project will need a great Dependency Injection (DI) framework. For a while in the Multiplatform world, developers had to decide how they were to get their dependencies injected throughout their app for use in various features. If you're an Android developer jumping into Kotlin Multiplatform, you might be surprised to find out that although the environment in KMP is oh so familiar, the plumbing is slightly different from Native Android development, and you'll immediately notice this when you want to include a KMP module in your app.

Assuming you're using one of the tried and true OGs to the likes of Dagger2, or Hilt, once you start building out a module with multiplatform technology, you'll immediately notice on your first build… you can't. Reasons like generating Java classes or Android-specific bindings behind the scenes stop classic DI libraries in their tracks. Ultimately, this post isn't about the best or worst library, it's just an exploration of the alternatives out there and my decision process for landing on Metro as a DI library of choice for Kotlin Multiplatform.

Let's begin with Koin, the first major entrant in the KMP DI space. This runtime-based library stiffened the learning curve needed to use the OGs, and did away with tons of boilerplate having to do with setting up your dependency graph in the first place. It made you not have to think about the concept of @Component and @Module in the traditional Dagger sense, and since it's Kotlin-based, it's also compatible with Native Android development. This easy barrier to entry made it even more enticing to developers wanting to enter the Android development field and couldn't be bothered with learning the complexities of Dagger to get a simple proof of concept up and running. With Koin you define the components you need with the behavior you need, wire them up in some KoinModule, and boom! App up and running.

Another popular KMP DI alternative is kotlin-inject. This also lowered the bar to entry for learning DI frameworks, the strength here being it was compatible with KMP projects and native Android apps just like Koin, except it prioritizes compile-time component generation (like Dagger / Hilt), and uses concepts already familiar to devs that are used to Dagger, although the concept of @Module, @Component, and @Inject are pretty much the same in general implementation; there's much less boilerplate and logical overhead associated with generating components. kotlin-inject takes the rawness of Dagger and the logical simplicity of Hilt and jams them together while maintaining the kotlin-specific, platform-agnostic portability of Koin.

Now there's Metro, a new entrant in the DI space. The creator, Zac Sweers has given a great introduction to the library as a whole on his own blog. Metro mashes kotlin-inject, Dagger (and Anvil!), Hilt, and Koin into a sundae of DI awesomeness. The documentation and project on GitHub have great explanations on implementation and integration. But there are some points to touch on here before we get into the implementation. The strengths of Metro shine brightest when you need to just get a project up and running (like Koin), and as described in its documentation it's shown to have similar (usually better) performance, and concepts that most developers are familiar with when using Dagger, Hilt, or kotlin-inject. You also have the option to use some of its built-in interoperability with either of those frameworks just in case you wanted to give the library a try for a phased integration into an existing project.

The Implementation

To start, I decided to bootstrap a project generated by the KMP-App-Template project from JetBrains. You can see the complete diff of changes on Github and a branch with everything I'll describe in this article. There's active development in making Metro more dev friendly, and extending usability based on other popular existing DI frameworks.

To integrate Metro into a project, you'll obviously need a Gradle import. Metro does something simpler, a Gradle plugin (Metro — Installation):

[versions]
ksp-version = "2.2.20-2.0.3"
metro = "0.6.8"

[plugins]
google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp-version" }
metro-di = { id = "dev.zacsweers.metro", version.ref = "metro" }
Enter fullscreen mode Exit fullscreen mode

The main concept to wrap your head around with Metro is the idea of Graphs. I advise you to study the documentation on Dependency Graphs (Metro — Dependency Graphs), for now I'll share some key concepts. Graphs are akin to @Component in Dagger/kotlin-inject, they are the starting point and definition of how dependencies are initialized and injected into services needed throughout your app.

Android Platform Dependency Graph based on Metro Repo sample

In my metro-sample branch, I began by defining an AppGraph like we see in the docs and Metro Repo samples (android-app sample). My rule of thumb for when to define a new @DependencyGraph is when you need dependencies on a specific Platform (think iOS, Android, Web, Desktop, etc). In this case, we'll need a Graph for the Android platform, so I created this graph in the :composeApp module. A Dependency Graph will be the base of your dependencies. Where in Koin you'd define a base module {}, and Dagger / kotlin-inject you'd have a @Component abstract class ApplicationComponent {}. With Hilt, this Component creation is effectively hidden behind predefined scoped components like SingletonComponent, ViewModelComponent, etc.

Understand the Graph and its Dependencies

Let's break down some of the boilerplate configuration Metro needs to instantiate itself on Android. These are some of the key files to make note of for this bootstrapped project:

ROOT
├── build.gradle.kts
├── composeApp
│   ├── build.gradle.kts
│   └── src/androidMain
│       └── kotlin/org/example/project
│           ├── AndroidAppGraph.kt
│           ├── KotlinProjectApp.kt
│           ├── di/
│           │   ├── ActivityKey.kt
│           │   └── MetroAppComponentFactory.kt
│           └── viewmodel/
│               ├── MetroViewModel.kt
│               ├── MetroViewModelFactory.kt
│               └── ViewModelGraph.kt
│
└── shared
    ├── build.gradle.kts
    └── src
        └── commonMain/kotlin/org/example/project
            ├── data/
            │   ├── DataProviders.kt
            ├── di/
            │   ├── ViewModelKey.kt
            │   └── ViewModelScope.kt
            └── screens/
                ├── DetailViewModel.kt
                └── ListViewModel.kt
Enter fullscreen mode Exit fullscreen mode

Quick breakdown for how these all come together: First we define how dependencies will be instantiated in the graph through bindings, in this project it'll be through DataProviders.kt. Once the bindings are defined, we actually bind them to a Graph, AndroidAppGraph.kt through one Metro's annotation parameters. We instantiate the Graph itself in our app through its inner Factory class and utilize the AndroidManifest and override the default AppComponentFactory (here's a quick reference to what this class is from Android).

DataProviders.kt (🔗 link)

Metro has a cool concept called @BindingContainer that I like to use to group similar dependencies into one object. In other DI libraries, you may know of this concept as a @Module. It helps reduce file clutter and has great potential for organizing UseCases based on a set of dependencies a Feature may need. In this simple KMP app, I just defined a basic HTTPClient Provider, and @Provides for the MuseumApi, MuseumStorage, and MuseumRepository. These providers will be important for your Graph to determine how to create your services when they're needed for @Inject later. You can read more about Binding Containers in the Metro Docs.

AndroidAppGraph.kt (🔗 link)

Here's where things get spicy. There's a lot of behind the scenes Metro compiler magic going on that involves generating all the dependencies we need during runtime.

📓 Utilize DependencyGraph parameters

@DependencyGraph has a parameter that allows your graph to access these containers to be used when injecting dependencies, in AndroidAppGraph we'll add DataProviders::class as a parameter for our bindingContainers

📓 Override Activity Constructor Injection

Like with all DI frameworks, we need a way to instantiate our graphs as soon as possible when our application starts, which is typically done by overriding the Application class and instantiating our Graph there. For compile-time libraries like Metro, field injection is not possible (although possible in Dagger), so we need a way to inject these dependencies through the constructor. Android allows us to do this for Activities by extending the main Activity class and declaring this class as tools:replace="android:appComponentFactory" in our AndroidManifest. This, coupled with defining an @ActivityKey, is important for our @Multibinds annotation in our Graph. Thankfully the Metro sample Repo conveniently provides us with some examples of overrides we can define in our own MetroAppComponentFactory.

📓 Define a Factory Method

These @DepencyGraph.Factory annotations allow us to generate our Graphs for use in our app code so we can have access to the dependencies provided by them. In our AndroidAppGraph, we simply define a Factory that takes an Application as a parameter. Since Android dependencies and libraries typically need some sort of Context, we @Provides (docs) our factory with one, and also define how to provide that Context with fun provideApplicationContext(application: Application).

📓 Instantiate the Graph

After we get all the setup and configuration out of the way, we need to actually create our graph. As I mentioned this is typically done in Android through overriding the default Application class. Which is what is done in KotlinProjectApp with:

val appGraph by lazy {
    createGraphFactory<AndroidAppGraph.Factory>()
        .create(application = this)
}
Enter fullscreen mode Exit fullscreen mode

Performing Injections

Finally, the project is set up and ready to go! … It's not runnable yet because all we've done is make it easier to define and extend future dependencies and injection points. Still too much jargon? How is this done, you ask? In typical DI fashion, let's break down how we inject dependencies into a ViewModel. When declaring a dependency in a ViewModel, we usually extend the class and define a constructor with the dependencies we need, then mark the ViewModel class with @Inject. In our ListViewModel we can see this in action like so:

@ContributesIntoMap(ViewModelScope::class)
@ViewModelKey(ListViewModel::class)
@Inject
class ListViewModel(museumRepository: MuseumRepository) : ViewModel() {
    // ... implementation
}
Enter fullscreen mode Exit fullscreen mode

A lot is going on behind the scenes to make these 4 lines compile correctly, so let's break it down.

  • ListViewModel is defined with MuseumRepository as a dependency. Metro knows how to create an instance of this ViewModel through our convenient MetroViewModelFactory class which extends ViewModelProvider.Factory and uses the DI Graph to provide this Repository during creation.

  • ContributesIntoMap, and ViewModelKey work hand in hand. They notify the Graph that we're defining some classes of the same type, but need the Factory to know which specific class to provide. Metro builds a simple Key-Value map into the graph with the Class as the Key, and Value is the specific ViewModel object itself.

  • @Inject is a common DI annotation that lets the DI framework know that we'll call upon it to grab a dependency when creating this Class for our graph.

Given these 3 annotations, Metro has the information it needs to put this ViewModel together when we inject one into a Screen.

Here's a simple visual representation of this simple AndroidAppGraph we have now:

Android Platform Dependency Graph based on Metro Repo sample

Injecting ViewModels into our Composables

Now that we've defined our ViewModel and Metro knows how to construct one with the necessary dependencies, when will we get to use this ViewModel? In Jetpack Compose, there's a convenient function viewModels(), that make it easy for us to grab a ViewModel from a ViewModel Factory during runtime. Unfortunately, when using KMP this doesn't exactly play nice with dependency injection libraries in the way that developers might experience with Dagger Hilt, this is another place where Metro shines. We need to utilize our ViewModel Factory, not create one from scratch at runtime and manage that. The metro sample code has a handy class MetroViewModel, that creates our convenient Composable function to grab our ViewModel.

@Composable
inline fun <reified VM : ViewModel> metroViewModel(
    viewModelStoreOwner: ViewModelStoreOwner =
        checkNotNull(LocalViewModelStoreOwner.current) {
            "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
        },
    key: String? = null,
): VM {
    return viewModel(viewModelStoreOwner, key, factory = metroViewModelProviderFactory())
}

@Composable
private fun metroViewModelProviderFactory(): MetroViewModelFactory {
    return (LocalActivity.current as HasDefaultViewModelProviderFactory)
        .defaultViewModelProviderFactory as MetroViewModelFactory
}
Enter fullscreen mode Exit fullscreen mode

Inside the metroViewModel() function, we pass in an instance of our MetroViewModelFactory. Compose is able to get a reference to our MainActivity which implements the HasDefaultViewModelProviderFactory interface provided by Android. Inside our MainActivity class you can see we override the defaultViewModelProviderFactory with the one provided from our Metro graph AndroidAppGraph.

Given these utility functions, we can now use our ViewModels in Android with Compose. We can see an example of usage in ListScreen:

@Composable
fun ListScreen(navigateToDetails: (objectId: Int) -> Unit) {
    val viewModel = metroViewModel<ListViewModel>()
    val objects by viewModel.objects.collectAsState()
    // ... more code ...
}
Enter fullscreen mode Exit fullscreen mode

Build & Run

As promised, the source code referenced here is hosted in my Github repo and can be run as a completely bootstrapped project fully configured for use with Metro!

Metro Bootstrap Repohttps://github.com/mastrgamr/KMP-App-Bootstrap/tree/metro-sample

KotlinProject Running with Metro DI

By now, you should hopefully have a basic understanding of how all the parts of Metro tie into itself, into Android, and in your app in order to run an Android App.

I plan to build on this and extend usage to iOS in another post, please leave a comment if you feel like this has helped you on your journey through Kotlin Multiplatform and Dependency Injection.

Happy coding!


Reference Repos:

Top comments (0)