1. Before you begin
Prerequisites
- Basic understanding of Kotlin Multiplatform.
- Experience with Kotlin.
- Basic understanding of Swift syntax.
- Xcode and iOS simulator installed.
What you need
- The latest stable version of Android Studio.
- A Mac machine with a macOS system.
- Xcode 16.1 and iPhone simulator with iOS 16.0 or higher.
What you learn
- How to share a Room Database between an Android app and an iOS app.
2. Get set up
To get started, follow these steps:
- Clone the GitHub repository with the following terminal command:
$ git clone https://github.com/android/codelab-android-kmp.git
Alternatively, you can download the repository as a zip file:
- In Android Studio, open the
migrate-room
project, which contains the following branches:
main
: Contains the starter code for this project, where you make changes to complete the codelab.end
: Contains the solution code for this codelab.
We recommend that you begin with the main
branch and follow the codelab step by step at your own pace.
- If you want to see the solution code, run this command:
$ git clone -b end https://github.com/android/codelab-android-kmp.git
Alternatively, you can download the solution code:
3. Understand the sample app
This tutorial consists of the Fruitties sample application built in native frameworks (Jetpack Compose on Android, SwiftUi on iOS).
The Fruitties app offers two main features:
- A list of Fruit items, each with a button to add the item to a Cart.
- A Cart displayed at the top, showing how many fruits have been added and their quantities.
Android app architecture
The Android app follows the official Android architecture guidelines to maintain a clear and modular structure.
iOS app architecture
KMP Shared Module
This project has already been set up with a shared module for KMP, although it is currently empty. If your project hasn't been set up with a shared module yet, start with the Getting Started with Kotlin Multiplatform codelab.
4. Prepare the Room database for KMP integration
Before you move the Room database code from the Fruitties Android app to the shared
module, you need to ensure that the app is compatible with Kotlin Multiplatform (KMP) Room APIs. This section walks you through that process.
One key update is using a SQLite driver compatible with both Android and iOS. To support Room database functionality across multiple platforms, you can use the BundledSQLiteDriver
. This driver bundles SQLite directly into the application, making it suitable for multiplatform use in Kotlin. For detailed guidance, refer to the Room KMP migration guide.
Update dependencies
First, add the room-runtime
and sqlite-bundled
dependencies to the libs.versions.toml
file:
# Add libraries
[libraries]
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidx-sqlite" }
Next, update the build.gradle.kts
of the :androidApp
module to use these dependencies, and remove use of libs.androidx.room.ktx
:
// Add
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
// Remove
implementation(libs.androidx.room.ktx)
Now, sync the project in Android Studio.
Modify the database module for BundledSQLiteDriver
Next, modify the database creation logic in the Android app to use the BundledSQLiteDriver
, making it compatible with KMP while maintaining functionality on Android.
- Open the
DatabaseModule.kt
file located atandroidApp/src/main/kotlin/com/example/fruitties/kmptutorial/android/di/DatabaseModule.kt
- Update the
providesAppDatabase
method as in the following snippet:
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
@Module
@InstallIn(SingletonComponent::class)
internal object DatabaseModule {
...
@Provides
@Singleton
fun providesAppDatabase(@ApplicationContext context: Context): AppDatabase {
val dbFile = context.getDatabasePath("sharedfruits.db")
return Room.databaseBuilder<AppDatabase>(context, dbFile.absolutePath)
.setDriver(BundledSQLiteDriver())
.build()
}
Build and run the Android app
Now that you switched the Native SQLite driver to the bundled one, let's verify the app builds and everything works correctly before you migrate the database to the :shared
module.
5. Move database code to the :shared module
In this step, we'll transfer the Room database setup from the Android app to the :shared
module, allowing the database to be accessible by both Android and iOS.
Update the build.gradle.kts
configuration of the :shared
module
Start by updating the build.gradle.kts
of the :shared
module to use Room multiplatform dependencies.
- Add the KSP and Room plugins:
plugins {
...
// TODO add KSP + ROOM plugins
alias(libs.plugins.ksp)
alias(libs.plugins.room)
}
- Add the
room-runtime
andsqlite-bundled
dependencies to thecommonMain
block:
sourceSets {
commonMain {
// TODO Add KMP dependencies here
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
}
}
- Add KSP configuration for each platform target by adding a new top level
dependencies
block. For ease, you can just add it to the bottom of the file:
// Should be its own top level block. For convenience, add at the bottom of the file
dependencies {
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
add("kspIosX64", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
}
- Also at the top level, add a new block to set the
Room
schema location:
// Should be its own top level block. For convenience, add at the bottom of the file
room {
schemaDirectory("$projectDir/schemas")
}
- Gradle sync the project
Move Room schema to :shared
module
Move the androidApp/schemas
directory to the :shared
module root folder next to the src/
folder:
From:
To:
Move DAOs and entities
Now that you added the necessary Gradle dependencies to the KMP shared module, you need to move the DAOs and entities from the :androidApp
module to the :shared
module.
This will involve moving files to their respective locations in the commonMain
source set in the :shared
module.
Move Fruittie
model
You can leverage the Refactor → Move functionality to switch modules without breaking the imports:
- Locate the
androidApp/src/main/kotlin/.../model/Fruittie.kt
file, right-click the file, and select Refactor → Move (or key F6): - In the Move dialog, select the
...
icon next to the Destination directory field. - Select the commonMain source set in the Choose Destination Directory dialog and click OK. You may have to disable the Show only existing source roots checkbox.
- Click the Refactor button to move the file.
Move CartItem
and CartItemWithFruittie
models
For the file androidApp/.../model/CartItem.kt
you need to follow these steps:
- Open the file, right-click the
CartItem
class, and select Refactor > Move. - This opens the same Move dialog, but in this case you also check the box for the
CartItemWithFruittie
member. Continue by selecting the
...
icon and selecting thecommonMain
source set just like you did for theFruittie.kt
file.
Move DAOs and AppDatabase
Do the same steps for the following files (you can select all three at the same time):
androidApp/.../database/FruittieDao.kt
androidApp/.../database/CartDao.kt
androidApp/.../database/AppDatabase.kt
Update the shared AppDatabase
to work across platforms
Now that you have the database classes moved to the :shared
module, you need to adjust them to generate the required implementations on both platforms.
- Open the
/shared/src/commonMain/kotlin/com/example/fruitties/kmptutorial/android/database/AppDatabase.kt
file. - Add the following implementation of
RoomDatabaseConstructor
:
import androidx.room.RoomDatabaseConstructor
// The Room compiler generates the `actual` implementations.
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
override fun initialize(): AppDatabase
}
- Annotate the
AppDatabase
class with@ConstructedBy(AppDatabaseConstructor::class)
:
import androidx.room.ConstructedBy
@Database(
entities = [Fruittie::class, CartItem::class],
version = 1,
)
// TODO Add this line
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
...
Move database creation to :shared
module
Next, you move the Android-specific Room setup from the :androidApp
module to the :shared
module. This is necessary because in the next step you will remove the Room
dependency from the :androidApp
module.
- Locate the
androidApp/.../di/DatabaseModule.kt
file. - Select the content of the
providesAppDatabase
function, right-click, and select Refactor > Extract Function to Scope: - Select
DatabaseModule.kt
from the menu.This moves the content to a global
appDatabase
function. Press Enter to confirm the function name. - Make the function public by removing the
private
visibility modifier. - Move the function into the
:shared
module by right-clicking Refactor > Move. - In the Move dialog, select the ... icon next to the Destination directory field.
- In the Choose Destination Directory dialog, select the shared >androidMain source set and select the /shared/src/androidMain/ folder, then click OK.
- Change the suffix in the To package field from
.di
to.database
- Click Refactor.
Clean up unneeded code from :androidApp
At this point, you moved the Room database to the multiplatform module and none of the Room dependencies are needed in the :androidApp
module, so you can remove them.
- Open the
build.gradle.kts
file in the:androidApp
module. - Remove the dependencies and configuration as in the following snippet:
plugins {
// TODO Remove
alias(libs.plugins.room)
}
android {
// TODO Remove
ksp {
arg("room.generateKotlin", "true")
}
dependencies {
// TODO Keep room-runtime
implementation(libs.androidx.room.runtime)
// TODO Remove
implementation(libs.androidx.sqlite.bundled)
ksp(libs.androidx.room.compiler)
}
// TODO Remove
room {
schemaDirectory("$projectDir/schemas")
}
- Gradle sync the project.
Build and run the Android app
Run the Fruitties Android app to ensure that the app runs properly and now uses the database from the :shared
module. If you had cart items added before, then you should see the same items at this point as well even though the Room database is now located in the :shared
module.
6. Prepare Room for use on iOS
To further prepare the Room database for the iOS platform, you need to set up some supporting code in the :shared
module for use in the next step.
Enable database creation for iOS app
The first thing to do is to add an iOS-specific database builder.
- Add a new file in the
:shared
module in theiosMain
source set namedAppDatabase.ios.kt
: - Add the following helper functions. These functions will be used by the iOS app to get an instance of the Room database.
package com.example.fruitties.kmptutorial.shared
import androidx.room.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.example.fruitties.kmptutorial.android.database.AppDatabase
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCObjectVar
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.value
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSError
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
fun getPersistentDatabase(): AppDatabase {
val dbFilePath = documentDirectory() + "/" + "fruits.db"
return Room.databaseBuilder<AppDatabase>(name = dbFilePath)
.setDriver(BundledSQLiteDriver())
.build()
}
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
private fun documentDirectory(): String {
memScoped {
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = errorPtr.ptr,
)
if (documentDirectory != null) {
return requireNotNull(documentDirectory.path) {
"""Couldn't determine the document directory.
URL $documentDirectory does not conform to RFC 1808.
""".trimIndent()
}
} else {
val error = errorPtr.value
val localizedDescription = error?.localizedDescription ?: "Unknown error occurred"
error("Couldn't determine document directory. Error: $localizedDescription")
}
}
}
Add "Entity" suffix to Room entities
Because you're adding wrappers for the Room entities in Swift, it's best to have Room entities' names differ from the names of the wrappers. We'll make sure that's the case by adding the Entity suffix to the Room entities using the @ObjCName
annotation.
Open the Fruittie.kt
file in the :shared
module and add the @ObjCName
annotation to the Fruittie
entity. Since this annotation is experimental, you may need to add the @OptIn(ExperimentalObjC::class)
annotation to the file.
Fruittie.kt
import kotlin.experimental.ExperimentalObjCName
import kotlin.native.ObjCName
@OptIn(ExperimentalObjCName::class)
@Serializable
@Entity(indices = [Index(value = ["id"], unique = true)])
@ObjCName("FruittieEntity")
data class Fruittie(
...
)
Then, do the same for the CartItem
entity in the CartItem.kt
file.
CartItem.kt
import kotlin.experimental.ExperimentalObjCName
import kotlin.native.ObjCName
@OptIn(ExperimentalObjCName::class)
@ObjCName("CartItemEntity")
@Entity(
foreignKeys = [
ForeignKey(
entity = Fruittie::class,
parentColumns = ["id"],
childColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class CartItem(@PrimaryKey val id: Long, val count: Int = 1)
7. Use Room in the iOS app
The iOS app is a pre-existing app that uses Core Data. In this codelab you won't worry about migrating any existing data in the database as this app is just a prototype. If you are migrating a production app to KMP, you will have to write functions to read the current Core Data database and insert those items into the Room database on first launch post-migration.
Open the Xcode project
Open the iOS project in Xcode by navigating to the /iosApp/
folder and opening Fruitties.xcodeproj
in the associated app.
Remove Core Data entity classes
First, you need to remove the Core Data entity classes to make room for the entity wrappers you'll create later. Depending on the kind of data your application stores in Core Data, you may remove the Core Data entities entirely, or keep them for data migration purposes. For this tutorial you can just remove them, since you won't need to migrate any existing data.
In Xcode:
- Go to the Project Navigator.
- Go to the Resources folder.
- Open the
Fruitties
file. - Click on and delete each entity.
To make these changes available in the code, clean and rebuild the project.
This will fail the build with the following errors, which is expected.
Creating entity wrappers
Next, we'll create entity wrappers for the Fruittie
and CartItem
entities to smoothly handle the API differences between Room and Core Data entities.
These wrappers will help us transition from Core Data to Room by minimizing the amount of code that needs to be updated right away. You should aim to replace these wrappers with direct access to the Room entities in the future.
For now, we'll instead create a wrapper for the FruittieEntity
class, giving it optional properties.
Create FruittieEntity
wrapper
- Create a new Swift file in the
Sources/Repository
directory by right-clicking the directory name and selecting New File from Template... - Name it
Fruittie
and ensure that only the Fruitties target is selected, not the test target. - Add the following code to the new Fruittie file:
import sharedKit
struct Fruittie: Hashable {
let entity: FruittieEntity
var id: Int64 {
entity.id
}
var name: String? {
entity.name
}
var fullName: String? {
entity.fullName
}
}
The Fruittie
struct wraps the FruittieEntity
class, making the properties optional and passing through the entity's properties. Additionally we make the Fruittie
struct conform to the Hashable
protocol, so it can be used in SwiftUI's ForEach
view.
Create CartItemEntity
wrapper
Next, create a similar wrapper for the CartItemEntity
class.
Create a new Swift file called CartItem.swift
in the Sources/Repository
directory.
import sharedKit
struct CartItem: Hashable {
let entity: CartItemWithFruittie
let fruittie: Fruittie?
var id: Int64 {
entity.cartItem.id
}
var count: Int64 {
Int64(entity.cartItem.count)
}
init(entity: CartItemWithFruittie) {
self.entity = entity
self.fruittie = Fruittie(entity: entity.fruittie)
}
}
Since the original Core Data CartItem
class had a Fruittie
property, we have included a Fruittie
property in the CartItem
struct as well. While the CartItem
class doesn't have any properties that are optional, the count
property has a different type in the Room entity.
Update repositories
Now that the entity wrappers are in place, you need to update the DefaultCartRepository
and DefaultFruittieRepository
to use Room instead of Core Data.
Update DefaultCartRepository
Let's begin with the DefaultCartRepository
class as it's the simpler of the two.
Open the CartRepository.swift
file in the Sources/Repository
directory.
- First, replace the
CoreData
import withsharedKit
:
import sharedKit
- Then remove the
NSManagedObjectContext
property and replace it with aCartDao
property:
// Remove
private let managedObjectContext: NSManagedObjectContext
// Replace with
private let cartDao: any CartDao
- Update the
init
constructor to initialise the newcartDao
property:
init(cartDao: any CartDao) {
self.cartDao = cartDao
}
- Next, update the
addToCart
method. This method needed to pull existing cart items from Core Data, but our Room implementation doesn't require this. Instead it will insert a new item or increment the count of an existing cart item.
func addToCart(fruittie: Fruittie) async throws {
try await cartDao.insertOrIncreaseCount(fruittie: fruittie.entity)
}
- Lastly, update the
getCartItems()
method. This method will call thegetAll()
method on theCartDao
and map theCartItemWithFruittie
entities to ourCartItem
wrappers.
func getCartItems() -> AsyncStream<[CartItem]> {
return cartDao.getAll().map { entities in
entities.map(CartItem.init(entity:))
}.eraseToStream()
}
Update DefaultFruittieRepository
To migrate the DefaultFruittieRepository
class, we apply similar changes as we did for the DefaultCartRepository
.
Update the FruittieRepository file to the following:
import ConcurrencyExtras
import sharedKit
protocol FruittieRepository {
func getData() -> AsyncStream<[Fruittie]>
}
class DefaultFruittieRepository: FruittieRepository {
private let fruittieDao: any FruittieDao
private let api: FruittieApi
init(fruittieDao: any FruittieDao, api: FruittieApi) {
self.fruittieDao = fruittieDao
self.api = api
}
func getData() -> AsyncStream<[Fruittie]> {
let dao = fruittieDao
Task {
let isEmpty = try await dao.count() == 0
if isEmpty {
let response = try await api.getData(pageNumber: 0)
let fruitties = response.feed.map {
FruittieEntity(
id: 0,
name: $0.name,
fullName: $0.fullName,
calories: ""
)
}
_ = try await dao.insert(fruitties: fruitties)
}
}
return dao.getAll().map { entities in
entities.map(Fruittie.init(entity:))
}.eraseToStream()
}
}
Replace @FetchRequest
property wrappers
We also need to replace the @FetchRequest
property wrappers in the SwiftUI views. The @FetchRequest
property wrapper is used to fetch data from Core Data and observe changes, so we can't use it with Room entities. Instead, let's use the UIModel to access the data from the repositories.
- Open the
CartView
in theSources/UI/CartView.swift
file. - Replace the implementation with the following:
import SwiftUI
struct CartView : View {
@State
private var expanded = false
@ObservedObject
private(set) var uiModel: ContentViewModel
var body: some View {
if (uiModel.cartItems.isEmpty) {
Text("Cart is empty, add some items").padding()
} else {
HStack {
Text("Cart has \(uiModel.cartItems.count) items (\(uiModel.cartItems.reduce(0) { $0 + $1.count }))")
.padding()
Spacer()
Button {
expanded.toggle()
} label: {
if (expanded) {
Text("collapse")
} else {
Text("expand")
}
}
.padding()
}
if (expanded) {
VStack {
ForEach(uiModel.cartItems, id: \.self) { item in
Text("\(item.fruittie!.name!): \(item.count)")
}
}
}
}
}
}
Update the ContentView
Update the ContentView
in the Sources/View/ContentView.swift
file to pass the FruittieUIModel
to the CartView
.
CartView(uiModel: uiModel)
Updating DataController
The DataController
class in the iOS application is responsible for setting up the Core Data stack. Since we're moving away from Core Data, we need to update the DataController
to initialize the Room database instead.
- Open the
DataController.swift
file inSources/Database
. - Add the
sharedKit
import. - Remove the
CoreData
import. - Instantiate the Room database in the
DataController
class. - Lastly, remove the
loadPersistentStores
method call from theDataController
initializer.
The final class should look like this:
import Combine
import sharedKit
class DataController: ObservableObject {
let database = getPersistentDatabase()
init() {}
}
Updating dependency injection
The AppContainer
class in the iOS application is responsible for initializing the dependency graph. Since we updated the repositories to use Room instead of Core Data, we need to update the AppContainer
to pass Room DAOs to the repositories.
- Open the
AppContainer.swift
in theSources/DI
folder. - Add the
sharedKit
import. - Remove the
managedObjectContext
property from theAppContainer
class. - Change the
DefaultFruittieRepository
andDefaultCartRepository
initializations by passing in Room DAOs from theAppDatabase
instance provided by theDataController
.
When you're done, the class will look like this:
import Combine
import Foundation
import sharedKit
class AppContainer: ObservableObject {
let dataController: DataController
let api: FruittieApi
let fruittieRepository: FruittieRepository
let cartRepository: CartRepository
init() {
dataController = DataController()
api = FruittieNetworkApi(
apiUrl: URL(
string:
"https://android.github.io/kotlin-multiplatform-samples/fruitties-api"
)!)
fruittieRepository = DefaultFruittieRepository(
fruittieDao: dataController.database.fruittieDao(),
api: api
)
cartRepository = DefaultCartRepository(
cartDao: dataController.database.cartDao()
)
}
}
Finally, update FruittiesApp
in main.swift
to remove the managedObjectContext
:
struct FruittiesApp: App {
@StateObject
private var appContainer = AppContainer()
var body: some Scene {
WindowGroup {
ContentView(appContainer: appContainer)
}
}
}
Build and run the iOS app
Finally, once you build and run the app by pressing ⌘R, the app should start with the database migrated from Core Data to Room.
8. Congratulations
Congratulations! You have successfully migrated standalone Android and iOS apps to a shared data layer using Room KMP.
For reference, here is the comparison of app architectures to see what was achieved:
Before
Android | iOS |
Post Migration Architecture
Learn more
- Learn which other Jetpack libraries support KMP.
- Read the Room KMP documentation.
- Read the SQLite KMP documentation.
- Check the official Kotlin Multiplatform documentation.