Итак, как я уже говорил в статье Learning KMM: Entry 1…
чтобы заставить себя не бросать этот проект, я попытаюсь написать о своем опыте изучения Kotlin Multiplatform Mobile.
Я не забыл об этом, просто был немного ленив занят 😅.
🥱 Сразу предупреждаю, что эта статья будет немного длиннее, чем первые две, и я разобью ее на две части.
⌛️ TLDR: посмотрите репозиторий и почитайте документацию по MVI Kotlin и декомпозиции здесь.
📸 Обзор
Итак, для начала, эта статья будет посвящена созданию многократно используемых компонентов и классов, которые содержат бизнес-логику для входа в мои barebone представления.
Для этого я использовал следующие пакеты Kotlin
- MVI Kotlin
- Essenty
- Napier
Архитектура MVI очень нова для меня, и я могу объяснять это немного по-идиотски, но, тем не менее, я буду стараться изо всех сил.
⚔️ MVI vs MVVM
MVI расшифровывается как Model-View-Intent и является..:
архитектурный паттерн, использующий однонаправленный поток данных.
Это означает, что данные движутся только в одном направлении от модели к представлению и наоборот. Этот поток помогает поддерживать единый источник истины и обычно содержит единое неизменяемое состояние всего представления. Он, как и MVVM, обычно позволяет модели ничего не знать о представлении.
MVVM расшифровывается как Model-View-ViewModel и является..:
структурирован для разделения программной логики и элементов управления пользовательского интерфейса.
MVVM — это то, с чем я более знаком. Этот паттерн обычно не подразумевает наличие всего неизменяемого состояния представления внутри компонента, но, как уже говорилось, модель представления совершенно не знает о представлении.
Из беглого чтения я знаю, что некоторые предпочитают MVVM MVI, так как в нем меньше компонентов и классов и его легче «запустить», чтобы подключить представления к компонентам бизнес-логики или моделям представления.
Также из беглого исследования, кажется, что есть два популярных пакета/библиотеки, которые обычно используются для MVI и MVVM в мультиплатформенных мобильных проектах на Kotlin.
- MVI Kotlin by arkivanov
- moko mvvm от icerock development
Я выбираю MVI Kotlin только потому, что я ничего не знаю о нем, так что это будет большим опытом обучения.
🏗 Установка
Итак, есть несколько необходимых пакетов, которые нужно добавить в общий файл gradle для начала работы.
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "Shared"
export("com.arkivanov.decompose:decompose:0.6.0")
export("com.arkivanov.essenty:lifecycle:0.4.1")
export("com.arkivanov.mvikotlin:mvikotlin-main:3.0.0-beta02")
}
}
commonMain {
dependencies {
implementation("com.arkivanov.mvikotlin:mvikotlin:3.0.0-beta02")
implementation("com.arkivanov.mvikotlin:rx:3.0.0-beta02")
implementation("com.arkivanov.mvikotlin:mvikotlin-main:3.0.0-beta02")
implementation("com.arkivanov.mvikotlin:mvikotlin-extensions-coroutines:3.0.0-beta02")
implementation("com.arkivanov.decompose:decompose:0.6.0")
implementation("com.arkivanov.essenty:lifecycle:0.4.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2")
implementation("io.github.aakira:napier:2.6.1")
}
}
named("iosMain") {
dependencies {
api("com.arkivanov.mvikotlin:mvikotlin:3.0.0-beta02")
api("com.arkivanov.mvikotlin:rx:3.0.0-beta02")
api("com.arkivanov.mvikotlin:mvikotlin-main:3.0.0-beta02")
api("com.arkivanov.mvikotlin:mvikotlin-extensions-coroutines:3.0.0-beta02")
api("com.arkivanov.decompose:decompose:0.6.0")
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2")
api("com.arkivanov.essenty:lifecycle:0.4.1")
api("io.github.aakira:napier:2.6.1")
}
}
Здесь есть несколько довольно специфических моментов, на которые следует обратить внимание, и с которыми я столкнулся. Во-первых, вам нужно убедиться, что зависимости iOS помечены как «api», а не «implementation», и, если честно, я не знаю, почему 😅. Во-вторых, важно убедиться, что вы экспортировали эти три библиотеки в ваш общий iOS-фреймворк, чтобы вы могли использовать их в своем Swift-коде. В проекте Android вы можете обойтись добавлением этих зависимостей прямо в файл gradle. Как только вы все это настроите, вы должны быть готовы к работе 🎲.
Создание модели 🎨
Первая часть MVI — это модель, вы не можете иметь MVI без модели!
В данном случае модель на самом деле очень проста для экрана входа в систему с двумя входами.
data class Model(
val username: String,
val password: String
)
🏪 Магазин
Хранилище — это место, где будет находиться бизнес-логика, которое будет принимать все «намерения» и «сообщения» и выводить все «метки» и «состояния».
В пакете MVI Kotlin вы можете выбрать расширения Reaktive или Coroutine и создать красивое хранилище. Я выбрал Coroutines из личных предпочтений и опыта.
Сначала для нашего магазина нам нужно определить состояние.
internal data class State(
val username: String = "",
val password: String = ""
)
Необходимо определить «Намерения» магазина. Мне нравится думать о «намерениях» как о действиях, которые «намереваются изменить состояние». В данном случае их три.
- Одно для обновления имени пользователя
- Одно для обновления пароля
- Одно обновление для попытки входа в систему
internal sealed interface Intent {
data class UpdateUsername(val username: String): Intent
data class UpdatePassword(val password: String): Intent
data class Login(val username: String, val password: String): Intent
}
Теперь, когда «Намерение» отправляется в магазин, оно выполняется «Исполнителем», который вы увидите в ближайшее время. Когда «Намерение» выполняется, оно выводит «Сообщение». Это «Сообщение» содержит все, что произошло в результате выполнения «Намерения». Выходной сигнал, или «Сообщение», затем отправляется в «Редуктор», который уменьшает состояние, используя «Сообщение». Это звучит очень запутанно, но все становится немного понятнее, когда вы видите реализацию.
Сначала «Магазин» во всей своей красе ⭐️
@OptIn(ExperimentalMviKotlinApi::class)
internal fun loginStore(storeFactory: StoreFactory) : Store<Intent, State, Nothing> =
storeFactory.create<Intent, Nothing, Msg, State, Nothing>(
name = "LoginStore",
initialState = State(),
executorFactory = coroutineExecutorFactory {
onIntent<Intent.Login> { intent ->
val token = logIn(intent.username, intent.password)
dispatch(Msg.LoggedIn(token))
}
onIntent<Intent.UpdateUsername> { dispatch(Msg.UpdateUsername(it.username)) }
onIntent<Intent.UpdatePassword> { dispatch(Msg.UpdatePassword(it.password)) }
},
reducer = { msg ->
when (msg) {
is Msg.LoggedIn -> copy()
is Msg.UpdatePassword -> copy(password = msg.password)
is Msg.UpdateUsername -> copy(username = msg.username)
}
}
)
private fun logIn(username: String, password:String) : String {
// Authenticate the user
// this is probably going to take a while
// this will also return a auth token
Napier.i("called log in $username, $password")
return "AuthToken"
}
А теперь «Сообщения» 📨.
private sealed interface Msg {
class UpdateUsername(val username: String): Msg
class UpdatePassword(val password: String): Msg
class LoggedIn(authToken: String) : Msg
}
Окончательное разбиение:
Возможно, более простой способ объяснить — это просто показать, как кто-то пытается войти в систему.
- Пользователь загружает экран входа в систему с состоянием по умолчанию нового класса данных Login (имя пользователя и пароль в пустой строке).
- Пользователь набирает имя пользователя, и каждое нажатие клавиши посылает «Intent» исполнителю.
- Поскольку при изменении имени пользователя нет никакой логики, мы немедленно отправляем «Сообщение», содержащее последнюю строку имени пользователя.
- Редуктор» подхватывает новое имя пользователя и КОПИРУЕТ (подчеркивание — копирует) весь объект состояния с новым именем пользователя.
- Те же шаги (2-4) выполняются с паролем.
- После того, как пользователь ввел имя пользователя и пароль, он может нажать кнопку для входа в систему.
- В исполнителе для «входа» вы можете увидеть, что мы имитируем некоторый тип бизнес-логики, которая попадает в механизм Auth для проверки имени пользователя и пароля и возвращает «токен Auth».
- После возврата токена мы отправляем сообщение, содержащее токен, который затем может быть скопирован в состояние или, надеюсь, в будущем сохранен локально.
Поскольку мы на самом деле не подключили наш аутентификатор (собираемся использовать Firebase Auth в следующем посте, надеюсь 🤞), я добавил некоторые логи, чтобы просто перепроверить, что отправляется в функцию входа.
Надеюсь, это объяснение имеет для вас некоторый смысл 🙂 .
И здесь я собираюсь перейти ко второй (технически 4-й) части серии, чтобы поговорить о том, как подключить магазин входа к представлению.
Оставайтесь с нами до выхода 4 части и, как всегда, вы можете просмотреть весь код здесь, в репозитории Voix GitHub.