📕 KMP for Mobile Native Developers: The Book.

by Santiago Mattiauda.

Content

Chapter 1: Introduction to Kotlin Multiplatform

What is Kotlin Multiplatform?

Kotlin Multiplatform is a technology that simplifies cross-platform development by allowing code sharing between different platforms, reducing development and maintenance time while maintaining the advantages of native programming.

Developed by JetBrains, this technology enables developers to write code in Kotlin and share it across Android, iOS, web, and desktop.

Developers can share business logic, data models, and other components across platforms, minimizing code duplication and facilitating maintenance. While not all elements can be shared due to inherent platform differences, Kotlin Multiplatform provides tools and libraries to optimize the amount of shared code.

This code-sharing capability not only reduces development and maintenance time but also preserves the flexibility and advantages of native programming.

Code Sharing Across Platforms

Kotlin Multiplatform enables maintaining a single codebase for application logic across different platforms. Additionally, it leverages the advantages of native programming, including high performance and full access to native SDKs.

Kotlin offers two main mechanisms for sharing code:

Strategies for Sharing Our Code

Share a Piece of Logic

We can start by sharing an isolated and critical part of the application, reusing existing Kotlin code to keep applications in sync.

This strategy aims to share the smallest and most significant logical unit of our application. What is a 'logical unit'? It's a portion of our application that solves a specific problem—such as validations or use cases—and is platform-independent. It's essential to promote or aspire to a good base design from the beginning.

Share Logic and Keep Native UI

When starting a new project, consider using Kotlin Multiplatform to implement data handling and business logic just once. Keep the user interface native to meet the most demanding requirements. While ideal for new projects, we can also leverage existing Android code in Kotlin, reusing already developed implementations.

The strategy is to maintain user interfaces in native frameworks, as they are crucial for user experience, while sharing the application's logic and infrastructure.

Share up to 100% of the code

Share up to 100% of your code with Compose Multiplatform, a modern declarative framework for creating user interfaces across multiple platforms.

With Compose Multiplatform, you can develop shared user interfaces for all platforms. While this technology is still evolving, it represents a promising option for new mobile application projects.

In its current state, Compose Multiplatform implements Material Design principles, which may have some limitations compared to iOS's Design System. However, the JetBrains team is developing Cupertino support (Apple's Design System) for future versions.

How to Really Benefit from Code Sharing

So far, we've explored the theoretical promises of Kotlin Multiplatform, but now let's examine its practical benefits and the libraries we can use when starting or migrating our development.

What Parts of Your Code Could You Share Across Platforms?

In 2021, JetBrains conducted a survey asking: "What parts of your code were you able to share across platforms?". While there isn't precise data about KMP adoption at that time, the survey results appear consistent when examining software architecture and design principles.

What parts of your code were you able to share across platforms?

This is why it's important to establish a good design, as we form a common team language when implementing solutions, regardless of the platform.

Defining an Architecture

When talking about architecture, we naturally think of Clean Architecture. This methodology consists of architectural patterns that separate frameworks and external elements from our domain and business logic. To implement it, we need to understand three fundamental concepts:

Domain

Fundamental concepts of our context (User, Product, Cart, etc.) and business rules defined exclusively by us (domain services).

Application

The layer where our application use cases reside (register user, publish product, add product to cart, etc.).

Infrastructure

Code that varies according to external decisions. This layer contains the implementations of interfaces defined at the domain level. We use the Dependency Inversion Principle (DIP) from SOLID to decouple from external dependencies.

This is where frameworks and external components are integrated, such as Repositories, HTTP Clients, and Caches.

Components in an application design

Since our domain and application layers exclusively encapsulate business logic, they constitute the main code to be shared across platforms. Without this code-sharing capability, we would have to duplicate specifications in both Kotlin and Swift for each respective platform.

Infrastructure

In this layer, we can safely implement native solutions. Following the established definitions, we use the Dependency Inversion Principle (DIP) from SOLID to decouple our external dependencies. To achieve this, we define contracts using Kotlin Multiplatform's (KMP) expect-actual pattern.

Networking Example

Let's look at an example of how to use native Android and iOS clients to make network requests.

To begin, we'll define our repository and a remote data source. For the data source, we'll create an interface that will have two implementations: one for Android and another for iOS, as shown in the following image.

For Android, we'll use Retrofit, and for iOS, we'll use URLSession.

Let's see how to implement this in code.

First, we'll define an expect function that will provide a platform-specific implementation of the data sources.

expect fun provideGameDataSource(): GameRemoteDataSources

From here, we'll create our repository and implement the corresponding data sources

class GameRepository(
        private val remoteDataSources: GameRemoteDataSources = provideGameDataSource(),
    ) {
    
        suspend fun fetch(): Result<GameResponse> {
            return remoteDataSources.getGames()
        }
    }

Android Implementation

actual fun provideGameDataSource(): GameRemoteDataSources {
        return AndroidGameRemoteDataSources()
    }
    
    class AndroidGameRemoteDataSources : GameRemoteDataSources {
    
        private val client = RetrofitClient(baseUrl)
        private val services = client.create<GameServices>()
    
        override suspend fun getGames(): Result<GameResponse> {
            return runCatching {
                val games = services.getGames()
                val jsonElement = Json.parseToJsonElement(games)
                Json.decodeFromJsonElement<GameResponse>(jsonElement)
            }
        }
    
        companion object {
            private const val baseUrl = "https://www.freetogame.com/api/"
        }
    }

iOS Implementation

actual fun provideGameDataSource(): GameRemoteDataSources {
        return IOSGameRemoteDataSources()
    }
    
    class IOSGameRemoteDataSources : GameRemoteDataSources {
    
        private val client = URLSessionClient()
    
        override suspend fun getGames(): Result<GameResponse> {
            return runCatching {
                val jsonString = client.fetch(baseUrl)
                Json.decodeFromString<GameResponse>(jsonString)
            }
        }
    
    
        companion object {
            private const val baseUrl = "https://www.freetogame.com/api/games"
        }
    }

Once these configurations are implemented, our KMP project will have the following structure

💡
Spoiler Alert: In the next chapter, we'll explore the structure of a Kotlin Multiplatform project.

As we mentioned that "we'll rely on SOLID's DIP to decouple from external dependencies", we can also use native code, that is, code implemented directly in the platform where we're using our multiplatform code. Let's look at an example of this in iOS with Swift.

Since GameRemoteDataSource is an interface in Kotlin, it translates to a protocol in Swift.

class SwiftGameRemoteDataSources: GameRemoteDataSources {
        
        func getGames() async throws -> Any? {
            
            guard let url = URL(string: "https://www.freetogame.com/api/games") else {
                throw GameServiceError.invalidURL
            }
            let (data, _) = try await URLSession.shared.data(from: url)
            let result = try JSONDecoder().decode(GameResponse.self, from: data)
            return result.map{ item in item.asDomainModel() }
        }
        
    }

Note that type compatibility is lost here, as we can see in the getGames function signature which returns Any?

We can implement this as follows

import Shared
    
    @Observable
    class GameViewModel{
        
       let repository = GameRepository(remoteDataSources: SwiftGameRemoteDataSources())
        
        var data:String = ""
        
        func load(){
            self.repository.fetch(completionHandler:{response,_ in
                self.data = "\(String(describing: response))"
            })
        }
    }

GitHub - santimattius/kmp-for-mobile-native-developers at feature_01_expect_actual
KMP for Mobile Native Developers. Contribute to santimattius/kmp-for-mobile-native-developers development by creating an account on GitHub.
https://github.com/santimattius/kmp-for-mobile-native-developers/tree/feature_01_expect_actual

While sharing business logic is valuable in Kotlin Multiplatform, reusing native code at the infrastructure level can be complex to maintain due to platform differences. For example, when handling preferences, Android requires a Context while iOS uses UserDefaults, creating platform-specific dependencies.

To address this, we'll explore multiplatform libraries that offer common abstractions for storage, networking, and other essential functionalities, enabling more consistent implementation and greater code reuse.

In the next section, we'll analyze the basic structure of a Kotlin Multiplatform project, including modules, source sets, and targets - fundamental elements for developing efficient multiplatform applications.

Chapter 2: Understanding the Basic Project Structure

In this section, we'll look at the structure of a multiplatform project and the fundamental concepts introduced when sharing code in KMP.

Each Kotlin Multiplatform project includes three modules:

The shared module consists of three source sets: androidMain, commonMain, and iosMain. A "source set" is a grouping of related files in Gradle, where each set handles its own dependencies. In Kotlin Multiplatform, these source sets can target different platforms within the shared module. The common set contains the Kotlin code that is shared, while the platform-specific sets implement specialized Kotlin code for each target. In the case of androidMain, Kotlin/JVM is used, and for iosMain, Kotlin/Native.

When the shared module is integrated as an Android library, the common Kotlin code is compiled to Kotlin/JVM. However, when integrated as an iOS framework, this same code is compiled to Kotlin/Native.

Let's now dive deeper into Source sets and Targets, and how they indicate which platforms we can share our code with.

Basic Concepts of Kotlin Multiplatform Project Structure

Targets

Targets define the specific platforms for which Kotlin will compile the shared code, such as Android and iOS in mobile projects.

In KMP, a target is an identifier that specifies the type of compilation. It determines the format of generated binary files, available language constructs, and dependencies that can be used.

kotlin {
        androidTarget {
            compilations.all {
                kotlinOptions {
                    jvmTarget = JavaVersion.VERSION_1_8.toString()
                }
            }
        }
    
        listOf(
            iosX64(),
            iosArm64(),
            iosSimulatorArm64()
        ).forEach { iosTarget ->
            iosTarget.binaries.framework {
                baseName = "Shared"
                isStatic = true
            }
        }
    }

As we can see in the code above, targets can specify particular configurations for each platform. For example, for Android we are indicating in kotlinOptions that the jvmTarget should be Java 1.8.

There is a default hierarchy within the targets, where if our definition is the following

kotlin {
        androidTarget()
        iosArm64()
        iosSimulatorArm64()
    }

The resulting source sets hierarchy is as follows

The "source sets" shown in green are created and active in the project, while those in gray from the default template are ignored. For example, the Kotlin Gradle plugin doesn't generate code for watchOS because the project has no targets defined for this platform.

The basics of Kotlin Multiplatform project structure | Kotlin
With Kotlin Multiplatform, you can share code among different platforms. This article explains the constraints of the shared code, how to distinguish between shared and platform-specific parts of your code, and how to specify the platforms on which this shared code works.
https://kotlinlang.org/docs/multiplatform-discover-project.html?_gl=1*uurev0*_ga*MTUxMDA4MTM3LjE2ODM3MjI2MjU.*_ga_9J976DJZ68*MTcwODAzNjc4NS4zOC4xLjE3MDgwMzcwNDQuMjEuMC4w&_ga=2.151554105.102716348.1708036787-151008137.1683722625#targets

Next, we'll see how to access these source sets and how to define specific dependencies within them.

Source sets

A Kotlin source set is a collection of files that share targets, dependencies, and compilation configurations. It is the main mechanism for sharing code in multiplatform projects.

Each source set in a multiplatform project:

Kotlin offers several predefined source sets. Among them, commonMain stands out, as it is present in all multiplatform projects and brings together all declared targets.

In the src directory of our shared module, we'll find the defined source sets. For example, in a project with commonMain, iosMain, and androidMain, the source sets are structured as follows:

In Gradle scripts, source sets are accessed by name within the kotlin.sourceSets {} block:

kotlin {
    
            // Targets
        androidTarget {
            compilations.all {
                kotlinOptions {
                    jvmTarget = JavaVersion.VERSION_1_8.toString()
                }
            }
        }
    
        listOf(
            iosX64(),
            iosArm64(),
            iosSimulatorArm64()
        ).forEach { iosTarget ->
            iosTarget.binaries.framework {
                baseName = "Shared"
                isStatic = true
            }
        }
    
            // Sourcets
        sourceSets {
            commonMain.dependencies {
                            //.....
            }
    
            androidMain.dependencies {
               //.....
            }
    
            iosMain.dependencies {
                //.....
            }
        }
    }

Within our source sets, we can define platform-specific code for each supported platform.

The basics of Kotlin Multiplatform project structure | Kotlin
With Kotlin Multiplatform, you can share code among different platforms. This article explains the constraints of the shared code, how to distinguish between shared and platform-specific parts of your code, and how to specify the platforms on which this shared code works.
https://kotlinlang.org/docs/multiplatform-discover-project.html#source-sets

Advanced Concepts of Multiplatform Project Structure

In this section, we'll explore some advanced concepts of the Kotlin Multiplatform project structure and how they relate to Gradle implementation. This information will be useful if you need to work with low-level abstractions of Gradle builds (configurations, tasks, publications, and others) or if you're creating a Gradle plugin for Kotlin Multiplatform builds.

DependsOn

dependsOn is a specific relationship in Kotlin that connects two source sets. This connection can occur between common and platform-specific source sets, such as when jvmMain depends on commonMain, or iosArm64Main depends on iosMain.

To better understand how it works, let's take two Kotlin source sets A and B. When we write A.dependsOn(B), this means:

  1. A has access to B's API, including its internal declarations.
  1. A can implement B's expected declarations. This is fundamental, as A can only provide actuals for B if there is a dependsOn relationship, either direct or indirect.
  1. B must compile for all of A's targets, in addition to its own targets.
  1. B inherits all of A's regular dependencies.

This dependsOn relationship generates a tree-like hierarchical structure between source sets.

kotlin {
        // Targets declaration
        sourceSets {
            // Example of configuring the dependsOn relation
            iosArm64Main.dependsOn(commonMain)
        }
    }

For more information about advanced concepts of the multiplatform project structure, you can refer to theofficial Kotlin documentation.

Advanced concepts of the multiplatform project structure | Kotlin
This article explains advanced concepts of the Kotlin Multiplatform project structure and how they map to the Gradle implementation. This information will be useful if you need to work with low-level abstractions of the Gradle build (configurations, tasks, publications, and others) or are creating a Gradle plugin for Kotlin Multiplatform builds.
https://kotlinlang.org/docs/multiplatform-advanced-project-structure.html#dependencies-and-dependson

Dependencies on Other Libraries or Projects

In multiplatform projects, you can configure dependencies from both published libraries and other Gradle projects.

Dependency configuration in Kotlin Multiplatform follows a structure similar to Gradle, where:

Dependency configuration in multiplatform projects has a distinctive feature: each Kotlin source set has its own dependencies {} block, allowing you to declare platform-specific dependencies.

kotlin {
        // Targets declaration
        sourceSets {
            androidMain.dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
            }
        }
    }

Let's consider a multiplatform project that uses a multiplatform library, such as kotlinx.coroutines:

kotlin {
        androidTarget()     // Android
        iosArm64()          // iPhone devices
        iosSimulatorArm64() // iPhone simulator on Apple Silicon Mac
    
        sourceSets {
            commonMain.dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
            }
        }
    }

For more information about multiplatform dependencies, you can refer to theKotlin Multiplatform Dependencies documentation.

Advanced concepts of the multiplatform project structure | Kotlin
This article explains advanced concepts of the Kotlin Multiplatform project structure and how they map to the Gradle implementation. This information will be useful if you need to work with low-level abstractions of the Gradle build (configurations, tasks, publications, and others) or are creating a Gradle plugin for Kotlin Multiplatform builds.
https://kotlinlang.org/docs/multiplatform-advanced-project-structure.html#dependencies-on-other-libraries-or-projects

Dependency Resolution

In the dependency resolution process for multiplatform projects, three fundamental aspects stand out:

  1. Multiplatform Dependencies Propagation: Dependencies declared in the commonMain source set automatically propagate to other source sets with dependsOn relationships. For example, a dependency added to commonMain extends to iosMain, jvmMain, iosSimulatorArm64Main, and iosX64Main. This prevents duplication and simplifies dependency management.
  1. Intermediate and Final State of Dependency Resolution: The commonMain source set acts as an intermediate state in dependency resolution, while platform-specific source sets represent the final state. After resolution, each multiplatform library is structured as a collection of its individual source sets, allowing for more precise management and ensuring project coherence.
  1. Resolution of Dependencies by Compatible Targets: Kotlin ensures that a dependency's source sets are compatible with those of the consumer. For example, if a source set compiles for androidTarget, iosX64, and iosSimulatorArm64, the dependency must offer source sets compatible with these targets. This ensures dependencies work across all target platforms.

In summary, dependency resolution in multiplatform projects is based on three pillars: automatic propagation from commonMain, management of intermediate and final states, and compatibility between dependencies and consumers. This system ensures efficient and coherent management in Kotlin multiplatform projects.

Sharing Code Across Platforms

If you have business logic common to all platforms, you can avoid code duplication by sharing it in the common source set.

Some dependencies between source sets are established automatically, eliminating the need to manually specify dependsOn relationships:

To access platform-specific APIs from shared code, use Kotlin's expect/actual declarations mechanism, which we explored in the previous post.

Sharing Code Across Similar Platforms

Multiplatform projects often require creating multiple native targets that can reuse much of the common logic and third-party APIs.

A common example is in iOS projects, where two targets are needed: one for iOS ARM64 devices and another for the x64 simulator. While these have separate specific source sets, they rarely require different code between them, and their dependencies are very similar. This allows sharing iOS-specific code between both targets.

Therefore, it's advantageous to have a shared set of sources for both iOS targets, allowing Kotlin/Native code to directly access the common APIs of both iOS device and simulator.

To implement this, you can share code between native targets using the hierarchical structure through two options:

Chapter 3: Dependency Injection

In software development, dependency management is essential. Dependency Injection (DI) is a design pattern that allows application components to receive their dependencies instead of creating them internally. This promotes modularization, facilitates code reuse, and simplifies unit testing. In Kotlin Multiplatform (KMP), DI is fundamental to ensure code cohesion and portability across different platforms.

Benefits of Dependency Injection

Although JetBrains does not provide a native dependency injection solution for the multiplatform ecosystem, they recommend using existing community-developed solutions.

Jetbrains tweet about Dependency Injection tip (link)

Implementing Dependency Injection in KMP

To implement Dependency Injection (DI) in Kotlin Multiplatform, there are several libraries and approaches available. Each offers different advantages and disadvantages:

Let's examine each of these options in detail.

Kodein

Kodein is a dependency injection (DI) library for Kotlin. It's designed to simplify dependency management in Kotlin applications across multiple platforms: Android, iOS, Web, and Backend. Thanks to its simple syntax and straightforward configuration, it has become a popular choice for Kotlin Multiplatform (KMP) projects.

Main Features of Kodein

  1. Concise and Declarative Syntax: Kodein uses an intuitive syntax that facilitates the definition and resolution of dependencies, improving code readability and maintainability.
  1. Platform Independent: Being developed purely in Kotlin, Kodein works on any platform without worrying about compatibility with specific frameworks or libraries.
  1. Kotlin Coroutines Support: Kodein integrates perfectly with Kotlin Coroutines, facilitating dependency management in asynchronous and reactive code.
  1. Modular Configuration: Offers a modular system for configuring dependencies, simplifying the organization and maintenance of extensive projects.
  1. Flexible Injection: Allows dependency injection through both constructor and property, offering versatility in dependency resolution.
  1. Annotation Support: Includes annotations to identify components and configurations, improving code clarity.

Basic Usage of Kodein

To implement Kodein in a Kotlin Multiplatform project, the first step is to add the dependency to the configuration file. Then, you can define dependency modules and register application components using the Kodein API. Finally, you'll be able to resolve these dependencies in any section of the application where you need them.

Let's look at a basic code example using Kodein:

// Define a dependency module
    val appModule = DI.Module("appModule") {
        bind<Database>() with singleton { Database() }
        bind<UserRepository>() with singleton { UserRepository(instance()) }
        bind<MyService>() with singleton { MyService(instance()) }
    }
    
    // Configure the DI container
    val di = DI {
        importAll(appModule)
    }
    
    // Resolve dependencies in a class
    class MyService(private val userRepository: UserRepository) {
        // ...
    }
    
    // Use resolved dependencies
    val myService by di.instance<MyService>()

In this example, a dependency module is defined that provides a database implementation and a user repository. Then, Kodein is configured with this module and an instance of MyService that depends on the user repository is resolved.

Getting started with Kodein-DI :: Kodein Open Source Initiative Documentation
Kodein-DI is a Dependency Injection library. It allows you to bind your business unit interfaces with their implementation and thus having each business unit being independent.
https://kosi-libs.org/kodein/7.22/getting-started.html

Koin

Koin is a lightweight Dependency Injection library for Kotlin that stands out for its simplicity and ease of use. Compatible with Android, backend, and iOS, it prioritizes clean and readable code.

Main Features of Koin

  1. Non-Intrusive: Uses pure Kotlin functions, integrating naturally without modifying existing architecture.
  1. Clear Syntax: Employs a simple DSL that facilitates dependency definition and resolution.
  1. Optimized Performance: By not using reflection, it improves performance and is compatible with iOS/Kotlin Native.
  1. Flexibility: Allows constructor and property injection, adapting to different needs.
  1. Lifecycle Control: Manages scopes to control instance creation and destruction.
  1. High Compatibility: Easily integrates with Kotlin frameworks and libraries.

Basic Usage of Koin

To implement Koin in a Kotlin Multiplatform project, follow these steps: first, add the Koin dependency to the project configuration file. Then, define dependency modules and register application components using the Koin API. Finally, resolve dependencies where you need them in your application.

Let's look at a basic implementation example with Koin:

In this example, a dependency module is defined that provides a database, a user repository, and a ViewModel. Then, Koin is configured with this module and an instance ofMyViewModel, which depends on the user repository, is obtained.

// Define a dependency module
    val myModule = module {
        single { Database() }
        single { UserRepository(get()) }
        factory { MyViewModel(get()) }
    }
    
    // Configure Koin with the defined module
    startKoin {
        modules(myModule)
    }
    
    // Resolve dependencies in a class
    class MyActivity : AppCompatActivity() {
        private val viewModel: MyViewModel by viewModel()
        // ...
    }
Koin - The Kotlin Dependency Injection Framework
The Kotlin Dependency Injection Framework
https://insert-koin.io/

Manual Dependency Injection in Kotlin Multiplatform

Manual Dependency Injection (DI) is a simple alternative for managing dependencies in KMP projects without using external libraries. This approach involves creating and injecting dependencies directly into the classes that need them.

Basic Principles

Implementation

  1. Identify: Determine which dependencies each class needs.
  1. Create: Generate instances at the top level using patterns like Singleton.
  1. Inject: Provide dependencies through constructors.
  1. Manage: Handle the lifecycle of dependencies.

Advantages and Challenges

Key Considerations

  1. Design: Create clear interfaces following SOLID principles.
  1. Lifecycle: Properly manage instance creation and destruction.
  1. Testing: Facilitate unit testing through mocks.
  1. Scalability: Maintain a modular and structured approach.
  1. Documentation: Maintain clear documentation and effective team communication.

Off Topic: Creating our own dependency injection framework

As the title suggests, while this is a secondary topic, it represents a viable alternative when we want to avoid external dependencies for dependency injection and reduce code repetition inherent to manual injection. While we won't delve into this topic, I'd like to conclude this article by recommending the following resource about it.

DIY: your own Dependency Injection library!
Demystifying the internals of DI libraries
https://blog.p-y.wtf/diy-your-own-dependency-injection-library

Summary of the Presented Alternatives

Koin is a lightweight library for implementing Dependency Injection in Kotlin applications. It stands out for its ease of use and efficiency, being highly valued by the developer community. Its cross-platform compatibility and frequent updates make it a reliable option for Kotlin Multiplatform projects.

Kodein is a robust library for managing dependencies in Kotlin Multiplatform projects. It offers an intuitive syntax, cross-platform support, and advanced features such as Kotlin Coroutines integration. Its efficiency, flexibility, and support from an active community position it as a solid solution for dependency injection in Kotlin.

Manual Dependency Injection allows total control over dependency management in Kotlin Multiplatform projects without relying on external libraries. While it requires more implementation and maintenance effort, it is ideal for small projects or teams that prefer a direct approach. However, it is crucial to carefully evaluate project requirements and weigh the advantages and challenges before opting for manual DI in a KMP project.

Chapter 4: Modularization

Modularization has gained greater importance in the face of growing complexity in mobile applications and platform diversity. This strategy is fundamental for improving code maintainability, scalability, and reusability. In this scenario, Kotlin Multiplatform (KMP) emerges as an ideal solution for developing mobile applications across different platforms, such as Android and iOS. Let's see how to modularize a KMP (Kotlin Multiplatform Mobile) project.

Benefits of Modularization in Kotlin Multiplatform

KMP allows sharing business logic, data models, and components across various mobile applications, resulting in more efficient development and greater consistency between application versions.

Modularization in a KMP project offers several significant benefits:

  1. Code Reusability: Independent modules facilitate the reuse of components and functionalities across different parts of the application and between platforms.
  1. Maintainability: Well-defined modules simplify code understanding and maintenance. Being able to develop, test, and update each module independently speeds up development and reduces errors.
  1. Scalability: Modularization facilitates project growth, allowing modules to be added or modified without affecting existing code.
  1. Decoupling: Separation into independent modules reduces coupling between components, making the code more flexible and easier to extend.

So far, we've explored the theory behind modularization. But what strategies can we implement to make the most of cross-platform development?

Strategies for Modularizing a Kotlin Multiplatform Project

There are various strategies for modularizing a KMP project. The most common ones are:

  1. Layer-based Division: Organizes code into modules representing different architecture layers, such as presentation, business logic, and data access.
  1. Feature-based Division: Groups code related to a specific functionality into a single module, facilitating its reuse and maintenance.
  1. Platform-based Division: Separates platform-specific code into different modules, keeping shared code in a central module.
  1. Domain-based Division: Organizes code into modules representing different application domains, such as user, authentication, and purchases.

This approach is valid for both monorepos and separate repositories, as the fundamental aspects are the configurations of projects using KMP.

Modularization in Practice

When creating a KMP project, whether it's an App or Library type, a Shared module is automatically generated that will function as a shared module between both platforms: Android and iOS.

Pros

Cons

When a KMP project grows, it's common to add more shared modules besides the initial one. This happens naturally when implementing new functionalities in KMP instead of using native modules, or when teams gradually adopt this technology.

To maintain scalable and manageable code, it's recommended to split the shared module into smaller feature modules.

Let's see this represented in the following image.

Example of a book selling app

As shown in the image, we have two modules (features) that represent different flows in our application, along with a shared module (data) that these features use. Additionally, we have sub-modules to manage specific information in our application (in this case, books).

While this approach offers clear benefits in the separation of responsibilities, it also presents specific challenges when generating binaries for each platform (especially in iOS, as we'll see later).

Pros

Cons

Multiple Shared Modules

When working with multiple shared modules, there are important differences between platforms. In Android, the application can directly depend on all or some feature modules as needed, as it uses Gradle modules for its definition. On the other hand, the iOS application can only depend on a single framework generated by the Kotlin Multiplatform module. To handle multiple modules in iOS, it's necessary to create an additional module called the Umbrella module, which depends on all modules in use. This module is configured to generate a framework containing all modules, known as the Umbrella framework.

The Android application can depend on the Umbrella module to maintain consistency, or use feature modules separately. The Umbrella module typically contains utilities and dependency injection configurations.

The Umbrella framework only exports selected modules, especially when consumed as a remote dependency. This helps minimize the final artifact size and excludes unnecessary auto-generated code.

An important limitation of this approach is that the iOS application must consume all feature modules included in the Umbrella framework, without being able to select only some of them.

Why do you need an Umbrella framework?

While it's technically possible to use multiple Kotlin Multiplatform module frameworks in iOS, it's not recommended. When a module is converted to a framework, it includes all its dependencies; if these are duplicated, it not only increases the application size but can also generate conflicts and errors.

Kotlin avoids generating common framework dependencies to maintain an efficient binary and eliminate redundancies. Sharing these dependencies is not feasible because the Kotlin compiler cannot anticipate the requirements of other compilations.

The optimal solution is to implement an Umbrella framework, which prevents dependency duplication, optimizes the final result, and avoids compatibility issues.

💡
For more details about the exact limitations, please check the TouchLab documentation.

Exposing Multiple KMP Frameworks in Detail

Kotlin Multiplatform has a fundamental limitation: the iOS platform cannot access Kotlin modules individually. Instead, it generates a single framework containing all exported Kotlin classes. While it is possible to generate multiple frameworks, this practice is inefficient as it produces a larger binary and creates overhead due to the duplication of Kotlin standard library classes.

Additionally, all shared dependencies between Kotlin modules are also duplicated.

In our example, we would have something like this:

The Book entity from the Home framework and the Book entity from the Checkout framework would represent the same entity defined in the data module. However, in our iOS application, these would be treated as two different entities in different contexts, generating unnecessary duplication.

The main limitation is that, in iOS, common classes from each framework are treated as different types. Therefore, a shared data structure cannot be used interchangeably between different frameworks.

Let's see our example implementation

Let's see how to implement what we discussed above in code. We'll create a KMP project that will include the Home, Checkout, and Data modules, following the structure we've seen in the examples.

In this first implementation, we'll separate the code using frameworks in iOS and modules in Android. The interesting part will be observing the behavior of our objects when working with multiple frameworks in iOS.

To illustrate this, we'll use the following class diagram:

As we can observe, the ProcessCheckout class from the :checkout module and the GetAll class from the :home module depend on BookRepository, which is located in the :data:book module.

The structure of our project would look like this:

As we can observe in the diagram below,

Ejemplo de una app de venta de libros

Android and iOS applications will be responsible for orchestrating the modules (frameworks in iOS) through native code (Kotlin or Swift).

The implementation of multiple modules/frameworks generated from KMP modules can facilitate the gradual adoption of this technology in existing projects. However, it's important to consider the limitations mentioned above if we opt for this approach.

Let's start with Android

Our Android application will use the following dependency configuration

dependencies {
            //Checkout
        implementation(projects.checkout)
        //Home
        implementation(projects.home)
        //Data
        implementation(projects.data)
    
        implementation(libs.compose.ui)
        implementation(libs.compose.ui.tooling.preview)
        implementation(libs.compose.material3)
        implementation(libs.androidx.activity.compose)
    
        debugImplementation(libs.compose.ui.tooling)
    }

We will include the modules :checkout, :home and :data in our build.gradle.kts file.

Next, we'll see a practical example of using classes between different modules and how the references from the :data module work, which is shared between the feature modules. For this, we'll create a ViewModel that will instantiate the ProcessCheckout and GetAll classes, both sharing a common dependency: BookRepository.

Looking at the imports, we'll notice that BookRepository comes from data and is the same dependency used by both home and checkout modules. The same happens with the book entity, thus avoiding dependency duplication.

Now, let's look at the same example in iOS to verify that the behavior is different.

Like in Android, let's start by configuring our Podfile with the :home and :checkout modules in our iOS application.

Now let's implement our previous example in Swift.

import Foundation
    import home
    import checkout
    
    class MainViewModel {
        
        let bookRepository = DataBookRepository()
        let processCheckout = ProcessCheckout(repository: bookRepository)
        let getAll = GetAll(repository: bookRepository)
        
        
        func checkout() {
            let books = getAll.invoke()
            let currentBook = books.first
            
            processCheckout.invoke(book: currentBook)
        }
    }

Similar to the previous example in Kotlin, we would have a BookRepository instance that we will use in both ProcessCheckout from the checkout framework and GetAll from the home framework. However, let's look at the first issue in the following image.

💡
The compiler renames the 'BookRepository' class to 'DataBookRepository' for use in Swift.

When trying to create an instance of the DataBookRepository class, the compiler shows an "Ambiguous use of init" error. This occurs because the compiler cannot determine which constructor to use, as there are two references with the same name, as we'll see in the following images.

We'll proceed by removing the line causing the error and continue with the implementation. This will allow us to confirm that the issue arises because the DataBookRepository class is simultaneously defined in both the checkout and home frameworks.

Pods>Development Pods>checkout>Frameworks>checkout.framework>Headers>checkout.h
Pods>Development Pods>home>Frameworks>home.framework>Headers>home.h

thus showing the duplication of classes. The same happens with the Book entity.

Implementing the Umbrella Module

Let's solve our problem by implementing the Umbrella module. We'll create a :shared module that will encompass our :checkout and :home features, allowing us to include a single framework in iOS.

Definition of the shared module in our project.

With this modification, the project structure will look like this.

In the 'shared' module, we will define the dependencies mentioned above. For this, we will add the following configuration in our build.gradle.kts file:

kotlin {
        //...
        
        cocoapods {
            summary = "Some description for the Shared Module"
            homepage = "Link to the Shared Module homepage"
            version = "1.0"
            ios.deploymentTarget = "16.0"
            podfile = project.file("../iosApp/Podfile")
            framework {
                baseName = "shared"
                export(project(":home"))
                export(project(":checkout"))
            }
        }
    
        sourceSets {
            commonMain.dependencies {
                api(project(":home"))
                api(project(":checkout"))
            }
            commonTest.dependencies {
                implementation(libs.kotlin.test)
            }
        }
    }

In the commonMain dependencies, we include the :home and :checkout modules, along with the definition of our Cocoapods framework. The respective exports allow us to access these dependencies from our iOS code.

Next, we'll see how our Podfile is configured with the shared module.

To conclude the example, let's look at the implementation of the code that previously had incompatibilities due to conflicts between the:homeand:checkoutframeworks.

As we can observe, it is now possible to reuse the BookRepository instance in both ProcessCheckout and GetAll classes, since the type definition is the same regardless of which module it belongs to. This same behavior applies to the Book entity, as evidenced in the checkout function.

Compilation

One of the main benefits of modularization is the reduction in compilation times, as unchanged modules can be cached. In theory, this works well, and the Android application effectively builds faster when only some modules have been modified.

However, a challenge arises when building the Kotlin/Native part of KMM, specifically with the gradle tasks linkDebugFrameworkIos and linkReleaseFrameworkIos. These tasks are time-consuming, regardless of the number of modified modules.

Despite this, I've found that it's not always necessary to rebuild the shared module. When making minor changes, it's enough to build the Android application or run iOS tests in the modified module for the changes to be reflected in the iOS application. It's even possible that tests in other modules will also work correctly.

While there's no exact formula for this, this approach definitely speeds up the feedback cycle when building the iOS application. However, I believe that implementing automated tests for KMM logic will result in an even faster and more efficient feedback cycle than running Android or iOS applications with each change.

💡
compilarKotlinIosArm64 and compilarKotlinIosX64 significantly speed up compilation time.

Chapter 5: Testing

Kotlin Multiplatform has a fundamental goal: to allow developers to write code once and run it on multiple platforms. However, any error in this shared code can impact all platforms simultaneously.

As Uncle Ben said: "With great power comes great responsibility." For this reason, testing shared code is fundamental.

All software development needs testing to ensure code quality and reliability. In this regard, Kotlin Multiplatform provides various tools and options to perform effective testing across all supported platforms.

Kotlin Multiplatform not only makes it easy to share code between platforms but also allows writing tests that work across all platforms we support.

Benefits of Testing in Kotlin Multiplatform

Testing in Kotlin Multiplatform offers several key benefits:

  1. Cross-platform consistency: Tests run on all supported platforms, ensuring uniform quality across the entire application.
  1. Development efficiency: Writing tests once for all platforms significantly reduces time and effort compared to creating separate tests.
  1. Early error detection: Automated tests identify code issues from the start, allowing corrections before they escalate.
  1. Greater confidence in changes: A robust test suite allows developers to modify code safely, knowing that tests will detect potential issues.

Tools for Testing in Kotlin Multiplatform

Kotlin Multiplatform provides a range of tools and libraries for testing across all compatible platforms.

The following libraries are available for writing tests. You can find references in the kmp-awesome repository.

While most of these tools are community creations, several have achieved widespread adoption due to their popularity.

In this article, we'll explore some of these tools as examples.

Types of Tests

When discussing tests, we generally refer to different types, represented in a pyramid as shown below.

As shown in the pyramid axes, the distribution is based on two fundamental aspects of testing: execution Speed and test Coverage.

The goal of Kotlin Multiplatform is to share business logic across multiple platforms. Since UI tests will depend on platform-specific frameworks, we'll focus on unit and integration tests.

Essential Unit Tests

To follow best practices, implement unit tests for the following components:

Let's explore what we mean by the concept of a unit.

What do we understand as a unit in our unit tests (Subject Under Test - SUT)?

What do we mean by unit? 🤔 Let's define this fundamental concept and its principles.

There's a common tendency to establish a one-to-one relationship between tests and classes. However, this practice can result in fragile tests that depend too heavily on specific implementations. The true goal isn't to achieve 100% coverage, but to ensure our tests effectively verify code behavior.

To develop more robust tests, let's consider two essential principles: tests should only change when business specifications change, and code refactoring should not affect the tests 👌

Integration

In this type of test, we focus on validating the interaction between our application components in a broader context. This involves more complex scenarios that include interactions with external elements, such as HTTP request libraries and storage systems.

It's important to consider this point when choosing an external library. If it includes testing tools, it will facilitate this type of testing (we'll see this in more detail in the example).

How to identify the scope of our tests

Let's see these concepts in action through a practical example: a simple application that displays a list of Rick and Morty characters.

The application consists of the following components

For the implementation, we use a ViewModel that acts as a state container for the views. The application has two main use cases: GetAllCharacters and RefreshCharacters, which manage the information. The CharacterRepository implements the repository pattern and serves as a single source of truth, interacting with two data sources: a local one (CharacterLocalDataSource) and a remote one (CharacterNetworkDataSources). These components are defined as contracts since they depend on concrete infrastructure implementations, such as a database and an HTTP client for server requests.

Now, how do we determine the scope of our tests? Let's define what each type represents:

With these concepts clear for our application, let's look at the scope in the following image.

Before implementing the tests in our example, let's see how to configure and run them in our project.

How to Configure and Run our Tests

Just like in shared code, we'll need specific dependencies for testing. For this, we'll configure a dedicated sourceset/target for tests. Next, we'll look at the necessary configuration for Android and iOS.

kotlin{
       sourceSets {
               commonTest.dependencies {
                implementation(kotlin("test-common"))
                implementation(kotlin("test-annotations-common"))
                implementation(libs.resource.test)
                implementation(libs.kotlinx.coroutines.test)
                implementation(libs.turbine)
                implementation(libs.kotest.framework.engine)
    
                implementation(libs.ktor.client.mock)
    
                implementation(libs.koin.test)
            }
            
            val androidTest = sourceSets.getByName("androidUnitTest") {
                dependencies {
                    implementation(kotlin("test-junit"))
                    implementation(libs.junit)
                    implementation(libs.sqldelight.jvm)
                }
            }
            
              
            iosTest.dependencies { 
                //ios testing dependencies
            }
       
       }
    
    }

In most cases, it's not necessary to configure platform-specific dependencies since our main goal is to validate shared code.

Once the dependencies are configured in their respective sourcesets, we'll define the directory for tests, as shown in the following image.

How to Run our Tests

Tests can be executed in two ways: individually or all at once.

To run a specific test, the IDE displays a play button next to each function marked with @Test.

when pressing this button, a dropdown menu will appear that allows you to select the platform on which you want to run your tests

Alternatively, we can run all tests using the Gradle verification task, either from the IDE or from the command line.

./gradlew :shared:allTests

This task will run all our tests across the different platforms configured in the project.

Now that we know how to configure and run our tests, let's see how to implement them and what approaches Kotlin Multiplatform offers.

Let's Code

Let's start with unit tests for our use cases. Before we begin, it's important to familiarize ourselves with some key concepts that will help us create efficient unit tests that provide quick feedback.

How to Avoid Slow and Coupled Tests

First Unit Test

Let's start with the use case for updating characters in the local data source. The following sequence diagram will help us visualize the update flow in our code.

In our example, we'll need to create test doubles for our data sources (DB and API). First, we'll implement them manually, and then we'll use a mocking library.

Let's start with the implementation of CharacterLocalDataSource:

class InMemoryCharacterLocalDataSource : CharacterLocalDataSource {
       //....
    }

An in-memory implementation will store the information only during the test context.

And for our CharacterNetworkDataSource we will create a Fake implementation.

class FakeCharacterNetworkDataSource : CharacterNetworkDataSource {
        private val jsonLoader = JsonLoader()
        
        override suspend fun find(id: Long): Result<NetworkCharacter> {
            return all().fold(onSuccess = { characters ->
                runCatching { characters.first { it.id == id } }
            }, onFailure = { Result.failure(it) })
        }
    
        override suspend fun all(): Result<List<NetworkCharacter>> {
            return runCatching {
                jsonLoader.load<CharactersResponse>("characters.json").results
            }
        }
    }

In this implementation, we use a JsonLoader class that loads a JSON file (which contains a copy of the real API response). This allows us to work with realistic data during testing.

To implement this JsonLoader, we use kotlinx-resources, a library that will be very useful in our upcoming tests. This tool makes it easy to load files from the local project directory.

class JsonLoader {
    
        private val json = Json {
            ignoreUnknownKeys = true
        }
    
        fun load(file: String): String {
            val loader = Resource("src/commonTest/resources/${file}")
            return loader.readText()
        }
    
        internal inline fun <reified R : Any> load(file: String) =
            this.load(file).convertToDataClass<R>()
    
        internal inline fun <reified R : Any> String.convertToDataClass(): R {
            return json.decodeFromString<R>(this)
        }
    
    }

This avoids having to create complex instances and manually generate data for tests.

💡
Tip: To improve the above, we could apply the Object Mother Pattern, which will allow us to have more readable, maintainable, and quickly generated tests 👌

There are several strategies for managing test instances:

  • Traditional
  • Builder Pattern
  • Object Mother
  • Named Arguments

Now that we have our test doubles ready, let's implement the first test to validate the successful case.

class RefreshCharactersTest {
    
        private val networkDataSource = FakeCharacterNetworkDataSource()
        private val localDataSource = InMemoryCharacterLocalDataSource()
        private val repository = CharacterRepository(localDataSource, networkDataSource)
    
        private val refreshCharacters = RefreshCharacters(repository)
    
        @AfterTest
        fun tearDown() {
            localDataSource.clear()
        }
        
        @Test
        fun `When I call refresh update the local storage`() = runTest {
            // test code
        }
    }

First, we create the necessary instances for our tests: the object under test and the repository. The CharacterRepository and RefreshCharacters classes are real implementations, not mocked.

💡
Another alternative would have been to create a test double for our repository, but as we saw earlier, we only create test doubles for those components that have external dependencies, such as input/output operations.

Once the instances are generated, we define the test. Here we can use the Given-When-Then pattern to structure the test, invoke the use case, and perform the assertion on the datasource. In this case, since we use Flow, we are using Turbine to interact with flows during testing.

class RefreshCharactersTest {
    
        private val networkDataSource = FakeCharacterNetworkDataSource()
        private val localDataSource = InMemoryCharacterLocalDataSource()
        private val repository = CharacterRepository(localDataSource, networkDataSource)
    
        private val refreshCharacters = RefreshCharacters(repository)
    
        @AfterTest
        fun tearDown() {
            localDataSource.clear()
        }
        
        @Test
        fun `When I call refresh update the local storage`() = runTest {
                //Given
                //When
            refreshCharacters.invoke()
            //Then
            localDataSource.all.test {
                assertEquals(true, awaitItem().isNotEmpty())
            }
        }
    }

But how do we validate the case when the NetworkDataSource returns an empty response? For this, we need to modify our data source.

The solution is to implement a stub.

class StubCharacterNetworkDataSource(
        private val characters: MutableList<NetworkCharacter> = mutableListOf()
    ) : CharacterNetworkDataSource {
        
        fun setCharacters(characters: List<NetworkCharacter>) {
            this.characters.clear()
            this.characters.addAll(characters)
        }
        override suspend fun find(id: Long): Result<NetworkCharacter> {
            return all().fold(onSuccess = { characters ->
                runCatching { characters.first { it.id == id } }
            }, onFailure = { Result.failure(it) })
        }
    
        override suspend fun all(): Result<List<NetworkCharacter>> {
            return runCatching { characters }
        }
    }

And the test would look like this

class RefreshCharactersTest {
    
        private val characters = JsonLoader.load<CharactersResponse>("characters.json").results
        private val networkDataSource = StubCharacterNetworkDataSource(characters.toMutableList())
        private val localDataSource = InMemoryCharacterLocalDataSource()
        private val repository = CharacterRepository(localDataSource, networkDataSource)
    
        private val refreshCharacters = RefreshCharacters(repository)
    
        @Test
        fun `When I call refresh update the local storage`() = runTest {
            refreshCharacters.invoke()
            localDataSource.all.test {
                assertEquals(true, awaitItem().isNotEmpty())
            }
        }
    
        @Test
        fun `When the service returns an empty response`() = runTest {
            networkDataSource.setCharacters(emptyList())
            refreshCharacters.invoke()
            localDataSource.all.test {
                assertEquals(true, awaitItem().isEmpty())
            }
        }
    }

As we need to generate different test scenarios, it's essential to have more flexible mocks. However, we must remember that this code also requires maintenance. For this reason, it's convenient to use mocking libraries like Mockk or Mockito, which will be familiar to Android developers. While Kotlin Multiplatform doesn't yet have solutions as established as these, they have served as inspiration for the community. In our example, we'll use Mokkery, a library inspired by Mockk according to its documentation.

💡
Mockk is a mocking library implemented entirely in Kotlin that, according to its documentation, offers multiplatform support. However, it still has some issues with native platforms like iOS and macOS.

Let's see how to replace our mocks using Mokkery

import dev.mokkery.answering.returns
    import dev.mokkery.everySuspend
    import dev.mokkery.mock
    
    class RefreshCharactersTest {
    
        private val characters = CharactersResponseMother.characters()
        private val networkDataSource = mock<CharacterNetworkDataSource>()
        private val localDataSource = InMemoryCharacterLocalDataSource()
        private val repository = CharacterRepository(localDataSource, networkDataSource)
    
        private val refreshCharacters = RefreshCharacters(repository)
    
        @Test
        fun `When I call refresh update the local storage`() = runTest {
                //Given
            everySuspend {
                networkDataSource.all()
            } returns Result.success(characters)
            //When
            refreshCharacters.invoke()
            //Then
            localDataSource.all.test {
                assertEquals(true, awaitItem().isNotEmpty())
            }
        }
    
        @Test
        fun `When the service returns an empty response`() = runTest {
            //Given
            everySuspend {
                networkDataSource.all()
            } returns Result.success(emptyList())
            //When
            refreshCharacters.invoke()
            //Then
            localDataSource.all.test {
                assertEquals(true, awaitItem().isEmpty())
            }
        }
    }

With Mokkery, we can create a mock of the CharacterNetworkDataSource interface in the following way:

private val networkDataSource = mock<CharacterNetworkDataSource>()

and configure the behavior of its methods

  everySuspend {
          networkDataSource.all()
      } returns Result.success(characters)

With a mocking library, we can also easily perform other types of verifications, such as checking that the 'all' method of networkDataSource was called exactly once.

    @Test
        fun `When I call refresh update the local storage`() = runTest {
            //Given
            everySuspend {
                networkDataSource.all()
            } returns Result.success(characters)
            //When
            refreshCharacters.invoke()
            //Then
            verifySuspend(mode = exactly(1)) {
                networkDataSource.all()
            }
            localDataSource.all.test {
                assertEquals(true, awaitItem().isNotEmpty())
            }
        }

Thanks to Mokkery, we can now test the remaining cases more efficiently and clearly.

Setup | Mokkery
How to add Mokkery to your Gradle project rapidly!
https://mokkery.dev/docs/Setup
It is recommended to generate mocks only for interfaces that are coupled to external data sources, as we saw in the previous example.

Integration Tests

As shown in the initial diagram, we will validate the integration of components and their behavior.

Integration tests allow us to validate the infrastructure, that is, the external libraries we use to manage our data. In our case, we use Ktor for HTTP requests and SQLDelight for local storage. Both libraries provide specific tools for testing.

Testing with Ktor

Ktor provides an "Engine" that allows us to create simulations of our services. The implementation is simple: we just need to define an Engine of type "MockEngine" and incorporate it into our client configuration.

val mockEngine = MockEngine { request ->
        respond(
            content = ByteReadChannel("""{"ip":"127.0.0.1"}"""),
            status = HttpStatusCode.OK,
            headers = headersOf(HttpHeaders.ContentType, "application/json")
        )
    }

To facilitate testing with Ktor, we will create some simple abstractions that will allow us to reuse configurations across all our tests. We will implement the following code to configure the Mock engine for testing.

fun testKtorClient(mockClient: MockClient = MockClient()): HttpClient {
        val engine = testKtorEngine(mockClient)
        return HttpClient(engine) {
            install(ContentNegotiation) {
                json(Json {
                    prettyPrint = true
                    isLenient = true
                    ignoreUnknownKeys = true
                })
            }
        }
    }
    
    private fun testKtorEngine(interceptor: ResponseInterceptor) = MockEngine { request ->
        val response = interceptor(request)
        respond(
            content = ByteReadChannel(response.content),
            status = response.status,
            headers = headersOf(HttpHeaders.ContentType, "application/json")
        )
    }

You can find the complete code here.

Now let's see how to implement our integration tests

class RefreshCharactersIntegrationTest {
    
        private val jsonResponse = JsonLoader.load("characters.json")
        //KtorClient setup
        private val mockClient = MockClient()
        private val ktorClient = testKtorClient(mockClient)
        private val networkDataSource = KtorCharacterNetworkDataSource(ktorClient)
    
        private val localDataSource = InMemoryCharacterLocalDataSource()
        private val repository = CharacterRepository(localDataSource, networkDataSource)
    
        private val refreshCharacters = RefreshCharacters(repository)
    
        @AfterTest
        fun tearDown() {
            localDataSource.clear()
        }
    
        @Test
        fun `When I call refresh update the local storage`() = runTest {
            //Given
            val response = DefaultMockResponse(jsonResponse, HttpStatusCode.OK)
            mockClient.setResponse(response)
            //When
            refreshCharacters.invoke()
            //Then
            localDataSource.all.test {
                assertEquals(true, awaitItem().isNotEmpty())
            }
        }

In the test, we will create an instance of KtorCharacterNetworkDataSource, which concretely implements our CharacterNetworkDataSource interface. This time we will initialize it with a special HttpClient for testing that uses MockEngine.

Let's apply the same approach to our CharacterLocalDataSource.

Testing | Ktor
Ktor provides a MockEngine that simulates HTTP calls without connecting to the endpoint.
https://ktor.io/docs/client-testing.html

Testing with SQLDelight

SQLDelight can be used for testing, but it requires platform-specific configuration. In its implementation, we need to define an appropriate driver for each platform.

// database.common.kt
    expect class DriverFactory {
        fun createDriver(): SqlDriver
    }
    
    fun createDatabase(driver: SqlDriver): CharactersDatabase {
        return CharactersDatabase(driver)
    }
    
    // database.android.kt
    actual class DriverFactory(private val context: Context) {
        actual fun createDriver(): SqlDriver {
            return AndroidSqliteDriver(CharactersDatabase.Schema, context, "app_database.db")
        }
    }
    
    //database.ios.kt
    actual class DriverFactory {
        actual fun createDriver(): SqlDriver {
            return NativeSqliteDriver(CharactersDatabase.Schema, "app_database.db")
        }
    }

In our case, we have a DriverFactory class implemented in both Android and iOS, each with its specific drivers. For testing, we follow the same principle, but apply it to the source code sets intended for tests.

//test.database.common.kt
    expect fun testDbDriver(): SqlDriver
    
    //test.database.android.kt
    actual fun testDbDriver(): SqlDriver {
        return JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
            .also {
                CharactersDatabase.Schema.create(it)
            }
    }
    //test.database.ios.kt
    actual fun testDbDriver(): SqlDriver {
        return inMemoryDriver(CharactersDatabase.Schema)
    }

As we can observe, this library uses a similar concept to what we implemented in our InMemoryCharacterLocalDataSource, which is an in-memory implementation.

Let's implement this change in our test.

class RefreshCharactersIntegrationTest {
    
        private val jsonResponse = JsonLoader.load("characters.json")
    
        //KtorClient setup
        private val mockClient = MockClient()
        private val ktorClient = testKtorClient(mockClient)
        private val networkDataSource = KtorCharacterNetworkDataSource(ktorClient)
    
        //SQLDelight setup
        private val db = createDatabase(driver = testDbDriver())
        private val localDataSource = SQLDelightCharacterLocalDataSource(db)
        private val repository = CharacterRepository(localDataSource, networkDataSource)
    
        private val refreshCharacters = RefreshCharacters(repository)
    
        @Test
        fun `When I call refresh update the local storage`() = runTest {
            //Given
            val response = DefaultMockResponse(jsonResponse, HttpStatusCode.OK)
            mockClient.setResponse(response)
            //When
            refreshCharacters.invoke()
            //Then
            localDataSource.all.test {
                assertEquals(true, awaitItem().isNotEmpty())
            }
        }
    
        @Test
        fun `When the service returns an empty response`() = runTest {
            //Given
            val response = DefaultMockResponse("{}", HttpStatusCode.OK)
            mockClient.setResponse(response)
            //When
            refreshCharacters.invoke()
    
            localDataSource.all.test {
                assertEquals(true, awaitItem().isEmpty())
            }
        }
    }

Similar to Ktor, we use instances of our local storage implementation, SQLDelightCharacterLocalDataSource.

Testing - SQLDelight
SQLDelight - Generates typesafe Kotlin APIs from SQL
https://cashapp.github.io/sqldelight/2.0.0/android_sqlite/testing/

So far, we have validated all components of the use case through our tests. Now, let's see how we can improve the code in our project and measure its coverage.

Validating our Dependency Injection

In integration tests, we make minimal adjustments to external library configurations to adapt them to testing needs. However, this process can become repetitive for each use case. This is where dependency injection and Koin help us optimize these configurations.

The first step is to configure our test dependencies with Koin.

val testPlatformModule: Module = module {
        single<SqlDriver> { testDbDriver() }
        single<MockClient> { MockClient() }
        single<HttpClient> { testKtorClient(get()) }
    }

After defining our dependencies, we configure the test to use Koin.

class RefreshCharactersIntegrationTest : KoinTest {
    
        private val jsonResponse = JsonLoader.load("characters.json")
    
        //KtorClient setup
        private val mockClient: MockClient by inject()
    
        @BeforeTest
        fun setUp() {
            startKoin {
                modules(
                    testPlatformModule,
                    sharedModule
                )
            }
        }
    
        @AfterTest
        fun tearDown() {
            stopKoin()
        }
        
        @Test
        fun `When I call refresh update the local storage`() = runTest {
            //.....
        }
    }

and in our test we request an instance of the object under test, in this case RefreshCharacters

  @Test
        fun `When I call refresh update the local storage`() = runTest {
            //Given
            val useCase = get<RefreshCharacters>() //from koin
            val localDataSource = get<CharacterLocalDataSource>()
            val response = MockResponse.ok(jsonResponse)
            mockClient.setResponse(response)
            //When
            useCase.invoke()
            //Then
            localDataSource.all.test {
                assertEquals(true, awaitItem().isNotEmpty())
            }
        }

Verifying our Dependency Graph with Koin

With Koin, we can verify that our dependency graph is created correctly. To do this, we simply call the checkModules() function within a test. This function will load all our modules and verify that each definition can be executed correctly.

// koin test functions
    fun startTestKoin(testModule: Module): KoinApplication {
        return startKoinApplication(listOf(testModule, sharedModule))
    }
    
    fun stopTestKoin() {
        stopKoin()
    }

class CheckModulesTest : KoinTest {
    
        @AfterTest
        fun tearDown() {
            stopTestKoin()
        }
    
        @Test
        fun `validate modules`() {
            startTestKoin(testPlatformModule)
                .checkModules()
        }
    }

If any definition is missing in our dependencies, the test will fail and specifically indicate which definition is missing. This verification is crucial when working with Koin.

Verifying your Koin configuration | Koin
Koin allows you to verify your configuration modules, avoiding discovering dependency injection issues at runtime.
https://insert-koin.io/docs/reference/koin-test/checkmodules/

Coverage Metrics

Test coverage metrics indicate what percentage of code has been tested, being fundamental to evaluate test quality and identify areas without coverage. While these metrics don't guarantee the complete absence of errors, tools like Jacoco and Slather allow us to calculate them and integrate them into the development cycle. In Kotlin Multiplatform, we'll use Kover, a Gradle plugin similar to Jacoco.

Kover

Kover is a toolset designed to measure test coverage of Kotlin code compiled for JVM and Android platforms. Its main component is a Gradle plugin that we'll explore next.

Kover Features

To implement Kover in our project, we only need to add its Gradle plugin:

plugins {
         id("org.jetbrains.kotlinx.kover") version "0.7.6"
    }

Once the plugin is added, we'll be able to run the Kover tasks available in Gradle.

In this case we will execute

./gradlew :shared:koverHtmlReportDebug

To generate the HTML report shown below

You can configure coverage limits in your projects using Kover by defining custom rules. For example:

koverReport {
        verify {
            rule("Basic Line Coverage") {
                isEnabled = true
                bound {
                    minValue = 80 // Minimum coverage percentage
                    maxValue = 100 // Maximum coverage percentage (optional)
                    metric = MetricType.LINE
                    aggregation = AggregationType.COVERED_PERCENTAGE
                }
            }
    
            rule("Branch Coverage") {
                isEnabled = true
                bound {
                    minValue = 70 // Minimum coverage percentage for branches
                    metric = MetricType.BRANCH
                }
            }
        }
    }

Although Kover is in Alpha version and does not yet support Kotlin Native, it is useful for validating our shared code.

Best Practices for Testing in Kotlin Multiplatform

To ensure effective testing in Kotlin Multiplatform, we can follow these software development best practices:

  1. Write tests from the start: Beginning with tests early in development helps detect problems early and builds a solid testing foundation.
  1. Automate testing: Automation ensures consistent execution and minimizes human error.
  1. Use parameterized tests: These tests allow evaluation of multiple data sets with a single test case, improving maintainability. For this we can use Kotest.
  1. Separate tests from implementations: Keeping test code separate from production code improves organization and facilitates future changes.

Rules for Using Tests in Multiplatform Projects

When implementing tests in Kotlin Multiplatform applications, consider these important guidelines:

GitHub - santimattius/kmp-for-mobile-native-developers at unit_and_integration_testing
Companing App for Talk: "KMP for Mobile Native Developers" - GitHub - santimattius/kmp-for-mobile-native-developers at unit_and_integration_testing
https://github.com/santimattius/kmp-for-mobile-native-developers/tree/unit_and_integration_testing

Chapter 6: Using Native Libraries in Kotlin Multiplatform

The adoption of Kotlin Multiplatform (KMP) represents a strategic step towards more coherent cross-platform development. This technology allows sharing code and business logic, reducing work duplication and improving consistency between applications. When starting projects with KMP for Android and iOS, a practical approach is to adapt and integrate existing native solutions into the KMP module, rather than rewriting everything from scratch. This strategy allows us to leverage both KMP functionalities and platform-specific code.

In this section, we'll explore how to extend the Bugsnag SDK for use from KMP modules, both in Android and iOS.

We'll start with the integration of existing native SDKs, focusing on avoiding unnecessary rewrites.

How do we include Android or iOS specific code in a KMP module?

Using Android Dependencies in KMP

To add Android-specific dependencies to a Kotlin Multiplatform module, the process is identical to traditional Android projects. We just need to add the dependency in the Android source set within the build.gradle(.kts) file in the shared directory.

In this example, we'll implement Bugsnag and Android Startup, two platform-exclusive dependencies for Android.

kotlin {
            sourcesets{
                  commonMain.dependencies{
                       // common dependencies
                  }
                androidMain.dependencies {
                  api(libs.bugsnag.android)
                  implementation(libs.androidx.startup.runtime)
              }
            }
    }

Once these dependencies are configured, we can use them within the Android sourceset.

Next, we'll explore the configuration of iOS-specific dependencies. Then we'll return to the implementation after having the dependencies configured on both platforms.

How to Use iOS Dependencies in KMP

Apple SDK dependencies like Foundation or Core Bluetooth are precompiled in Kotlin Multiplatform projects and require no additional configuration.

You can reuse libraries and frameworks from the iOS ecosystem in your iOS sourcesets. Kotlin is compatible with Objective-C and Swift dependencies, as long as they expose their APIs to Objective-C using the @objc attribute. However, Swift-only dependencies are not yet supported.

CocoaPods integration has the same limitation: it doesn't support Swift-only pods.

To manage iOS dependencies in Kotlin Multiplatform projects, we recommend using CocoaPods. You should only manage dependencies manually if you need to customize the interoperability process or have a specific reason to do so.

In our case, we'll use CocoaPods. To begin, we need to configure the CocoaPods plugin in our KMP project:

[versions]
    kotlin = "your-kotlin-version"
    
    [plugins]
    cocoaPods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }

Next, we'll apply the CocoaPods plugin in both the root project and the shared module.

//root build.gradle.kts
    plugins {
        alias(libs.plugins.androidApplication) apply false
        alias(libs.plugins.androidLibrary) apply false
        alias(libs.plugins.kotlinMultiplatform) apply false
        alias(libs.plugins.jetbrainsKotlinAndroid) apply false
        alias(libs.plugins.cocoaPods) apply false
    }
    //shared module build.gradle.kts
    plugins {
        alias(libs.plugins.kotlinMultiplatform)
        alias(libs.plugins.cocoaPods)
        alias(libs.plugins.androidLibrary)
    }

With the CocoaPods plugin installed, we can configure our shared module as a pod and define the necessary dependencies. For this example, we'll use Bugsnag as a native library.

kotlin{
        cocoapods {
            version = "1.0"
            summary = "Some description for a Kotlin/Native module"
            homepage = "Link to a Kotlin/Native module homepage"
            name = "Shared"
            ios.deploymentTarget = "14.0"
    
            framework {
                baseName = "Shared"
                isStatic = false
            }
    
            pod("Bugsnag"){
                version = "6.28.0"
            }
        }
    }

Now that we have configured the dependencies for both platforms, let's see how to reuse these native APIs through Kotlin Multiplatform.

Expect/Actual

Kotlin provides an elegant mechanism to access platform-specific APIs while developing common logic: expect and actual declarations.

The mechanism is simple: the common source set of a multiplatform module defines an expect declaration, and each platform source set provides its corresponding actual declaration. The compiler verifies that each declaration marked with the expect keyword in the common sources has its corresponding declaration marked with actual in all target platforms.

This system works with most Kotlin declarations: functions, classes, interfaces, enums, properties, and annotations. In this section, we'll focus on using expect/actual functions and properties.

Now let's look at the practical application of this concept using the Bugsnag API as an example.

The diagram above shows the general concept of our implementation. Let's now look at the code in detail.

In commonMain, we'll define the expect declarations for our API functions, ensuring consistency across platforms. Bugsnag represents an ideal case, as it maintains consistent nomenclature in its APIs for both Android and iOS, from method signatures to entity structures.

// bugsnag.common.kt
    package com.santimattius.kmp.playground
    
    //SDK configurations
    expect class Configuration
    // Information track
    expect class TrackableException
    
    object Bugsnag {
    
        private val provider: PlatformTracker = PlatformTracker()
    
        fun initialize(config: Configuration) {
            provider.initialize(config)
        }
    
        fun track(exception: TrackableException) {
            provider.track(exception)
        }
    }
    
    internal expect class PlatformTracker(){
        fun initialize(config: Configuration)
        fun track(exception: TrackableException)
    }

Let's look at the Android implementation.

In the androidMain sourceset, we'll implement concrete versions of the classes defined in the shared code. For the entities Configuration and TrackableException, we'll use typealias as a specific solution. Let's look at this implementation in detail.

package com.santimattius.kmp.playground
    
    import com.bugsnag.android.Bugsnag
    import com.bugsnag.android.Configuration as BugsnagConfiguration
    
    actual typealias Configuration = BugsnagConfiguration
    actual typealias TrackableException = Throwable
    
    internal actual class PlatformTracker {
        actual fun initialize(config: Configuration) {
            val context = applicationContext ?: run {
                // TODO: add logging later
                return
            }
            Bugsnag.start(context, config)
        }
    
        actual fun track(exception: TrackableException) {
            Bugsnag.notify(exception)
        }
    }

As we can observe in the imports, the typealias acts as direct references to the native API entities.

import com.bugsnag.android.Bugsnag
    import com.bugsnag.android.Configuration as BugsnagConfiguration

This is where we directly use Android dependencies.

How can we get the Android Context in KMP?

Among the Android-specific dependencies we defined earlier, we find Android Startup. Kotlin Multiplatform's flexibility to implement platform-specific code allows us to apply an elegant solution:

Bugsnag needs Android's applicationContext to work during application startup. Through Android Startup, we can obtain this context and, if desired, initialize our library.

The solution involves implementing an Android Startup Initializer to capture the applicationContext.

internal var applicationContext: Context? = null
        private set
    
    class ContextInitializer: Initializer<Unit> {
        override fun create(context: Context) {
            applicationContext = context.applicationContext
        }
    
        override fun dependencies(): List<Class<out Initializer<*>>> {
            return emptyList()
        }
    }

After defining the initializer, we need to register it in the AndroidManifest. To do this, we'll first create this file in our androidMain directory.

In the AndroidManifest.xml file, we need to add the following configuration:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools">
    
        <application>
            <provider
                android:name="androidx.startup.InitializationProvider"
                android:authorities="${applicationId}.androidx-startup"
                android:exported="false"
                tools:node="merge">
                <meta-data
                    android:name="com.santimattius.kmp.playground.ContextInitializer"
                    android:value="androidx.startup" />
            </provider>
        </application>
    
    </manifest>

To use Bugsnag in Android, we just need to reference our Bugsnag object from the application. Let's see an example:

import android.app.Application
    import com.santimattius.kmp.playground.Bugsnag
    import com.santimattius.kmp.playground.Configuration
    
    class MainApplication : Application() {
    
        override fun onCreate() {
            super.onCreate()
            // Initialization
            Bugsnag.initialize(config = Configuration.load(this))
            // Send test exception
            Bugsnag.track(exception = Throwable(message = "This is a test!!"))
        }
    }

Thanks to the alias definitions, we can use native APIs directly from our Kotlin Multiplatform module, as shown in our import declarations.

Next, we'll look at the iOS implementation.

package com.santimattius.kmp.playground
    
    import cocoapods.Bugsnag.Bugsnag
    import cocoapods.Bugsnag.BugsnagConfiguration
    import kotlinx.cinterop.ExperimentalForeignApi
    import platform.Foundation.NSException
    
    @OptIn(ExperimentalForeignApi::class)
    actual typealias Configuration = BugsnagConfiguration
    actual typealias TrackableException = NSException
    
    @OptIn(ExperimentalForeignApi::class)
    internal actual class PlatformTracker {
        actual fun initialize(config: Configuration) {
            Bugsnag.startWithConfiguration(config)
        }
    
        actual fun track(exception: TrackableException) {
            Bugsnag.notify(exception)
        }
    }

Looking at the imports, we can see that our definitions use native APIs provided by both the iOS platform and Bugsnag.

import cocoapods.Bugsnag.Bugsnag
    import cocoapods.Bugsnag.BugsnagConfiguration
    import kotlinx.cinterop.ExperimentalForeignApi
    import platform.Foundation.NSException

import SwiftUI
    import Shared
    import Bugsnag
    
    @main
    struct iOSApp: App {
        
        init() {
            // Initialization
             let config = BugsnagConfiguration.loadConfig()
             config.appVersion = "1.0.0-alpha"
    
             Bugsnag.shared.initialize(config: config)
             // Send test exception
             let exception = NSException(name:NSExceptionName(rawValue: "NamedException"),
                                  reason:"Something happened",
                                  userInfo:nil)
             Bugsnag.shared.track(exception: exception)
        }
        
        var body: some Scene {
            WindowGroup {
                ContentView()
            }
        }
    }

Just like in Android, we're using the platform's native types, but this time accessing them through our Shared module.

Our Kotlin Multiplatform solution is ready! 🎉

Can we use it from a Kotlin Multiplatform module?

Can we use our Bugsnag SDK adaptation from a Kotlin Multiplatform (KMP) module? The answer is yes. In fact, making existing functionality accessible from our KMP modules is one of the main benefits driving the adoption of this technology.

To better understand how it works, let's analyze a practical example with a repository.

class CrashRepository {
    
        private val coroutineScope = CoroutineScope(Dispatchers.Default)
        
        suspend fun crash() {
            val handler = CoroutineExceptionHandler { _, exception ->
                println("CoroutineExceptionHandler got $exception")
                // send log to bugsnag
                Bugsnag.track(exception.asTrackableException())
            }
            val job = coroutineScope.launch(handler) { // root coroutine, running in GlobalScope
                throw AssertionError()
            }
            val deferred = coroutineScope.async(handler) { // also root, but async instead of launch
                throw ArithmeticException() // Nothing will be printed, relying on user to call deferred.await()
            }
            joinAll(job, deferred)
        }
    }

This is a deliberate example of how to generate an exception 😀. Our goal is to capture this exception using CoroutineExceptionHandler and report it to Bugsnag through our SDK adaptation. For this, we need to transform the exception to the TrackableException type.

💡
While we could have directly used the Throwable type in the track function signature since it's native to Kotlin, we chose to define the TrackableException type to achieve a clearer and more expressive API.

To implement this conversion, we create a Throwable extension function with platform-specific implementations.

//bugsnag.common.kt
    expect fun Throwable.asTrackableException(): TrackableException
    
    //bugsnag.android.kt
    actual fun Throwable.asTrackableException() = this
    
    //bugsnag.ios.kt
    actual fun Throwable.asTrackableException() = NSException.exceptionWithName(
        name = this::class.simpleName,
        reason = message ?: toString(),
        userInfo = null
    )

In Android, since TrackableException is an alias for Throwable, the extension simply returns the original exception. In iOS, we create a new NSException that encapsulates the error information.

With this complete implementation, let's analyze its advantages and disadvantages.

Pros and Cons

Pros

Cons

In my opinion, these solutions should be limited to specific cases, such as external integrations without native KMP support. It's important to evaluate the cost of rewriting these modules. Although interoperability works in both directions (we can use native dependencies in KMP and vice versa), it's better to avoid duplicate solutions. Software development experience has taught us the problems caused by code duplication.

GitHub - santimattius/kmp-native-api-playground
Contribute to santimattius/kmp-native-api-playground development by creating an account on GitHub.
https://github.com/santimattius/kmp-native-api-playground

Chapter 7: Libraries

In Kotlin Multiplatform (KMP) application development, it's essential to have a robust set of libraries that facilitate common tasks such as networking, data storage, and state management. These libraries are specifically designed to work consistently across all platforms supported by KMP, allowing developers to maintain a single codebase while leveraging native capabilities of each platform.

Below, we'll explore some of the most popular and proven libraries in the KMP community, organized by categories according to their main functionality. These tools have been selected based on their maturity, active support, and adoption in real projects.

Networking

Networking libraries are fundamental for creating applications that work on both Android and iOS using Kotlin Multiplatform. These libraries allow both platforms to communicate with the Internet using the same code.

Let's look at the best available libraries for making network connections in Kotlin Multiplatform:

Ktor

Ktor is an open-source framework for creating web and server applications in Kotlin. Ktor Client is a component of Ktor used to make HTTP requests from a Kotlin multiplatform application. With Ktor Client, you can make HTTP requests from your shared code across compatible platforms.

Ktor Client provides a declarative and fluid API for making HTTP requests simply and efficiently, making it suitable for developing multiplatform applications that need to interact with web services. You can use Ktor Client to make GET, POST, PUT, DELETE, and other HTTP operations, as well as easily handle headers, parameters, and request and response data.

internal fun apiClient(baseUrl: String) = HttpClient {
    
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
                ignoreUnknownKeys = true
            })
        }
        install(Logging) {
            logger = Logger.DEFAULT
            level = LogLevel.ALL
        }
    
        defaultRequest {
            url(baseUrl)
            contentType(ContentType.Application.Json)
        }
    }
    
    //using ktor client
    class KtorRemoteMoviesDataSource(
        private val client: HttpClient,
    ) : RemoteMoviesDataSource {
    
        override suspend fun getMovies(): Result<List<MovieDto>> = runCatching {
            // invoke service
            val response = client.get("movie/popular")
            val result = response.body<MovieResponse>()
            result.results
        }
    }

For more information on how to use Ktor in your Kotlin Multiplatform projects, you can check theofficial Ktor documentation.

Creating a cross-platform mobile application | Ktor
The Ktor HTTP client can be used in multiplatform projects. In this tutorial, we'll create a simple Kotlin Multiplatform Mobile application, which sends a request and receives a response body as plain HTML text.
https://ktor.io/docs/getting-started-ktor-client-multiplatform-mobile.html#code

Ktorfit

Ktorfit is a HTTP client/Kotlin Symbol Processor for Kotlin Multiplatform ( Android, iOS, Js, Jvm, Linux) using KSP and Ktor clients inspired by Retrofit.

class ServiceCreator(baseUrl: String) {
    
        private val client = HttpClient {
            install(ContentNegotiation) {
                json(Json { isLenient = true; ignoreUnknownKeys = true })
            }
        }
        private val ktorfit = Ktorfit.Builder()
            .baseUrl(baseUrl)
            .httpClient(client)
            .build()
    
        fun createPictureService() = ktorfit.create<PictureService>()
    }
    
    interface PictureService {
    
        @GET("random")
        suspend fun random(): Picture
    }

For more information about Ktorfit and how to use it in your projects, you can check theofficial Ktorfit documentation.

Ktorfit
Ktorfit is a HTTP client/Kotlin Symbol Processor for Kotlin Multiplatform (Js, Jvm, Android, iOS, Linux) using KSP and Ktor clients inspired by Retrofit
https://foso.github.io/Ktorfit/

For a complete example, you can check out this Github repository:

https://github.com/santimattius/kmp-networking

Storage

Mobile applications need to store information on the device. Kotlin Multiplatform makes this easier by providing tools that work the same way on both Android and iOS.

Let's look at the best available tools for storing data in Kotlin Multiplatform, starting with how to handle user preferences.

Datastore

Jetpack Datastore is a data storage solution that allows you to store key-value pairs or objects written with protocol buffers. Datastore uses Kotlin coroutines and Flow to store data asynchronously, consistently, and transactionally.

If you currently use SharedPreferences to store data, consider migrating to Datastore.

import androidx.datastore.core.DataStore
    import androidx.datastore.preferences.core.PreferenceDataStoreFactory
    import androidx.datastore.preferences.core.Preferences
    import kotlinx.atomicfu.locks.SynchronizedObject
    import kotlinx.atomicfu.locks.synchronized
    import okio.Path.Companion.toPath
    
    private lateinit var dataStore: DataStore<Preferences>
    
    private val lock = SynchronizedObject()
    
    fun getDataStore(producePath: () -> String): DataStore<Preferences> =
        synchronized(lock) {
            if (::dataStore.isInitialized) {
                dataStore
            } else {
                PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
                    .also { dataStore = it }
            }
        }
    
    internal const val dataStoreFileName = "counter.preferences_pb"

For a complete example of using DataStore in KMP, you can check out the following link:

GitHub - santimattius/kmp-shared-preferences at feature/data-store
Example using Multiplaform Settings. Contribute to santimattius/kmp-shared-preferences development by creating an account on GitHub.
https://github.com/santimattius/kmp-shared-preferences/tree/feature/data-store

Multiplatform Settings

This is a Kotlin library for Multiplatform apps that enables common code to persist key-value data.

import com.russhwolf.settings.Settings
    import com.santimattius.kmp.skeleton.core.preferences.IntSettingConfig
    import kotlinx.coroutines.flow.Flow
    
    //commonMain
    expect fun provideSettings(): Settings
    
    class SettingsRepository(
        settings: Settings = provideSettings(),
    ) {
    
    
        private val _counter = IntSettingConfig(settings, "counter", 0)
        val counter: Flow<Int> = _counter.value
    
        fun increment() {
            val value = _counter.get().toInt() + 1
            _counter.set("$value")
        }
    
        fun decrease() {
            val value = _counter.get().toInt() - 1
            if (value < 0) {
                _counter.set("0")
            } else {
                _counter.set("$value")
            }
        }
    }

Initialization in Android/iOS

//androidMain
    import androidx.preference.PreferenceManager
    import android.content.SharedPreferences
    import com.russhwolf.settings.Settings
    import com.russhwolf.settings.SharedPreferencesSettings
    
    actual fun provideSettings(context:Context): Settings {
        val preferences = PreferenceManager.getDefaultSharedPreferences(context)
        return SharedPreferencesSettings(sharedPref)
    }
    
    //iosMain
    import com.russhwolf.settings.NSUserDefaultsSettings
    import com.russhwolf.settings.Settings
    import platform.Foundation.NSUserDefaults
    
    actual fun provideSettings(): Settings{
        return NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults)
    }
    

As we can see, Multiplatform Settings uses the preferences implementations of each platform.

For more detailed information and documentation about Multiplatform Settings, you can check the official documentation on GitHub.

GitHub - russhwolf/multiplatform-settings: A Kotlin Multiplatform library for saving simple key-value data
A Kotlin Multiplatform library for saving simple key-value data - russhwolf/multiplatform-settings
https://github.com/russhwolf/multiplatform-settings/tree/main

For a complete example, you can check out the following Github repository:

GitHub - santimattius/kmp-shared-preferences: Example using Multiplaform Settings
Example using Multiplaform Settings. Contribute to santimattius/kmp-shared-preferences development by creating an account on GitHub.
https://github.com/santimattius/kmp-shared-preferences/tree/main

KStore

A tiny Kotlin multiplatform library that assists in saving and restoring
objects to and from disk using kotlinx.coroutines, kotlinx.serialisation and okio. Inspired by
RxStore

Features

GitHub - xxfast/KStore: A tiny Kotlin multiplatform library that assists in saving and restoring objects to and from disk using kotlinx.coroutines, kotlinx.serialisation and kotlinx.io
A tiny Kotlin multiplatform library that assists in saving and restoring objects to and from disk using kotlinx.coroutines, kotlinx.serialisation and kotlinx.io - xxfast/KStore
https://github.com/xxfast/KStore

Database

SQLDelight

SQLDelight generates typesafe Kotlin APIs from your SQL statements. It verifies your schema, statements, and migrations at compile-time and provides IDE features like autocomplete and refactoring which make writing and maintaining SQL simple.

CREATE TABLE Favorite (
        resourceId INTEGER PRIMARY KEY NOT NULL,
        title TEXT NOT NULL,
        overview TEXT NOT NULL,
        imageUrl TEXT NOT NULL,
        type TEXT NOT NULL
    );
    
    selectAllFavorite:
    SELECT * FROM Favorite;

The SQLDelight plugin generates the necessary classes to interact with the database. In this example, AppDatabase and databaseQueries are generated by SQLDelight.

class SQLDelightFavoriteLocalDataSource(
        db: AppDatabase,
        private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
    ) : FavoriteLocalDataSource {
    
        private val databaseQueries = db.appDatabaseQueries
    
        override val all: Flow<List<Favorite>>
            get() = databaseQueries
                .selectAllFavorite()
                .asFlow()
                .mapToList(dispatcher)
    
    }

For more information about SQLDelight and how to use it in your projects, you can check the official SQLDelight documentation.

https://cashapp.github.io/sqldelight/2.0.1/

You can also find practical examples in the accompanying example.

Room

Room is Android's official database library, and now it can also be used in Kotlin Multiplatform projects.

From now on, the same database you create for the Android target will be available across all targets.

// shared/src/commonMain/kotlin/Database.kt
    
    @Database(entities = [TodoEntity::class], version = 1)
    @ConstructedBy(AppDatabaseConstructor::class)
    abstract class AppDatabase : RoomDatabase() {
      abstract fun getDao(): TodoDao
    }
    
    // The Room compiler generates the `actual` implementations.
    @Suppress("NO_ACTUAL_FOR_EXPECT")
    expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
        override fun initialize(): AppDatabase
    }
    
    @Dao
    interface TodoDao {
      @Insert
      suspend fun insert(item: TodoEntity)
    
      @Query("SELECT count(*) FROM TodoEntity")
      suspend fun count(): Int
    
      @Query("SELECT * FROM TodoEntity")
      fun getAllAsFlow(): Flow<List<TodoEntity>>
    }
    
    @Entity
    data class TodoEntity(
      @PrimaryKey(autoGenerate = true) val id: Long = 0,
      val title: String,
      val content: String
    )
    

For more detailed information about Room in Kotlin Multiplatform, you can check the official documentation at the following link

Room (Kotlin Multiplatform)  |  Android Developers
The Room persistence library provides an abstraction layer over SQLite to allow for more robust database access while harnessing the full power of SQLite. This page focuses on using Room in Kotlin Multiplatform (KMP) projects. For more information on using Room, see Save data in a local database using Room or our official samples.
https://developer.android.com/kotlin/multiplatform/room

Multiplatform Jetpack Libraries

Google officially supports Kotlin Multiplatform for sharing business logic between iOS and Android. Many Jetpack libraries have already been adapted to take advantage of KMP.

The following Jetpack libraries are compatible with KMP:

Chapter Summary

These are some of the libraries that help us address the challenge of delegating platform-specific implementations. They allow our code to be fully multiplatform, giving us the flexibility to choose which business logic to share while keeping critical aspects like the user interface separate.

If you want to discover other libraries with Kotlin Multiplatform support, you can visit the following Github repository or the Jetbrains website where you can find official and community libraries.

Book Example Repositories

References

Chapter 1: Introduction to Kotlin Multiplatform

Chapter 2: Understanding the Basic Project Structure

Chapter 4:Modularization

Chapter 5:Testing