Compose Multiplatform простое приложение c MVI

от автора

Статья об использовании мультиплатформенного решения на 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

Clean Architecture

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   } }

https://developer.android.com/kotlin/multiplatform/migrate

Settings

Theme

Theme

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!")         }      } }
feed

feed

Достаточно добавить роутов 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

Еще не провер но пишут что решили

https://youtrack.jetbrains.com/projects/CMP/issues/CMP-8764/iOS-Application-crashed-when-Touch-the-OutlinedTextField

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://github.com/app-z/Passwords

Ссылки по теме


ссылка на оригинал статьи https://habr.com/ru/articles/941196/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *