Kotlin Multiplatform Basics
Introduction
Kotlin Multiplatform (KMP) is a technology that allows developers to share code across different platforms while still taking advantage of the native capabilities each platform offers. Rather than rewriting the same business logic for each platform (Android, iOS, web, desktop), KMP enables you to write this code once in Kotlin and compile it for multiple target platforms.
In this guide, we'll explore the basic concepts of Kotlin Multiplatform, how to set up a project, and how to structure your code to maximize code sharing while preserving platform-specific functionality.
Why Kotlin Multiplatform?
Before diving into the technical aspects, let's understand the key benefits of using Kotlin Multiplatform:
- Code sharing: Write business logic, networking, and data processing code once
- Platform-specific APIs: Access native platform APIs when needed
- Gradual adoption: Can be integrated into existing projects
- Reduced maintenance: Single codebase for core functionality means fewer bugs and consistent behavior
- Team collaboration: Android and iOS developers can work on shared code together
Setting Up a Kotlin Multiplatform Project
Let's start by setting up a basic Kotlin Multiplatform project. We'll use the IntelliJ IDEA or Android Studio for this.
Prerequisites
- JDK 11 or later
- IntelliJ IDEA or Android Studio
- Kotlin plugin 1.9.0 or later
Creating a New Project
- Open IntelliJ IDEA or Android Studio
- Select "New Project"
- Choose "Kotlin Multiplatform" as the project type
- Select "Mobile Application" template
- Configure your project name, location, and package name
- Click "Finish"
The wizard will generate a project structure with the following main components:
my-kmp-project/
├── androidApp/ // Android-specific code
├── iosApp/ // iOS-specific code
├── shared/ // Shared Kotlin code
│ ├── src/
│ │ ├── androidMain/ // Android-specific implementations
│ │ ├── commonMain/ // Cross-platform code
│ │ ├── iosMain/ // iOS-specific implementations
│ │ └── ...
│ └── build.gradle.kts
├── build.gradle.kts // Root build file
└── settings.gradle.kts // Project settings
Understanding the Project Structure
The most important part of a KMP project is the shared
module, which contains:
- commonMain: Contains platform-independent code that will be shared across all targets
- androidMain: Contains Android-specific implementations
- iosMain: Contains iOS-specific implementations
Each of these source sets has its own dependencies and can access platform-specific APIs.
Creating Your First Multiplatform Code
Let's create a simple greeting function in our shared module:
- Navigate to
shared/src/commonMain/kotlin
- Create a new Kotlin file called
Greeting.kt
- Add the following code:
package com.example.myapplication
class Greeting {
fun greet(): String {
return "Hello, Kotlin Multiplatform!"
}
fun greetWithPlatform(): String {
return "Hello from ${getPlatformName()}"
}
// This is expected to be implemented in platform-specific code
expect fun getPlatformName(): String
}
Notice the expect
keyword. This tells the Kotlin compiler that the actual implementation will be provided in platform-specific code.
Now, let's implement the platform-specific parts:
For Android (shared/src/androidMain/kotlin/com/example/myapplication/Platform.kt
):
package com.example.myapplication
actual fun Greeting.getPlatformName(): String = "Android"
For iOS (shared/src/iosMain/kotlin/com/example/myapplication/Platform.kt
):
package com.example.myapplication
actual fun Greeting.getPlatformName(): String = "iOS"
Using the Shared Code in Platform Projects
Android
In your Android app, you can use the shared code like any other Kotlin class:
package com.example.myapplication.android
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView
import com.example.myapplication.Greeting
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val greeting = Greeting()
findViewById<TextView>(R.id.text_view).text = greeting.greetWithPlatform()
}
}
iOS
In Swift, you can access your Kotlin code through the generated framework:
import UIKit
import shared
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let greeting = Greeting()
let label = UILabel(frame: CGRect(x: 0, y: 0, width: 300, height: 21))
label.center = view.center
label.textAlignment = .center
label.text = greeting.greetWithPlatform()
view.addSubview(label)
}
}
Common Patterns in Kotlin Multiplatform
Dependency Injection
You can use dependency injection to provide platform-specific implementations:
// In commonMain
interface StorageService {
fun saveData(key: String, value: String)
fun readData(key: String): String?
}
// In the common code
class UserRepository(private val storageService: StorageService) {
fun saveUser(user: User) {
storageService.saveData("user", user.toJson())
}
fun getUser(): User? {
val userData = storageService.readData("user") ?: return null
return User.fromJson(userData)
}
}
Then, each platform provides its own implementation:
// In androidMain
class SharedPreferencesStorageService(private val context: Context) : StorageService {
private val prefs = context.getSharedPreferences("app_data", Context.MODE_PRIVATE)
override fun saveData(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}
override fun readData(key: String): String? {
return prefs.getString(key, null)
}
}
// In iosMain
class NSUserDefaultsStorageService : StorageService {
private val defaults = NSUserDefaults.standardUserDefaults
override fun saveData(key: String, value: String) {
defaults.setObject(value, key)
}
override fun readData(key: String): String? {
return defaults.stringForKey(key)
}
}
Networking
For networking, you can use libraries like Ktor that support Kotlin Multiplatform:
// In build.gradle.kts (shared module)
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:2.3.2")
implementation("io.ktor:ktor-client-content-negotiation:2.3.2")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.2")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-android:2.3.2")
}
}
val iosMain by getting {
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.2")
}
}
Then in your common code:
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.serialization.json.Json
class WeatherService {
private val client = HttpClient()
suspend fun getWeather(city: String): WeatherData {
val response: HttpResponse = client.get("https://weather-api.example.com/weather?city=$city")
val jsonString = response.bodyAsText()
return Json.decodeFromString(jsonString)
}
}
Real-World Example: Task Management App
Let's put everything together by creating a simple task management app. We'll focus on the shared module:
// Data class in commonMain
@Serializable
data class Task(
val id: String = UUID.randomUUID().toString(),
val title: String,
val description: String = "",
var completed: Boolean = false
)
// Repository in commonMain
class TaskRepository(
private val storageService: StorageService,
private val coroutineScope: CoroutineScope
) {
private val _tasks = MutableStateFlow<List<Task>>(emptyList())
val tasks: StateFlow<List<Task>> = _tasks.asStateFlow()
init {
loadTasks()
}
fun addTask(task: Task) {
val updatedList = _tasks.value + task
_tasks.value = updatedList
saveTasks()
}
fun toggleTaskCompletion(id: String) {
_tasks.value = _tasks.value.map { task ->
if (task.id == id) task.copy(completed = !task.completed) else task
}
saveTasks()
}
private fun loadTasks() {
coroutineScope.launch {
val tasksJson = storageService.readData("tasks") ?: "[]"
_tasks.value = Json.decodeFromString(tasksJson)
}
}
private fun saveTasks() {
coroutineScope.launch {
storageService.saveData("tasks", Json.encodeToString(_tasks.value))
}
}
}
Handling Platform-Specific UI
While the business logic is shared, UI code is typically platform-specific. Here's how you might structure the UI layer:
Android UI (in androidApp)
class TaskListActivity : AppCompatActivity() {
private val viewModel: TaskViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_task_list)
val adapter = TaskAdapter { taskId -> viewModel.toggleTaskCompletion(taskId) }
findViewById<RecyclerView>(R.id.tasks_recycler_view).adapter = adapter
lifecycleScope.launch {
viewModel.tasks.collect { tasks ->
adapter.submitList(tasks)
}
}
findViewById<FloatingActionButton>(R.id.add_task_fab).setOnClickListener {
showAddTaskDialog()
}
}
private fun showAddTaskDialog() {
// Show dialog to add new task
}
}
iOS UI (in iosApp)
class TaskListViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
private let viewModel = TaskViewModel()
private var tasks: [Task] = []
private let tableView = UITableView()
override func viewDidLoad() {
super.viewDidLoad()
setupTableView()
setupAddButton()
// Collect tasks from shared viewModel
viewModel.observeTasks { [weak self] tasks in
self?.tasks = tasks
self?.tableView.reloadData()
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return tasks.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// Create and configure cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let task = tasks[indexPath.row]
viewModel.toggleTaskCompletion(id: task.id)
}
private func setupAddButton() {
// Setup navigation bar add button
}
}
Testing in Kotlin Multiplatform
One of the benefits of Kotlin Multiplatform is that you can write tests once for your shared code. Here's how to set up testing:
// In shared/src/commonTest/kotlin
class TaskRepositoryTest {
@Test
fun testAddTask() {
val mockStorage = MockStorageService()
val repository = TaskRepository(mockStorage, TestCoroutineScope())
val task = Task(title = "Test Task")
repository.addTask(task)
assertEquals(1, repository.tasks.value.size)
assertEquals("Test Task", repository.tasks.value.first().title)
}
@Test
fun testToggleCompletion() {
val mockStorage = MockStorageService()
val repository = TaskRepository(mockStorage, TestCoroutineScope())
val task = Task(id = "1", title = "Test Task")
repository.addTask(task)
repository.toggleTaskCompletion("1")
assertTrue(repository.tasks.value.first().completed)
}
}
Summary
In this guide, we've covered the basics of Kotlin Multiplatform:
- How to set up a Kotlin Multiplatform project
- Understanding the project structure with common and platform-specific code
- Creating shared code with
expect
andactual
declarations - Using shared code in Android and iOS applications
- Common patterns for dependency injection and networking
- A real-world example of a task management app
- Testing shared code
Kotlin Multiplatform allows you to share business logic across platforms while still leveraging platform-specific capabilities. It's a powerful approach that can significantly reduce development time and improve code quality by eliminating duplicate code.
Additional Resources
- Official Kotlin Multiplatform Documentation
- Getting Started with Kotlin Multiplatform Mobile
- KMP Samples Repository
- Ktor Multiplatform Client
Exercises
- Create a simple KMP application that displays different greetings based on the time of day.
- Implement a shared data persistence layer using both SQLDelight and platform-specific storage solutions.
- Build a currency converter app that uses a shared networking module to fetch exchange rates.
- Extend the task management app with categories and due dates.
- Implement unit tests for the shared business logic of your application.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)