Blog Infos
Author
Published
Topics
, , , ,
Published
Photo by Alex Rosario on Unsplash

Kotlin Multiplatform (KMP) is a key feature of the Kotlin language designed to facilitate code sharing between Android and iOS within the mobile development ecosystem. However, when focusing on the iOS side of development, there are specific challenges and considerations that can impact the overall experience for iOS developers.

In this article, I will explore a range of helpful techniques aimed at improving the development workflow in KMP projects, particularly for iOS platform teams. By leveraging these insights, teams can achieve a more seamless integration between Kotlin and Swift, ultimately leading to more efficient and enjoyable development processes.

I’ll use a sample project based on the Magic card game, to illustrate these approaches and apply the same architectural decisions.

I’m also assuming the reader has knowledge about KotlinSwift and KMP.

. . .
Golden rule

Although Kotlin and Swift share many syntactic similarities, their underlying architectures are different. We must keep in mind that adoption will always be more challenging for iOS, and therefore, we should be empathetic to the challenges this team may face. We must embrace these challenges as our own and be committed to find the best solutions.

Starting point

One key discussion was whether to opt for a single-module or multi-module approach and how to structure it.

We chose a multi-module project structured by layers to ensure a clear separation of concerns across the UI, Domain (optional), and Data layers, which also highlights cross-platform similarities, enhancing collaboration and streamlining synchronised feature development across teams.

The next discussion was about which layers would be shared. For example, should it be the Data Layer along with the UI Layer (state holders) or just the Data Layer? And which components within these layers — network, database, managers (repositories), models, view models, etc.?

We chose to focus on sharing only the Data Layer. Perhaps the most commonly adopted approach and you’ll understand why.

Finally, we decided to adopt a monorepo approach due to the team’s size, code governance, and the project’s complexity.

Architecture

By choosing to share only the Data Layer, it means that each platform will be fully responsible for implementing the UI and Domain Layers.

Although the Domain Layer could be optional, I will later explore the benefits of creating a lightweight Data Layer on iOS to align with the shared module’s Data Layer, where the Domain Layer will play an important role.

Shared code

The Data Layer of the applications will reside here, in the shared module. This is where we define our models, data providers (both local and remote) and data managers, which orchestrate these components.

Each platform consumes this module just like they would with any other external dependency.

Duplicated code

The UI and Domain Layers of each application will reside within their respective modules. I’ll focus on the iOS implementation, and this will be its structure:

Shared code

Based on our decisions, the shared module will also follow a gradle multi-module architecture. The modules data-modelscore-database, and core-network are independent, while data-managers depends on all three. Each module is responsible for providing its own Dependency Injection (DI) logic, but the core-di module manages the root DI system, utilised by all platforms. This is why the core-di module depends on all other modules and will also contain the Objective-C framework settings.

These five modules constitute the Data Layer.

Facade

It is important to ensure that the artefact each platform will consume is as optimised as possible in terms of size and content. To achieve this, there are several best practices available for this task. The CardsManager class implements the Facade design pattern to encapsulate and protect access to the internal core-database and core-network modules.

Visibility Modifiers

We should leverage the internal modifier to ensure that functions, properties, classes, objects, and interfaces are only visible within the same module. In some cases, we may need to use the public modifier to make elements accessible to other Kotlin modules, but iOS doesn’t necessarily need access to them. To exclude these from the iOS compilation, we can use the @HiddenFromObjC annotation which prevents a function or property from being exported.

//module: core-network
@HiddenFromObjC
class ApiClient(val client: HttpClient, val baseUrl: String) {}
//module: core-database
@HiddenFromObjC
class MagicDao(driver: SqlDriver) {}
//module: data-managers
class CardsManager : KoinComponent {
private val remote: ApiClient by inject()
private val local: MagicDao by inject()
}
view raw CardsManager.kt hosted with ❤ by GitHub
Framework settings

Given that we’ve opted for a multi-module architecture with interdependencies between the modules, it’s crucial to apply specific techniques to ensure the final binary preserves these relationships in a readable manner. Currently, KMP has a limitation where each binary framework is compiled as a “closed world”. As a result, it’s impossible to pass custom types between two frameworks, even if they are identical in Kotlin.

Let’s say I have two modules, shared and shared-models, each providing their own binary frameworks: Shared and SharedModels, respectively. The shared-models contains a data class Hello, and the shared module depends on shared-models with a public method that takes Hello as a parameter. When these modules are exported to Swift, we observe the following:

SharedModels: public class Hello : KotlinBase
Shared: public class Shared_modelsHello : KotlinBase
Shared: open func update(state: Shared_modelsHello)
view raw export.swift hosted with ❤ by GitHub

Instead of:

SharedModels: public class Hello : KotlinBase
Shared: open func update(state: Hello)
view raw export.swift hosted with ❤ by GitHub

This means that the Shared framework includes all external dependencies from SharedModel and generates new types to reference those external types. As a result, we end up having Shared_modelsHello instead of just Hello.

To address this limitation we need to bundle not only the classes from the current project but also the classes from its dependencies. To specify which dependencies to export to a binary, we use the export method. This ensures that the external types from dependent modules are correctly referenced, avoiding the duplication of types:

kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
export(projects.sharedModels)
}
}
sourceSets {
commonMain.dependencies {
api(projects.sharedModels)
}
}
}

This solution is known as an umbrella framework. It prevents the iOS app from bloating with duplicated dependencies, optimises the resulting artefact, and eliminates frustrations caused by dependency incompatibilities. Additionally, the Facade pattern plays a crucial role by preventing certain code from being exported to the final binary. This is particularly beneficial for the core-database module, which relies on SQLDelight to generate all database access code.

note: using export will also assist us with other tasks, I’ll elaborate on this as it becomes relevant in the following sections.

Koin

Koin is being used to manage DI in the project. As mentioned, core-di module unique responsibility is to manage the root DI system, utilised by all platforms, centralising its configuration and initialisation:

object DependencyInjection {
/**
* DI engine initialization.
* This function must be called by the iOS app inside the respective App struct.
*/
@Suppress("unused")
fun initKoin(enableNetworkLogs: Boolean) = initKoin(enableNetworkLogs = enableNetworkLogs, appDeclaration = {})
/**
* DI engine initialization.
* This function must be called by the Android app inside the respective Application class.
*/
@HiddenFromObjC
fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration) {
startKoin {
appDeclaration()
modules(
networkDiModule("https://api.magicthegathering.io/v1/", enableNetworkLogs),
databaseDiModule(),
managersDiModule()
)
}
}
}

By delegating each module the responsibility of providing its own DI setup through a public function — such as networkDiModuledatabaseDiModule, and managersDiModule — we achieve a more lightweight Koin configuration. This approach allows us to access the DI of a specific module without needing to rely on the core-di module, which is particularly advantageous for testing scenarios where only certain dependencies are required.

The previously mentioned framework technique of using export is also crucial for maintaining consistency between external types from dependent modules that will be used in iOS:

kotlin {
androidTarget()
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "MagicDataLayer"
export(projects.dataManagers)
export(projects.dataModels)
}
}
sourceSets {
commonMain.dependencies {
implementation(projects.coreNetwork)
implementation(projects.coreDatabase)
api(projects.dataManagers)
api(projects.dataModels)
implementation(libs.kmp.koin.core)
}
}
}
dataManagers and dataModels will be accessible to the iosApp, so it is important to maintain type consistency
Tests

When structuring our team, we can adopt a horizontal approach, where one team works on the shared module and another on the platforms, or we can take a vertical approach, where each team works on a feature from the shared module through to the platforms. Regardless of the chosen approach, it is imperative that the shared module is as thoroughly covered with (useful) tests as possible.

As mentioned, each module is responsible for providing its own DI setup. Therefore, to test the core-database module, inside commonTest, I have the following:

@HiddenFromObjC
expect fun databaseDiTestModule(): Module
view raw commonTest.kt hosted with ❤ by GitHub

Which will be used:

class MagicDaoTest : KoinTest {
private lateinit var dao: MagicDao
@BeforeTest
fun setUp() {
startKoin {
modules(databaseDiTestModule())
}
dao = get<MagicDao>()
}
@AfterTest
fun finish() {
stopKoin()
}
//Test functions...
}
view raw MagicDaoTest.kt hosted with ❤ by GitHub

If I want to test my data-managers module, specifically the CardsManager class, one of its properties is a MagicDao instance. Although the data-managers module depends on the core-database module, we don’t have access to its commonTest folder, which prevents me from using the databaseDiTestModule() method in commonTest from data-managers. To work around this current limitation, I will replicate its initialisation in the commonMain folder, and as a result:

@HiddenFromObjC
expect fun databaseDiModule(): Module
@HiddenFromObjC
expect fun databaseDiTestModule(): Module
view raw commonMain.kt hosted with ❤ by GitHub

With this approach, modules that depend on core-database can use databaseDiTestModule() and retrieve its test instance for use in theirs commonTest folders:

class CardsManagerTest : KoinTest {
@BeforeTest
fun setUp() {
startKoin {
modules(databaseDiTestModule(), managersDiModule(), ...)
}
}
}
all tests will be executed for each target
Kotlin & Swift interoperability

Since Kotlin primarily generates Objective-C code, Swift’s calls to Kotlin through Objective-C, can result in loss of critical Kotlin language features, such as:

  • Enums
  • Sealed classes
  • Coroutines (concurrency)
  • Default args
  • Generics

André Oriani wrote an excellent series of articles on this topic. Additionally, the Kotlin-Swift interopedia guide is also a valuable resource.

Focusing on Coroutines, we have two brilliant community solutions: Rick Clephas’s KMP-NativeCoroutines (KMP-NC) library and the SKIE Gradle plugin from Touchlab. I’ll focus on the former, as it is the one I have used.

Concurrency by KMP-NC

KMP-NC solves two major limitations: lack of cancellation support for Kotlin suspend functions when converted to async function in Swift and the loss of generics on protocols in Objective-C. Since it generates code through KSP, the export configuration, as discussed previously, will play an important role in ensuring that the generated code uses the correct external types, thereby avoiding errors such as:

//module B
class MyObject {
@NativeCoroutines
suspend fun observeValue(): String
}
//module A
object MyObjectProvider : KoinComponent {
fun myObject() = get<MyObject>()
}
> This causes an issue on iOS:
> Generic parameter 'Output' could not be inferred
> Value of type 'Module_bMyObject' has no member 'observeValue'
view raw kmpnc.kt hosted with ❤ by GitHub

Another thing to keep in mind are Exceptions. All Kotlin exceptions are unchecked, meaning that errors are caught at runtime. In contrast, Swift only has checked errors that must be handled at compile time. Therefore, if Swift or Objective-C code calls a Kotlin method that throws an exception, the Kotlin method should be marked with the @Throws annotation, specifying a list of “expected” exception classes.

However, KMP-NC hides the original declaration and removes the @Throws from the generated functions since the generated functions are not designed to throw exceptions. The solution is straightforward: we create a public function that explicitly exposes the types of exceptions that may be thrown, thus adding them to the public API:

class RateLimitException(message: String) : Throwable(message)
@Throws(RateLimitException::class, ThrowableType2:class, ThrowableType3:class, ...)
fun exportedExceptions() {}
view raw Exceptions.kt hosted with ❤ by GitHub

Which will be converted to:

public class RateLimitException : KotlinThrowable {
public init(message: String)
}
public class CardsManager : KotlinBase, Koin_coreKoinComponent {
public init()
/**
* @note This method converts instances of RateLimitException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
open func exportedExceptions() throws
}
Better Exceptions

It is essential for platform teams to iterate over different error types and adjust their UI Layer logic accordingly. In Kotlin, it’s common practice to create a Result<T> sealed class, which represents either success or failure.

sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable) : Result<Nothing>()
}
view raw Result.kt hosted with ❤ by GitHub

When converted to Swift, we get:

open class Result<T> : KotlinBase where T : AnyObject {}
public class ResultError : Result<KotlinNothing> {
public init(exception: KotlinThrowable)
open func doCopy(exception: KotlinThrowable) -> ResultError
open func isEqual(_ other: Any?) -> Bool
open func hash() -> UInt
open func description() -> String
open var exception: KotlinThrowable { get }
}
view raw Result.swift hosted with ❤ by GitHub

The problem with using Nothing as the error type is that it fails when attempting to retrieve the specific errors, such as in a Result<NSArray>:

let successResult = result as? ResultSuccess<NSArray> //Works
let errorResult = result as? ResultError //Fails
Cast from 'Result<NSArray>' to unrelated type 'ResultError' always fails

It occurs because ResultError and ResultSuccess<NSArray> do not share a direct inheritance or subtype relationship. To address this, we adapt Result to use a generic parameter for the error type too:

sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error<T>(val exception: Throwable) : Result<T>()
}
view raw Result.kt hosted with ❤ by GitHub

The compiler will permit the conversion because both ResultError<T> and ResultSuccess<T> become generic instances of Result<T>. This establishes a more flexible relationship between the two types, enabling the cast to work correctly, making the following scenario possible:

do {
let result = try await ...
if let successResult = result as? ResultSuccess<NSArray> {
print("Success! \(successResult.data)")
} else if let errorResult = result as? ResultError {
switch errorResult.exception {
case is Type1Exception:
print("We got a Type1Exception")
case is Type2Exception:
print("We got a Type2Exception")
default:
print("We got a \(errorResult.exception)")
}
} else {
print("Unexpected result type...")
}
} catch {
print("Unexpected error \(error)")
}

This way, the iOS team has access to the specific type of error returned by the Shared Data Layer, rather than simply handling a more generic Swift Error type in a do-catch statement.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Sharing code across platforms is a wonderful superpower. But sometimes, sharing 100% of your codebase isn’t the goal. Maybe you’re migrating existing apps to multiplatform, maybe you have platform-specific libraries or APIs you want to…
Watch Video

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Developer

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform ...

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Deve ...

Jobs

Documentation

To provide the best development experience, documentation is also essential. We can leverage Kotlin’s documentation tool, KDoc, to streamline this. To export code documentation for iOS, simply add the following configuration to the iOS targets:

listOf(iosArm64(), iosSimulatorArm64()).forEach { _ ->
compilerOptions {
freeCompilerArgs.add("-Xexport-kdoc")
}
}

Typical Javadoc’s block tag syntax:

/**
* Retrieves all card sets from the local database.
*
* @return A list of [CardSet] representing all card sets in the database.
*/
fun getSets(): List<CardSet>
view raw kdoc.kt hosted with ❤ by GitHub

Will be exported to:

/**
* Retrieves all card sets from the local database.
*
* @return A list of [CardSet] representing all card sets in the database.
*/
open func getSets() -> [CardSet]
view raw kdoc.swift hosted with ❤ by GitHub

It’s so simple that there’s really no excuse not to use it.

So far, we have covered several techniques to enhance the iOS development experience, focusing on the development of the Shared Data Layer. Next, we will dive into specific techniques tailored for the iosApp, to fully leverage and benefit from what has been discussed up to this point.

iosApp code

The iOS app is also organised into layers. The App package serves as the entry point and includes the setup for DI. The Core defines the DI protocols and container. The Data acts as a bridge between the App’s Data Layer and the Shared Data Layer, where the implementation logic resides. The Domain contains the bridge protocols and data structures, and also the app protocols. Finally, the Presentation contains all the logic related to the UI Layer:

package dependencies
Domain

This layer defines the protocols to be implemented by the Data Layer and used by the DI system, helping to minimize the reliance on concrete types from the Shared Data Layer. This approach establishes a clear boundary for communication with the MagicDataLayer, ensuring that it is concentrated within the Data Layer (acting as a bridge) and not spread across other layers of the application, such as the UI Layer.

Inside the Domain package, there is a DomainProtocols library containing a DomainBridge file, where these “bridge protocols” are defined:

public protocol ErrorException {}
public protocol DomainRateLimitException: ErrorException {}
public protocol DomainCardSet { ... }
public protocol DomainCard { ... }
public class DomainCardList {
let cards: [any DomainCard]
...
}
public class DomainException: Error {
public let error: Error?
public let domainError: ErrorException?
...
}
DomainProtocols library

These protocols will be used as extensions, allowing concrete types to be referred to by their “domain types.” I’ll demonstrate this shortly.

There is also a CardDomain library, which contains the protocols that will have concrete implementations:

import DomainProtocols
public protocol DomainCardsManagerProtocol {
func getCardSet(setCode: String) async -> Result<DomainCardList, DomainException>
func getCardSets() -> [any DomainCardSet]
func observeCardSets() async throws -> AsyncStream<[any DomainCardSet]>
...
}
CardDomain library
Data

This layer is responsible for implementing the Domain protocols and bridging them with the corresponding Shared Data Layer types. To achieve this, I’ve created a DataBridge file within a DataExtensions library, as shown bellow:

import DomainProtocols
import MagicDataLayer
extension KotlinThrowable: @retroactive ErrorException {}
extension RateLimitException: @retroactive DomainRateLimitException {}
extension Card: @retroactive DomainCard {}
extension CardSet: @retroactive DomainCardSet {}
DataExtensions library

The MagicDataLayer contains types such as KotlinThrowableRateLimitExceptionCardCardSet, and CardsManager. Swift’s ability to extend a class and make it conform to our “domain type” is incredibly useful. From now on, for instance, whenever we need to reference a Card, we’ll use its DomainCard alias type instead (interface).

Lastly, we also have a CardData library, where we will implement the concrete versions of the protocols provided by CardDomain.

import CardDomain
import DomainProtocols
import MagicDataLayer
public class CardsManagerMock: DomainCardsManagerProtocol { ... }
import CardDomain
import DataExtensions
import DomainProtocols
import KMPNativeCoroutinesAsync
import MagicDataLayer
extension CardsManager: @retroactive DomainCardsManagerProtocol { ... }
The CardsManager will conform to the DomainCardsManagerProtocol to prevent direct access. Instead, the protocol should be used for interaction.

The relationship established between the Domain, Data, and Shared Data Layers is crucial for ensuring that dependencies on Shared types are not scattered across all layers. It allows us to leverage a DI system to inject the necessary types where and when needed. For example, the platform team can continue developing the UI Layer using mocks, while the Shared team progresses with their work.

If we think about it, this approach isn’t exclusive to KMP; it also applies to any Swift 3rd-party library we use, or even those we develop ourselves. It also provides a testable architecture.

Presentation

The same philosophy applies to this layer: its classes will have dependencies built based on protocols from the Domain Layer, and the DI system will be responsible for providing the desired instances.

@MainActor
public class CardListViewModel: ObservableObject, CardListViewModelProtocol {
private let manager: DomainCardsManagerProtocol
public init(manager: DomainCardsManagerProtocol) {
self.manager = manager
...
}
}

The DomainCardManagerProtocol instance can be either mocked or real, as demonstrated in the following section.

Dependency Injection

So far, we’ve discussed the importance of layering our code to contain dependencies on shared types. Now, let’s delve into how we can efficiently utilize a DI system to provide the required instances.

The Core package contains two libraries: the DI where a DIContainer is defined, and the FactoryProtocols with the following:

@MainActor
public protocol FactoryProtocol {
associatedtype T
static var createName: String { get }
static var mockName: String { get }
static func register()
static func create<T>() -> T
static func mock<T>() -> T
}

The App will be responsible for creating factories for each type needed. For instance, CardsManagerFactory:

import CardData
import CardDomain
import DI
import FactoryProtocols
import MagicDataLayer
class CardsManagerFactory: FactoryProtocol {
typealias T = DomainCardsManagerProtocol
public private(set) static var createName: String = "CardsManager"
public private(set) static var mockName: String = "CardsManagerMock"
static func register() {
DIContainer.shared.register(DomainCardsManagerProtocol.self, name: createName) { _ in CardsManager() }
DIContainer.shared.register(DomainCardsManagerProtocol.self, name: mockName) { _ in CardsManagerMock() }
}
public static func create<CardsManager>() -> CardsManager {
return DIContainer.shared.resolve(DomainCardsManagerProtocol.self, name: createName) as! CardsManager
}
public static func mock<CardsManagerMock>() -> CardsManagerMock {
return DIContainer.shared.resolve(DomainCardsManagerProtocol.self, name: mockName) as! CardsManagerMock
}
}

This setup allows for easy switching between different types:

https://gist.github.com/httpsgistgithubcomGuilhE15a304023dcdf9cb94ee1f0f1306bf80fileappswift

Packages and Build Phases

The project’s structure was established using Swift Package Manager (SPM) allowing us to have a package for each layer:

The MagicDataLayer will be integrated by the app’s target Build Phase “Compile Kotlin Framework” script:

But there’s a catch. SPM will try to resolve its dependencies before the Build Phase, which means that packages depending on the MagicDataLayer will fail, since the Build Phase will only run after. This situations arise when no build cache exists or if the Derived Data is cleared. We can solve this situation by replicating this Build Phase script in the app’s scheme Build step by adding a Pre-actions Run Script:

This process ensures proper resolution of SPM packages.

This concludes our exploration of techniques, from Shared code to iosApp code, aimed at enhancing the developer experience for iOS platform teams. However, before we wrap up, there’s an important point to mention regarding a key player in this process. The team as a whole.

. . .

Team

The technology behind KMP has already proven its worth. While it still has its specificities in certain cases, which will continue to improve, what will ultimately determine its success is how teams utilize it, and in turn, the experiences, benefits, and frustrations they derive from it.

The iOS team’s buy-in is easier to achieve nowadays, and this article aims to share a few techniques to make that process even smoother.

Our conclusion is that communication is the most critical factor for team success. The development process will need to be more rigorous, particularly regarding planning tasks and establishing clear contracts between teams. We can draw a parallel with what is already done (or should be done) between front-end and back-end teams. The difference now is that, since the mobile team has unified, this also needs to happen within the team itself, while continuing to maintain communication externally as well.

I believe this will be the key to success.

For an in-depth look at best practices on repositoriy configurations to scale projects effectively, Touchlab wrote an excellent series titled KMP For Native Mobile Teams.

Conclusion

In this article, we’ve explored various strategies and techniques for improving the iOS developer experience when working with KMP. By focusing on key areas such as project architecture, dependency injection, and Kotlin-Swift interoperability, teams can overcome many of the common challenges encountered in KMP projects. The clear separation of layers and careful management of dependencies not only simplify the development process but also allow both iOS and Android teams to work more independently while maintaining shared functionality.

Ultimately, the success of KMP hinges not only on the technology itself but on how effectively teams collaborate and communicate. A well-coordinated approach — fostering strong communication and defining clear boundaries — ensures that both platform-specific and shared modules work harmoniously together. With the continuous evolution of KMP, it’s clear that the potential for further improvements is vast, and adopting these practices will help teams create more streamlined and enjoyable workflows.

I hope the insights shared here will help create a smoother integration process for your iOS teams.

. . .

As always, I hope you find this article useful, thanks for reading. You can explore these strategies in the playground available here:

https://github.com/GuilhE/Magic?source=post_page—–fa8cb2c1aa92——————————–

 

. . .

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Using annotations in Kotlin has some nuances that are useful to know
READ MORE
blog
One of the latest trends in UI design is blurring the background content behind the foreground elements. This creates a sense of depth, transparency, and focus,…
READ MORE
blog
Now that Android Studio Iguana is out and stable, I wanted to write about…
READ MORE
blog
With JCenter sunsetted, distributing public Kotlin Multiplatform libraries now often relies on Maven Central…
READ MORE
Menu