docs/quickstart/compose-multiplatform-annotations.md
This tutorial demonstrates a Compose Multiplatform application that displays museum art from The Metropolitan Museum of Art Collection API. It uses Koin Annotations for dependency injection across Android and iOS platforms with shared UI. You need around 20 min to complete the tutorial.
:::note update - 2024-11-12 :::
:::info The source code is available at on Github :::
First, add the Koin annotations dependencies:
plugins {
id("com.google.devtools.ksp") version kspVersion
}
dependencies {
// Koin for Compose Multiplatform
implementation("io.insert-koin:koin-core:$koin_version")
implementation("io.insert-koin:koin-compose-viewmodel:$koin_version")
// Koin Annotations
implementation("io.insert-koin:koin-annotations:$koin_annotations_version")
ksp("io.insert-koin:koin-ksp-compiler:$koin_annotations_version")
}
The application fetches museum art objects from a remote API and displays them in a list. Users can tap on an item to see detailed information:
MuseumAPI -> MuseumStorage -> MuseumRepository -> ViewModels -> Compose UI
Technologies used:
All the common/shared code is located in
composeAppGradle project
The museum art object data class:
@Serializable
data class MuseumObject(
val objectID: Int,
val title: String,
val artistDisplayName: String,
val medium: String,
val dimensions: String,
val objectURL: String,
val objectDate: String,
val primaryImage: String,
val primaryImageSmall: String,
val repository: String,
val department: String,
val creditLine: String,
)
We create an API interface to fetch data:
interface MuseumApi {
suspend fun getData(): List<MuseumObject>
}
@Single
class KtorMuseumApi(private val client: HttpClient) : MuseumApi {
override suspend fun getData(): List<MuseumObject> {
return try {
client.get(API_URL).body()
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
emptyList()
}
}
}
interface MuseumStorage {
suspend fun saveObjects(newObjects: List<MuseumObject>)
fun getObjectById(objectId: Int): Flow<MuseumObject?>
fun getObjects(): Flow<List<MuseumObject>>
}
@Single
class InMemoryMuseumStorage : MuseumStorage {
private val storedObjects = MutableStateFlow(emptyList<MuseumObject>())
override suspend fun saveObjects(newObjects: List<MuseumObject>) {
storedObjects.value = newObjects
}
override fun getObjectById(objectId: Int): Flow<MuseumObject?> {
return storedObjects.map { objects ->
objects.find { it.objectID == objectId }
}
}
override fun getObjects(): Flow<List<MuseumObject>> = storedObjects
}
The repository coordinates between the API and storage:
@Single(createdAtStart = true)
class MuseumRepository(
private val museumApi: MuseumApi,
private val museumStorage: MuseumStorage,
) {
private val scope = CoroutineScope(SupervisorJob())
init {
initialize()
}
fun initialize() {
scope.launch {
refresh()
}
}
suspend fun refresh() {
museumStorage.saveObjects(museumApi.getData())
}
fun getObjects(): Flow<List<MuseumObject>> = museumStorage.getObjects()
fun getObjectById(objectId: Int): Flow<MuseumObject?> = museumStorage.getObjectById(objectId)
}
:::note
The @Single(createdAtStart = true) annotation ensures the repository is created when Koin starts, triggering the data fetch immediately.
:::
We organize our dependencies into separate modules:
@Module
@ComponentScan
class DataModule {
@Single
fun providesHttpClient(): HttpClient {
val json = Json { ignoreUnknownKeys = true }
return HttpClient {
install(ContentNegotiation) {
json(json, contentType = ContentType.Any)
}
}
}
}
The @ComponentScan annotation automatically discovers all @Single annotated classes in this package (MuseumApi, MuseumStorage, MuseumRepository).
Let's create ViewModels for our two screens:
// List screen ViewModel
@KoinViewModel
class ListViewModel(museumRepository: MuseumRepository) : ViewModel() {
val objects: StateFlow<List<MuseumObject>> =
museumRepository.getObjects()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
// Detail screen ViewModel
@KoinViewModel
class DetailViewModel(private val museumRepository: MuseumRepository) : ViewModel() {
fun getObject(objectId: Int): StateFlow<MuseumObject?> {
return museumRepository.getObjectById(objectId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}
}
Declare them in a module:
@ComponentScan
@Module
class ViewModelModule
The @KoinViewModel annotation automatically registers these as ViewModel definitions, and @ComponentScan discovers them.
For platform-specific components (Android vs iOS):
@ComponentScan
@Module
class PlatformComponentModule
Combine all modules:
@Configuration
@Module(includes = [DataModule::class, ViewModelModule::class, PlatformComponentModule::class])
class AppModule
@Configuration - Enables automatic module discovery with @KoinApplication@Module(includes = [...]) - Declares which modules to includeCreate a @KoinApplication object:
@KoinApplication
object KoinApp
fun initKoin(configuration: KoinAppDeclaration? = null) {
KoinApp.startKoin {
includes(configuration)
}
val platformInfo = KoinPlatform.getKoin().get<PlatformComponent>().getInfo()
println("Running on: $platformInfo")
}
The @KoinApplication annotation generates a startKoin() extension function that automatically loads all modules.
All the common Compose app is located in
commonMainfromcomposeAppGradle module
The ViewModels are injected using koinViewModel() in Compose:
@Composable
fun App() {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
) {
Surface {
val navController: NavHostController = rememberNavController()
NavHost(navController = navController, startDestination = ListDestination) {
composable<ListDestination> {
val vm = koinViewModel<ListViewModel>()
ListScreen(viewModel = vm, navigateToDetails = { objectId ->
navController.navigate(DetailDestination(objectId))
})
}
composable<DetailDestination> { backStackEntry ->
val vm = koinViewModel<DetailViewModel>()
DetailScreen(
objectId = backStackEntry.toRoute<DetailDestination>().objectId,
viewModel = vm,
navigateBack = { navController.popBackStack() }
)
}
}
}
}
}
:::info
The koinViewModel() function retrieves ViewModel instances automatically registered via @KoinViewModel.
:::
In Android, Koin is initialized from the main entry point:
// Call from Android entry point
initKoin()
All the iOS app is located in
iosAppfolder
In iOS, initialize Koin from the SwiftUI App entry point:
@main
struct iOSApp: App {
init() {
KoinKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
The Compose UI is started with:
fun MainViewController() = ComposeUIViewController { App() }
Here's how the annotations approach compares to the Compiler Plugin DSL:
With Annotations (compose-annotations/):
@Configuration
@Module(includes = [DataModule::class, ViewModelModule::class])
class AppModule
@Single
class MuseumRepository(api: MuseumApi, storage: MuseumStorage)
@KoinViewModel
class ListViewModel(repository: MuseumRepository) : ViewModel()
// Start Koin
KoinApp.startKoin()
Compiler Plugin DSL (compose/):
val appModule = module {
includes(dataModule, viewModelModule)
}
val dataModule = module {
single<MuseumRepository>() withOptions { createdAtStart() }
}
val viewModelModule = module {
viewModel<ListViewModel>()
}
// Start Koin
startKoin { modules(appModule) }
Both approaches achieve the same result:
single<T>() syntax