Почему так удобно использовать паттерн MVI в KMM
Привет!
Меня зовут Стефан Серхир. Я мобильный разработчик в KTS. Пишу под Android, iOS и КММ (Kotlin Multiplatform Mobile) и веду курсы в школе Metaclass. Недавно мы провели вебинар, в котором разобрали Model-View-Intent (MVI) в KMM на практике и посмотрели, как это выглядит в коде iOS и Android. Статья написана по мотивам этого вебинара.
Подход MVI в KMM очень удобен, потому что:
Удобно шарить бизнес-логику между всеми платформами
Можно выделять отдельный функционал в фича-модули
Сам MVI позволяет легко разделять экран на различные состояния и менять их в зависимости от действий пользователя
MVI очень легко ложится на Jetpack Compose (Android) и SwiftUi (iOS)
Что будет в статье:
Что такое MVI
Про MV-паттерны мы подробно говорили на другом вебинаре.
MVI (Model-View-Intent) — архитектурный паттерн, который базируется на идее Unidirectional Data Flow (однонаправленного потока данных) и хорошо сочетается с декларативными UI-фреймворками, такими как Compose на Android и SwiftUI на iOS. У MVI есть несколько реализаций — это MVICore, MVIKotlin, Mosby и еще много других, а некоторые из них являются мультиплатформенными, то есть поддерживают KMP (Kotlin Multiplatform).
Исторически корни MVI тянутся еще с веба, но уже успешно применяются в том же Flutter (BLoC).
Итак, у нас есть UI — наша вьюшка (View), которую видят пользователи. Суть MVI заключается в том, что пользователи могут взаимодействовать с вьюшкой с помощью специальных интентов — в данном случае это означает намерение пользователей совершить действие в UI.
Интент посылается в так называемую черную коробку, которая обрабатывает его и возвращает наружу новый стейт — обновляет модель. Так как вьюшка подписана на этот стейт, она получает его и рендерит новый стейт.
В итоге у UI есть только одна ответственность — отрисовать текущий стейт. То есть в нашей вьюшке нет логики.
Эту черную коробку обычно называют MVI-Feature (MVI-фича), а то, каким образом она преобразует один стейт в другой, — бизнес-логикой.
Преимущества и недостатки MVI
Преимущества:
Хорошо интегрируется с декларативными UI фреймворками, такими как Compose и SwiftUI
Один единственный стейт, который рисуется на вьюшке
Стейт меняется в одном месте. Благодаря этому легко дебажить, и на экране невозможно увидеть не консистентное состояние. То есть мы всегда видим один конкретный стейт
Недостатки:
Стейт может быть большим на сложном экране, например, может быть 15 полей в стейте. Если не считать дифф, а каждый раз перерисовывать весь экран при обновлении стейта, то UI может подлагивать… Для этого нужно считать дифф состояния и перерисовывать только изменения.
Что такое KMM
KMM (Kotlin Multiplatform Mobile) — подмножество Kotlin Multiplatform.
Kotlin Multiplatform — это технология, которая позволяет писать код на языке Kotlin под множество платформ — или, в понятиях KMP, таргетов. Таргетами может быть любая популярная платформа, например, десктоп: Linux, MacOS, Windows. Если говорить про мобильные операционные системы — это Android и iOS. Еще с недавнего времени стала поддерживаться «Аврора ОС».
Также Kotlin Multiplatform поддерживает Web и операционные системы умных часов, например, watchOS.
В этой статье мы будем рассматривать конкретно KMM. У нас будет два таргета: Android и iOS. А также акцентируем внимание на работе MVI с этими двумя платформами.
У нас в проекте есть два приложения на iOS и Android, которые содержат в себе нативный код. При этом эти платформы зависят от shared code (еще его называют common code, общий код). Общий код может содержать бизнес-логику, базовые штуки по типу работы с сетью или базой данных и сложные вычисления.
Также в common code может быть платформенно-специфичный код. В случае с KMM — это iOS- и Android-специфичные API и классы. Это преимущество KMM, так как он позволяет закрывать платформенные штуки «интерфейсами», используя механизм expect/actual. Возможные реализации мы разбирали в статье «KMM глазами iOS-разработчика».
Разберем проект, чтобы посмотреть, как это все работает.
MVI в KMM на практике
Рассмотрим MVI на практике. Пример проекта лежит на GitHub.
В этом проекте у нас есть два приложения: iOS и Android. У них один экран и простой интерфейс, которые мы показали выше.
В этих приложениях мы можем загрузить информацию о пользователе: имя, фамилия, дата рождения, пол и тип девайса. Также есть одна кнопка «Загрузить». Если нажать на нее, у нас появляется экран загрузки.
Также у нас может случиться ошибка, о чем приложение оповестит нас:
В этом случае будет возможность перезагрузки. И в итоге мы сможем получить наши данные.
У приложений нет навигации, а только один экран и одна MVI Feature. Под капотом и iOS, и Android приложения используют KMM и MVI (реализация — MVIKotlin).
Теперь рассмотрим исходный код этого проекта. Если работать через Android Studio или IntelliJ IDEA, вид проекта стоит выбрать как Project, чтобы увидеть весь исходный код
В первую очередь нас интересуют папки, которые лежат в корне проекта: androidApp
, iosApp
и shared
. В них находится исходный код приложения. В androidApp
лежит код для Android, в iosApp
— код для iOS, а в shared
— общий код.
Рассматриваем проект со стороны Android
Здесь лежит одна MainActivity, в коде которой в строчке private val viewModel by viewModel
мы инжектим ViewModel. А в onCreate для упрощения собираем граф зависимостей:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initKoin {
androidContext(applicationContext)
}
}
Также здесь вызывается setContent
, в котором мы описываем наши вьюшки. В нашем случае UI написан на Compose:
setContent {
val state by viewModel.state.collectAsState()
MainScreen(
state = state,
onLoadClick = viewModel::load,
)
}
Этот вызов аналогичен обычному вызову setContent, в который мы передаем layout-файл с версткой. Это обычный extension, который оборачивает Composable в Compose view, — специальная вьюшка для Composable.
Внутри setContent
мы делаем следующие действия:
Подписываемся на стейт, который приходит из ViewModel, вызывая
collectAsState
(но лучше это делать при помощиcollectAsStateWithLifecycle
) Это важно, поскольку мы работаем с Compose, в котором лучше оперировать стейтом, а не просто Flow или RX-сущностямиВызываем Composable-функцию
MainScreen
. Это обычная Composable-функция, в которую мы передаем стейт и событие клика на загрузку в нашу ViewModel. В данном случаеviewModel::load
— это ссылка на методload
у ViewModel. Немного позже посмотрим, что там находится
MainScreen
Если мы зайдем в MainScreen, то увидим when
, в котором есть три ветки:
when {
state.error -> ErrorState(
modifier = Modifier
.align(Alignment.Center),
onLoadClick = onLoadClick,
)
state.loading -> CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center),
color = buttonPrimaryActiveColor,
strokeWidth = 3.dp,
)
userInfo != null -> UserInfoDetails(
modifier = Modifier
.align(Alignment.Center),
userInfo = userInfo,
onLoadClick = onLoadClick,
)
}
Здесь у нас есть три возможных состояния экрана:
error
— состояние с ошибкойloading
— состояние загрузкиuserInfo
— состояние экрана, когда у нас есть данные, и мы отображаем их
В больших проектах для этого будут написаны обертки, поэтому это будет выглядеть красивее в коде. Но у нас упрощенный пример, поэтому в данном случае это выглядит так.
Так вьюшка и отрисовывает стейты: есть when
, где есть все возможные состояния экрана. И на каждое возможное состояние экрана отрисовывается определенный стейт. Это можно легко сделать в Compose.
Если вернуться в MainActivity, то можно увидеть, что стейт берется из ViewModel-и, поэтому заглянем во ViewModel и посмотрим, что находится там.
MainViewModel
В конструкторе у нас есть два private свойства
store
— это MVI FeaturestateMapper
, который мапит доменный стейт в UiMainState
class MainViewModel(
private val store: MainStore,
private val stateMapper: Mapper,
)
В идеале вьюшка должна оперировать UiState
, а не доменным стейтом, так как она не должна знать, что происходит в нашей доменной области.
Во ViewModel нас интересуют публичные члены класса:
Свойство
state
, которое объявлено с типомCStateFlow
(что за первая буковкаC
в названии, мы разберем чуть ниже), и стоит тип нашего стейта (UIMainState):val state: CStateFlow
. Свойство state представляет собой тот стейт, на который подписывается вьюшкаМетод
load: fun load() = store.accept(MainStore.Intent.Load)
. Этот метод проксирует событие в MVI Feature. В данном случае — событие загрузки. Чтобы отправить этот интент в MVI Feature, мы вызываем методaccept
и прокидываем туда нужный нам интент — в данном случае этоMainStore.Intent.Load
Важно понимать, что ViewModel должна брать UiState
из стора. Поэтому в блоке init
мы вызываем bindAndStart
:
init {
bindAndStart {
store.states.map(stateMapper::map) bindTo (::acceptState)
}
load()
}
Внутри лямбды этого метода происходит следующее:
Берем наш
store
и его стейты, которые он выдаетМапим их в
UiState
с помощьюstateMapper
, который мапит из доменного стейта вUiState
Привязываем эти стейты к методу — в нашем случае к
acceptState
Внутри метода
acceptState
помещаем UI стейт вmutableState
— private поле типаMutableStateFlow
В итоге наш стейт получается из mutableState
, который мы предварительно приводим к типу CStateFlow
вызовом экстеншена cStateFlow()
Как уже отмечалось ранее, мы работаем с типом CStateFlow
, а не просто со StateFlow
. Дело в том, что это не андроидовская ViewModel, а KMM-ная ViewModel. Она находится в модуле shared, в commonMain
. И эта ViewModel шарится между iOS и Android.
В Kotlin интерфейсы поддерживают дженерики. И в Kotlin как раз StateFlow
представлен в виде интерфейса. О нюансах интеропа Kotlin и Swift вы можете прочитать в статье о рассмотрении KMM c точки зрения iOS-разработчика.
При этом в Swift тоже есть интерфейсы. Они называются протоколами. Но интерфейсы в Swift, не поддерживают дженерики, для чего мы и оборачиваем наш обычный корутиновский StateFlow
в обертку, которая называется CStateFlow
.
Когда Kotlin код скомпилируется в Objective C для iOS, там получается класс CStateFlow
. И классы на Swift могут содержать в себе дженерики. То есть это просто обертка для совместимости с iOS. Но по сути это тот же StateFlow
.
Мы рассмотрели ViewModel, и теперь можем пойти еще глубже и попасть в наш Store, а именно — в объявление MainStore.
MainStore
Это обычный интерфейс, который наследует интерфейс Store.
Интерфейс Store — это базовый интерфейс из библиотеки MVI Kotlin. Когда мы работаем с MVI Kotlin, он наследуется от базового стора и отдает в базовый Store три дженерика:
interface MainStore : Store
MainStore.Intent
— типы интентов, которые могут посылаться в нашу фичу — в черную коробкуMainStore.State
— тип стейта, который отдает фича наружуNothing
— тип лейбла. В данном случае у нас нет лейблов, поэтому там стоит Nothing
MVI хорош тем, что он позволяет отрисовывать один конкретный стейт. В этом случае лейблы используются для одноразовых событий, как, например, показать тост, диалог или выполнить навигацию. То есть это одноразовые штуки, которые не должны находиться в общем стейте.
В нашем конкретном примере нет лейблов, так как нет навигации или тостов с ошибкой.
Внутри самого интерфейса объявлены две сущности:
1. Доменный стейт:
data class State internal constructor(
val details: UserInfo? = null,
val isLoading: Boolean = false,
val isError: Boolean = false,
)
У него есть три свойства:
details
— данные о пользователеisLoading
— показывает, выполняется ли загрузкаisError
— показывает, пришла ли к нам ошибка
В больших проектах вместо типа Boolean
в isError
будет условный StringMessage
, потому что ошибки в бизнес-логике бывают разные, и в зависимости от типа ошибки (грубо говоря, типа исключения) они обрабатываются по-разному. И одним Boolean
для разных типов ошибок не обойтись.
2. Объявление интентов:
sealed interface Intent {
object Load : Intent
}
Здесь мы определяем тип интентов, которые могут отправляться в нашу фичу.
В данном случае у нас есть только один интент — Load
. Это интент загрузки. При этом здесь мог быть и другой, например, рефреш или ввод текста.
Обычно интенты объявляются в виде sealed
-интерфейсов или sealed
-классов, потому что так их удобно обрабатывать в самой фиче.
В итоге интерфейс MainStore
— это по сути API бизнес-логики. Здесь нет деталей реализации. Здесь просто описан стейт, которым наша фича оперирует. Также описаны интенты, которые фича может принимать.
Сама реализация бизнес-логики находится в MainExecutor
, а в довесок к нему идут MainReducer
и MainStoreFactory
. Разберем их подробнее.
StoreFactory
Так как фича представлена в виде интерфейса, его реализацию нужно создать. Для этого есть StoreFactory, которая позволяет создавать инстанс MVI-фичи.
Обычно все StoreFactory
очень простые — у них есть один метод по типу create:
fun create(): MainStore = object :
MainStore,
Store by storeFactory.create(
name = MainStore::class.simpleName,
initialState = MainStore.State(),
bootstrapper = null,
executorFactory = {
MainExecutor(
repository = repository,
)
},
reducer = MainReducer(),
) {}
Метод create () создает Object, который наследует интерфейс MainStore
и с помощью делегата создает реализацию нашего Store-а
Помимо создания, обычно в StoreFactory
ещё описывают Messages. Но никто не запрещает это сделать и в другом месте. Здесь нет ограничений. Например, можно описать Store в одном файле, интенты — в другом, стейт — в третьем. Но лучше, чтобы это находилось в одном месте и было связно.
В нашем случае MainStoreFactory
описывает sealed interface Message
:
sealed interface Message {
object SetLoading : Message
data class SetUserInfo(val userInfo: UserInfo) : Message
object SetError : Message
}
Это важный момент в контексте MVI, о котором мы тоже поговорим позже.
Напомним, что Store создается с помощью делегата StoreFactory
. Когда мы создаем эту MVI-фичу, мы можем задать ей некоторое поведение по умолчанию. Например, мы хотим, чтобы все наши MVI-фичи логировали события, которые в них приходят, и стейты, которые они после этих событий выдали. Для этого нужно задать базовый StoreFactory
, который будет содержать в себе такое поведение.
Если мы зайдем в DI модуль (mainModule), где создается StoreFactory
, то увидим, что она создается на основе LoggingStoreFactory
. Если посмотреть на название, то можно понять, что LoggingStoreFactory
просто логирует события фичи, реализация логгера при этом может быть любой, в нашем случае это Napier.
В библиотеке MVIKotlin есть несколько готовых реализаций StoreFactory
. Можно самим выбирать, какую реализацию использовать. Также есть дефолтная реализация, которая ничего не делает — не накладывает поведение.
Обычно в 100% случаев используется как минимум LoggingStoreFactory, который позволяет логировать то, что происходит внутри MVI-фичи. Это помогает, например, при отладке приложения.
Мы разобрали, что такое MainStoreFactory
. Теперь рассмотрим Executor
, в котором находится бОльшая часть бизнес-логики.
Executor
Ядро черной коробки, которое принимает в себя интенты, что-то с ними делает, обрабатывает их и выводит новый стейт. В нем находится бизнес-логика приложения, походы через репозиторий в сеть, базы данных, вычисления.
В нашем проекте Executor
называется MainExecutor. Он наследует базовый Executor
.
Базовый Executor
в данном случае просто оборачивает методы из библиотечного Executor
в scope.launch
:
final override fun executeIntent(intent: Intent, getState: () -> State) {
scope.launch {
suspendExecuteIntent(intent, getState)
}
}
final override fun executeAction(action: Action, getState: () -> State) {
scope.launch {
suspendExecuteAction(action, getState)
}
}
Это нужно, чтобы каждый раз не приходилось оборачивать suspend-вызовы в scope.launch
.
По умолчанию scope
в Executor
работает на главном потоке, так как изменение состояния должно быть в одном потоке, но при этом с помощью корутин можно легко тяжеловесные или IO операции выполнять на других потоках. Также важно понимать, что при dispose
-е фичи ее scope
отменяется, поэтому настоятельно рекомендуется использовать cancelable suspend вызовы. Таким образом, из-за structured concurrency в Kotlin Coroutines все наши операции в рамках нашего scope также отменятся. Эта концепция удобно реализована в iOS на уровне языка Swift.
Также здесь есть дженерики, которые отдаются в базовый Executor
:
internal abstract class BaseExecutor(
mainContext: CoroutineContext = Dispatchers.Main,
) : CoroutineExecutor(mainContext = mainContext) {
Здесь есть пять дженериков:
Intent
— то, что пользователь посылает в фичуAction
— внутренние действияState
— стейт, которым оперирует фичаMessage
— результат обработки интента, на основе которого Reducer (о нем поговорим далее) создаст новый стейтLabel
— одноразовое событие
Что новый покемон Action
? Итак, допустим у нас есть приложение с UI. С этим приложением может взаимодействовать пользователь, то есть отсылать интенты в фичу. Но иногда требуется повзаимодействовать с фичей не извне, а изнутри.
Взаимодействие извне — это когда пользователь сам выполнил действие и интент отправился.
Взаимодействие изнутри — это когда нам, например, нужно, в случае отсутствия сети не пытаться ходить не в сеть, а идти сразу в локальную базу данных, чтобы не тратить лишние ресурсы и память.
Для таких кейсов есть Action
. Это внутренние действия, которые мы можем посылать в свою фичу. Они используются довольно редко, но бывают полезными.
Еще в MainExecutor
нас интересует метод suspendExecuteIntent
, который определяется из базового Executor
. В этот метод мы принимаем наши интенты, которые посылает пользователь в черную коробку.
Сигнатура метода содержит в себе параметр intent
и лямбду getState
:
override suspend fun suspendExecuteIntent(
intent: MainStore.Intent,
getState: () -> MainStore.State,
) = when (intent) {
is MainStore.Intent.Load -> loadUserInfo()
}
getState
— это лямбда, которую нужно вызвать. Она вернет нам текущий стейт.
Если бы вместо getState: () -> MainStore
.State было бы написано state: MainStore.State
, то приходила бы ссылка на определенный объект State
. В этом случае мы не смогли бы получить актуальный стейт фичи. Поэтому это сделано в виде лямбды, которая позволяет получить именной текущий стейт в момент вызова лямбды.
Далее по коду берем when
от Intent. Так как Intent
— это sealed
-интерфейс, у которого один наследник, у нас возможна только одна ветка — Load
. В ней мы вызываем метод loadUserInfo()
.
В следующем методе мы диспатчим Message.SetLoading
:
when (val response = repository.getUserInfo()) {
is Response.Success -> dispatch(MainStoreFactory.Message.SetUserInfo(response.data))
is Response.Failed -> dispatch(MainStoreFactory.Message.SetError)
}
MainExecutor
содержит бизнес-логику, но он не преобразует текущий стейт в новый. Преобразованием стейта из старого в новый после некоторых действий бизнес-логики занимается сущность Reducer
. Это нужно, чтобы Executor
содержал только бизнес-логику. Поэтому все, что делает Executor
— диспатчит Message.SetLoading
.
Reducer
, про который мы поговорим позже, принимает эти Messages
, берет старый стейт и на основе этих Messages
преобразует его в новый стейт. А фича затем выплюнет этот стейт наружу.
Получается, мы говорим, что нужно установить состояние загрузки в начале метода loadUserInfo
. Далее мы идем в репозиторий и получаем информацию об этом пользователе.
Метод getUserInfo
возвращает обертку в виде Response
. Это базовая обертка, у которой два наследника: Success
и Failed
.
В реальных проектах в продакшене репозиторий не всегда вернет данные. Он может вернуть исключение, например, может произойти ошибка. В этом случае обычно всегда оборачивают типы данных у репозитория в какую-то обертку. В данном случае это обертка Response
.
В итоге получаются две ситуации: успешно сходили за данными и неуспешно. Получается, что у when
есть две ветки.
Если мы успешно сходили за данными, то мы диспатчим эти данные — Message.SetUserInfo(response.data))
. Этот Message идет в Reducer, где он преобразует старый стейт в новый. А когда у нас происходит ошибка, мы ставим ошибку: Message.SetError.
Теперь рассмотрим Reducer.
Reducer
Обычный класс. Он наследуется от базового библиотечного Reducer
, который переопределяет один метод — reduce
. Рассмотрим MainReducer:
internal class MainReducer : Reducer {
override fun MainStore.State.reduce(
msg: MainStoreFactory.Message,
) = when (msg) {
is MainStoreFactory.Message.SetError -> copy(
isError = true,
isLoading = false,
)
is MainStoreFactory.Message.SetUserInfo -> copy(
details = msg.userInfo,
isLoading = false,
)
is MainStoreFactory.Message.SetLoading -> copy(
isLoading = true,
isError = false,
)
}
}
Суть метода reduce
— преобразовать старый стейт в новый. Это происходит с помощью метода copy
, и это важно, поскольку наш стейт обязан быть Immutable (неизменяемым). Таким образом, мы копируем наш старый стейт и меняем в нем определенные свойства, которые нам нужны.
Message
, который приходит в Reducer
, был объявлен в виде sealed
-интерфейса. Поэтому мы можем взять when
в методе reduce
от этого Message
. В итоге у нас будет три ветки, в которых будет преобразование стейта: SetError
, SetUserInfo
и SetLoading
.
Одноразовые события
Выше был упомянут Label
, который используется для одноразовых событий, рассмотрим его на практике — выведем тост.
Допустим, у нас есть стор, и мы, хотим добавить в него лейбл. Для этого пишем следующий код:
sealed interface Label {
object Toast : Label
}
Лейбл называется object Toast
. Его мы должны поместить в дженерик. После этого заходим в Executor
, где вместо Nothing
передаем Label
:
internal class MainExecutor(
private val repository: Repository,
) : BaseExecutor() {
Помимо того, что мы уже отображаем состояние ошибки на экране, мы хотим еще отображать тост. Пишем так:
when (val response = repository.getUserInfo()) {
is Response.Success -> dispatch(MainStoreFactory.Message.SetUserInfo(response.data))
is Response.Failed -> {
publish(MainStore.Label.Toast) // "публикуем” наш label
dispatch(MainStoreFactory.Message.SetError)
}
Возвращаемся во ViewModel и подписываемся на лейблы у фичи.
init {
bindAndStart {
store.states.map(stateMapper::map) bindTo ::acceptState
store.labels. bindTo ::acceptState // в метод acceptState будут приходить label-ы
}
load()
}
Лейблы биндим к какому-то методу, в нашем случае — acceptLabel
:
private fun acceptLabel(label: MainStore.Label) {
}
Теперь лейблы будут приходить во ViewModel
, и в методе acceptLabel
мы сможем обрабатывать их.
Резюме по сделанному:
Объявили в сторе лейбл в виде
sealed
-интерфейса/классаПошли в MVI сущности, где поставили в дженерике лейбл вместо
Nothing
Вызвали
publish
и передали туда лейблВо ViewModel берем лейблы у стора и биндим их
На UI подписываемся на лейблы по типу
viewModel.labels.collect { … }
Показываем наши тостики
Так как MVI рассматривается в контексте КММ, мы посмотрим, как это все выглядит на iOS.
Рассматриваем проект со стороны iOS
Чтобы разобрать проект на iOS, нужно открыть репозиторий в Xcode. Xcode — это среда разработки, в которой пишутся iOS-приложения, используя 2 языка: Objective-C и Swift. Objective-C — это как Java, а Swift — как Kotlin. Наш пример написан на втором.
В iOS среди основных сущностей можно выделить:
MainViewController
— аналог фрагмента из AndroidMainAssembly
— здесь добавляются зависимости, в том числе ViewModel из Koin«аMainView
— рутовая вью нашего контроллера
Swift по сути ничего не знает про Koin, поэтому для того, чтобы нам получить зависимости из KMM, а затем их использовать в Swift-овом коде нужно использовать какой-либо iOS DI, в нашем случае для упрощения мы используем библиотеку Swinject, но можно обойтись и без сторонних фреймворков, о чём мы писали в статьях:
Кратко разберем MainViewController и MainView.
MainViewController
UIViewController в iOS — это по сути аналог фрагментов в Android. Посмотрим на его код:
final class MainViewController: UIViewController {
private let viewModel: MainViewModel
…
private func bindUI() {
viewModel.state.subscribe { [weak self] state in
guard let state = state, let self = self else { return }
if state.error {
self.mainView.updateState(.error)
} else if state.loading {
self.mainView.updateState(.loading)
} else if let details = state.userInfo {
self.mainView.updateState(.normal(userInfo: details))
}
}
}
…
}
Сюда передается ViewModel при помощи Swinject. А в методе bindUI
мы так же, как и в Android, подписываемся на стейты, которые приходят из ViewModel. Далее мы обновляем MainView в соответствии с полученным стейтом.
MainView
Рутовая вьюшка на нашем экране. Это аналог MainScreen, который был в Android. В ней мы с помощью кейсов показываем тот или иной стейт.
Общий код в Shared
Напомним, что при помощи КММ можно пошарить бизнес-логику, слой данных, но также с помощью КММ можно пошарить и еще больше кода.
Если мы заглянем в папку res в androidApp, то кроме стилей, нет других ресурсов: строк, цветов, шрифтов.
В нашем примере мы пошарили ресурсы между iOS и Android, поэтому они находятся в shared, в CommonMain
. Из ресурсов шарить можно цвета, строки, картинки, шрифты, а в коммьюнити уже есть несколько готовых решений, мы используем библиотеки MOKO от IceRock и libres.
Дефолтная структура ресурсов в KMM-модуле
Это удобно, когда работа происходит в команде. Например, если iOS-разработчик реализует фичу, то Android-разработчику не придется идти в Figma, копировать цвета, строки и картинки, скачивать и преобразовывать их в Drawable XML-файлы. В этом случае Android-разработчику останется только наверстать UI, а вся логика и ресурсики будут в KMM.
Это удешевляет и ускоряет разработку, а также делает ее более приятной. Вместо того, чтобы плодить эти ресурсы дважды, они создаются только один раз и шарятся между платформами. Более того, мы по сути реализуем практически (за исключением платформенных приколов) одинаковое поведение на iOS и Android. И если будут баги, то наверняка они будут общими