Статья об использовании мультиплатформенного решения на 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
// файл MainActivity.kt class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { App() } } } // файл MyApplication.kt class MyApplication : Application() { override fun onCreate() { super.onCreate() initKoin { androidContext(this@MyApplication) } } }
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
Связь модуля с приложением Android и iOS
# Android dependencies { ... implementation(project(":shared")) } # iOS val xcfName = "sharedKit" iosX64 { binaries.framework { baseName = xcfName } } iosArm64 { binaries.framework { baseName = xcfName } } iosSimulatorArm64 { binaries.framework { baseName = xcfName } }
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
ссылка на оригинал статьи https://habr.com/ru/articles/941196/
Добавить комментарий