Кроссплатформенная разработка
Один код для многих платформ — вот что вам никто не расскажет о KMP
Реальные сложности создания кроссплатформенного приложения с Kotlin Multiplatform — и то, как я решал каждую из них.
Привет, Kotlin-разработчики. Скажу честно. Когда я начал разрабатывать своё приложение для разделения расходов на Kotlin Multiplatform, я подумал: «Напиши один раз, запускай везде. Что может быть сложного?»
Оказалось, может быть очень сложно. Но и невероятно полезно.
Это не очередной туториал «как начать работу с KMP». Таких и без того полно. Это скорее статья, которую я сам хотел бы прочитать до начала работы: реальные проблемы, реальные ошибки и реальные решения. Всё основано на моём опыте создания ExpenseSplitter KMP — полноценного приложения для совместного учёта расходов, которое работает на Android, Desktop и Web из одной общей кодовой базы.
Вот как всё было на самом деле.
Проект — что я делаю
Прежде чем переходить к сложностям, покажу, что вообще делает приложение.
ExpenseSplitter KMP — приложение для управления групповыми расходами. Вы создаёте группу, добавляете участников, добавляете траты, а приложение рассчитывает, кто кому сколько должен — и, что важнее, определяет минимальное количество переводов, необходимое для полного погашения долгов.
Технологический стек:
- Compose Multiplatform — общий интерфейс для всех платформ;
- Room Multiplatform — локальное хранение данных;
- Koin — внедрение зависимостей;
- Kotlin Coroutines + Flow — асинхронное управление состоянием;
- архитектура MVVM + UDF;
- целевые платформы: Android, Desktop, Web (Wasm).
На бумаге всё звучит аккуратно. А теперь — как это выглядело на практике.
Сложность №1 — «Room работает только на Android». Уже нет. Но документация будто об этом не знает
Первой стеной стало хранение данных.
Room много лет был исключительно Android-библиотекой. Практически все ответы на Stack Overflow, статьи и YouTube-уроки — только про Android. Когда я решил использовать Room Multiplatform, оказалось, что во многом придётся разбираться самому.
Проблема была не просто в добавлении зависимости. Каждой платформе нужен собственный SQL-драйвер и собственный способ определить путь к файлу базы данных:
- Android использует внутреннее хранилище;
- Desktop должен писать в каталог данных пользователя;
- Web (Wasm): поскольку поддержка SQLite в Room для Wasm пока экспериментальная, я применил отдельную платформенную стратегию — сделал
WebExpenseRepositoryна основе реактивного хранилища в памяти сMutableStateFlow.
Именно здесь шаблон KMP expect/actual одновременно становится вашим лучшим другом и главным источником путаницы.
Реализация была разной для каждой платформы.
package org.paraspatil.expensesplitter.data
import android.content.Context
import androidx.room.Room
fun createDatabase(context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"expense_splitter.db"
)
.fallbackToDestructiveMigration()
.build()
}
package org.paraspatil.expensesplitter.data
import androidx.room.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import java.io.File
fun createDatabase(): AppDatabase {
val dbFile = File(System.getProperty("user.home"), "expense_splitter.db")
return Room.databaseBuilder<AppDatabase>(
name = dbFile.absolutePath,
)
.fallbackToDestructiveMigration(true)
.setDriver(BundledSQLiteDriver())
.build()
}
Управление веб-базой данных для целевого объекта Wasm (WebAssembly) осуществляется с помощью паттерна реактивного репозитория в оперативной памяти.
class WebExpenseRepository : ExpenseRepository {
// The "Source of Truth": A Map acting as a Database Table
// Key: GroupID, Value: List of People
private val peopleMap = MutableStateFlow(mapOf<String, List<Person>>("default" to emptyList()))
private val activeGroupId = MutableStateFlow("default")
/**
* READ: Returns a Flow that reactively filters people based on the active group.
* When peopleMap or activeGroupId changes, the UI updates automatically.
*/
override fun getAllPeople(): Flow<List<Person>> =
activeGroupId.map { id -> peopleMap.value[id] ?: emptyList() }
/**
* WRITE: Simulates a SQL INSERT by updating the immutable map state.
*/
override suspend fun insertPerson(person: Person) {
val currentData = peopleMap.value.toMutableMap()
val currentGroupId = activeGroupId.value
// Append person to the specific group's list
val updatedList = (currentData[currentGroupId] ?: emptyList()) + person
currentData[currentGroupId] = updatedList
peopleMap.value = currentData // This line triggers the UI refresh
}
}
Главное здесь другое: интерфейс ExpenseRepository остаётся одинаковым везде. Для Android и Desktop Room выполнял основную работу — менялись только драйвер и путь к файлу. Для Web я заменил Room на реактивное хранилище в памяти. ViewModel вообще не знает, с какой реализацией работает. В этом и есть настоящая сила абстракции в KMP.
Урок: в KMP всегда задавайте вопрос: «Что здесь действительно зависит от платформы?». Изолируйте только эту часть. Всё остальное отправляйте в commonMain.
Сложность №2 — математика оказалась простой. UX — нет
Вот проблема, которая вообще не связана с Kotlin, но напрямую связана с продуктовым мышлением.
Посчитать, кто кому сколько должен, — простая арифметика. Но что делать, если в группе из пяти человек каждый платил за что-то своё?
Очень быстро получается 10–15 мелких переводов. Человек A должен B ₹120. B должен C ₹80. C должен A ₹200. Начинается хаос. Никто не хочет делать 15 банковских переводов после одного ужина.
Мне нужен был более умный алгоритм. Решением стал жадный алгоритм погашения долгов, полностью реализованный в доменном слое и никак не связанный с UI.
Основная идея проста.
package org.paraspatil.expensesplitter.domain.settlement
import kotlin.math.abs
import kotlin.math.round
fun calculateSettlements(
balances: Map<String, Double>
): List<Settlements> {
val settlements = mutableListOf<Settlements>()
val mutableBalances = balances.mapValues { (_, v) ->
round(v * 100) / 100
}.toMutableMap()
// Keep matching biggest debtor with biggest creditor until all balances are zero
while (true) {
val debtor = mutableBalances.entries.find { it.value < -0.01 } ?: break
val creditor = mutableBalances.entries.find { it.value > 0.01 } ?: break
val settledAmount = minOf(abs(debtor.value), creditor.value)
settlements.add(
Settlements(
fromPersonId = debtor.key,
toPersonId = creditor.key,
amount = round(settledAmount * 100) / 100
)
)
mutableBalances[debtor.key] = round((debtor.value + settledAmount) * 100) / 100
mutableBalances[creditor.key] = round((creditor.value - settledAmount) * 100) / 100
}
return settlements
Алгоритм делит участников на две группы: кредиторы — те, кому должны деньги, должники — те, кто должен деньги. Затем он жадно сопоставляет самые крупные значения, создаёт одну транзакцию на совпадение и обновляет оставшиеся балансы.
Результат: если раньше группе из пяти человек могло понадобиться 10 переводов, теперь иногда достаточно всего четырёх. То есть математического минимума.
А поскольку вся эта логика находится в commonMain и не имеет платформенных зависимостей, её можно полностью тестировать обычными модульными тестами. Никакой Android-эмулятор для этого не нужен.
Сложность №3 — три вкладки, одна истина и синхронизация в реальном времени
Эта проблема выглядела как техническая, но на самом деле была UX-проблемой. В приложении есть три вкладки: People, Expenses и Balances. И они тесно связаны. Добавили человека во вкладке People — он должен сразу появиться в форме добавления расхода. Изменили расходы — Balances должна тут же пересчитать расчёты.
Моя первая идея была простой: загружать данные отдельно для каждой вкладки. Это было ошибкой. Очень быстро возникли рассинхронизированные состояния, когда одна вкладка уже показывала новые данные, а другая — старые.
Решением стал Unidirectional Data Flow с единственным источником истины.
Я создал один ExpenseUiState, который хранит всё состояние приложения.
package org.paraspatil.expensesplitter.presentation
import org.paraspatil.expensesplitter.domain.model.Expense
import org.paraspatil.expensesplitter.domain.model.ExpenseGroup
import org.paraspatil.expensesplitter.domain.model.Person
import org.paraspatil.expensesplitter.domain.settlement.Settlements
data class ExpenseUiState(
val people: List<Person> = emptyList(),
val expenses: List<Expense> = emptyList(),
val settlements: List<Settlements> = emptyList(),
val balances: Map<String, Double> = emptyMap(),
val newPersonName: String = "",
val expenseAmount: String = "",
val expenseDescription: String = "",
val selectedPaidBy: String = "",
val isLoadings: Boolean = false,
val errorMessages: String? = null,
val isSplashScreenFinished: Boolean = false,
val historyGroups: List<ExpenseGroup> = emptyList(),
val currentGroupID: String = "default",
val currentGroupName: String = "Current Split",
val showResetDialog: Boolean = false,
val renameTextFieldValue: String = "",
val splitType: String = "EQUAL",
val splitValues: Map<String, String> = emptyMap()
)
ViewModel предоставляет единственный StateFlow.
scope.launch {
combine(
repository.getAllPeople(),
repository.getAllExpenses()
) { people, expenses ->
Pair(people, expenses)
}.collect { (people, expenses) ->
val result = calculateExpenseUseCase(people, expenses)
_uiState.update {
it.copy(
people = people,
expenses = expenses,
balances = result.balances,
settlements = result.settlements
)
}
}
}
Теперь все вкладки подписаны на один и тот же поток. Когда база данных меняется после добавления человека или расхода, оператор combine автоматически публикует новое состояние. Все три вкладки обновляются одновременно. Без ручного обновления. Без рассинхронизации.
Это тот паттерн, который стоило использовать с первого дня. Один state. Один flow. Ноль несогласованности.
Сложность №4 — FAB съела мою кнопку
После исправления я над этим смеялся. До исправления — совсем нет.
Когда в Compose вы размещаете FloatingActionButton внутри Scaffold, она обычно находится в правом нижнем углу. Это нормально. Но во вкладке Settlement у меня также была кнопка Reset All, размещённая внизу содержимого.
На мобильном устройстве FAB оказалась прямо поверх неё. Кнопка существовала в коде. Но на экране её было не видно. И пользователь никак не мог нажать её.
Это типичный конфликт интерфейса, особенно неприятный для Compose Multiplatform, потому что layout общий, а размеры экранов могут различаться радикально — от мобильного телефона до большого настольного окна.
Исправление оказалось простым: я добавил стратегический нижний отступ для FloatingActionButton, чтобы поднять её над нижней частью интерфейса.
// In ExpenseScreen.kt (Shared UI)
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = { /* Add Expense Logic */ },
containerColor = MaterialTheme.colorScheme.primary,
// The Fix: 72.dp padding ensures the FAB sits safely
// above the "Reset All" button in the Settlement tab.
modifier = Modifier.padding(bottom = 72.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
}
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
when (selectedTabIndex) {
0 -> SettlementTab(viewModel, uiState) // Contains "Reset All" at the bottom
1 -> ExpenseTab(uiState)
2 -> PeopleTab(viewModel, uiState)
}
}
}
Но урок здесь гораздо шире, чем просто правильный padding. В Compose Multiplatform нужно тестировать layout одновременно на мобильных и настольных устройствах как можно раньше. То, что идеально выглядит на большом мониторе, может полностью сломаться на экране шириной 360dp.
Сложность №5 — пользователи обязательно сломают ваше приложение. Валидация не опциональна
Об этом я подумал слишком поздно. Нужно было думать с самого начала.
Что произойдёт, если пользователь откроет приложение и нажмёт Add Expense до того, как добавит хотя бы одного человека?
В моих ранних сборках — падение. Деление на ноль внутри расчёта. Не аккуратная ошибка. Просто некрасивый креш.
Решением стала проверка входных данных внутри ViewModel. Вместо того чтобы позволять приложению падать, я добавил проверки в addExpense(), которые записывают сообщение в поле errorMessages внутри ExpenseUiState. UI читает это поле и показывает ошибку пользователю.
fun addExpense() {
val currentState = _uiState.value
if (currentState.expenseAmount.isBlank() || currentState.selectedPaidBy.isBlank()) {
_uiState.value = currentState.copy(
errorMessages = "Please enter amount and select paid by"
)
return
}
try {
val amount = currentState.expenseAmount.toDouble()
if (amount <= 0) {
_uiState.value = currentState.copy(
errorMessages = "Amount must be greater than 0"
)
return
}
// ... split logic continues
} catch (e: Exception) {
_uiState.value = currentState.copy(
errorMessages = "Invalid input: ${e.message}"
)
}
}
Никакого падения, просто понятное сообщение о том, что нужно сделать. Например, Snackbar: «Сначала добавьте участников». Причём ещё до того, как пользователь начнёт вводить расход.
Более общий принцип: никогда не предполагайте, что пользователи будут пользоваться приложением в том порядке, который вы придумали. Не будут. Проверяйте всё на границе между UI и бизнес-логикой.
Самое важное изменение мышления
Если свести всё, чему я научился во время создания этого приложения, к одной фразе, она звучит так:
Перестаньте мыслить платформами. Начните мыслить слоями.
Как Android-разработчик, я привык спрашивать: «Куда этот код должен идти в Android?». KMP заставляет задавать другой вопрос: «А этому коду вообще нужно знать, на какой платформе он работает?».
В 95% случаев ответ — нет. Бизнес-логике всё равно, работает она на Android-смартфоне, Windows-компьютере или в браузере. Алгоритм погашения долгов не знает, какая под ним операционная система. StateFlow не волнует, собирает ли его Composable в нативном desktop-окне или веб-страница через Wasm.
Помещайте эти 95% в commonMain. Используйте expect/actual только для оставшихся 5%, которым действительно нужна информация о платформе: пути к базе данных, файловый доступ, платформенные API.
Именно этот сдвиг мышления делает KMP действительно мощным. И именно его, на мой взгляд, слишком плохо объясняют в руководствах для начинающих.
Что дальше
Приложение имеет открытый исходный код: expense-splitter-kmp.
Если вы Android-разработчик и вам интересно попробовать KMP, мой совет очень простой: просто начните. Возьмите небольшой проект. Сделайте его. Путаница пройдёт. Полученные навыки останутся.
-
Новости4 недели назадВидео и подкасты о мобильной разработке 2026.31
-
Аналитика промо-кампаний4 недели назадКак проверить, упоминают ли нейросети ваш бренд: обзор пяти сервисов
-
Разработка4 недели назад50 вопросов о System Design, ответы на которые должен знать каждый Senior iOS-разработчик: часть 4
-
Новости3 недели назадВидео и подкасты о мобильной разработке 2026.32
