DEV Community

Atlantis
Atlantis

Posted on

Cross-Platform UI Development with Jetpack Compose Multiplatform

[Article by Matteo Somensi]

commercial

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.

platforms

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
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.

architecture

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.

plugins

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 {
            ...
    }
    ...
Enter fullscreen mode Exit fullscreen mode

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.

mvi

// 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)
        }
    )
}
Enter fullscreen mode Exit fullscreen mode

The Data Layer

data

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()) }
    }

Enter fullscreen mode Exit fullscreen mode

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's Room.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
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}

Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)