Как стать автором
Поиск
Написать публикацию
Обновить

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

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров949

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

Clean Architecture
Clean Architecture

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

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

Ссылки по теме
Теги:
Хабы:
+4
Комментарии2

Публикации

Ближайшие события