Как использовать Kotlin Multiplatform ViewModel в SwiftUI и Jetpack Compose

от автора

Мы в IceRock Development уже много лет пользуемся подходом MVVM, а последние 4 года наши ViewModel расположены в общем коде, за счет использования нашей библиотеки moko-mvvm. В последний год мы активно переходим на использование Jetpack Compose и SwiftUI для построения UI в наших проектах. И это потребовало улучшения MOKO MVVM, чтобы разработчикам на обеих платформах было удобно работать с таким подходом.

30 апреля 2022 вышла новая версия MOKO MVVM — 0.13.0. В этой версии появилась полноценная поддержка Jetpack Compose и SwiftUI. Разберем на примере как можно использовать ViewModel из общего кода с данными фреймворками.

Пример будет простой — приложение с экраном авторизации. Два поля ввода — логин и пароль, кнопка Войти и сообщение о успешном входе после секунды ожидания (во время ожидания крутим прогресс бар).

Создаем проект

Первый шаг простой — берем Android Studio, устанавливаем Kotlin Multiplatform Mobile IDE плагин, если еще не установлен. Создаем проект по шаблону «Kotlin Multiplatform App» с использованием CocoaPods integration (с ними удобнее, плюс нам все равно потребуется подключать дополнительный CocoaPod).

KMM wizard
KMM wizard

git commit

Экран авторизации на Android с Jetpack Compose

В шаблоне приложения используется стандартный подход с Android View, поэтому нам нужно подключить Jetpack Compose перед началом верстки.

Включаем в androidApp/build.gradle.kts поддержку Compose:

val composeVersion = "1.1.1"  android {     // ...      buildFeatures {         compose = true     }     composeOptions {         kotlinCompilerExtensionVersion = composeVersion     } }

И подключаем необходимые нам зависимости, удаляя старые ненужные (относящиеся к обычному подходу с View):

dependencies {     implementation(project(":shared"))      implementation("androidx.compose.foundation:foundation:$composeVersion")     implementation("androidx.compose.runtime:runtime:$composeVersion")     // UI     implementation("androidx.compose.ui:ui:$composeVersion")     implementation("androidx.compose.ui:ui-tooling:$composeVersion")     // Material Design     implementation("androidx.compose.material:material:$composeVersion")     implementation("androidx.compose.material:material-icons-core:$composeVersion")     // Activity     implementation("androidx.activity:activity-compose:1.4.0")     implementation("androidx.appcompat:appcompat:1.4.1") }

При выполнении Gradle Sync получаем сообщение о несовместимости версии Jetpack Compose и Kotlin. Это связано с тем что Compose использует compiler plugin для Kotlin, а их API пока не стабилизировано. Поэтому нам нужно поставить ту версию Kotlin, которую поддерживает используемая нами версия Compose — 1.6.10.

Далее остается сверстать экран авторизации, привожу сразу готовый код:

@Composable fun LoginScreen() {     val context: Context = LocalContext.current     val coroutineScope: CoroutineScope = rememberCoroutineScope()      var login: String by remember { mutableStateOf("") }     var password: String by remember { mutableStateOf("") }     var isLoading: Boolean by remember { mutableStateOf(false) }      val isLoginButtonEnabled: Boolean = login.isNotBlank() && password.isNotBlank() && !isLoading      Column(         modifier = Modifier.padding(16.dp),         horizontalAlignment = Alignment.CenterHorizontally     ) {         TextField(             modifier = Modifier.fillMaxWidth(),             value = login,             enabled = !isLoading,             label = { Text(text = "Login") },             onValueChange = { login = it }         )         Spacer(modifier = Modifier.height(8.dp))         TextField(             modifier = Modifier.fillMaxWidth(),             value = password,             enabled = !isLoading,             label = { Text(text = "Password") },             visualTransformation = PasswordVisualTransformation(),             onValueChange = { password = it }         )         Spacer(modifier = Modifier.height(8.dp))         Button(             modifier = Modifier                 .fillMaxWidth()                 .height(48.dp),             enabled = isLoginButtonEnabled,             onClick = {                 coroutineScope.launch {                     isLoading = true                     delay(1000)                     isLoading = false                     Toast.makeText(context, "login success!", Toast.LENGTH_SHORT).show()                 }             }         ) {             if (isLoading) CircularProgressIndicator(modifier = Modifier.size(24.dp))             else Text(text = "Login")         }     } }

И вот наше приложение для Android с экраном авторизации готово и функционирует как требуется, но без общего кода.

Приложение на Android
Приложение на Android

git commit

Экран авторизации в iOS с SwiftUI

Сделаем тот же экран в SwiftUI. Шаблон уже создал SwiftUI приложение, поэтому нам достаточно просто написать код экрана. Получаем следующий код:

struct LoginScreen: View {     @State private var login: String = ""     @State private var password: String = ""     @State private var isLoading: Bool = false     @State private var isSuccessfulAlertShowed: Bool = false          private var isButtonEnabled: Bool {         get {             !isLoading && !login.isEmpty && !password.isEmpty         }     }          var body: some View {         Group {             VStack(spacing: 8.0) {                 TextField("Login", text: $login)                     .textFieldStyle(.roundedBorder)                     .disabled(isLoading)                                  SecureField("Password", text: $password)                     .textFieldStyle(.roundedBorder)                     .disabled(isLoading)                                  Button(                     action: {                         isLoading = true                         DispatchQueue.main.asyncAfter(deadline: .now() + 1) {                             isLoading = false                             isSuccessfulAlertShowed = true                         }                     }, label: {                         if isLoading {                             ProgressView()                         } else {                             Text("Login")                         }                     }                 ).disabled(!isButtonEnabled)             }.padding()         }.alert(             "Login successful",             isPresented: $isSuccessfulAlertShowed         ) {             Button("Close", action: { isSuccessfulAlertShowed = false })         }     } }

Логика работы полностью идентична Android версии и также не использует никакой общей логики.

git commit

Реализуем общую ViewModel

Все подготовительные шаги завершены. Пора вынести из платформ логику работы экрана авторизации в общий код.

Первое, что для этого мы сделаем — подключим в общий модуль зависимость moko-mvvm и добавим ее в список export’а для iOS framework (чтобы в Swift мы видели все публичные классы и методы этой библиотеки).

val mokoMvvmVersion = "0.13.0"  kotlin {     // ...      cocoapods {         // ...                  framework {             baseName = "MultiPlatformLibrary"              export("dev.icerock.moko:mvvm-core:$mokoMvvmVersion")             export("dev.icerock.moko:mvvm-flow:$mokoMvvmVersion")         }     }      sourceSets {         val commonMain by getting {             dependencies {                 api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1-native-mt")                  api("dev.icerock.moko:mvvm-core:$mokoMvvmVersion")                 api("dev.icerock.moko:mvvm-flow:$mokoMvvmVersion")             }         }         // ...         val androidMain by getting {             dependencies {                 api("dev.icerock.moko:mvvm-flow-compose:$mokoMvvmVersion")             }         }         // ...     } }

Также мы изменили baseName у iOS Framework на MultiPlatformLibrary. Это важное изменение, без которого мы в дальнейшем не сможем подключить CocoaPod с функциями интеграции Kotlin и SwiftUI.

Осталось написать саму LoginViewModel. Вот код:

class LoginViewModel : ViewModel() {     val login: MutableStateFlow<String> = MutableStateFlow("")     val password: MutableStateFlow<String> = MutableStateFlow("")      private val _isLoading: MutableStateFlow<Boolean> = MutableStateFlow(false)     val isLoading: StateFlow<Boolean> = _isLoading      val isButtonEnabled: StateFlow<Boolean> =         combine(isLoading, login, password) { isLoading, login, password ->             isLoading.not() && login.isNotBlank() && password.isNotBlank()         }.stateIn(viewModelScope, SharingStarted.Eagerly, false)      private val _actions = Channel<Action>()     val actions: Flow<Action> get() = _actions.receiveAsFlow()      fun onLoginPressed() {         _isLoading.value = true         viewModelScope.launch {             delay(1000)             _isLoading.value = false             _actions.send(Action.LoginSuccess)         }     }      sealed interface Action {         object LoginSuccess : Action     } }

Для полей ввода, которые может менять пользователь, мы использовали MutableStateFlow из kotlinx-coroutines (но можно использовать и MutableLiveData из moko-mvvm-livedata). Для свойств, которые UI должен отслеживать, но не должен менять — используем StateFlow. А для оповещения о необходимости что-то сделать (показать сообщение о успехе или чтобы перейти на другой экран) мы создали Channel, который выдается на UI в виде Flow. Все доступные действия мы объединяем под единый sealed interface Action, чтобы точно было известно какие действия может сообщить данная ViewModel.

git commit

Подключаем общую ViewModel к Android

На Android чтобы получить из ViewModelStorage нашу ViewModel (чтобы при поворотах экрана мы получали туже-самую ViewModel) нам нужно подключить специальную зависимость в androidApp/build.gradle.kts:

dependencies {     // ...     implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1") }

Далее добавим в аргументы нашего экрана LoginViewModel:

@Composable fun LoginScreen(     viewModel: LoginViewModel = viewModel() )

Заменим локальное состояние экрана, на получение состояния из LoginViewModel:

val login: String by viewModel.login.collectAsState() val password: String by viewModel.password.collectAsState() val isLoading: Boolean by viewModel.isLoading.collectAsState() val isLoginButtonEnabled: Boolean by viewModel.isButtonEnabled.collectAsState()

Подпишемся на получение действий от ViewModel’и используя observeAsAction из moko-mvvm:

viewModel.actions.observeAsActions { action ->     when (action) {         LoginViewModel.Action.LoginSuccess ->             Toast.makeText(context, "login success!", Toast.LENGTH_SHORT).show()     } }

Заменим обработчик ввода у TextField‘ов с локального состояния на запись в ViewModel:

TextField(     // ...     onValueChange = { viewModel.login.value = it } )

И вызовем обработчик нажатия на кнопку авторизации:

Button(     // ...     onClick = viewModel::onLoginPressed ) {     // ... }

Запускаем приложение и видим что все работает точно также, как работало до общего кода, но теперь вся логика работы экрана управляется общей ViewModel.

git commit

Подключаем общую ViewModel к iOS

Для подключения LoginViewModel к SwiftUI нам потребуются Swift дополнения от MOKO MVVM. Подключаются они через CocoaPods:

pod 'mokoMvvmFlowSwiftUI', :podspec => 'https://raw.githubusercontent.com/icerockdev/moko-mvvm/release/0.13.0/mokoMvvmFlowSwiftUI.podspec'

А также, в самой LoginViewModel нужно внести изменения — со стороны Swift MutableStateFlowStateFlowFlow потеряют generic type, так как это интерфейсы. Чтобы generic не был потерян нужно использовать классы. MOKO MVVM предоставляет специальные классы CMutableStateFlowCStateFlow и CFlow для сохранения generic type в iOS. Приведем типы следующим изменением:

class LoginViewModel : ViewModel() {     val login: CMutableStateFlow<String> = MutableStateFlow("").cMutableStateFlow()     val password: CMutableStateFlow<String> = MutableStateFlow("").cMutableStateFlow()      // ...     val isLoading: CStateFlow<Boolean> = _isLoading.cStateFlow()      val isButtonEnabled: CStateFlow<Boolean> =         // ...         .cStateFlow()          // ...     val actions: CFlow<Action> get() = _actions.receiveAsFlow().cFlow()      // ... }

Теперь можем переходить в Swift код. Для интеграции делаем следующее изменение:

import MultiPlatformLibrary import mokoMvvmFlowSwiftUI import Combine  struct LoginScreen: View {     @ObservedObject var viewModel: LoginViewModel = LoginViewModel()     @State private var isSuccessfulAlertShowed: Bool = false          // ... }

Мы добавляем viewModel в View как @ObservedObject, также как мы делаем с Swift версиями ViewModel, но в данном случе, за счет использования mokoMvvmFlowSwiftUI мы можем передать сразу Kotlin класс LoginViewModel.

Далее меняем привязку полей:

TextField("Login", text: viewModel.binding(\.login))     .textFieldStyle(.roundedBorder)     .disabled(viewModel.state(\.isLoading))

mokoMvvmFlowSwiftUI предоставляет специальные функции расширения к ViewModel:

  • binding возвращает Binding структуру, для возможности изменения данных со стороны UI

  • state возвращает значение, которое будет автоматически обновляться, когда StateFlow выдаст новые данные

Аналогичным образом заменяем другие места использования локального стейта и подписываемся на действия:

.onReceive(createPublisher(viewModel.actions)) { action in     let actionKs = LoginViewModelActionKs(action)     switch(actionKs) {     case .loginSuccess:         isSuccessfulAlertShowed = true         break     } }

Функция createPublisher также предоставляется из mokoMvvmFlowSwiftUI и позволяет преобразовать CFlow в AnyPublisher от Combine. Для надежности обработки действий мы используем moko-kswift. Это gradle плагин, который автоматически генерирует swift код, на основе Kotlin. В данном случае был сгенерирован Swift enum LoginViewModelActionKs из sealed interface LoginViewModel.Action. Используя автоматически генерируемый enum мы получаем гарантию соответствия кейсов в enum и в sealed interface, поэтому теперь мы можем полагаться на exhaustive логику switch. Подробнее про MOKO KSwift можно прочитать в статье.

В итоге мы получили SwiftUI экран, который управляется из общего кода используя подход MVVM.

git commit

Выводы

В разработке с Kotlin Multiplatform Mobile мы считаем важным стремиться предоставить удобный инструментарий для обеих платформ — и Android и iOS разработчики должны с комфортом вести разработку и использование какого-либо подхода в общем коде не должно заставлять разработчиков одной из платформ делать лишнюю работу. Разрабатывая наши MOKO библиотеки и инструменты мы стремимся упростить работу разработчиков и под Android и iOS. Интеграция SwiftUI и MOKO MVVM потребовала множество экспериментов, но итоговый результат выглядит удобным в использовании.

Вы можете самостоятельно попробовать проект, созданный в этой статье, на GitHub.

Также, если вас интересует тема Kotlin Multiplatform Mobile, рекомендуем наши материалы на kmm.icerock.dev.

Для начинающих разработчиков, желающих погрузиться в разработку под Android и iOS с Kotlin Multiplatform у нас работает корпоративный университет, материалы которого доступны всем на kmm.icerock.dev — University. Желающие узнать больше о наших подходах к разработке могут также ознакомиться с материалами университета.

Мы также можем помочь и командам разработки, которым нужна помощь в разработке или консультации по теме Kotlin Multiplatform Mobile.


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


Комментарии

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

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