[Article by Matteo Somensi]
What is Compose Multiplatform?
Compose Multiplatform is a declarative UI toolkit designed to create native interfaces for multiple platforms, such as Android, iOS, Desktop, and Web, while sharing a significant portion of the codebase. You can think of it like digital LEGO blocks: build your bricks (UI components) once and reuse them to assemble different structures (applications).
Why Compose Multiplatform?
- Accelerated Time-to-Market: Reusing code across platforms reduces development time, enabling you to focus on features.
- Consistent UI: A single codebase ensures design consistency and a cohesive user experience.
- Boosted Productivity: The learning curve is relatively gentle, especially if you're already familiar with Jetpack Compose.
- Growing Community: Supported by Google and JetBrains, Compose Multiplatform has a thriving and growing ecosystem.
What Are We Building?
In this article, we'll create a simple application that:
- Fetches data from a REST API: Retrieves content from an external service.
- Uses a local database: Stores data locally for offline functionality.
- Delivers a seamless UI: Built entirely with Compose Multiplatform.
Desktop | Android |
---|---|
The architecture we'll use
We’ll follow the Presentation-Domain-Data (PDD) architecture.
This architectural pattern clearly separates the concerns of our application into three distinct layers: Presentation, Domain, and Data. The Presentation layer handles the user interface, the Domain layer encapsulates the business logic, and the Data layer manages data access. By adhering to this pattern, we'll create a more maintainable and scalable application.
Here's a breakdown of each layer:
- Presentation layer: This layer is responsible for the user interface and user interactions. In our case, this will be implemented using Compose Multiplatform.
- Domain layer: This layer contains the core business logic of the application. It defines entities, use cases, and rules related to the domain.
- Data layer: This layer handles data access, such as fetching data from a REST API or storing data in a local database.
By separating these concerns, we gain the following benefits:
- Improved Maintainability: Changes in one layer are isolated from the others.
- Better Scalability: The app can grow more easily to meet evolving requirements.
- Easier Testing: Each layer can be tested independently.
Let's get started
First, let’s review the structure of a Compose Multiplatform (CMP) project. Unlike a typical Kotlin Multiplatform (KMP) project, the UI is also defined in the common module.
To create the project, use the JetBrains wizard since CMP project creation isn’t yet integrated into IntelliJ IDEA.
Structure of a Compose Multiplatform Project
A Compose Multiplatform (CMP) project typically has a well-defined structure to organize code and resources across different platforms. Here's a breakdown of the common directory structure:
- Root directory:
- build.gradle.kts: The main build script for the project, defining modules, dependencies, and build configurations.
- gradle: Contains Gradle-specific files and scripts.
- settings.gradle.kts: Specifies the root project and includes subprojects.
- composeApp: Contains the common code shared across all platforms, including business logic, data models, and UI components that can be rendered on different platforms.
- commonMain: Contains the core business logic, data models, and platform-independent UI components.
- androidMain: Contains Android-specific implementations or overrides.
- iosMain: Contains iOS-specific implementations or overrides.
- desktopMain: Contains desktop-specific implementations or overrides.
Plugins and Dependencies
Plugins and libraries play a crucial role in Compose Multiplatform development. The libs.versions.toml file specifies the tools we’ll use:
- Ktor: For network requests.
- Room (SQLite): For local database storage.
- Navigation Compose: For managing app navigation.
- Kotlin Coroutines: For asynchronous operations.
- Compose: To build the UI.
- Coil: For fetching and displaying images from the network.
- Kotlin Serialization: For JSON parsing.
Note: We’ll use the Marvel Comics API to fetch data, which requires API keys. For more information, visit Marvel Comics Developer Portal.
The build.gradle.kts file defines the project's build configurations, including the necessary plugins for compilation and external dependencies. Here, we'll find both dependencies shared across all platforms (like Ktor or Compose) and those specific to a particular platform (e.g., native Koin dependencies for Android or iOS).
plugins {
alias(libs.plugins.kotlinMultiplatform)
...
sourceSets {
val desktopMain by getting
androidMain.dependencies {
implementation(compose.preview)
...
commonMain.dependencies {
implementation(compose.runtime)
...
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
...
nativeMain.dependencies {
...
dependencies {
...
}
...
The Domain Layer
The Domain Layer represents the core of the app, encapsulating the business logic and shared data models. Kotlin data classes are used to define these models concisely and safely.
To make future modularization easier, we organize features into distinct packages. For example, the Character feature will include a domain package housing the Character
data class and the CharacterRepository
, which bridges the Presentation layer with business logic.
The Presentation Layer
After defining the domain classes, we’ll build the user interface using the Model-View-Intent (MVI) pattern. In this pattern, the View is represented by a composable function, the Model is a ViewModel (a successful concept in Android that we're bringing into the multiplatform world).
Why MVI?
Unidirectional Data Flow: MVI promotes a unidirectional data flow, making it easier to reason about the application's state and handle side effects.
Testability: Separating concerns makes it easier to write unit tests for both the View and the ViewModel.
Scalability: As the application grows, MVI helps maintain a clear and organized architecture.
// CharacterListAction.kt
sealed interface CharacterListAction {
data class OnSearchQueryChange(val query: String) : CharacterListAction
data class OnCharacterClick(val character: Character) : CharacterListAction
data class OnTabSelected(val index: Int) : CharacterListAction
}
// CharacterListState.kt
data class CharacterListState(
val searchQuery: String = "",
val searchResults: List<Character> = emptyList(),
val favoriteCharacters: List<Character> = emptyList(),
val isLoading: Boolean = true,
val selectedTabIndex: Int = 0,
val errorMessage: UiText? = null
)
// CharacterListViewModel.kt
class CharacterListViewModel(
private val characterRepository: CharacterRepository
) : ViewModel() {
private var cachedCharacters = emptyList<Character>()
private var searchJob: Job? = null
private var observeFavoriteJob: Job? = null
private val _state = MutableStateFlow(CharacterListState())
val state = _state
.onStart {
if (cachedCharacters.isEmpty()) {
observeSearchQuery()
}
observeFavoriteCharacters()
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000L),
_state.value
)
fun onAction(action: CharacterListAction) {
when (action) {
is CharacterListAction.OnCharacterClick -> {
}
is CharacterListAction.OnSearchQueryChange -> {
_state.update {
it.copy(searchQuery = action.query)
}
}
is CharacterListAction.OnTabSelected -> {
_state.update {
it.copy(selectedTabIndex = action.index)
}
}
}
}
...
// CharacterListScreen.kt
@Composable
fun CharacterListScreenRoot(
viewModel: CharacterListViewModel = koinViewModel(),
onCharacterClick: (Character) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
CharacterListScreen(
state = state,
onAction = { action ->
when (action) {
is CharacterListAction.OnCharacterClick -> onCharacterClick(action.character)
else -> Unit
}
viewModel.onAction(action)
}
)
}
The Data Layer
The Data Layer focuses on retrieving data from external sources and managing local storage. We implement a feature-specific repository as defined by the domain interface:
interface CharacterRepository {
suspend fun searchCharacters(query: String): Result<List<Character>, DataError.Remote>
suspend fun getCharacterDescription(characterId: String): Result<String?, DataError>
fun getFavoriteCharacters(): Flow<List<Character>>
fun isCharacterFavorite(id: String): Flow<Boolean>
suspend fun markAsFavorite(character: Character): EmptyResult<DataError.Local>
suspend fun deleteFromFavorites(id: String)
}
The implementation will abstract the retrieval of data from the network and the saving of the favorites list to a local database.
What is Ktor?
Ktor is a lightweight and flexible framework designed for creating connected applications in Kotlin. It’s ideal for building web services, HTTP clients, and other applications requiring efficient network communication. Since Ktor is built on Kotlin coroutines, it handles asynchronous operations seamlessly.
In our core package, we define an HTTP client factory using Ktor’s DSL to configure the client. This includes setting up the contentNegotiation responsible for parsing, customize timeouts, and add an interceptor for logging:
object HttpClientFactory {
fun create(engine: HttpClientEngine): HttpClient {
return HttpClient(engine) {
install(ContentNegotiation) {
json(
json = Json {
ignoreUnknownKeys = true
}
)
}
install(HttpTimeout) {
socketTimeoutMillis = 20_000L
requestTimeoutMillis = 20_000L
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
println(message)
}
}
level = LogLevel.ALL
}
defaultRequest {
contentType(ContentType.Application.Json)
}
}
}
}
What is Koin?
Koin is a dependency injection (DI) framework specifically designed for Kotlin. It aims to be simple, lightweight, and easy to use, while still providing the benefits of DI, such as loose coupling, testability, and maintainability.
We use Koin to supply platform-specific implementations for the HttpClientEngine
(OkHttp for Android/Desktop and Darwin for iOS) and inject dependencies like our repository and database:
//initKoin.kt in CommonMain
fun initKoin(config: KoinAppDeclaration? = null) {
startKoin {
config?.invoke(this)
modules(sharedModule, platformModule)
}
}
//Modulse.kt in CommonMain
expect val platformModule: Module
val sharedModule = module {
single { HttpClientFactory.create(get()) }
singleOf(::KtorRemoteCharacterDataSource).bind<RemoteCharacterDataSource>()
singleOf(::DefaultCharacterRepository).bind<CharacterRepository>()
single {
get<DatabaseFactory>().create()
.setDriver(BundledSQLiteDriver())
.build()
}
single { get<FavoriteCharacterDatabase>().favoriteCharacterDao }
viewModelOf(::CharacterListViewModel)
viewModelOf(::CharacterDetailViewModel)
viewModelOf(::SelectedCharacterViewModel)
}
//Modules.android.kt in AndroidMain
actual val platformModule: Module
get() = module {
single<HttpClientEngine> { OkHttp.create() }
single { DatabaseFactory(androidApplication()) }
}
What is Room?
Room is an abstraction layer over SQLite that simplifies database management in Android applications. It’s part of the Android Jetpack architecture components and offers features like type safety, compile-time verification, and ease of use.
Room generates boilerplate code at compile time based on your entity, DAO, and database definitions, letting you focus on higher-level logic.
expect/actual CharacterDatabaseConstructor Object
In a Compose Multiplatform project, certain functionalities, such as database creation, require platform-specific implementations. Kotlin's expect
/actual
mechanism facilitates this by allowing you to define a shared interface (expect
) in the common module and provide corresponding platform-specific implementations (actual
) in the target modules.
For instance, the CharacterDatabaseConstructor
object is declared as expect
in the shared module. Its responsibility is to create an instance of the database. Each target platform then provides its actual
implementation.
- On Android, the
actual
implementation uses Room'sRoom.databaseBuilder()
to handle database creation efficiently. - For iOS or other platforms, the implementation might leverage an alternative database solution or use an in-memory database, particularly for testing purposes.
This approach ensures that the database creation logic is tailored to the specific requirements and constraints of each platform while maintaining a consistent interface in the shared module.
Abstract FavoriteCharacterDatabase
The FavoriteCharacterDatabase
class is an abstract representation of the application's database, annotated with @Database
. It specifies:
- Entities: The data models that correspond to tables in the database.
- DAOs: Data Access Objects that provide methods for interacting with the database.
By extending RoomDatabase
, the class inherits Room's built-in functionalities for managing the database lifecycle and transactions. You define abstract methods in this class to access DAOs, and Room automatically generates the implementation for these methods at compile time.
DAO (Data Access Object)
DAOs, annotated with @Dao
, are interfaces that define methods for accessing and modifying data in the database.
You use annotations like @Query, @Insert, @Update, and @Delete to define SQL queries that Room will execute. DAOs provide a convenient and type-safe way to interact with your database.
Taking the favorite characters database as an example, this database must be created using the platform-specific factory. We then specify the DAO for the relevant table, which is mapped to the CharacterEntity entity.
// CharacterEntity.kt in CommonMain
@Entity
data class CharacterEntity(
@PrimaryKey(autoGenerate = false)
val id: String,
val name: String,
val description: String,
val imageUrl: String
)
// CharacterDatabaseConstructor.jt in CommonMain
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object CharacterDatabaseConstructor : RoomDatabaseConstructor<FavoriteCharacterDatabase> {
override fun initialize(): FavoriteCharacterDatabase
}
// FavoriteCharacterDatabase.kt in CommonMain
@Database(
entities = [CharacterEntity::class],
version = 1
)
@TypeConverters(
StringListTypeConverter::class
)
@ConstructedBy(CharacterDatabaseConstructor::class)
abstract class FavoriteCharacterDatabase: RoomDatabase() {
abstract val favoriteCharacterDao: FavoriteCharacterDao
companion object {
const val DB_NAME = "marvel.db"
}
}
// FavoriteCharacterDao.kt in CommonMain
@Dao
interface FavoriteCharacterDao {
@Upsert
suspend fun upsert(character: CharacterEntity)
@Query("SELECT * FROM CharacterEntity")
fun getFavoriteCharacters(): Flow<List<CharacterEntity>>
@Query("SELECT * FROM CharacterEntity WHERE id = :id")
suspend fun getFavoriteCharacter(id: String): CharacterEntity?
@Query("DELETE FROM CharacterEntity WHERE id = :id")
suspend fun deleteFavoriteCharacter(id: String)
}
// DatabaseFactory.kt in CommonMain
expect class DatabaseFactory {
fun create(): RoomDatabase.Builder<FavoriteCharacterDatabase>
}
// DatabaseFactory.kt in AndroidMain
actual class DatabaseFactory(
private val context: Context
) {
actual fun create(): RoomDatabase.Builder<FavoriteCharacterDatabase> {
val appContext = context.applicationContext
val dbFile = appContext.getDatabasePath(FavoriteCharacterDatabase.DB_NAME)
return Room.databaseBuilder(
context = appContext,
name = dbFile.absolutePath
)
}
}
To summarize, the CharacterRepository
has a concrete implementation named DefaultCharacterRepository
. Koin injects the RemoteDataSource (a specific implementation leveraging Ktor) and the FavoriteCharacterDao into this implementation, enabling it to fetch and update the list of favorite characters stored in the database. The DAO itself is also provided by Koin via dependency injection.
The App and The Navigation
The entry point for the application on each platform is their respective main functions.
However, all these functions do is utilize the root composable, which is defined in the Common Main module.
This root composable leverages the composables available in the Material library (which is continuously growing to include what already exists for the Android world).
The latest addition is navigation! It is now possible to use NavHost to define the routes within our application.
// App.kt in Common Main
@Composable
@Preview
fun App() {
MaterialTheme {
val navController = rememberNavController()
NavHost(
navController = navController, startDestination = Route.CharacterGraph
) {
navigation<Route.CharacterGraph>(
startDestination = Route.CharacterList
) {
composable<Route.CharacterList>
...
}
composable<Route.CharacterDetail
>...
}
}
}
}
}
...
// main.kt in Desktop Main
fun main() = application {
initKoin()
Window(
onCloseRequest = ::exitApplication,
title = "Cmp Heroes",
) {
App()
}
}
For ease of use, the routes are defined with a sealed class.
// Route.kt in Common Main
sealed interface Route {
@Serializable
data object CharacterGraph: Route
@Serializable
data object CharacterList: Route
@Serializable
data class CharacterDetail(val id: String): Route
}
Conclusion
As demonstrated in this article, Compose Multiplatform provides developers with a powerful toolkit for building cross-platform applications using a shared codebase. By leveraging Kotlin's capabilities and the Compose UI framework, we have created an application where most of the logic, including UI and navigation, resides within the commonMain
module.
The core functionality and user interface are implemented in a platform-agnostic manner, significantly reducing code duplication and lowering development time and cost. This allows developers to focus on building features rather than rewriting logic for each platform. Platform-specific folders contain only minimal code for tasks like database creation and the initialization of Koin for dependency injection, ensuring seamless integration with platform services.
This approach streamlines development while fostering a consistent and unified user experience across all platforms. By centralizing logic and UI in the shared module, Compose Multiplatform achieves high code reusability and maintainability, with platform-specific code limited to essential integrations.
Compose Multiplatform is a compelling solution for cross-platform development, enabling efficient code sharing while supporting platform-specific functionalities. As the ecosystem grows, it holds great promise for creating universal applications that are both efficient and adaptable.
Top comments (0)