Почему так удобно использовать паттерн MVI в KMM

17f04910dd67491c64e722eff5b043e3.jpg

Привет!

Меня зовут Стефан Серхир. Я мобильный разработчик в 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, общий код). Общий код может содержать бизнес-логику, базовые штуки по типу работы с сетью или базой данных и сложные вычисления. 

0370b5232fc966ece3199d816d4c386d.jpg

Также в 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 мы делаем следующие действия:

  1. Подписываемся на стейт, который приходит из ViewModel, вызывая collectAsState (но лучше это делать при помощи collectAsStateWithLifecycle) Это важно, поскольку мы работаем с Compose, в котором лучше оперировать стейтом, а не просто Flow или RX-сущностями

  2. Вызываем 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 Feature

  • stateMapper, который мапит доменный стейт в 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()
}

Внутри лямбды этого метода происходит следующее:

  1. Берем наш store и его стейты, которые он выдает

  2. Мапим их в UiState с помощью stateMapper, который мапит из доменного стейта в UiState

  3. Привязываем эти стейты к методу — в нашем случае к acceptState

  4. Внутри метода 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 мы сможем обрабатывать их.

Резюме по сделанному:

  1. Объявили в сторе лейбл в виде sealed-интерфейса/класса

  2. Пошли в MVI сущности, где поставили в дженерике лейбл вместо Nothing

  3. Вызвали publish и передали туда лейбл

  4. Во ViewModel берем лейблы у стора и биндим их

  5. На UI подписываемся на лейблы по типу viewModel.labels.collect { … } 

  6. Показываем наши тостики

Так как MVI рассматривается в контексте КММ, мы посмотрим, как это все выглядит на iOS.

Рассматриваем проект со стороны iOS

Чтобы разобрать проект на iOS, нужно открыть репозиторий в Xcode. Xcode — это среда разработки, в которой пишутся iOS-приложения, используя 2 языка: Objective-C и Swift. Objective-C — это как Java, а Swift — как Kotlin. Наш пример написан на втором. 

В iOS среди основных сущностей можно выделить:

  • MainViewController — аналог фрагмента из Android

  • MainAssembly — здесь добавляются зависимости, в том числе 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-модуле

Дефолтная структура ресурсов в KMM-модуле

Это удобно, когда работа происходит в команде. Например, если iOS-разработчик реализует фичу, то Android-разработчику не придется идти в Figma, копировать цвета, строки и картинки, скачивать и преобразовывать их в Drawable XML-файлы. В этом случае Android-разработчику останется только наверстать UI, а вся логика и ресурсики будут в KMM. 

Это удешевляет и ускоряет разработку, а также делает ее более приятной. Вместо того, чтобы плодить эти ресурсы дважды, они создаются только один раз и шарятся между платформами. Более того, мы по сути реализуем практически (за исключением платформенных приколов) одинаковое поведение на iOS и Android. И если будут баги, то наверняка они будут общими

© Habrahabr.ru