Статья об использовании мультиплатформенного решения на Compose с минимальным количеством сторонних beta библиотек
Gradle
Добавление зависимостей для каждой платформы делается в build.gradle.kts
androidMain | Android | |
commonMain | Общие библиотеки | Для всех платформ |
iosMain | ios |
sourceSets
sourceSets {
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.ktor.client.android)
implementation(libs.koin.androidx.compose)
// Koin
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
}
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(compose.materialIconsExtended)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.data.store.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.androidx.room.runtime)
implementation(libs.sqlite.bundled)
implementation(libs.coil)
implementation(libs.coil.compose)
implementation(libs.coil.network)
implementation(libs.navigation.compose)
// implementation(libs.screen.size)
// Koin
api(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.composeVM)
implementation(libs.ktor.logging)
implementation("org.jetbrains.compose.ui:ui-backhandler:1.8.2")
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
Точки входа в приложение на разных платформах
MainActivity - Android
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}
iOSApp - iOs
// файл App.kt
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
// файл ContentView.swift
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
}
}
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea()
}
}
Это минимальный код для запуска общего кода который находится в commonMain.
Очень порадовало что koin с версии 4 поддерживает создание ViewModel в commonMain кроссплатформенном коде без доработок iOs/Android зависимого кода
В общем-то при добавлении экранов, запросов в сеть не требуется каких либо добавлений для каждой платформы
Но для базы данных Room и DataStore требуется добавить один раз Адаптер Expect/Actual
Основная идея сделать мост к файловой системе определенной платформы. Например для Room это сделано через expect/actual так:
expect fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase>
RoomDatabase actual
// iOs
actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val dbFilePath = documentDirectory() + "/$DB_Name"
return Room.databaseBuilder<AppDatabase>(
name = dbFilePath,
)
}
@OptIn(ExperimentalForeignApi::class)
private fun documentDirectory(): String {
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
return requireNotNull(documentDirectory?.path)
}
// Android
actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val appContext = KoinPlatform.getKoin().get<Application>()
val dbFile = appContext.getDatabasePath(DB_Name)
return Room.databaseBuilder<AppDatabase>(
context = appContext,
name = dbFile.absolutePath
)
}
Модуль koin DI создается в commonMain
databaseModule
val databaseModule = module {
// database
single {
getRoomDatabase(getDatabaseBuilder())
}
}
fun getRoomDatabase(
builder: RoomDatabase.Builder<AppDatabase>
): AppDatabase {
return builder
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(DispatchersRepository.io())
.fallbackToDestructiveMigration(
dropAllTables = true
)
.build()
}
Есть один нюанс при работе с iOs. Dao interface должен возвращать Flow или быть suspend иначе под iOs приложение падает.
@Dao
@Dao
interface PasswordsDao {
@Query("SELECT * FROM Passwords ORDER BY id")
fun getAllPasswords(): Flow<List<PasswordsEntity>>
@Query("SELECT * FROM Passwords WHERE name LIKE '%' || :filter || '%' ORDER BY id")
fun getFilteredPasswords(filter: String): Flow<List<PasswordsEntity>>
@Query("SELECT * FROM Passwords WHERE id = :id")
suspend fun getPasswords(id: String): PasswordsEntity
@Query("SELECT COUNT(*) as count FROM Passwords")
suspend fun count(): Int
@Query("SELECT id FROM Passwords ORDER BY id DESC")
suspend fun getMaxId(): String
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAllPasswords(passwords: List<PasswordsEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPassword(password: PasswordsEntity)
@Update(onConflict = OnConflictStrategy.IGNORE)
suspend fun updatePassword(password: PasswordsEntity)
@Delete
suspend fun deletePassword(password: PasswordsEntity)
}
Аналогичным образом подключается DataStore, который используется для хранения AppPreferences. В проекте таким образом хранится Theme.
AppPreferences
class AppPreferences(
private val dataStore: DataStore<Preferences>
) {
private val themeKey = stringPreferencesKey("com.spacex/theme")
suspend fun getTheme() = dataStore.data.map { preferences ->
preferences[themeKey] ?: Const.Theme.DARK_MODE.name
}.first()
suspend fun changeThemeMode(value: String) = dataStore.edit { preferences ->
preferences[themeKey] = value
}
}
Clean Architecture

Структура папок проекта commonMain выглядит так. Если требуется добавить отдельный module это можно сделать из меню File->New->Module...->Android->Kotlin multiplatform shared module
Settings

Theme устанавливаются в Settings. Koin может создать несколько копий viewmodel, а хотелось бы динамически переключать тему для всего приложения по клику на чекбокс в Settings. Поэтому создается SettingsViewModel в App() и пробрасываем ее ниже до самого SettingsScreen. Там же в App() устанавливается тема см. PasswordsTheme->MaterialTheme при старте или по переключению чекбокс в экране Settings
fun App()
fun App() {
val settingViewModel = koinViewModel<SettingsViewModel>()
val currentTheme by settingViewModel.viewState.collectAsStateWithLifecycle()
PasswordsTheme(currentTheme.currentTheme) {
NavigationApplication(settingViewModel)
}
}
@Composable
fun PasswordsTheme(
appTheme: String?,
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when (appTheme) {
Const.Theme.LIGHT_MODE.name -> {
LightColorScheme
}
Const.Theme.DARK_MODE.name -> {
DarkColorScheme
}
else -> {
if (darkTheme) {
DarkColorScheme
} else {
LightColorScheme
}
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content,
typography = CustomTypography()
)
}
MVI
Концепция MVVM рекомендует создание val uiState: StateFlow во viewModel который доступен во View (Activity, Fragment, Compose fun). Из этого View дергаются публичные методы viewModel. View соответственно коллектит этот uiState
MVI устроен сложнее. Все методы viewModel не публичные, кроме одного handleEvent(Event). Обращение к viewModel идет через так называемые Events. Еще их называют Intents (Намерения) то что мы намереваемся запросить во viewModel. Это замена дергать публичные метод в viewModel. switch/case как раз и дернет эти методы когда встретит/разберет отправленный Event
Так же добавляется Effect который так же как и uiState доступен View. Effect используется для поднятия Тостов и для навигации, подразумевается что он не влияет на uiState. При чем он SharedFlow
Итого получается две "трубы" из viewModel к View (uiState и Effects) и один публичный handleEvent(Event) во viewModel
Можно немного перенести логику в базовый класс BaseViewModel. В нем создать uiState, Event и Effect
abstract class BaseViewModel<Event : ViewEvent, UiState : ViewState, Effect : ViewSideEffect>
(initUiState: UiState) : ViewModel()
BaseViewModel
package com.storage.passwords.utils
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.spacex.utils.UiText
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import passwords.composeapp.generated.resources.Res
import passwords.composeapp.generated.resources.unknown_error
interface ViewEvent
interface ViewState
interface ViewSideEffect
abstract class BaseViewModel<Event : ViewEvent, UiState : ViewState, Effect : ViewSideEffect>
(initUiState: UiState) : ViewModel() {
val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception: Throwable ->
viewModelScope.launch {
onCoroutineException(
if (exception.message != null)
UiText.StaticString(exception.message!!)
else
UiText.StringResource(Res.string.unknown_error)
)
}
}
abstract fun onCoroutineException(message: UiText)
val defaultViewModelScope = CoroutineScope(SupervisorJob() + coroutineExceptionHandler)
abstract fun runInitialEvent()
abstract fun handleEvents(event: Event)
private val _viewState: MutableStateFlow<UiState> = MutableStateFlow(initUiState)
val viewState = _viewState
.onStart {
runInitialEvent()
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = _viewState.value
)
private val _event: MutableSharedFlow<Event> = MutableSharedFlow()
private val _effect = MutableSharedFlow<Effect>()
val effect = _effect.asSharedFlow()
init {
subscribeToEvents()
}
private fun subscribeToEvents() {
defaultViewModelScope.launch {
_event.collect {
handleEvents(it)
}
}
}
fun setEvent(event: Event) {
defaultViewModelScope.launch { _event.emit(event) }
}
protected fun setState(reducer: UiState.() -> UiState) {
val newState = viewState.value.reducer()
_viewState.value = newState
}
protected fun setEffect(builder: () -> Effect) {
val effectValue = builder()
defaultViewModelScope.launch { _effect.emit(effectValue) }
}
}
Взято отсюда android-compose-mvi-navigation. Немного доработал инициализацию uiState и добавил СoroutineExceptionHandler
val viewState = _viewState
.onStart {
runInitialEvent()
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = _viewState.value
)
Во первых runInitialEvent() перезапустится через 5 сек если была переподписка. Это происходит из-за привязки к жизненному циклу см. ниже Lifecycle.State.STARTED
Например приложение ушло в фон и более чем через 5 сек вернулось. Во вторых после перехода на другой экран, через 5 сек так же произойдет приостановка flow, а при возврате будет перезапущен runInitialEvent(). Это полезно когда например с экрана списка перешли на экран где добавили или изменили запись в базе данных и надо чтобы список обновился при возвращении. init {} во viewModel не сработает! Но опять же надо пробыть на экране добавления/редактирования более 5 сек или изменить значение в WhileSubscribed(t)
Collect
val state = viewModel.viewState.collectAsStateWithLifecycle()
StateFlow compose multiplatform умеет обрабатывать с учетом жизненного цикла из коробки. А SharedFlow нет. Поэтому для Efects применим следующий код для учета жизненного цикла
val effect = viewModel.effect
.flowWithLifecycle(
localLifecycleOwner.lifecycle,
Lifecycle.State.STARTED
)
LaunchedEffect(key1 = localLifecycleOwner.lifecycle) {
effect.collect {
...
UiText
Еще хотел бы отметить одно удобство. Часто из viewModel требуется передать строковый ресурс или саму строку во View. Решение создать обертку причем применятся может как в coroutine контексте так и нет
UiText
sealed interface UiText {
data class StaticString(val value: String) : UiText
class StringResource(
val resId: org.jetbrains.compose.resources.StringResource,
vararg val args: Any
) : UiText
@Composable
fun asString(): String {
return when (this) {
is StaticString -> value
is StringResource -> stringResource(resId, *args)
}
}
suspend fun asStringForSuspend(): String {
return when (this) {
is StaticString -> value
is StringResource -> getString(resId, *args)
}
}
}
Server
При создании проекта IntelliJ IDEA предлагает опцию создать сервер для тестирования
embeddedServer
package com.storage.passwords
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.request.receive
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun main() {
embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
routing {
get("/") {
call.respondText("Ktor: ${Greeting().greet()}")
}
get("/passwords") {
call.response.headers.append(
HttpHeaders.ContentType,
ContentType.Application.Json.toString()
)
call.respondText(
"""
[
{ "id":"1", "name":"password1", "password":"ADAD%ADAD", "note":"I note it"},
{ "id":"2", "name":"password2", "password":"1ADAD%ADAD", "note":"I note it eee"},
{ "id":"3", "name":"password3", "password":"2ADAD%ADAD", "note":"I note it fff"}
]
""".trimMargin()
)
}
get("/submit") {
val receivedData = call.receive<String>() // Assuming plain text data
// Process the received data
println("Received POST data: $receivedData")
// Send a response back to the client
call.respondText("Data received successfully!")
}
post("/submit-password") {
// Receive the data from the request body
val receivedData = call.receive<String>() // Assuming plain text data
// Process the received data
println("Received POST data: $receivedData")
// Send a response back to the client
call.respondText("Data received successfully!")
}
}
}

Достаточно добавить роутов get и post и можно полноценно тестировать
get("/submit") {
get("/passwords") {
call.response.headers.append(
HttpHeaders.ContentType,
ContentType.Application.Json.toString()
)
call.respondText("....")
...
post("/submit-password") {
...
Android Эмулятор не видит адрес http://0.0.0.0:8080/ поэтому можно как вариант запустить ifconfig и посмотреть адрес вида 192.168.1.100
Прописать в ConfigRepository. BuildKonfig.Is_Debug_Server используется для управления переключением при сборке проекта
ConfigRepository
class ConfigRepository {
private val isDebugBuild = BuildKonfig.Is_Debug_Server
fun getBaseUrl(): String {
return if (isDebugBuild)
BASE_URL_DEBUG
else
BASE_URL_RELEASE
}
companion object Companion {
private const val BASE_URL_RELEASE = "https://0.0.0.0:8080"
private const val BASE_URL_DEBUG = "http://192.168.1.215:8080"
// private const val BASE_URL_DEBUG = "http://192.168.231.7:8080"
}
}
Под iOs так же необходимы дополнительные настройки проекта для тестирования с сервером. Не забудьте убрать в продакшн NSExceptionAllowsInsecureHTTPLoads
ios plist file for Netty Server
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>
Для iOs так же потребуется запустить xcode и выбрать team
In Xcode, under the "Signing & Capabilities" tab of an app target, a specific development team must be selected
Глаз

Еще пришлось сделать один хак при просмотре поля по нажатию на глаз. После отпускания кнопки глаза проваливаешься на Detail. Причем требуется небольшая задержка, чтобы этот механизм не сработал
.clickable {
if (timeLeft <= 0) {
onClick.invoke(passwordItem) // переход на Detail
}},
И реализация небольшой задержки. Возможно как-то проще?
delay(100)
var showPassword by remember { mutableStateOf(false) }
val navAllow = derivedStateOf { !showPassword }
LaunchedEffect(key1 = navAllow.value) {
while (timeLeft > 0) {
delay(100)
timeLeft--
}
}
...
// Показать пароль или звездочки
Text(
modifier = Modifier
.padding(top = 4.dp, start = 24.dp),
text = if (showPassword) passwordItem.password else "*********"
)
...
// Пока жмем на иконку видим пароль
Icon(
modifier = Modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
// Handle the down event
showPassword = true
do {
val event = awaitPointerEvent()
} while (event.changes.any { it.pressed })
showPassword = false
timeLeft = 1
}
},
imageVector = if (showPassword) Icons.Filled.Visibility else Icons.Outlined.Visibility,
contentDescription = ""
)

Меню
Меню реализовано через бургер меню. BurgerMenu это обертка над ModalNavigationDrawer
BurgerMenu
BurgerMenu(
drawerState = drawerState,
onAddItem = {
navController.currentBackStackEntry?.savedStateHandle?.apply {
val jsonFalconInfo = Json.encodeToString("-1")
set(PASSWORD_ID_PARAM, jsonFalconInfo)
}
navController.navigate(Screen.Detail.route)
},
onAboutItem = {
navController.navigate(Screen.About.route)
},
onSettingsItem = {
navController.navigate(Screen.Settings.route)
}
) {
NavHost(
...
@Composable
fun BurgerMenu(
onAboutItem: () -> Unit,
onAddItem: () -> Unit,
onSettingsItem: () -> Unit,
drawerState: DrawerState,
content: @Composable () -> Unit
) {
val scope = rememberCoroutineScope()
ModalNavigationDrawer(
drawerState = drawerState,
// gesturesEnabled = drawerState.isOpen,
drawerContent = {
ModalDrawerSheet {
Text("Menu", modifier = Modifier.padding(16.dp))
HorizontalDivider()
NavigationDrawerItem(
label = {
Text(text = stringResource(Res.string.about))
},
selected = false,
onClick = {
onAboutItem.invoke()
scope.launch { drawerState.close() }
}
)
NavigationDrawerItem(
label = {
Text(text = stringResource(Res.string.title_settings))
},
selected = false,
onClick = {
onSettingsItem.invoke()
scope.launch { drawerState.close() }
}
)
NavigationDrawerItem(
label = {
Text(text = stringResource(Res.string.add_password))
},
selected = false,
onClick = {
onAddItem.invoke()
scope.launch { drawerState.close() }
}
)
}
}
) {
// Main screen content
content()
}
}
Navigation
Навигация через NavHost org.jetbrains.androidx.navigation:navigation-compose navigationCompose = "2.9.0-beta05". На момент написания статьи появился navigationCompose = "2.9.0-rc01" в которой исправлено несколько багов
routes
sealed class Screen(val route: String) {
object Home : Screen("home")
object Detail : Screen("detail")
object About : Screen("about")
object Settings : Screen("settings")
}
...
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
PasswordsScreen(
drawerState = drawerState,
currentItem = { password_id ->
navController.currentBackStackEntry?.savedStateHandle?.apply {
val jsonFalconInfo = Json.encodeToString(password_id)
set(PASSWORD_ID_PARAM, jsonFalconInfo)
}
navController.navigate(Screen.Detail.route)
}
)
}
composable(Screen.About.route) {
AboutScreen(
onStartClick = {
navController.popBackStack()
}
)
}
composable(
route = Screen.Detail.route,
) {
navController.previousBackStackEntry?.savedStateHandle?.get<String>(PASSWORD_ID_PARAM)
?.let { jsonId ->
val password_id = Json.decodeFromString<String>(jsonId)
DetailScreen(
password_id = password_id,
onBackHandler = {
navController.popBackStack()
}
)
}
}
composable(Screen.Settings.route) {
SettingsScreen(
viewModel = settingViewModel,
{
navController.popBackStack()
}
)
}
P.S.
OutlinedTextField - не работает в iOs. Приложение падает при попытке редактирования поля. Есть решение по замене на нативное поле или ждать исправления в следующих версиях Compose
Еще не провер но пишут что решили
Can confirm I no longer replicate this issue with
androidx-lifecycle = "2.9.3"
androidx-navigation = "2.9.0-rc01"
Были проблемы с навигацией назад с использованием Gesture, но вроде после обновления библиотеки навигации можно убрать хак BackHandler
BackHandler
BackHandler(enabled = true) {
println("BackHandler")
viewModel.setEvent(DetailEvent.NavigationBack)
}
Проект в котором использован код из статьи доступен на Github